구독하기

300쪽을 다 그려도 첫 쪽은 빨리 뜬다

Whiimsy

브라우저에서 문서 다루기 #4 — 300쪽 PDF를 한꺼번에 그려도 첫 쪽은 1초 안에 떠서, 비용을 늦게 알아채요. 그사이 캔버스 300장이 2.2GB를 잡고 1쪽 글자는 6~9초 뒤에야 생겨요. 화면 근처 쪽만 그리면 캔버스 5장, 37MB로 충분해요.

지난 글에서는 형광펜을 좌표와 인용문으로 같이 저장했어요. 그런데 형광펜을 칠할 문서가 수백 쪽이면 다른 문제가 먼저 나오는데요, 모든 쪽을 한꺼번에 그리는 pages.map(<Page />) 한 줄에서 시작하는 문제예요. 이 글에서는 300쪽 PDF를 전부 그릴 때와 화면 근처 쪽만 그릴 때를 재 보고, 보이는 쪽만 그릴 때 챙길 것을 정리해 볼게요.

확인하려고 300쪽짜리 PDF를 하나 만들었어요.

  • 문서 — Letter 크기(612×792pt) 300쪽. 쪽마다 쪽 번호와 제목 한 줄, 본문 40줄이 있어서 텍스트 span이 모두 12,600개예요
  • 화면 — 창 1280×900, devicePixelRatio 2. 레티나 노트북에서 여는 상황이에요
  • 뷰어 — Next.js 16.3.6과 react-pdf 10.5.0(pdfjs-dist 5.4.296)을 next build로 띄웠어요. PDF 주석 레이어는 끄고(renderAnnotationLayer={false}) 쟀어요

숫자는 headless Chromium 140으로 2~5번 잰 범위예요. 브라우저 메모리는 Chromium 프로세스 전체의 RSS(Resident Set Size)를 더한 값이고, 캔버스 메모리는 살아 있는 캔버스마다 가로 × 세로 × 4바이트를 더한 값이에요.

첫 쪽은 1초 안에 떠요

전부 그리는 코드는 보통 이렇게 생겼어요. 문서를 열어 쪽 수를 받으면 쪽마다 <Page>를 하나씩 깔아요.

<Document file={file} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
  {Array.from({ length: numPages }, (_, i) => (
    <Page key={i + 1} pageNumber={i + 1} />
  ))}
</Document>

이렇게 열어도 1쪽 그림은 0.8~1.0초에 떠요. 화면 근처 쪽만 그릴 때(0.5~0.8초)와 크게 다르지 않아서, 화면만 보면 문제가 없어 보여요. 그런데 150쪽을 보고 있을 때 메모리에 남아 있는 캔버스와 DOM을 세어 보면 차이가 커요.

두 방식을 표로 비교해 볼게요.

  전부 그리기 보이는 쪽만
1쪽 그림 0.8~1.0초 0.5~0.8초
1쪽 글자(텍스트 레이어) 6.2~8.6초 0.6~0.8초
살아 있는 캔버스 300장 · 2,219MB 2~5장 · 15~37MB
DOM 노드 26,150개 819~1,074개
브라우저 메모리(최대) 3.0~3.1GB 0.27~0.33GB

첫 그림은 비슷한데, 캔버스 메모리는 60분의 1로 줄고 1쪽 글자는 10배쯤 빨리 생겼어요.

캔버스는 그리기 전에 크기부터 정해요

react-pdf의 Canvas 컴포넌트는 그리기 전에 캔버스의 width와 height부터 정해요. 크기를 scale × devicePixelRatio 배율로 만든 viewport에 맞추기 때문에, 이 문서를 scale 1, devicePixelRatio 2로 그리면 쪽 하나가 1224×1584 픽셀이에요. 아래는 소스에서 주요한 부분만 남긴 거예요.

// react-pdf 10.5.0 Page/Canvas
const renderViewport = page.getViewport({ scale: scale * devicePixelRatio })
canvas.width = renderViewport.width
canvas.height = renderViewport.height
page.render({
  canvas,
  canvasContext: canvas.getContext('2d', { alpha: false }),
  viewport: renderViewport,
})

픽셀마다 RGBA 4바이트라서 쪽 하나에 7.4MB, 300쪽이면 2,219MB예요. 화면에 보이는 건 한두 쪽인데, 캔버스는 300장 모두 이 크기로 살아 있어요.

devicePixelRatio가 1인 모니터라면 300쪽이 555MB로 줄지만, 3인 아이폰에서는 한 쪽만 16.6MB예요. 모바일 Safari에는 캔버스 메모리 총량에 한도가 있어서, react-pdf 저장소에는 "Total canvas memory use exceeds the maximum limit (384 MB)" 오류가 이슈로 여러 번 올라왔어요. 이 문서를 아이폰에서 scale 1로 그리면 24쪽째에서 384MB를 넘는 계산이에요.

1쪽 글자는 6~9초 뒤에 생겨요

그림보다 더 늦은 건 텍스트 레이어였어요. 1쪽 그림은 1초 안에 떴는데 1쪽 글자는 6.2~8.6초 뒤에 생겼고, 300쪽 그림이 다 끝나는 8초 안팎과 거의 겹쳤어요. 그동안은 1쪽에서도 글자를 드래그할 수 없고, 지난 글의 형광펜도 인용문으로 제자리를 확인할 수 없어요.

react-pdf의 TextLayer는 쪽마다 글자를 두 번 받아요. 마운트할 때 page.getTextContent()로 한 번 받고, 텍스트 레이어를 그릴 때 page.streamTextContent()로 한 번 더 받아요. 로그를 찍어 보니 1쪽 글자 내용은 1.1~1.5초에 이미 받아 뒀는데, 텍스트 레이어는 그 뒤로 5~6초가 더 걸려서 끝났어요. 두 번째 요청과 그리기가 300쪽 그림 작업과 차례를 다투면서 늦어지는 것으로 보여요.

화면 근처 쪽만 마운트하기

보이는 쪽만 그리려면 자리는 300개를 다 깔고, 화면 근처에 온 쪽만 <Page>를 마운트하면 돼요. 아래는 재현 저장소 코드에서 측정용 콜백을 빼고 간소화한 거예요.

function LazyPage({ n, size }) {
  const ref = useRef(null)
  const [near, setNear] = useState(false)
  useEffect(() => {
    const io = new IntersectionObserver(([entry]) => setNear(entry.isIntersecting), {
      rootMargin: '100% 0px', // 화면 위아래로 한 화면씩 더
    })
    io.observe(ref.current)
    return () => io.disconnect()
  }, [])
  return (
    <div ref={ref} style={size}>
      {near ? <Page pageNumber={n} /> : <span>{n}</span>}
    </div>
  )
}

자리(div) 크기는 1쪽의 viewport로 정했어요. 자리가 다 있어야 스크롤바 길이가 맞고, 150쪽으로 바로 건너뛸 수도 있거든요.

처음 열면 1·2쪽만 마운트돼서 캔버스가 2장(15MB)이에요. 150쪽으로 건너뛰면 148~152쪽 5장(37MB)이 되고, 150쪽이 다 그려지기까지 0.1~0.2초 걸렸어요. 한 화면씩 100ms 간격으로 끝까지(28.8초) 내려 봐도 캔버스는 가장 많을 때 5장·37MB였고, 브라우저 메모리는 0.58GB를 넘지 않았어요. 끝에 닿았을 때는 300쪽이 모두 한 번씩 그려진 뒤였고요.

메모리가 이렇게 바로 돌아오는 건 react-pdf가 언마운트할 때 캔버스의 width와 height를 0으로 만들기 때문이에요. 소스 주석에도 이렇게 하면 대부분의 브라우저가 그래픽 자원을 바로 해제한다고 적혀 있어요. 그래서 멀어진 쪽은 CSS로 숨기지 말고 언마운트해야 이 정리가 일어나요.

인라인 콜백이면 텍스트 레이어가 비어요

재현 화면은 표를 채우려고 0.5초마다 상태를 갱신해요. 부모 컴포넌트가 0.5초마다 다시 그려지는 셈인데요, 스크롤할 때마다 현재 쪽 번호를 상태로 올리는 뷰어도 비슷해요. 이때 <Page>에 콜백을 넘기는 두 가지 방식을 비교해 볼게요.

// ① 쪽마다 콜백 고정 (onText도 부모에서 useCallback으로 고정해 둬요)
const PageItem = memo(function PageItem({ n, onText }) {
  const handleText = useCallback(() => onText(n), [n, onText])
  return <Page pageNumber={n} onRenderTextLayerSuccess={handleText} />
})

// ② 인라인 콜백: 부모가 다시 그려질 때마다 새 함수가 넘어가요
<Page pageNumber={n} onRenderTextLayerSuccess={() => onText(n)} />

전부 그리기 모드에서 텍스트 span 수를 0.5초마다 100초 동안 세어 봤어요.

②는 100초 내내 텍스트 레이어가 거의 비어 있었어요. 10초 이후 155번 쟀는데 149번은 span이 0개였고, 12,600개가 다 있던 건 한 번뿐이었어요. 그사이 지운 span이 모두 125,034개로, 300쪽 텍스트 레이어를 열 번쯤 비운 셈이에요. 50ms 넘게 메인 스레드를 잡은 작업(long task)도 합쳐서 12.7초로 ①(1.3~1.4초)의 열 배쯤이었어요.

원인은 TextLayer의 의존성 배열에 있어요. 텍스트 레이어를 그리는 useLayoutEffect가 onRenderTextLayerSuccess를 감싼 콜백을 의존성으로 들고 있어서, 콜백이 바뀌면 그리던 작업을 취소하고 레이어를 비운 뒤 처음부터 다시 그려요.

// react-pdf 10.5.0 Page/TextLayer, 주요한 부분만 남겼어요
useLayoutEffect(function renderTextLayer() {
  layer.innerHTML = ''
  const task = new pdfjs.TextLayer({
    container: layer,
    textContentSource: page.streamTextContent({ includeMarkedContent: true }),
    viewport,
  })
  task.render().then(onRenderSuccess)
  return () => task.cancel()
}, [
  customTextRenderer, onRenderError, onRenderSuccess, page,
  pageIndex, pageNumber, textContent, viewport,
])

300쪽 텍스트를 0.5초 안에 다 그리지 못하니, 다 그리기 전에 또 취소되는 거예요. 그런데 Canvas 쪽은 반대로 짜여 있어요. 그리기 effect의 의존성에서 콜백을 일부러 뺐고, 그 이유를 주석으로 적어 뒀어요. 그래서 ②에서도 그림은 멀쩡하고 글자만 사라져서, 화면만 봐서는 알아채기 어려워요.

onRenderTextLayerSuccess는 인자 없이 불려서 어느 쪽인지 알려 주지 않아요. 그래서 ①처럼 쪽마다 콜백을 따로 만들어 고정하는 컴포넌트가 필요해요. customTextRenderer도 같은 의존성 배열에 있으니, 검색어를 칠하는 함수를 넘길 때도 useCallback으로 고정해 두세요.

pdf.js 기본 뷰어도 보이는 쪽만 그려요

pdfjs-dist 5.7.284의 web/pdf_viewer 소스에서 기본 뷰어의 동작을 확인해 봤어요. 정리하면 이래요.

  • 자리 — 문서를 열면 모든 쪽의 자리를 1쪽 크기로 먼저 깔고, 처음 한 쪽을 그린 뒤 나머지 쪽 크기를 받아서 고쳐요. 250쪽마다 앞선 요청이 끝나길 기다리고, 5,000쪽이 넘으면 미리 받지 않아요
  • 그리는 순서 — 보이는 쪽을 먼저 그리고, 다 그리면 스크롤하는 방향으로 한 쪽을 더 그려 둬요
  • 지우기 — 그린 쪽을 max(10, 2 × 보이는 쪽 수 + 1)개까지만 두고, 넘으면 가장 오래전에 본 쪽부터 지워요
  • 캔버스 한 장 크기 — 캔버스 한 장의 픽셀 수를 2²⁵(약 3,355만)로 묶고, iOS와 Android에서는 524만으로 더 줄여요

제 재현 화면과 가장 크게 다른 점은 지우는 때예요. 저는 화면에서 멀어지면 바로 언마운트했는데, pdf.js는 최근에 그린 10쪽 정도를 남겨 둬요. 메모리를 조금 더 쓰는 대신, 조금 전에 본 쪽으로 돌아가면 다시 그리지 않아도 되는 선택이에요.

물론 보이는 쪽만 그리기가 공짜는 아닙니다

가장 먼저 걸리는 건 브라우저 찾기예요. 보이는 쪽만 그리면 150쪽 글자는 DOM에 아예 없거든요. 전부 그리기에서는 document.body.textContent에 「Section 150」이 있었는데, 보이는 쪽만 모드에서는 없었어요.

브라우저의 찾기(Ctrl+F)는 DOM에 있는 글자에서 찾으니 멀리 있는 쪽은 찾지 못해요. pdf.js 기본 뷰어는 찾기 기능을 따로 두고, getTextContent()로 한 쪽씩 글자를 뽑아 검색해요.

쪽 크기도 챙겨야 해요. 이 글에서는 1쪽 크기로 자리를 깔았는데, 중간에 가로 쪽이 섞여 있으면 스크롤 위치가 틀어져요. 쪽 크기를 먼저 다 받아 두거나, pdf.js처럼 받는 대로 고치되 화면보다 위에 있는 자리가 바뀌면 스크롤 위치를 보정해야 해요.

스크롤바를 끌어서 멀리 가면, 마운트하고 그리는 동안은 쪽 번호만 있는 빈 자리가 보여요. rootMargin을 늘리면 덜 보이지만 그만큼 캔버스가 늘어나요.

형광펜도 영향을 받아요. 텍스트 레이어가 없는 쪽에서는 인용문으로 제자리를 확인할 수 없어요. 지난 글에서 사각형을 PDF 좌표로 같이 저장해 둔 게 여기서 쓸모가 있는데요, 텍스트 레이어가 뜨기 전에도 사각형으로 먼저 그릴 수 있거든요.

아직 쪽 크기가 섞인 문서로는 재 보지 않았어요. 실제 아이폰에서 몇 쪽째에 캔버스가 비는지도 계산으로만 봤고요.

직접 해보기

이번에도 최소 재현 저장소를 만들었어요. 같은 300쪽 PDF를 두 방식으로 띄우고, 화면 위 표에서 캔버스 수와 캔버스 메모리, 1쪽 글자 시간을 바로 볼 수 있어요. 전부 그리기 모드에서는 인라인 콜백으로 바꿔 볼 수도 있고요.

github.com/danbom/pdf-page-virtualization

  • 전부 그리기에서 캔버스가 300장으로 나오나요? 레티나 화면이면 2,219MB예요
  • 1쪽 글자 시간이 보이는 쪽만 모드보다 10배쯤 늦나요?
  • 보이는 쪽만 모드에서 「150쪽으로 가기」를 누르면 0.2초 안에 그려지나요?
  • 끝까지 스크롤해도 캔버스가 5장 안팎에 머무르나요?
  • 전부 그리기 모드에서 「인라인 콜백으로 바꿔 보기」를 누르면 텍스트 span이 대부분 0으로 나오나요?

적용해보기

  • 뷰어에서 모든 쪽의 <Page>를 한꺼번에 마운트하고 있지는 않나요?
  • 가장 긴 문서를 devicePixelRatio가 가장 높은 기기에서 열 때 캔버스 메모리가 얼마인지 계산해 봤나요?
  • <Page>에 넘기는 콜백과 customTextRenderer가 부모가 다시 그려질 때마다 새로 만들어지지는 않나요?
  • 보이는 쪽만 그린다면, 사용자는 문서 전체에서 글자를 어떻게 찾나요?

"나라면 이렇게 했을 텐데" 싶은 지점이 있었다면, 그 얘기를 듣고 싶어요. 특히 쪽 크기가 제각각인 문서에서 스크롤 위치를 어떻게 지키고 계신지 궁금해요.

다음 편에서는 보이는 쪽만 그리면 잃는 문서 전체 검색을 텍스트 레이어 없이 만들어 보려고 해요.

감사합니다.

참고