junyeokk
Blog
Media·2025. 09. 17

PDF.js Web Worker 설정

브라우저에서 PDF를 렌더링하려면 PDF.js(pdfjs-dist)를 사용한다. PDF.js는 Mozilla가 만든 JavaScript PDF 렌더링 라이브러리로, 브라우저 네이티브 PDF 뷰어 없이도 PDF를 Canvas나 DOM으로 렌더링할 수 있다. 그런데 PDF 파싱은 연산이 무겁다. 수백 페이지짜리 PDF를 메인 스레드에서 파싱하면 UI가 멈춘다. 이 문제를 해결하기 위해 PDF.js는 Web Worker를 사용해 파싱 작업을 별도 스레드에서 처리한다.

React 프로젝트에서는 보통 react-pdf라는 래퍼 라이브러리를 쓰는데, 내부적으로 pdfjs-dist에 의존한다. 이 조합에서 Worker 설정, SSR 회피, CMap 설정까지 제대로 하려면 몇 가지 삽질 포인트를 알아야 한다.


Web Worker가 필요한 이유

PDF 파일은 단순한 텍스트가 아니다. 내부적으로 크로스 레퍼런스 테이블, 스트림 압축, 폰트 임베딩, 이미지 디코딩 등 복잡한 구조를 가지고 있다. 이걸 파싱하는 과정이 CPU를 많이 먹는다.

Web Worker 없이 PDF.js를 사용하면 이 파싱이 전부 메인 스레드에서 돌아간다. 사용자가 PDF를 열 때마다 UI가 프리즈되고, 버튼 클릭이나 스크롤이 먹통이 되는 현상이 발생한다. Worker를 활성화하면 파싱 로직이 별도 스레드에서 실행되므로 메인 스레드는 렌더링에만 집중할 수 있다.

PDF.js의 아키텍처를 간단히 보면:

메인 스레드 (UI) Worker 스레드 ┌──────────────┐ ┌──────────────┐ │ react-pdf │ ← 메시지 → │ pdf.worker │ │ (Document, │ │ (PDF 파싱, │ │ Page 등) │ │ 폰트 처리, │ │ │ │ 이미지 디코딩)│ └──────────────┘ └──────────────┘

메인 스레드의 pdfjs 라이브러리가 Worker를 생성하고, PDF 바이너리 데이터를 Worker에 넘긴다. Worker가 파싱을 완료하면 렌더링에 필요한 데이터(페이지 구조, 폰트 정보, 이미지 데이터 등)를 메인 스레드로 돌려보낸다. 이 통신은 postMessage 기반이라 메인 스레드가 블로킹되지 않는다.


Worker 설정 방법

PDF.js Worker를 설정하는 방법은 여러 가지가 있다. 핵심은 pdfjs.GlobalWorkerOptions.workerSrc에 Worker 파일의 경로를 지정하는 것이다.

CDN에서 로드

가장 간단한 방법이다. unpkg나 cdnjs 같은 CDN에서 Worker 파일을 로드한다.

tsx
메인 스레드 (UI)                 Worker 스레드
┌──────────────┐                ┌──────────────┐
│  react-pdf   │  ← 메시지 →   │  pdf.worker   │
│  (Document,  │                │  (PDF 파싱,   │
│   Page 등)   │                │   폰트 처리,  │
│              │                │   이미지 디코딩)│
└──────────────┘                └──────────────┘

pdfjs.version을 사용하면 react-pdf가 의존하는 pdfjs-dist 버전과 Worker 버전이 자동으로 맞춰진다. 버전이 불일치하면 런타임 에러가 발생하므로 이 패턴이 안전하다.

장점: 번들 크기에 영향 없음, 설정 간단 단점: CDN 의존성, 오프라인 환경에서 작동 불가

로컬 파일로 복사

pdfjs-dist 패키지의 Worker 파일을 public/ 디렉토리에 복사해서 로컬에서 서빙하는 방법이다.

bash
import { pdfjs } from 'react-pdf';

pdfjs.GlobalWorkerOptions.workerSrc = 
  `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
tsx
cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs

빌드 스크립트에 복사 단계를 추가하거나, copy-webpack-plugin 같은 도구를 사용하면 자동화할 수 있다.

Webpack에서 번들링

Webpack의 Worker 로더를 사용해서 Worker 파일을 직접 번들에 포함시키는 방법도 있다.

javascript
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';

이 방식은 Worker 파일이 빌드 산출물에 포함되므로 별도의 CDN이나 파일 복사가 필요 없다. 다만 번들 크기가 약 400KB(min 기준) 늘어난다.


Next.js에서의 SSR 문제

Next.js는 서버 사이드 렌더링(SSR)을 기본으로 한다. 문제는 PDF.js가 브라우저 전용 API(Canvas, Web Worker 등)에 의존한다는 것이다. 서버에서 react-pdf를 import하면 canvas is not defined 같은 에러가 발생한다.

해결 1: webpack alias로 서버 번들에서 제외

Next.js의 webpack 설정에서 서버 빌드 시 react-pdf와 pdfjs-dist를 아예 빈 모듈로 대체한다.

typescript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /pdf\.worker\.(min\.)?mjs$/,
        type: 'asset/resource',
      },
    ],
  },
};

false로 설정하면 해당 모듈을 빈 객체({})로 대체한다. 서버에서는 PDF 렌더링이 필요 없으므로 이렇게 해도 문제없다.

해결 2: dynamic import로 클라이언트에서만 로드

next/dynamic을 사용해 PDF 뷰어 컴포넌트를 클라이언트에서만 로드하는 방법이다.

tsx
// next.config.ts
const nextConfig = {
  webpack: (config, { isServer }) => {
    if (isServer) {
      config.resolve.alias = {
        ...config.resolve.alias,
        'react-pdf': false,
        'pdfjs-dist': false,
      };
    }
    return config;
  },
};

두 방법을 함께 사용하면 더 안전하다. webpack alias로 서버 번들에서 제외하고, dynamic import로 컴포넌트 로딩 자체를 클라이언트로 제한한다.

Worker 초기화 위치

Worker 설정은 반드시 브라우저 환경에서만 실행되어야 한다. 모듈 최상위에서 typeof window 체크를 하는 것이 일반적이다.

tsx
import dynamic from 'next/dynamic';

const PDFViewer = dynamic(
  () => import('@/components/PDFViewer'),
  { ssr: false }
);

'use client' 디렉티브만으로는 부족하다. Next.js의 클라이언트 컴포넌트도 초기 렌더링 시 서버에서 실행되기 때문이다. typeof window !== 'undefined' 가드가 반드시 필요하다.


CMap 설정

CMap(Character Map)은 PDF 내부의 문자 인코딩을 유니코드로 매핑하는 데이터다. PDF에 한글, 중국어, 일본어 같은 CJK 문자가 포함되어 있으면 CMap이 필요하다.

CMap이 없으면 어떻게 되나

PDF에 폰트가 임베딩되어 있으면 CMap 없이도 글자가 보일 수 있다. 하지만 외부 폰트 매핑 파일을 참조하는 PDF의 경우, CMap이 없으면 글자가 아예 렌더링되지 않거나 깨져서 보인다. 특히 한글 PDF에서 이 문제가 자주 발생한다.

설정 방법

tsx
'use client';

import { pdfjs } from 'react-pdf';

if (typeof window !== 'undefined') {
  pdfjs.GlobalWorkerOptions.workerSrc = 
    `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
}
  • cMapUrl: CMap 파일들이 호스팅된 디렉토리 URL. pdfjs-dist 패키지에 포함된 cmaps 폴더를 CDN으로 서빙한다.
  • cMapPacked: true로 설정하면 바이너리 압축된 .bcmap 파일을 사용한다. 일반 .cmap 파일보다 크기가 훨씬 작다.

CMap 파일은 필요할 때만 다운로드된다. 한글 PDF를 열면 한글 관련 CMap만 fetch하고, 영문 PDF는 CMap 요청 자체가 발생하지 않는다. 그래서 cMapUrl을 설정해놔도 성능 부담이 거의 없다.

로컬 호스팅

CDN 대신 CMap 파일을 로컬에서 서빙할 수도 있다.

bash
const PDF_OPTIONS = {
  cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
  cMapPacked: true,
};

// Document 컴포넌트에 전달
<Document file={pdfUrl} options={PDF_OPTIONS}>
  <Page pageNumber={1} />
</Document>
tsx
cp -r node_modules/pdfjs-dist/cmaps/ public/cmaps/

오프라인 환경이나 CDN에 의존하고 싶지 않을 때 유용하다.


react-pdf 기본 사용법

Worker와 CMap 설정을 마쳤으면 실제로 PDF를 렌더링하는 코드를 작성한다.

tsx
const PDF_OPTIONS = {
  cMapUrl: '/cmaps/',
  cMapPacked: true,
};

Document 컴포넌트

Document는 PDF 파일 전체를 로드하는 컨테이너다. file prop에 URL, ArrayBuffer, 또는 Base64 문자열을 전달할 수 있다.

Prop설명
filePDF 소스 (URL, ArrayBuffer, Base64)
onLoadSuccess문서 로드 완료 콜백 (총 페이지 수 반환)
onLoadError로드 실패 콜백
optionspdfjs 옵션 (CMap 등)
loading로딩 중 표시할 컴포넌트

Page 컴포넌트

Page는 개별 페이지를 렌더링한다. Document 안에 위치해야 하며, 부모의 PDF 데이터를 Context로 받는다.

Prop설명
pageNumber렌더링할 페이지 번호 (1부터 시작)
scale확대/축소 비율 (기본값 1)
renderTextLayer텍스트 선택 레이어 렌더링 여부
renderAnnotationLayer링크, 양식 등 어노테이션 렌더링 여부
width페이지 너비 고정 (scale 대신 사용 가능)
onLoadSuccess페이지 로드 완료 콜백

renderTextLayerrenderAnnotationLayerfalse로 설정하면 텍스트 선택과 링크 클릭이 비활성화되지만 렌더링 성능이 좋아진다. PDF를 이미지처럼 보여주기만 하는 경우에 적합하다.


확대/축소 구현

PDF 확대/축소는 Page 컴포넌트의 scale prop으로 제어한다.

tsx
'use client';

import { useState, useCallback } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';

if (typeof window !== 'undefined') {
  pdfjs.GlobalWorkerOptions.workerSrc = 
    `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
}

const PDF_OPTIONS = {
  cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
  cMapPacked: true,
};

export function PDFViewer({ url }: { url: string }) {
  const [numPages, setNumPages] = useState(0);
  const [currentPage, setCurrentPage] = useState(1);

  const onLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
    setNumPages(numPages);
  }, []);

  return (
    <div>
      <Document 
        file={url} 
        onLoadSuccess={onLoadSuccess}
        options={PDF_OPTIONS}
      >
        <Page 
          pageNumber={currentPage}
          renderTextLayer={false}
          renderAnnotationLayer={false}
        />
      </Document>
      <div>
        <button 
          onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
          disabled={currentPage <= 1}
        >
          이전
        </button>
        <span>{currentPage} / {numPages}</span>
        <button 
          onClick={() => setCurrentPage(p => Math.min(numPages, p + 1))}
          disabled={currentPage >= numPages}
        >
          다음
        </button>
      </div>
    </div>
  );
}

zoomLevel이 0이면 컨테이너에 맞게 자동 조절하고, 사용자가 직접 값을 지정하면 그 비율로 렌더링한다. PageonLoadSuccess 콜백에서 getViewport({ scale: 1 })로 원본 페이지 크기를 얻어와서 비율을 계산한다.


트러블슈팅

Worker 버전 불일치

Error: The API version "4.x.x" does not match the Worker version "3.x.x"

pdfjs.version으로 CDN URL을 구성하면 이 문제가 발생하지 않는다. 직접 버전을 하드코딩하면 react-pdf를 업데이트할 때 Worker 버전도 같이 바꿔야 한다.

Canvas 크기 제한

브라우저에는 Canvas 최대 크기 제한이 있다(보통 16384×16384px). PDF를 너무 크게 확대하면 이 제한에 걸려서 페이지가 렌더링되지 않는다. 줌 범위에 상한선(보통 300%~500%)을 두는 것이 좋다.

메모리 사용량

PDF.js는 각 페이지를 Canvas로 렌더링한다. 모든 페이지를 한 번에 렌더링하면 메모리 사용량이 급증한다. 현재 보이는 페이지만 렌더링하고, 이전/다음 페이지를 미리 렌더링하는 가상화 전략을 사용하는 것이 좋다.

tsx
function usePDFZoom(containerRef: React.RefObject<HTMLDivElement>) {
  const [zoomLevel, setZoomLevel] = useState(0); // 0 = 자동
  const [autoScale, setAutoScale] = useState(1);

  useEffect(() => {
    if (!containerRef.current || !pageSize) return;

    const containerWidth = containerRef.current.clientWidth - 32; // padding 제외
    const containerHeight = containerRef.current.clientHeight - 32;
    
    // PDF 원본 크기 대비 컨테이너에 맞는 비율 계산
    const scaleX = containerWidth / pageSize.width;
    const scaleY = containerHeight / pageSize.height;
    const fitScale = Math.min(scaleX, scaleY);
    
    setAutoScale(fitScale);
  }, [pageSize, containerRef]);

  const effectiveScale = zoomLevel > 0 ? zoomLevel : autoScale;

  return { effectiveScale, zoomLevel, setZoomLevel };
}

CORS 에러

외부 URL에서 PDF를 로드할 때 CORS 에러가 발생할 수 있다. PDF 서버에서 Access-Control-Allow-Origin 헤더를 설정하거나, 프록시를 통해 PDF를 로드해야 한다. CMap CDN(unpkg 등)은 기본적으로 CORS를 허용하므로 별도 설정이 필요 없다.


정리

  • Worker를 활성화하지 않으면 PDF 파싱이 메인 스레드에서 돌아가 UI가 프리즈되므로, workerSrc 설정은 선택이 아니라 필수다
  • Next.js SSR 환경에서는 typeof window 가드 + dynamic import(ssr: false) 조합으로 브라우저 전용 로딩을 보장해야 한다
  • CMap 설정은 CJK 문자가 포함된 PDF에서 필수이며, 필요한 파일만 on-demand로 fetch되므로 성능 부담이 거의 없다

관련 문서