PDF Export 구현 여정
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를 직접 조합하는 방식을 선택했습니다.
기본 구현
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이 다음 페이지에 표시되는 문제가 있었습니다.
페이지 2 시작: [Row 5의 1/4]
원인 분석
임의의 픽셀 위치에서 분할하기 때문에 행 경계를 무시하는 것이 원인이었습니다.
해결책: Row-aware Splitting
행 경계를 인식하는 분할 알고리즘을 구현했습니다.
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'
6. 다크모드 → 라이트모드 변환 구현
문제 상황
웹 애플리케이션은 다크모드로 운영되지만 PDF 출력물은 라이트모드로 생성해야 했습니다.
검정 배경
흰 텍스트
흰색 배경
검정 텍스트
첫 번째 시도: 직접 DOM 수정 (실패)
// 문제의 접근법
const exportPdf = async () => {
// 1. 화면을 라이트모드로 변경
document.body.classList.add('light-theme');
// 2. 캡처
await html2canvas(element);
// 3. 화면을 다시 다크모드로 복원
document.body.classList.remove('light-theme');
};
해결책: 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-light-theme 클래스 추가
커스텀 색상 사용
원하는 색상으로 커스터마이징할 수 있습니다.
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%를 차지합니다.
- DOM 트리 전체를 JavaScript로 클론
- 모든 요소의 computedStyle 파싱
- CSS를 Canvas 2D API로 변환하여 렌더링
- 메인 스레드에서 동기 실행 (UI 블로킹)
대안 비교
| 방식 | 속도 | 장단점 |
|---|---|---|
| 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 유지
'je개발 복습 > ETC' 카테고리의 다른 글
| [ 네트워크 관리사 ] CIDR 표기법 (0) | 2025.08.03 |
|---|---|
| [ Tanstack Query ] staleTime / gcTime 설정 방식 (1) | 2025.07.29 |
| [ Modbus ] Modbus (모드버스) 통신에 대한 이해 (1) | 2025.07.20 |
| [ Linux ] Shell 날짜 조회하기 (0) | 2023.01.15 |
| [ Linux ] 크론탭(Crontab), Shell 로 정기적인 명령어 (0) | 2023.01.15 |