사이드 프로젝트에서 그래프와 혈당 수치가 담겨있는 테이블을 PDF 형식으로 내보내기 기능을 만드는데, React 코드를 PDF로 변환해주는 라이브러리 중 맘에 드는 것을 찾지 못해서 Cursor와 함께 직접 만들게 된 과정을 정리한 내용입니다.
일단 라이브러리 선택 조건은 다음과 같았습니다.
- 별도의 코드 변환없이 기존 컴포넌트를 그대로 바로 사용할 수 있도록 해야한다.
- 각 페이지의 Header와 Footer를 원하는대로 직접 제어할 수 있어야 한다.
- pdf 내에서 텍스트 검색이 되어야 한다.
사실 이번 개발은 라이브러리 선택만 잘하면 금방 끝날 거라고 생각했지만, 생각했던 것 보다 쉽지 않은 작업이었습니다.
마음에 드는 라이브러리를 찾기 위한 과정
react-pdf/renderer (링크)
React 코드를 사용해서 pdf로 변환해주는 인기 라이브러리 중 하나 입니다.
업데이트도 지속적으로 되고 있고, 해당 라이브러리를 사용해서 변환한 pdf는 텍스트 검색이 된다는 장점이 있었습니다.
또한 Header와 Footer도 fixed라는 속성을 통해 제가 원하는대로 만들 수 있었습니다.
하지만 라이브러리에서 제공하는 컴포넌트를 사용해서 기존 컴포넌트를 다시 작성해줘야하는 번거로움이 있었습니다.
간단한 코드로 예시를 한 번 들어보겠습니다.
const SimpleBox = () => (
<div style={{ padding: 20, border: '1px solid black' }}>
혈당 내역 리포트
</div>
);
이렇게 간단하게 작성한 코드를 react-pdf/renderer로 작성한다면 다음과 같이 작성해줘야 했습니다.
const PDFBox = () => (
<Document>
<Page size="A4">
<View style={{ padding: 20, border: '1px solid black' }}>
<Text>혈당 내역 리포트</Text>
</View>
</Page>
</Document>
);
위 코드 예시는 단순히 간단한 텍스트가 적힌 Box 영역을 변경했지만, 테이블이나 그래프를 PDF에 맞게 코드로 다시 작성해줘야하는 부분이 많이 번거로웠습니다.
그리고 tailwind나 styled-components를 사용하고 있다면 react-pdf-tailwind, @react-pdf/styled-components를 따로 다운받아서 사용해야 기존 스타일 코드를 그대로 이용할 수 있었습니다. 뿐만아니라 폰트도 따로 설정해줘야 했습니다.
실제 업무에서 이 라이브러리를 사용한다고 했을 때, 기존 컴포넌트를 재사용하지 못하고 pdf 문서를 위해 변환 작업을 매번 해줘야 한다고 하면 너무 불편하고 유지보수할 때도 좋지 못해서 장기적으로 봤을 때 생산성을 저하 시키는 라이브러리라고 생각을 했습니다.
html2canvas-pro(링크) + jspdf(링크)
렌더링 완료 후, UI를 이미지화 해서 pdf로 변환하는 방식을 생각해보게되었습니다. 이 방법도 다들 많이 사용하는 방법이었습니다.
이렇게 개발을 진행하게 되면 react-pdf/renderer에서 기존 컴포넌트를 별도의 코드로 변환해줄 필요도 없이 바로 사용가능하니 훨씬 사용하기 편리했습니다.
일단 먼저 대략적으로 어떻게 개발할건지 생각을 정리한 후 사용할 라이브러리만 제가 직접 지정하고, 그 외에 코드 생성은 AI 에이전트에게 맡겼습니다.
사용한 라이브러리를 먼저 말씀드리자면, html2canvas-pro와 jspdf를 사용했습니다.
html2canvas-pro를 사용한 이유는 기존 html2cavas에서 발생한 다양한 수정 사항과 새로운 기능등을 포함한 프로젝트였기 때문입니다. html2canvas는 업데이트가 4년전에 멈췄지만 pro는 최근까지도 계속 업데이트가 진행되고 있습니다.
그리고 jspdf는 보편적으로 가장 많이 사용되고 있고, html2canvas-pro와 마찬가지로 근래에도 업데이트가 진행되고 있어서 선택하게 되었습니다.
개발은 AI 에이전트에 맡겨서 다음과 같은 과정으로 만들었습니다. 참고로 이러한 방식을 오프 스크린이라 부릅니다.
- PDF에서 사용할 데이터를 먼저 fetch
- 사용자가 보지 못하는 화면 영역 (ex. position: absolute, left: -9999px)에서 div를 생성
- 2번에서 만든 요소에 ReactDom.createRoot로 PDF 전용 컴포넌트 렌더링
- 렌더링이 완료될 때까지 기다린 후, html2canvas로 렌더링된 화면 영역을 캡쳐
- 캡쳐된 이미지를 a4 기준으로 잘라서 jsPDF로 pdf 저장
- 2번에서 만든 div 제거
참고로 데이터를 먼저 fetch한 이유는 createRoot로 렌더링한 경우에는 Provider를 상속받지 못하기 때문에 react-query를 사용한 컴포넌트에서 No QueryClient sett, use QueryClientProvider to set one 이라는 에러가 발생했습니다.
처음에는 createRoot에서 QueryClientProvider를 한 번더 감싸주는 걸로 해결하려 했는데 이렇게 되면 이미 사용하고 있는 QueryClientProvider를 포함해서 2개로 되어버리니 의도치 않게 중복 fetch를 할 수 있고, 유지보수 측면에서 좋은 패턴이 아니라고 생각했습니다. 그래서 컴포넌트 밖에서 데이터 fetch 후 props로 전달받는 방향으로 코드를 수정했습니다.
pdf로 정상적으로 변환은 되었지만 이 방식에서는 개선해야할 문제점이 여러 개 있었습니다.
- 다음 페이지로 넘어갈 때 요소가 잘려서 보이는 문제
- 페이지마다 Header와 Footer를 적용하기 어려움
- 많은 데이터를 다운받아야할 때 발생할 성능 저하 우려
- 이미지로 생성되어 텍스트 검색 불가
이 때부터 '아, 생각보다 이거 쉽지 않은 문제다'라고 느끼고 직접 만들어봐야겠다고 생각을 하게 되었습니다.
직접 만들어보자!
첫번째 시도
사실 저는 @react-pdf/renderer를 완전히 포기하지 못했습니다. 맞춤형으로 컴포넌트를 직접 변환해야한다는 단점을 빼고는 제가 원하는 모든 기능을 다 갖추고 있었기 때문입니다.
그래서 기존 컴포넌트를 json으로 구조화 시켜서 다시 react-pdf/renderer 컴포넌트로 자동 변환시켜주는 라이브러리를 생각하게 됩니다.
클로드로 개발을 진행했는데, 이 친구 또한 어려움을 토로합니다.

@react-pdf/renderer가 모든 css 속성을 다 지원하는 게 아니기 때문에 지원/미지원 속성을 관리해서 스타일을 매핑하는 과정을 거쳐야 했고, json으로 구조화할 때 Context API와 react-query 등등 기타 라이브러리들이 호환되도록 코드를 작성하기가 어렵다는 문제가 있었습니다.
성공적으로 json 구조화를 하려면 렌더링 후 DOM 파싱을 하는 게 현실적이라는 결론을 내주는 클로드.
하지만 이미 위에서 단점으로 한 번 언급했었던 '많은 데이터를 다운받아야할 때 발생할 성능 저하 우려'가 다시금 문제를 원점으로 돌아가게 만들어서 이 시도는 포기하게 됩니다.
무조건 해결하겠다는 집념으로 react-pdf로 구글링을 이것저것 해보다가 우연히 2024년에 카카오 개발 컨퍼런스에서 react-pdf와 관련된 발표를 한 것을 발견하게 됩니다.
구세주 등장!
- 이 글을 혹시나 보시게 된다면, 컨퍼런스로 좋은 내용을 공유해주셔서 감사하다는 말씀을 이렇게 나마 전합니다❤️
20분 가량되는 영상인데, 제가 고민하고 있던 내용들과 해결 과정이 다 담겨 있어서 엄청 많은 도움을 얻었습니다. 특히나 저도 건강 관련으로 사이드 프로젝트를 진행하고 있다보니 더 많이 와닿았던 영상이었습니다.
이 영상을 통해 알게된 점은 크게 3가지가 있습니다.
- html2pdf.js 존재
- break-inside: avoid; 속성
- 페이지 분할 로직
이렇게 배우게 된 3가지로 다양한 시도를 하게 됩니다.
두번째 시도
컨퍼런스 영상이 올라온 시점으로부터 1년 반 정도의 시간이 흘렀으니 뭔가 달라져있지 않을거라는 기대감과 함께 html2pdf.js를 직접 사용해봤습니다.
하지만 영상 속에서 해당 라이브러리의 문제로 삼고 있던 '엘리먼트 이미지를 축소해서 페이지에 맞추는 기능'은 아직도 여전히 옵션에 존재하지 않았습니다.
이 문제는 AI의 도움을 얻어 출력하려는 요소를 css 속성의 transform으로 scale 값을 조정하여 맞춰나가는 방법으로 해결했습니다.
const handleDownloadPdf = useCallback(async () => {
const html2pdf = (await import('html2pdf.js')).default;
const element = document.getElementsByClassName('blood-sugar-analysis-content')[0] as HTMLElement;
const originalWidth = element.offsetWidth;
const a4WidthPx = 794 - 10 * 2; // A4 기준 (margin 좌우 10px씩, 총 20px 제거)
const scaleRatio = a4WidthPx / originalWidth;
element.style.transform = `scale(${scaleRatio})`;
element.style.transformOrigin = 'top left';
element.style.width = originalWidth + 'px';
const opt = {
margin: 10,
filename: 'document.pdf',
html2canvas: {
scale: 2,
},
jsPDF: {
unit: 'px',
format: [794, 1123] as [number, number], // A4 px 기준
},
pagebreak: { mode: 'avoid-all', before: '.page-break-before' },
};
html2pdf()
.set(opt)
.from(element as HTMLElement)
.save();
}, []);
하지만 html2canvas-pro + jspdf로 개발할 때 느꼈던 문제점 2~4번을 동일하게 느꼈습니다.
사실 html2pdf.js가 html2canvas와 jsPDF를 사용해서 만들어졌기 때문에 어쩔 수 없는 부분이었습니다.
세번째 시도
다시 직접 만드는 것을 시도합니다.
일단 만들기 전에, 카카오헬스케어에서 만든 pasta는 어떻게 PDF를 생성하고 있는지 분석을 해봤습니다.
오프 스크린 방식으로 PDF를 생성하지 않고 서버에 요청해서 생성하는 것으로 보였습니다.
여기서 힌트를 얻어, 프론트엔드에서만 해결하려고 하지 않고 프론트엔드 + 백엔드로 PDF 생성을 하는 것을 시도하게 됩니다.
시도 과정은 다음과 같습니다.
- PDF로 출력할 화면이 그려진 별도의 페이지를 생성합니다. 예를들면 http://localhost:3000/document/health 라는 페이지에서 PDF로 출력할 화면을 만들어두는 거죠.
- Express.js로 서버를 만들어서 puppeteer로 http://localhost:3000/document/health 경로에 접근해서 PDF를 생성을 합니다.
- puppeteer에서 pdf 생성을 지원합니다.(링크)
생각보다 간단하죠?
자체적으로 알아서 페이지를 분할해주기 때문에 PDF도 문제없이 깔끔하게 만들어지고, Safari, Edge, Firefox 와 같은 브라우저에 영향을 받지 않고 서버에서 실행되는 Chromium 기준으로 렌더링해서 PDF를 생성하기 때문에 일관된 PDF를 만든다는 장점이 있습니다.
또한 텍스트 검색도 가능했습니다.
다만 여기서 발생한 단점이 하나 있었는데요.
생성된 PDF의 각 페이지에 Header와 Footer를 설정하기 위해서는 서버 쪽에서 코드를 작성해야한다는 문제가 있었습니다.
코드로 표현하면 아래와 같습니다.
const pdf = await page.pdf({
format: 'A4',
displayHeaderFooter: true,
headerTemplate: `
<div style="
font-size: 10px;
width: 100%;
padding: 0 10mm;
display: flex;
justify-content: space-between;
color: #333;
font-family: Noto Sans KR;
">
<span>헤더 왼쪽</span>
<span class="date"></span>
</div>
`,
footerTemplate: `
<div style="
font-size: 10px;
width: 100%;
padding: 0 10mm;
display: flex;
justify-content: space-between;
color: #333;
font-family: Noto Sans KR;
">
<span>푸터 왼쪽</span>
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>
`,
printBackground: true,
margin: { top: 0, bottom: 0, left: 0, right: 0 },
});
그리고 위 코드를 하면 아래와 같이 보여지게 됩니다.

위와 같이 코드를 작성하면서 느낀 문제점은 다음과 같습니다.
- 기본으로 제공하는 글꼴 스타일은 궁서체라서 NotoSans KR과 같은 폰트를 적용하려면 서버에 직접 폰트를 설치해야하는 문제
- 위 예제에서 Footer와 Header를 간단하게 스타일링했음에도 불구하고 코드가 길고 다소 지저분해 보이는 문제
- html 코드가 문자열로 표현되다보니 prettier와 같은 코드 포매터 적용 불가
- PDF 화면을 구성하는 코드가 프론트엔드(메인 컨텐츠)와 백엔드(Header/Footer)로 나뉘어져 있으니 유지보수 어려움.
puppeteer를 포기할까라는 생각을 잠깐 했지만, puppeteer가 제공하는 장점을 무시할 수 없었습니다. puppeteer를 사용하지 않고는 프론트엔드 코드만으로 PDF 문서 내에서 텍스트 검색을 구현하는 게 쉽지 않았기 때문입니다.
* 참고로 pasta에서 생성한 PDF 문서는 이미지 기반으로 문서를 만들었기 때문에 텍스트 검색이 되지 않았습니다.
결국 저는 문제를 해결하기위해 puppeteer는 PDF 생성만 담당하게 하고, PDF 문서에 보여지는 모든 화면은 프론트에서 담당하도록 설계를 다시 하게 됩니다.
네번째 시도
라이브러리 코드 분석
프론트엔드 쪽 코드를 설계를 하려고 하니 너무 막막해서 카카오 컨퍼런스 영상을 정말 여러번 돌려봤습니다.
그러나 컨퍼런스에서 설명하는 코드들은 전체 코드 중 일부만 설명이 되다보니 제대로 이해가 되지 않는 부분들이 많았습니다.
그래서 아래 처럼 그림을 그려가며 이해하려고 노력했습니다. 참고로 아래 이미지는 전체 메모 중 일부만 가져왔습니다.

그리고 html2pdf.js와 react-pdf/renderer 프로젝트를 다운받아서 코드도 분석했습니다.
두 라이브러리를 분석한 이유는 페이지 분할 로직은 카카오에서 html2pdf.js를 참고했다고 해서 저도 분석을 해봤고, 컨퍼런스에서 공개된 카카오 코드 중 PDF 페이지 생성 컴포넌트는 react-pdf/renderer와 유사하다고 느꼈기 때문에 두 라이브러리 코드를 참고했습니다.
먼저 html2pdf.js 프로젝트의 pageBreak.js(원본 코드 링크)로직을 확인해보면 다음과 정리할 수 있습니다. 전체 코드를 직접 확인해보시면 아시겠지만 주석으로 각 코드의 동작에 대해 잘 설명이 되어있어서 쉽게 이해할 수 있습니다.
- DOM 전체를 순회하며 각 요소마다 page break 설정 여부를 확인합니다.
var els = root.querySelectorAll('*');
Array.prototype.forEach.call(els, function pagebreak_loop(el) {
...
}
2. pagebreak_loop 안에서 rules에 pagebreak mode옵션 값을 디폴트 값으로 설정해줍니다.
var rules = {
before: false,
after: mode.legacy && legacyEls.indexOf(el) !== -1,
avoid: mode.avoidAll
};
3. 그리고 요소의 style을 분석하여 brea-after, break-before, break-inside 등이 적용되어 있다면 rules에 true로 설정해줍니다.
var style = window.getComputedStyle(el);
rules = {
before: rules.before || breakOpt.includes(style.breakBefore || style.pageBreakBefore),
after: rules.after || breakOpt.includes(style.breakAfter || style.pageBreakAfter),
avoid: rules.avoid || avoidOpt.includes(style.breakInside || style.pageBreakInside)
};
여기서 각 페이지 분할과 관련된 CSS 스타일 속성에 대해서 가볍게 정리하고 넘어가겠습니다.
- break-before : break-before를 설정한 요소 앞에서 페이지를 나눌지 결정
- break-after: break-after를 설정한 요소 뒤에서 페이지를 나눌지 결정
- break-inside: break-inside를 설정한 요소 내부에서 페이지가 나뉘는 것을 허용할지/막을지 결정
- 참고로 page-break-before와 같이 page-break가 붙은 속성은 모두 deprecated 되었기 때문에 사용을 권장하지 않습니다.
4. 그런 다음 요소가 페이지 어디에 있는지 위치 정보를 얻습니다.
var clientRect = el.getBoundingClientRect();
5. 그리고 나서 avoid 로직 체크를 합니다. 이 로직이 제일 핵심 로직입니다. avoid가 설정되어 있어야만 요소가 잘리지 않고 형태가 보존된 상태로 페이지가 분할이 되기 때문입니다.
// Avoid: Check if a break happens mid-element.
if (rules.avoid && !rules.before) {
var startPage = Math.floor(clientRect.top / pxPageHeight);
var endPage = Math.floor(clientRect.bottom / pxPageHeight);
var nPages = Math.abs(clientRect.bottom - clientRect.top) / pxPageHeight;
// Turn on rules.before if the el is broken and is at most one page long.
if (endPage !== startPage && nPages <= 1) {
rules.before = true;
}
}
6. 이제 page break를 실행하는 코드가 드디어 나옵니다.
만약 요소에 before가 설정되어 있다면, 이 요소 전에 여백을 채우고 해당 요소는 다음 페이지에서 표현이 되게 불할을 합니다.
혹은 요소에 after가 설정되어 있따면, 이 요소 후에 여백을 채워서 다음 요소가 다음 페이지에서 표현이 되게 분할을 합니다.
// Before: Create a padding div to push the element to the next page.
if (rules.before) {
var pad = createElement('div', {style: {
display: 'block',
height: pxPageHeight - (clientRect.top % pxPageHeight) + 'px'
}});
el.parentNode.insertBefore(pad, el);
}
// After: Create a padding div to fill the remaining page.
if (rules.after) {
var pad = createElement('div', {style: {
display: 'block',
height: pxPageHeight - (clientRect.bottom % pxPageHeight) + 'px'
}});
el.parentNode.insertBefore(pad, el.nextSibling);
}
이렇게 html2pdf.js는 강제로 로직을 통해 페이지를 분할한 것처럼 보이게 해서 이미지화를 한 후 A4 용지 기준으로 자른 결과물을 보면, 자연스럽게 페이지가 분할된 PDF를 확인할 수 있습니다.
다음으로는 react-pdf/renderer 입니다. 코드를 보기 전에 카카오 컨퍼런스 영상에서 보여준 코드 일부를 먼저 살펴보겠습니다.

이 코드의 형태를 확인했을 때 react-pdf/renderer와 사용법이 많이 비슷하다고 생각했습니다.
그렇게 생각한 이유는 react-pdf/renderer에서도 동일하게 Page 컴포넌트가 있는데 위 코드에서도 Page 컴포넌트를 확인할 수가 있는데, '카카오 파스타'에서 pdf를 생성을 통해 확인했을 때 react-pdf/renderer의 Page 컴포넌트 동작 방식과 유사했기 때문입니다.
하지만 카카오에서는 물론 div태그를 View 등의 태그로 변환시키거나 하는 것은 아니었지만, Page 컴포넌트 자체는 상당히 유용한 컴포넌트라서 약간 아이디어를 얻어 유사하게 구현한 것은 아닐까하고 생각이 들었습니다.
사실 저 또한 위와 같은 형태로 쉽게 PDF 문서 형태를 만드는 것을 지향했기 때문에, 일단 어떤 식으로 코드 구현을 했는지 궁금해서 react-pdf/renderer 프로젝트를 확인해보았습니다.
먼저 Page 컴포넌트 Text 컴포넌트 등이 따로 존재하는 것이 아니라 아래와 같이 따로 정의해두고 있었습니다.
export const View = 'VIEW';
export const Text = 'TEXT';
export const Link = 'LINK';
export const Page = 'PAGE';
export const Note = 'NOTE';
...
직접 코드 분석을 하기에는 시간이 오래 걸릴 것 같아서 ai에게 부탁했더니 아래 같은 순서로 실행이 된다고 알려줬습니다.
- JSX 심볼을 export하는 엔트리
- 여기서 @react-pdf/primitives를 그대로 export하므로, Document/Page/View/Text를 import해서 JSX로 쓸 수 있음 . - Document/Page/View/Text의 실체
- 각 컴포넌트는 함수 컴포넌트가 아니라 문자열 타입 상수('DOCUMENT', 'PAGE', 'VIEW', 'TEXT')로 정의됨. - React Reconciler가 트리 노드 생성
- 루트 컨테이너에 문서 장착
- 레이아웃 계산 파이프라인 실행
- PDF 드로잉(렌더링)
- 최종 산출물 반환
제가 추구하는 방식과 사뭇 달랐고, div 태그를 View 등의 태그로 변환시키는 것을 만들려고 한 게 아니었기 때문에 구체적인 로직까지 살펴보지는 않았습니다.
최종 결과물
이렇게 두 가지 라이브러리 코드까지 분석하고 나니 비로소 컨퍼런스 내용이 온전히 이해가 가기 시작했습니다.
또한 제가 원하는 코드의 방향도 제대로 설정하게 되어서 ai 에이전트로 제가 만족할 수 있는 좋은 결과물이 나오게 되었습니다.
참고로 해당 코드가 나오게 하기 위해 claude-4.6-opus-high-thinking를 사용해서 개발했습니다.
<div className="blood-sugar-analysis-document">
<DocumentGroup
renderHeader={(currentPage, totalPages) => (
<Header title="혈당 리포트" currentPage={currentPage} totalPages={totalPages} />
)}
renderFooter={(currentPage, totalPages) => <Footer currentPage={currentPage} totalPages={totalPages} />}
>
<Document>
<BloodSugarStatSummary />
<BloodSugarHistoryTable />
</Document>
</DocumentGroup>
</div>
저는 Page 컴포넌트와 동일한 역할을 할 Document라는 컴포넌트를 만들어서 Document 컴포넌트가 페이지 분할을 진행하도록 했습니다. 이 때 페이지 분할 코드는 다음과 같습니다.
offsets.map((offset, index) => {
const currentPage = pageOffset + index + 1;
const nextOffset = offsets[index + 1];
// 다음 페이지 시작점까지만 클리핑하여 중복 방지. 마지막 페이지는 가용 높이 전체 사용.
const sliceHeight = nextOffset !== undefined ? nextOffset - offset : availableHeight;
return (
<Page
key={index}
header={effectiveRenderHeader?.(currentPage, totalPages)}
footer={effectiveRenderFooter?.(currentPage, totalPages)}
>
{pageTopItems}
<div style={{ overflow: 'hidden', height: `${sliceHeight}px` }}>
<div style={{ marginTop: `${-offset}px` }}>{children}</div>
</div>
</Page>
);
}
아까 html2pdf.js에서 봤던 코드와 많이 다르죠?
이 코드는 offset만큼 위로 당겨서 특정 구간만 보이도록 "잘라내는 방식"이라고 생각하시면 됩니다. ABCD라는 컨텐츠가 있는데 1페이지에서는 AB 컨텐츠만 보이도록 잘라낸 후, 2페이에서는 CD를 위로 당겨서 보이게 하는 거죠.
에이전트에게는 html2pdf.js의 page_break 처럼 계속 만들어달라고 했는데, 제가 예상했던 결과물이 나오지 않았지만 오히려 더 잘 이해할 수 있는 코드인 것 같아서 좋다고 생각을 하게 되었습니다.
그리고 전체 페이지 수 계산은 Document 컴포넌트들을 감싸는 DocumentGroup이라는 컴포넌트를 생성해서 이 컴포넌트에서 현재 페이지 수와 총 페이지 수를 표현할 수 있도록 했습니다.
또한 각 페이지에서 나타낼 header와 footer는 DocumentGroup과 Document 컴포넌트에서 모두 사용할 수 있도록 구성했습니다.
DocumentGroup에서 header와 footer를 사용한다면 현재 페이지 수 표현을 제외하고 모든 페이지에 공통된 내용이 담긴 형태로 제공이 되고, Document 같은 경우에는 Document에서 표현할 header와 footer를 개별적으로 표현할 수 있게 됩니다. 예를 들면 첫번째 Document에는 '추이 분석', 두번째 Document는 '혈당 내역' 등으로 표현을 할 수 있게 되는 거죠.
마지막으로 puppeteer로 실행시켜주면 끝입니다. 참고로 pdf로 변환해주는 puppeteer 코드도 한결 가벼워졌습니다.
const pdf = await page.pdf({
format: 'A4',
printBackground: true, // 배경색/이미지 포함
margin: { top: 0, bottom: 0, left: 0, right: 0 },
});
이렇게 고생 끝에 만들어진 최종 PDF 완성본입니다.
참고로 빌드 하지 않고 출력해서 react-query devtools과 next.js 마크가 노출되는 부분은 양해 바랍니다ㅠㅠ
트러블 슈팅
<table>에 page break 적용했을 때 발생하는 문제 해결하기
문제 상황
table을 js로직으로 page break 적용하기가 상당히 까다로웠습니다. 행과 행 사이에 여백을 적용하는 게 쉽지 않았기 때문입니다.
예를 들어서, <table /> 내 <tr />과 <tr /> 사이에 <div />를 넣는다고 해서 둘 사이에 여백이 생기지 않습니다.
그리고 설사 여백이 생긴다고 하더라고 table에 page break 적용된 문서를 보면 디자인적으로 다소 지저분하고 어색한 부분이 있습니다.
아래는 puppeteer를 사용해서 테이블에 page break를 적용한 문서 중 일부인데 표의 아래,위가 닫혀있지 않고 양 옆으로 선이 중간에 끊긴 상태로 이어져 있습니다.

제가 원하는 해결 조건은 다음과 같았습니다.
- 테이블이 다음 페이지로 넘어갈 때, 그리고 새로운 페이지에 테이블이 시작될 때 border가 모두 있어야 한다는 점
- 어떤 환경에서든 똑같이 형태로 테이블이 분할되어야 한다는 점
- 테이블 디자인이 변경되었을 때 유지보수가 번거로우면 안된다는 점 (ex. 변경된 테이블 디자인에 맞춰서 다시 pdf 변환 환경에 맞춰 코드를 수정해야하는 부분)
저는 위 해결 조건을 만족하는 결과물을 만들기 위해 3가지 방식을 시도했습니다.
첫번째 문제 해결 시도 - 행 높이를 계산해서 페이지 경계가 행 중간에 오면 다음 페이지로 넘기기
행 높이를 미리 계산해서 페이지의 경계에 걸릴 것 같다면 다음 페이지에서 해당 행이 표현되게끔 로직을 작성해야했는데, 문제 상황에서 예시 이미지로 보여드렸던 테이블 디자인이 나오는 형태였기 때문에 깊게 고려하지 않고 넘어갑니다.
두번째 문제 해결 시도 - 특정 행 개수를 기준으로 테이블을 잘라서 표현
처음에는 이 해결 방법이 제일 가능성있게 느껴졌습니다. A4 기준으로 최대 표현할 수 있는 개수를 정해놓고 테이블을 쪼개니 제가 원하는 형식대로 문서가 만들어졌습니다.

테이블이 완벽한 형태를 이루며 페이지 분할이 되었습니다.
하지만 예외 상황이 발생합니다.
위 이미지에서 정상적으로 페이지 분할이 된 상황은 1페이지에는 그래프만 나타내고, 2페이지부터 테이블 표현만 시작하게 만든 상황이라서 제가 원하는 대로 문서가 만들어졌지만,
1페이지부터 그래프와 테이블을 혼합으로 나타내게 되는 상황을 생각해보니 특정 행 개수를 계산하기가 번거롭기 시작했습니다.
그래프를 그리고 남은 영역만큼 테이블을 표현하고, 그 값을 기준으로 2, 3페이지에 테이블들을 표현해야했죠.
이 뿐만이 아니라 위 이미지에서 보시다시피 메모를 적는 칸이 있어서 각 행마다 메모 내용이 제각각이면 그 행의 높이를 전부 일일이 다 계산해줘야 하는 문제가 발생했습니다. 특정 한 행을 기준으로 계산을 할 수 없었죠.
세번째 문제 해결 시도 - table 코드를 div로 변경해서 표현
그래서 생각을 바꿔보았습니다. 꼭 table 코드로 표현을 해야하는 이유가 있을까? 라고 생각을 해보았죠.
이 때 저는 그렇지 않다고 답을 내렸습니다.
지금 보고 있는 화면을 그대로 PDF로 잘 변환하는 게 목표였기 때문에, 마크업을 잘 지켜야하는 상황이 아니었습니다.
혹시나하고 카카오에서는 어떻게 하고 있을지 확인해보니 카카오도 역시나 table 태그를 사용하지 않고 div로 테이블을 표현하고 있었습니다. 아마 비슷한 문제를 겪지 않았을까하는 생각을 해봤습니다.
저는 바로 table을 div로 변경하기로 결정합니다.
일일이 table을 div로 변경하기 귀찮으니 cursor에서 에이전트를 통해 빠르게 변경을 시켜줬습니다.
대신에 role="table", role="row" 등을 사용해서 변환해달라고 했습니다.
이렇게 변경하고나니 페이지 경계에 행이 걸리게 되었을 때 깔끔하게 분할이 되는 모습을 확인할 수 있었습니다.
참고로 테이블 디자인에서 첫번째 행 위쪽과 양 옆은 경계선이 생기지 않도록 수정했습니다. 없는 게 더 깔끔해보이더라구요.

마무리
사실 대표적인 4가지의 시도들을 작성했을뿐이지 숨겨진 크고 작은 시도들이 많았습니다. 제 다운로드 폴더 함에는 수많은 샘플들이 지금 저장되어있죠.

처음에는 제가 만들어야 할 것에 대해 구체적으로 생각하지 않고 요청을 하다보니 제가 원하는대로 나오지 않더라구요. 이거 만들면서 에이전트가 자동으로 생성해놓은 코드들이 마음에 들지 않고 짜증이 나서 포기하고 싶기도 했습니다.
원하는 결과를 얻기 위해 이렇게 에이전트에 요청만해서는 안되겠다 싶어서 카카오 컨퍼런스 영상으로 공부하고 기존 라이브러리 코드들을 분석하면서 내가 많이 아는 만큼 좋은 결과물을 뽑아낼 수 있다는 걸 다시금 느낄 수 있었던 계기가 된 것 같습니다.
요즘 누구나 바이브 코딩이면 원하는 걸 만들 수 있다고 하는데, 요청을 제대로 하지 못하면 완성도 높은 결과물을 뽑아내기 힘들다는 것을 다시 한번 느낄 수 있었습니다.
다음 목표는 이렇게 만든 react-pdf 변환 기능을 상반기 내에 npm에 라이브러리로 배포해보려고 합니다.
지금 의존적이지 않게 기능을 만든 상태라서 당장이라도 배포할 수 있겠지만, A4 용지 한정으로 만든거라서 다른 용지 사이즈로 했을 때도 잘 동작하는지 체크해보고 그 외에 예외 상황에 대해서 좀 더 살펴본 다음, 라이브러리로 배포할 것 같습니다.
긴 글 읽어주셔서 감사합니다!
궁금한 점이 있으시다면 언제든지 편하게 댓글 남겨주세요!