je개발 복습/ETC

[ html2canvas, jsPdf ] 웹 화면을 PDF 로 다운받는 작업 후기와 숙제

Je-chan 2025. 11. 25. 15:58

PDF Export 구현 여정

이 글은 대시보드 형태의 웹 애플리케이션에서 PDF 내보내기 기능을 구현하면서 겪었던 다양한 문제들과 그 해결 과정을 기록한 내용입니다. 특히 다크모드 화면을 라이트모드 PDF로 변환하고 테이블 행이 페이지 경계에서 잘리지 않도록 하는 과정에서 마주한 기술적 도전과 해결책을 공유합니다.

1. 요구사항

핵심 요구사항

PDF Export 기능을 구현하기 위해 다음과 같은 핵심 요구사항을 정의했습니다.

  • A4 Portrait 모드로 PDF 생성
  • 컨텐츠가 가로로 잘리지 않고 A4 너비에 맞게 자동 축소
  • Toolbar는 PDF에서 제외
  • 화면은 다크모드지만 PDF는 라이트모드로 출력
  • Grid/Table 셀이 페이지 경계에서 잘리지 않음
  • 페이지 번호 표시

대상 페이지

대상 페이지는 여러 섹션으로 구성되어 있습니다.

  • 헤더 정보 섹션
  • 데이터 섹션 A (차트 + 테이블)
  • 데이터 섹션 B (차트 + 다중 행 테이블)
  • 데이터 섹션 C (차트 + 다중 행 테이블)
  • 요약 섹션 (차트)

2. 구현 방식 비교

PDF 생성을 위해 세 가지 방식을 검토하고 구현했습니다.

방식 라이브러리 장점 단점
react-to-print react-to-print 빠름 (~1초), 네이티브 렌더링 인쇄 다이얼로그 표시
html2pdf.js html2pdf.js 올인원 솔루션 가로 잘림, 페이지 분할 제어 어려움
html2canvas + jsPDF html2canvas, jspdf 세밀한 제어 가능 구현 복잡, 느림

3. 1차 시도: html2pdf.js

구현

먼저 useHtml2Pdf.ts 훅을 생성하여 간단하게 구현했습니다.

const { exportPdf, isExporting } = useHtml2Pdf({
  filename: 'report',
  orientation: 'portrait',
  enableLightTheme: true,
  pagebreakBeforeClass: ['page-break'],
});

발생한 문제들

문제 1: 컨텐츠 가로 잘림

  • 증상: 1012px 너비의 컨텐츠가 A4 너비(약 595px @72dpi)를 초과하여 오른쪽이 잘림
  • 원인: html2pdf.js는 자동 스케일링을 지원하지 않음
  • 시도한 해결책: pdfContentWidth: 'auto' 옵션 추가하여 컨텐츠 너비 조정
  • 결과: 부분적 해결했지만 여전히 문제 존재

문제 2: 라이트모드 전환 시 화면 깜빡임

  • 증상: PDF 생성 중 화면이 라이트모드로 잠깐 바뀌었다가 돌아옴
  • 원인: DOM에 직접 스타일을 적용한 후 캡처
  • 해결책: onclone 콜백 사용하여 클론된 DOM에만 스타일 적용
onclone: (clonedDoc, clonedElement) => {
  // 원본 DOM은 변경하지 않고 클론에만 라이트모드 적용
  clonedElement.classList.add('pdf-light-theme');
}

문제 3: 페이지 경계에서 컨텐츠 잘림

  • 증상: 테이블 행이 페이지 경계에서 반으로 잘림
  • 원인: html2pdf.js의 페이지 분할 알고리즘 한계
  • 결과: html2pdf.js로는 해결 불가하여 2차 시도로 전환

4. 2차 시도: html2canvas + jsPDF 직접 제어

설계 철학

html2pdf.js의 한계를 극복하기 위해 html2canvas와 jsPDF를 직접 조합하는 방식을 선택했습니다.

1
섹션별 개별 캡처: .pdf-section 클래스로 마킹된 요소를 각각 캡처
2
자동 스케일: 캡처된 이미지를 PDF 페이지 너비에 맞게 축소
3
스마트 페이지 분할: 섹션이 페이지에 들어가면 배치하고 아니면 새 페이지 생성

기본 구현

useCanvasPdf.ts 훅을 생성했습니다.

const { exportPdf, isExporting } = useCanvasPdf({
  filename: 'report',
  orientation: 'portrait',
  sectionSelector: '.pdf-section',
  enableLightTheme: true,
  showPageNumbers: true,
});

5. 문제 해결 과정

문제 1: 긴 테이블이 완전히 잘림

증상

다중 행 테이블(30행 이상)이 페이지보다 길 때 첫 페이지 분량만 표시되고 나머지는 사라지는 현상이 발생했습니다.

원인 분석

jsPDF.addImage()는 이미지를 페이지 경계에서 자동 분할하지 않습니다. 이미지 높이가 페이지보다 크면 단순히 클리핑됩니다.

// 문제의 코드
pdf.addImage(dataUrl, 'JPEG', x, y, width, height);
// height가 페이지를 넘어가면 잘림

해결책

splitCanvasIntoPages() 함수를 구현하여 캔버스를 페이지 높이에 맞게 여러 청크로 분할했습니다.

const splitCanvasIntoPages = (sourceCanvas, options) => {
  while (canvasOffsetPx < totalHeightPx) {
    // 페이지에 맞는 높이만큼 캔버스 일부 추출
    const chunkCanvas = document.createElement('canvas');
    ctx.drawImage(sourceCanvas, 0, canvasOffsetPx, width, splitHeightPx, 0, 0, width, splitHeightPx);

    images.push({ page, dataUrl: chunkCanvas.toDataURL(), width, height });

    // 다음 페이지로
    canvasOffsetPx += splitHeightPx;
    page++;
  }
};

문제 2: 테이블 셀이 반으로 잘림

증상

페이지 분할은 되지만 행이 3/4만 보이고 나머지 1/4이 다음 페이지에 표시되는 문제가 있었습니다.

문제 상황
페이지 1 끝: [Row 5의 3/4] ← 셀이 잘림
페이지 2 시작: [Row 5의 1/4]

원인 분석

임의의 픽셀 위치에서 분할하기 때문에 행 경계를 무시하는 것이 원인이었습니다.

해결책: Row-aware Splitting

행 경계를 인식하는 분할 알고리즘을 구현했습니다.

1
행 경계 감지: DOM에서 테이블 행의 위치 수집
2
최적 분할 지점 계산: 페이지에 들어가는 마지막 완전한 행에서 분할
const getRowBoundaries = (element) => {
  const rows = element.querySelectorAll(rowSelector);
  return Array.from(rows).map(row => {
    return row.getBoundingClientRect().bottom - element.getBoundingClientRect().top;
  });
};

const findBestSplitPoint = (maxHeightPx, currentOffsetPx, rowBoundaries) => {
  // 페이지에 들어가는 마지막 행 경계 찾기
  let bestBoundary = currentOffsetPx;
  for (const boundary of rowBoundaries) {
    if (boundary > currentOffsetPx && boundary <= maxPositionPx) {
      bestBoundary = boundary;
    }
  }
  return bestBoundary - currentOffsetPx;
};

문제 3: 행을 찾지 못함

증상

디버그 로그에서 Found rows count: 0 출력되며 여전히 셀 잘림이 발생했습니다.

원인 분석

기본 rowSelector가 특정 그리드 라이브러리용이었는데 실제 테이블은 다른 UI 라이브러리의 테이블을 사용하고 있었습니다.

// 설정
rowSelector: '.grid-row'

// 실제 DOM
<TableRow>  →  .table-row-root

해결책

다양한 테이블 라이브러리를 지원하는 선택자로 변경했습니다.

// 변경 전
rowSelector = '.grid-row'

// 변경 후 (다양한 테이블 라이브러리 지원)
rowSelector = '.grid-row, tbody > .table-row-root, tbody > tr'
💡 TIP
tbody >를 붙여 헤더 행을 제외하고 본문 행만 선택합니다.

6. 다크모드 → 라이트모드 변환 구현

문제 상황

웹 애플리케이션은 다크모드로 운영되지만 PDF 출력물은 라이트모드로 생성해야 했습니다.

다크모드 → 라이트모드 변환
화면 (다크모드)
검정 배경
흰 텍스트
PDF (라이트모드)
흰색 배경
검정 텍스트

첫 번째 시도: 직접 DOM 수정 (실패)

// 문제의 접근법
const exportPdf = async () => {
  // 1. 화면을 라이트모드로 변경
  document.body.classList.add('light-theme');

  // 2. 캡처
  await html2canvas(element);

  // 3. 화면을 다시 다크모드로 복원
  document.body.classList.remove('light-theme');
};
⚠️ 문제
사용자 화면이 잠깐 라이트모드로 깜빡임 (UX 저하)

해결책: onclone 콜백 활용

html2canvas는 캡처 전에 DOM을 클론합니다. onclone 콜백에서 클론된 DOM에만 스타일을 적용하면 원본 화면은 변경되지 않습니다.

const html2canvasOptions = {
  backgroundColor: '#FFFFFF',  // 캔버스 배경을 흰색으로

  onclone: (clonedDoc, clonedElement) => {
    // 1. 라이트 테마 CSS를 클론된 문서에 주입
    const styleEl = clonedDoc.createElement('style');
    styleEl.textContent = lightThemeCSS;
    clonedDoc.head.appendChild(styleEl);

    // 2. 클론된 요소에 라이트 테마 클래스 추가
    clonedElement.classList.add('pdf-light-theme');

    // 원본 DOM은 전혀 변경되지 않음!
  },
};

라이트 테마 CSS 구현

usePdfLightTheme.ts에서 CSS 변수를 오버라이드하여 색상을 변환합니다.

export const PDF_LIGHT_THEME_COLORS = {
  background: {
    base: '#FFFFFF',
    panel: '#F8F9FA',
  },
  text: {
    primary: '#000000',
    secondary: '#27282A',
    tertiary: '#71747B',
  },
  border: {
    base: '#E4E4E7',
  },
};

생성되는 CSS는 다음과 같습니다.

/* 라이트 테마 오버라이드 */
.pdf-light-theme,
.pdf-light-theme * {
  /* 배경색 */
  --palette-background-base: #FFFFFF !important;
  --palette-background-paper: #FFFFFF !important;

  /* 텍스트색 */
  --palette-text-primary: #000000 !important;
  --palette-text-secondary: #27282A !important;

  /* 테두리색 */
  --palette-border-base: #E4E4E7 !important;
}

/* UI 컴포넌트 직접 오버라이드 */
.pdf-light-theme .paper-root {
  background-color: #FFFFFF !important;
  color: #000000 !important;
}

.pdf-light-theme .table-cell {
  background-color: #FFFFFF !important;
  border-color: #E4E4E7 !important;
}

/* 차트 */
.pdf-light-theme .chart-background {
  fill: #FFFFFF !important;
}

.pdf-light-theme .chart-title,
.pdf-light-theme .chart-axis-labels text {
  fill: #000000 !important;
}

동작 흐름

PDF 생성 프로세스
exportPdf() 호출
html2canvas가 DOM 클론 생성
onclone 콜백 실행
├─ 클론된 <head>에 라이트 테마 CSS 주입
└─ 클론된 요소에 .pdf-light-theme 클래스 추가
클론된 DOM을 캔버스로 렌더링
클론 폐기 (원본 DOM은 그대로 유지)
결과: 화면은 다크모드 / PDF는 라이트모드

커스텀 색상 사용

원하는 색상으로 커스터마이징할 수 있습니다.

useCanvasPdf({
  enableLightTheme: true,
  lightThemeColors: {
    background: {
      base: '#F5F5F5',  // 약간 회색 배경
    },
    text: {
      primary: '#333333',  // 진한 회색 텍스트
    },
  },
});

7. 최종 구현

파일 구조

src/lib/pdf/
├── index.ts                 # Public exports
├── useCanvasPdf.ts          # 메인 훅 (html2canvas + jsPDF)
├── useHtml2Pdf.ts           # html2pdf.js 래퍼 (대안)
├── usePdfExport.ts          # react-to-print 래퍼 (대안)
├── usePdfLightTheme.ts      # 라이트모드 CSS 생성
└── README.md                # 문서

핵심 기능

1. 섹션별 캡처

<div ref={contentRef}>
  <div className="pdf-section">Section 1</div>
  <div className="pdf-section">Section 2</div>
  <div className="pdf-section">Section 3</div>
</div>

2. 라이트모드 변환 (화면 변경 없음)

onclone: (clonedDoc, clonedElement) => {
  const styleEl = clonedDoc.createElement('style');
  styleEl.textContent = lightThemeCSS;
  clonedDoc.head.appendChild(styleEl);
  clonedElement.classList.add('pdf-light-theme');
}

3. Row-aware 페이지 분할

// 행 경계 수집 (scale 적용)
const rowBoundaries = getRowBoundaries(element).map(b => b * scale);

// 행 경계 기반 분할
const { images } = splitCanvasIntoPages(canvas, options, rowBoundaries);

사용법

const { contentRef, exportPdf, isExporting } = useCanvasPdf({
  filename: 'dashboard-report',
  orientation: 'portrait',
  sectionSelector: '.pdf-section',
  rowSelector: '.grid-row, tbody > .table-row-root, tbody > tr',
  enableLightTheme: true,
  showPageNumbers: true,
  hideSelectors: ['.no-print', '.pdf-hide', '.pdf-toolbar'],
});

8. 성능 분석 및 한계

측정 결과

5개 섹션이 있는 리포트 페이지 기준으로 측정한 결과입니다.

단계 소요 시간 비율
html2canvas 22,088ms 98.2% ← 병목
splitCanvasIntoPages 267ms 1.2%
toDataURL 64ms 0.3%
jsPDF addImage 40ms 0.2%
jsPDF save 17ms 0.1%
getRowBoundaries 5ms 0.0%
총합 ~22,500ms 100%

병목 원인: html2canvas

html2canvas가 전체 시간의 98%를 차지합니다.

html2canvas의 동작 방식
  1. DOM 트리 전체를 JavaScript로 클론
  2. 모든 요소의 computedStyle 파싱
  3. CSS를 Canvas 2D API로 변환하여 렌더링
  4. 메인 스레드에서 동기 실행 (UI 블로킹)
성능 한계
scale을 낮춰도 크게 빨라지지 않는 이유는 캔버스 렌더링보다 DOM 클로닝/CSS 파싱이 더 오래 걸리기 때문입니다.

대안 비교

방식 속도 장단점
react-to-print ~1초 가장 빠름 (하지만 인쇄 다이얼로그 표시)
서버사이드 (Puppeteer) 2-3초 빠름 (하지만 서버 인프라 필요)
useCanvasPdf (현재) ~22초 클라이언트만으로 완결 (하지만 느림)

9. 결론

달성한 것

✅ 구현 완료
  • A4 Portrait PDF 생성
  • 가로 잘림 없이 자동 스케일
  • 라이트모드 PDF (화면 변경 없음)
  • Grid/Table 행이 페이지 경계에서 잘리지 않음
  • 페이지 번호 표시
  • 특정 요소 PDF에서 제외
⚠️ 남은 한계
  • 성능: html2canvas의 구조적 한계로 ~22초 소요
  • html2canvas를 사용하는 한 근본적인 속도 개선 어려움

권장 사항

  • 빠른 반응성 필요: react-to-print 사용 (인쇄 다이얼로그 허용 시)
  • 다이얼로그 없이 빠른 PDF: 서버사이드 렌더링 (Puppeteer/Playwright)
  • 현재 요구사항으로 충분: useCanvasPdf 유지