junyeokk
Blog
React Ecosystem·2025. 09. 17

react-pdf

브라우저에서 PDF 파일을 보여줘야 할 때, 가장 먼저 떠오르는 방법은 <iframe>이나 <embed>로 브라우저 내장 뷰어를 사용하는 것이다.

html
<iframe src="/document.pdf" width="100%" height="600px" />

간단하지만 문제가 많다. 브라우저마다 내장 PDF 뷰어의 UI가 다르고, 모바일에서는 아예 지원하지 않는 경우도 있다. 페이지 전환이나 확대/축소 같은 커스텀 컨트롤을 추가하려면 뷰어 내부에 접근할 방법이 없다. 그리고 PDF 위에 드로잉 레이어를 얹거나, 특정 페이지만 렌더링하는 것도 불가능하다.

react-pdf는 Mozilla의 pdf.js를 React 컴포넌트로 감싼 라이브러리다. PDF를 <canvas>에 렌더링하기 때문에 브라우저 내장 뷰어에 의존하지 않고, 어떤 환경에서든 동일한 결과물을 보여준다. 페이지 단위로 렌더링되므로 원하는 페이지만 표시하거나, 위에 오버레이를 얹는 것도 자유롭다.


설치와 기본 구조

bash
npm install react-pdf

react-pdf의 핵심은 DocumentPage 두 컴포넌트다. Document가 PDF 파일 전체를 로드하고, Page가 특정 페이지를 렌더링한다.

tsx
import { Document, Page } from 'react-pdf';

function PDFViewer() {
  const [numPages, setNumPages] = useState<number>(0);

  return (
    <Document
      file="/sample.pdf"
      onLoadSuccess={({ numPages }) => setNumPages(numPages)}
    >
      <Page pageNumber={1} />
    </Document>
  );
}

Document는 파일을 로드하면 onLoadSuccess 콜백을 호출하면서 총 페이지 수를 알려준다. PagepageNumber prop으로 몇 번째 페이지를 렌더링할지 지정한다. 1부터 시작한다.

file prop에는 여러 형태를 넣을 수 있다.

형태예시
URL 문자열"/api/files/document.pdf"
Base64"data:application/pdf;base64,..."
File 객체<input type="file">에서 받은 File
ArrayBufferfetch로 받은 바이너리 데이터

Web Worker 설정

PDF 파싱은 무거운 작업이다. 수십 페이지짜리 PDF를 메인 스레드에서 파싱하면 UI가 멈춘다. pdf.js는 이 문제를 Web Worker로 해결한다. PDF 파싱 로직을 별도 스레드에서 실행해서 메인 스레드가 블로킹되지 않도록 한다.

react-pdf를 사용하려면 이 Worker를 반드시 설정해야 한다. 설정하지 않으면 콘솔에 경고가 뜨고, 메인 스레드에서 파싱이 실행되면서 성능이 저하된다.

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

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

pdfjs.version을 사용하는 이유가 있다. react-pdf가 내부적으로 사용하는 pdfjs-dist 버전과 Worker 파일의 버전이 일치해야 하기 때문이다. 하드코딩하면 라이브러리를 업데이트할 때마다 Worker URL도 같이 바꿔야 하는데, pdfjs.version을 참조하면 항상 맞는 버전을 가져온다.

CDN 대신 로컬 파일을 사용할 수도 있다.

tsx
// node_modules에서 복사해서 public 폴더에 넣기
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';

// 또는 webpack의 worker-loader 활용
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs';
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;

CDN 방식은 설정이 간단하지만 외부 의존성이 생기고, 로컬 방식은 번들 크기가 늘어나지만 오프라인에서도 동작한다. 프로젝트 특성에 맞게 선택하면 된다.


Next.js에서의 SSR 문제

Next.js에서 react-pdf를 사용하면 높은 확률로 서버 사이드 에러를 만난다. pdf.js가 내부적으로 canvas, document, window 같은 브라우저 API에 의존하기 때문이다. 서버에는 이런 API가 없으니 당연히 에러가 발생한다.

해결 방법은 두 가지다.

1. dynamic import로 클라이언트 전용 로드

tsx
import dynamic from 'next/dynamic';

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

컴포넌트 자체를 클라이언트에서만 로드하면 서버에서는 아예 실행되지 않는다. 가장 간단한 방법이다.

2. webpack 설정으로 canvas 의존성 제거

ts
// next.config.ts
const nextConfig = {
  webpack: (config) => {
    config.resolve.alias.canvas = false;
    return config;
  },
};

pdf.js가 canvas 패키지를 import하려고 시도하는데, 브라우저에서는 네이티브 Canvas API를 사용하므로 이 패키지가 필요 없다. canvas = false로 설정하면 webpack이 이 import를 무시한다.

Worker 설정도 클라이언트에서만 실행되도록 해야 한다.

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

typeof window !== 'undefined' 체크를 빼먹으면 서버에서 Worker를 설정하려다 에러가 난다. Node.js에는 Web Worker가 없기 때문이다.


cMap 설정

PDF 내부에서 텍스트를 표현하는 방식은 단순하지 않다. 특히 한국어, 중국어, 일본어 같은 CJK 문자는 Character Map(cMap)이라는 매핑 테이블이 필요하다. cMap은 PDF 내부의 글리프 ID를 실제 유니코드 문자로 변환하는 역할을 한다.

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

<Document file={src} options={options}>
  <Page pageNumber={1} />
</Document>

cMapPacked: true는 압축된 형태의 cMap 파일(.bcmap)을 사용하겠다는 설정이다. 비압축 cMap보다 파일 크기가 작아서 로딩이 빠르다.

cMap을 설정하지 않으면 PDF는 렌더링되지만, 텍스트 레이어에서 글자가 깨지거나 검색이 안 되는 문제가 생길 수 있다. 시각적으로는 정상으로 보여도 텍스트 선택이나 복사가 안 되는 경우가 이 설정이 빠져서인 경우가 많다.


페이지 네비게이션

Page 컴포넌트의 pageNumber를 상태로 관리하면 페이지 전환을 구현할 수 있다.

tsx
function PDFViewer({ src }: { src: string }) {
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(0);

  return (
    <div>
      <Document
        file={src}
        onLoadSuccess={({ numPages }) => setTotalPages(numPages)}
      >
        <Page pageNumber={currentPage} />
      </Document>

      <div className="controls">
        <button
          onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
          disabled={currentPage <= 1}
        >
          이전
        </button>
        <span>{currentPage} / {totalPages}</span>
        <button
          onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
          disabled={currentPage >= totalPages}
        >
          다음
        </button>
      </div>
    </div>
  );
}

Document는 한 번만 렌더링되고, PagepageNumber가 바뀔 때마다 해당 페이지만 새로 렌더링된다. PDF 파일 전체를 다시 로드하지 않기 때문에 페이지 전환이 빠르다.


확대/축소

Pagescale prop으로 확대/축소를 제어한다. 기본값은 1이고, 2로 설정하면 2배 크기로 렌더링된다.

tsx
const [scale, setScale] = useState(1);

<Page pageNumber={currentPage} scale={scale} />

<button onClick={() => setScale(s => Math.max(0.5, s - 0.25))}>축소</button>
<button onClick={() => setScale(s => Math.min(3, s + 0.25))}>확대</button>

단순히 scale 값을 올리면 PDF가 컨테이너를 넘칠 수 있다. 컨테이너 크기에 맞는 자동 스케일을 계산하려면 PDF 원본 크기를 알아야 한다.

tsx
const onPageLoadSuccess = (page) => {
  const viewport = page.getViewport({ scale: 1 });
  // viewport.width, viewport.height = PDF 원본 크기
  
  const containerWidth = containerRef.current.clientWidth;
  const containerHeight = containerRef.current.clientHeight;
  
  const scaleX = containerWidth / viewport.width;
  const scaleY = containerHeight / viewport.height;
  const fitScale = Math.min(scaleX, scaleY); // 컨테이너에 딱 맞는 스케일
  
  setScale(fitScale);
};

getViewport({ scale: 1 })을 호출하면 scale 1일 때의 PDF 페이지 크기를 얻는다. 컨테이너의 가로/세로 비율과 비교해서 더 작은 쪽에 맞추면 PDF가 컨테이너 안에 완전히 들어오는 "fit" 모드가 된다.


텍스트 레이어와 주석 레이어

Page 컴포넌트는 기본적으로 세 가지 레이어를 렌더링한다.

  1. Canvas 레이어: PDF를 시각적으로 렌더링
  2. 텍스트 레이어: 투명한 텍스트를 canvas 위에 오버레이 (선택/복사용)
  3. 주석 레이어: PDF 내부 링크, 폼 필드 등

텍스트 레이어를 활성화하면 PDF의 텍스트를 마우스로 드래그해서 선택하고 복사할 수 있다. 단, 별도의 CSS 파일을 import해야 텍스트가 canvas와 정확히 겹쳐 보인다.

tsx
import 'react-pdf/dist/Page/TextLayer.css';
import 'react-pdf/dist/Page/AnnotationLayer.css';

<Page
  pageNumber={1}
  renderTextLayer={true}
  renderAnnotationLayer={true}
/>

PDF 위에 드로잉이나 피드백 오버레이를 얹는 경우에는 텍스트/주석 레이어가 이벤트를 가로채서 방해될 수 있다. 이런 경우에는 두 레이어를 모두 비활성화하는 게 낫다.

tsx
<Page
  pageNumber={1}
  renderTextLayer={false}
  renderAnnotationLayer={false}
/>

로딩 상태 처리

PDF 파일 크기가 크면 로딩에 시간이 걸린다. DocumentPage 모두 loading prop으로 로딩 중에 보여줄 컴포넌트를 지정할 수 있다.

tsx
<Document
  file={src}
  loading={<Spinner />}
  onLoadSuccess={onDocumentLoad}
>
  <Page
    pageNumber={currentPage}
    loading={<PageSkeleton />}
    onLoadSuccess={onPageLoad}
  />
</Document>

Document의 loading은 PDF 파일 자체를 다운로드하는 동안 보여지고, Page의 loading은 개별 페이지를 렌더링하는 동안 보여진다. 페이지 전환 시에도 Page의 loading이 잠깐 나타날 수 있다.

빈 문자열 ""을 넘기면 로딩 표시를 완전히 숨길 수 있다. 커스텀 로딩 상태를 onLoadSuccess 콜백으로 직접 관리하고 싶을 때 유용하다.

tsx
<Document file={src} loading="">
  <Page pageNumber={1} loading="" onLoadSuccess={() => setLoading(false)} />
</Document>

에러 처리

PDF 로드가 실패할 수 있는 상황은 다양하다. 파일 URL이 잘못됐거나, CORS 문제이거나, PDF 파일 자체가 손상된 경우 등. onLoadError 콜백으로 에러를 잡을 수 있다.

tsx
<Document
  file={src}
  onLoadError={(error) => {
    console.error('PDF load failed:', error);
    setError('PDF 파일을 불러올 수 없습니다.');
  }}
  error={<div className="text-red-500">PDF 로드 실패</div>}
>

error prop은 에러 발생 시 렌더링할 fallback 컴포넌트다. onLoadError로 에러 상태를 직접 관리하면서 동시에 error prop으로 기본 fallback도 제공하는 게 안전하다.


성능 최적화

한 페이지만 렌더링

모든 페이지를 한 번에 렌더링하면 메모리를 많이 먹는다. 특히 수백 페이지짜리 PDF라면 브라우저가 버벅거릴 수 있다. 현재 보고 있는 페이지만 렌더링하는 게 기본 전략이다.

tsx
// ❌ 모든 페이지 한 번에 렌더링
{Array.from({ length: numPages }, (_, i) => (
  <Page key={i} pageNumber={i + 1} />
))}

// ✅ 현재 페이지만 렌더링
<Page pageNumber={currentPage} />

Document 리렌더링 방지

Documentfile이나 options prop이 매 렌더링마다 새 객체를 생성하면 Document가 PDF를 처음부터 다시 로드한다. useMemo나 모듈 상수로 빼서 참조를 안정적으로 유지해야 한다.

tsx
// ❌ 매 렌더링마다 새 객체
<Document options={{ cMapUrl: '...', cMapPacked: true }}>

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

<Document options={PDF_OPTIONS}>

콜백 메모이제이션

onLoadSuccess 같은 콜백도 useCallback으로 감싸야 불필요한 리렌더링을 방지할 수 있다.

tsx
const onDocumentLoadSuccess = useCallback(
  ({ numPages }: { numPages: number }) => {
    setTotalPages(numPages);
  },
  [setTotalPages]
);

react-pdf vs 대안

react-pdf<iframe>@react-pdf/renderer
역할PDF 뷰어브라우저 내장 뷰어PDF 생성
커스텀 UI완전 자유불가능N/A
모바일 지원제한적
오버레이/드로잉가능불가능N/A
번들 크기~500KB (Worker 별도)0~300KB

이름이 비슷한 @react-pdf/renderer와 헷갈리기 쉬운데, 역할이 완전히 다르다. @react-pdf/renderer는 React 컴포넌트로 PDF 파일을 생성하는 라이브러리이고, react-pdf는 기존 PDF 파일을 화면에 표시하는 라이브러리다.


정리

  • pdf.js 기반 React 래퍼로, <canvas> 렌더링 덕분에 브라우저/모바일 관계없이 동일한 PDF 뷰어를 구현할 수 있다
  • Web Worker 설정은 필수이고, Next.js에서는 ssr: false dynamic import + canvas = false webpack 설정으로 SSR 문제를 해결한다
  • CJK 텍스트 선택/복사를 위해 cMap 설정이 필요하고, 성능을 위해 현재 페이지만 렌더링 + options/callback 메모이제이션이 중요하다

관련 문서