React로 PDF를 생성시, 페이지가 넘어가도 잘리지 않고 이어지게 렌더시키기

미연·2025년 10월 20일

웹에서 리포트나 스크립트를 PDF로 추출해야 할 때가 있다.
단순히 화면을 캡처해서 PDF로 변환하면 되지 않을까? 싶지만,
실제로는 페이지가 넘어가는 지점에서 콘텐츠가 잘리거나, 여백이 뒤틀리는 문제를 마주하게 된다.

이때 html-to-imagejsPDF 라이브러리를 사용해서
A4 용지 기준으로 페이지 단위가 정확히 나뉘는 PDF를 만드는 방법을 정리한다.


1. 사용 라이브러리

html-to-image

DOM 요소를 이미지(PNG, JPEG, SVG 등)로 변환해주는 라이브러리다.
내부적으로 DOM을 순회하며 인라인 스타일, 폰트, 이미지 등을 직렬화하여
<canvas>를 거치지 않고도 고품질 캡처가 가능하다.

import * as htmlToImage from 'html-to-image';

const dataUrl = await htmlToImage.toJpeg(element, { pixelRatio: 3 });

/* pixelRatio를 높이면 해상도가 올라간다.
리포트 PDF처럼 텍스트가 선명해야 하는 경우 3 정도를 권장한다. */

jsPDF

JavaScript로 PDF 문서를 생성하는 라이브러리다.
페이지 추가, 이미지 삽입, 텍스트 배치 등을 코드로 제어할 수 있다.

 import jsPDF from 'jspdf';

 const doc = new jsPDF('p', 'mm', 'a4', true);
 // 'p': portrait, 'mm': 밀리미터 단위, 'a4': 용지 크기

이 두 라이브러리를 조합하면 흐름은 간단하다:
DOM → 이미지 캡처 → PDF에 이미지 삽입 → 페이지 분할

2. A4 한 페이지의 높이를 상수로 고정

페이지 한 장에 담길 데이터의 개수 만큼 chunk로 자른 후, 페이지를 map으로 돌려서 여러 장을 렌더시키는 방식이다.
이때 map으로 돌릴 때 페이지 컨테이너의 높이를 상수로 고정시켜야 하는데, 911px로 고정했다.

// A4 용지 사이즈의 height 값
const A4_HEIGHT_SIZE = 911;

<Stack sx={{ width: '100%', height: `${A4_HEIGHT_SIZE}px`, padding: '10px' }}>
    {/* A4 내용물을 렌더링 */}
</Stack>

A4 용지의 실제 사이즈는 210mm × 297mm이다.

PDF 변환 시 jsPDF는 mm 단위로 동작하지만, 화면에 렌더링되는 DOM은 px 단위로 동작한다. 이 둘을 연결하려면 한 페이지가 몇 px인지를 고정해야 한다.

컨테이너 width가 약 643px일 때 A4 비율을 적용하면,

643 × 1.4142 ≈ 909 ~ 911px

즉 911px은 A4 비율(1:√2)을 px로 환산한 근사값이기 때문에, 이를 A4 사이즈로 잡았다.

3. 헤더 · 콘텐츠 · 푸터의 높이를 계산하기

페이지 컨테이너의 고정 높이가 정해졌다면, 그 안의 영역별 높이를 정확히 분배해야 한다.
디자인 시안에 맞춰서 영역별 높이를 분배해보자.
그렇지 않으면 콘텐츠가 푸터를 침범하거나, 다음 페이지로 넘쳐흐른다.

  ┌─────────────────────────┐
  │  Header (83px)          │ ← 제목, 콘텐츠 정보
  ├─────────────────────────┤
  │                         │
  │  Content (804px)        │ ← 911 - 83 - 24 = 804px
  │  padding: 10px          │
  │                         │
  ├─────────────────────────┤
  │  Footer (24px)          │ ← 페이지 번호
  └─────────────────────────┘
         총 911px

이것을 /const/상수.ts 로 생성하여 따로 관리했다.

  export const A4_HEIGHT_SIZE = 911;

  export const HEADER_HEIGHT = 83;
  export const FOOTER_HEIGHT = 24;

  export const PADDING_VALUE = 10;
  export const CONTENT_HEIGHT = A4_HEIGHT_SIZE - (HEADER_HEIGHT + FOOTER_HEIGHT);
  // 911 - 83 - 24 = 804px

Q. 왜 이렇게까지 해야 하나요?

일반적인 웹 페이지는 스크롤이 있어서 콘텐츠가 얼마나 길든 상관없다.
하지만 PDF는 물리적 용지다.
한 장의 경계를 넘으면 다음 장으로 넘어가야 하고, 넘어가는 지점에서 콘텐츠가 잘리면 안 된다.

따라서

1️⃣ 전체 높이(911px)에서 헤더와 푸터를 먼저 빼고
2️⃣ 남은 콘텐츠 영역(804px)에 몇 개의 아이템이 들어갈 수 있는지 계산한 뒤
3️⃣ 초과분은 다음 페이지로 넘겨서 동일한 구조(헤더-콘텐츠-푸터)로 반복 렌더링한다.

4. 첫 페이지 vs 이후 페이지 분리

같은 PDF이지만 첫 페이지에만 존재하는 요소가 있다.
예를 들면

[1페이지]                       [2페이지~]
┌─────────────────────────┐    ┌─────────────────────────┐
│  Header (리포트 타이틀)    │    │                         │
├─────────────────────────┤    │                         │
│  Title 영역              │    │                         │
│   - 학원 로고             │    │  Content (856px)        │
│   - 학생 정보             │    │  - 테이블 (헤더 없음)       │
│   - 점수/완료일            │    │  - 최대 12개 항목         │
│                         │    │                         │
│  Content (810px)        │    │                         │
│   - 테이블 (헤더 있음)      │    │                         │
│   - 최대 9개 항목          │    │                         │
│   - 범례(Status)         │    │  - 범례(Status)          │
├─────────────────────────┤    ├─────────────────────────┤
│  Footer                 │    │  Footer                 │
└─────────────────────────┘    └─────────────────────────┘
       총 911px                       총 911px

일반적으로 첫 페이지에는 헤더(타이틀) + 콘텐츠 + 푸터 등의 정보가 포함되고, 이후 다른 페이지에는 콘텐츠 + 푸터 등의 정보만 포함된다.
보통 헤더, 푸터 영역이 차지하는 높이만큼 콘텐츠 영역이 줄어든다.

// FirstPage — 메타 정보가 있으므로 Content 높이 810px
<Stack spacing="12px" sx={{ p: '14px 24px', height: '810px' }}>
  <Title ... />   {/* 로고, 유저 정보 등 */}
  <Table hasTableHeader ... />  {/* 테이블 헤더 포함 */}
</Stack>

// OtherPage — 메타 정보 없으므로 Content 높이 856px
<Stack spacing="12px" sx={{ p: '31px 24px 20px 24px', height: '856px' }}>
  <PronunciationTable hasTableHeader={false} ... />  {/* 테이블 헤더 생략 */}
</Stack>

콘텐츠 높이가 다르니, 한 페이지에 담을 수 있는 데이터 수도 달라진다.
이를 다른 메서드로 빼서 처리했다.

export function paginate(data: Speak[]): Speak[][] {
  const firstPageData = _.take(data, 9);    // 첫 페이지: 9개
  const rest = _.drop(data, 9);
  const otherPages = _.chunk(rest, 12);     // 이후 페이지: 12개씩
  return [firstPageData, ...otherPages];
}

그리고 렌더링 시 index로 분기한다.

{_.map(pageData, (page, index) => (
  <Stack key={index} sx={{ width: '100%', height: '911px', padding: '10px' }}>
    {index === 0
      ? <PDFFirstPage ... />   // 첫 페이지
      : <PDFOtherPage ... />   // 이후 페이지
    }
  </Stack>
))}

4. 한 줄 vs 두 줄 — 텍스트 높이에 따른 페이지 분할

여기서 더 까다로운 문제가 생길 수 있다.
데이터의 텍스트가 한 줄인지 두 줄인지에 따라 한 페이지에 들어가는 아이템 수가 달라진다.

예를 들어 자막이 주요 기능이라면

  • 영어만 또는 한국어만 표시 → 한 문장이 한 줄 차지
  • 영어+한국어 동시 표시 → 한 문장이 두 줄 차지
  [한 줄 모드]                    [두 줄 모드]
  ┌─────────────────────┐        ┌─────────────────────┐
  │ Hello, world.       │ ~42px  │ Hello, world.       │
  │ Nice to meet you.   │ ~42px  │ 안녕하세요.            │ ~86px
  │ How are you?        │ ~42px  │ Nice to meet you.   │
  │ ...                 │        │ 만나서 반갑습니다.      │ ~86px
  │ (최대 15문장)         │        │ ...                 │
  └─────────────────────┘        │ (최대 8문장)          │
                                 └─────────────────────┘

행 높이가 다르니, 한 페이지에 수용할 수 있는 최대 문장 수도 다르다.
따라서 한 줄 & 두 줄 높이의 고정 사이즈를 각각 나누어 상수로 관리해야 한다.

  // 한 줄 높이 (영어만 or 한국어만)
  export const ONE_LINE_MIN_HEIGHT = 42;
  export const ONE_LINE_MID_HEIGHT = 44;
  export const ONE_LINE_MAX_HEIGHT = 46;

  // 두 줄 높이 (영어 + 한국어)
  export const TWO_LINE_HEIGHT = 86;

  // 한 페이지 최대 문장 수
  export const ONE_LINE_MAX_SENTENCE = 15;  // 804 / ~46 ≈ 17 → 여유 두고 15
  export const TWO_LINE_MAX_SENTENCE = 8;   // 804 / 86 ≈ 9  → 여유 두고 8

이 값들을 기반으로 데이터를 chunk로 페이지 단위로 나누었다.

  const trimParaNum = useMemo(
    () => _.chunk(
      originalSync,
      isOneLine ? ONE_LINE_MAX_SENTENCE : TWO_LINE_MAX_SENTENCE
    ),
    [originalSync, isOneLine]
  );

이렇게 하면 한 줄 모드에서는 15문장씩, 두 줄 모드에서는 8문장씩
정확히 A4 한 장에 맞게 분할되어 페이지 경계에서 콘텐츠가 잘리지 않는다.


5. 정리 — PDF는 "계산된 레이아웃"이다.

웹에서 PDF를 만드는 건 단순한 스크린 캡처가 아니라,
"A4 용지라는 물리적 제약 안에서 레이아웃을 수학적으로 설계하는 것"이다.

1️⃣ A4 높이를 px로 환산 : 컨테이너 너비 × √2 ≈ 911px
2️⃣ 영역별 높이 분배 : Header + Content + Footer = 911px
3️⃣ 아이템 높이 계산 : 한 줄 / 두 줄에 따라 행 높이가 다름
4️⃣ 페이지당 아이템 수 결정 : Content 높이 ÷ 행 높이 = 최대 문장 수
5️⃣ 데이터를 chunk로 분할 : 초과분은 다음 페이지로 넘김

이 과정을 거치면, 몇 페이지가 되든 모든 페이지가 동일한 구조를 유지하며
페이지가 넘어가는 지점에서도 콘텐츠가 잘리거나 어색하지 않은 PDF를 만들 수 있다.

CSS의 @media print나 break-inside: avoid 같은 속성으로도 어느 정도 제어할 수 있지만,
정밀한 페이지 분할이 필요한 리포트에서는 이처럼 직접 높이를 계산하는 방식이 훨씬 안정적이다.

profile
FE Developer

0개의 댓글