본문 바로가기

React

React로 PDF 뷰어 및 다운로드 기능 구현하기

실무에서 PDF 문서 열람과 다운로드는 잦은 요구사항입니다. React에서 pdf.js 기반 라이브러리를 사용하면 빠르게 뷰어를 만들고, 인증이 필요한 파일도 안전하게 내려받을 수 있습니다. 아래 예제로 바로 적용해보세요.

1. 개요

목표는 다음과 같습니다: 브라우저 내 PDF 뷰어 구현, 페이지 이동/확대 기능 제공, 파일 다운로드 버튼 제공, 인증 및 CORS 고려, 모바일 대응과 성능 최적화입니다.

2. 설치 및 준비

pdf.js를 감싼 react-pdf 라이브러리를 사용합니다. 워커 설정이 필요합니다.

// 설치
// npm
// npm i react-pdf
// yarn
// yarn add react-pdf
// pdf.js 워커 설정 (전역 1회)
import { pdfjs } from 'react-pdf';

pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;

CDN 워커가 불편하면 빌드 도구에 맞게 pdf.worker를 번들링하세요. Next.js/Vite 환경에서는 동적 import 또는 public 경로 배치를 권장합니다.

3. 기본 PDF 뷰어 컴포넌트

Document와 Page를 사용해 첫 페이지를 렌더링합니다.

import React, { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';

pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;

export default function PdfViewer({ fileUrl }) {
  const [numPages, setNumPages] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  return (
    <div style={{ maxWidth: 900, margin: '0 auto' }}>
      {loading && <p>문서를 불러오는 중입니다...</p>}
      {error && <p>불러오기 실패: {String(error)}</p>}
      <Document
        file={fileUrl}
        onLoadSuccess={({ numPages }) => { setNumPages(numPages); setLoading(false); }}
        onLoadError={(e) => { setError(e.message); setLoading(false); }}
      >
        <Page pageNumber={1} renderTextLayer={false} renderAnnotationLayer={false} />
      </Document>
      {numPages && <p>총 {numPages} 페이지</p>}
    </div>
  );
}

텍스트/주석 레이어 비활성화는 초반 성능에 도움이 됩니다. 접근성/검색이 필요하면 켜주세요.

4. 페이지 네비게이션 및 확대/축소

페이지 이동과 줌을 제공해 UX를 개선합니다.

import React, { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';

pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;

export function PdfViewerWithControls({ fileUrl }) {
  const [numPages, setNumPages] = useState(0);
  const [page, setPage] = useState(1);
  const [scale, setScale] = useState(1.2);

  const nextPage = () => setPage((p) => Math.min(p + 1, numPages));
  const prevPage = () => setPage((p) => Math.max(p - 1, 1));
  const zoomIn = () => setScale((s) => Math.min(s + 0.2, 3));
  const zoomOut = () => setScale((s) => Math.max(s - 0.2, 0.5));

  return (
    <div>
      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <button onClick={prevPage} disabled={page <= 1}>이전</button>
        <span>{page} / {numPages || '-'}</span>
        <button onClick={nextPage} disabled={!numPages || page >= numPages}>다음</button>
        <button onClick={zoomOut}>-</button>
        <span>{Math.round(scale * 100)}%</span>
        <button onClick={zoomIn}>+</button>
      </div>

      <Document
        file={fileUrl}
        onLoadSuccess={({ numPages }) => setNumPages(numPages)}
      >
        <Page
          pageNumber={page}
          scale={scale}
          renderTextLayer={false}
          renderAnnotationLayer={false}
        />
      </Document>
    </div>
  );
}

모바일 대응을 위해 컨테이너 너비에 비례한 scale 계산을 추가하면 좋습니다. ResizeObserver로 컨테이너 폭을 감지해 자동 확대/축소를 적용하세요.

5. 다운로드 버튼 구현

서버에서 PDF를 받아 blob으로 저장하고 파일명을 Content-Disposition 헤더에서 추출하는 방식을 권장합니다.

function getFilenameFromDisposition(disposition) {
  if (!disposition) return null;
  const match = /filename\*=UTF-8''([^;\n]+)/.exec(disposition) || /filename="?([^";\n]+)"?/.exec(disposition);
  return match ? decodeURIComponent(match[1]) : null;
}

async function downloadPdf(fileUrl, authToken) {
  const res = await fetch(fileUrl, {
    headers: {
      ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
    },
    cache: 'no-store',
    credentials: 'include', // 쿠키 기반 인증 시
  });

  if (!res.ok) throw new Error('다운로드 실패');
  const blob = await res.blob();
  const url = window.URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  const filename = getFilenameFromDisposition(res.headers.get('Content-Disposition')) || 'document.pdf';
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  window.URL.revokeObjectURL(url);
}

// 버튼 예시
// <button onClick={() => downloadPdf('/api/files/123', token)}>PDF 다운로드</button>

대용량 파일은 스트리밍 응답을 사용해도 브라우저 다운로드 동작에는 지장이 없습니다.

6. 서버 인증/보안 고려

인증이 필요한 경우:

- Bearer 토큰 또는 쿠키 기반 인증을 적용합니다. 위 예시처럼 Authorization 헤더 또는 credentials 옵션을 사용합니다.

- CORS 설정에서 Access-Control-Allow-Origin과 Access-Control-Expose-Headers: Content-Disposition을 설정해야 파일명 추출이 가능합니다.

- 다운로드 전 권한 체크를 서버에서 반드시 수행하고, 응답 헤더에 Content-Type: application/pdf, Content-Disposition: attachment; filename="..."를 설정합니다.

7. 모바일 및 성능 최적화

- 첫 렌더에서 renderTextLayer/renderAnnotationLayer를 끄고 필요 시 토글합니다.

- 페이지 이동 시 스크롤을 활용하는 여러 Page 렌더링은 비용이 큽니다. 한 페이지씩 렌더링하거나 가상화(보이는 페이지만 렌더) 전략을 사용합니다.

- 워커 경로를 CDN 대신 앱 내부에 두면 네트워크 의존성을 줄일 수 있습니다.

- 이미지가 많은 PDF에서는 scale을 과도하게 올리지 않도록 UX 가이드라인을 제공합니다.

8. 오류 처리와 UX

- 로딩 스피너, 재시도 버튼, 오류 메시지(네트워크/권한/손상 파일 구분)를 제공합니다.

- onLoadError, onSourceError 핸들러로 에러 로그를 수집하고 사용자에게 다음 액션(새로고침, 관리자 문의)을 안내합니다.

- 암호화된 PDF는 onPassword 콜백을 활용해 비밀번호 입력 UI를 제공할 수 있습니다.

9. 프레임워크/배포 시 주의점

- Next.js: SSR에서 pdf.js는 브라우저 API에 의존합니다. 동적 import로 클라이언트 전용 처리합니다.

// Next.js 예시
// pages/pdf-viewer.js
import dynamic from 'next/dynamic';

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

export default function Page() {
  return <PdfViewerWithControls fileUrl="/api/files/123" />;
}

- Vite/CRA: 워커 파일 경로가 올바른지 확인합니다. public 폴더에 pdf.worker.min.js를 두고 상대 경로로 지정해도 됩니다.

10. 마무리

react-pdf를 사용하면 뷰어와 다운로드 기능을 빠르게 구축할 수 있습니다. 인증, CORS, 워커 설정만 정확히 잡으면 실무에서도 안정적으로 동작합니다. 위 컴포넌트를 프로젝트에 옮겨 적용하고, 성능과 모바일 UX를 단계적으로 개선해보세요.