웹 어플리케이션에서 특정 영역을 pdf로 저장해야 할 때가 있습니다. 저 같은 경우에도 차트, 테이블 등 다양한 형태의 데이터를 시각적으로 제공하는 대시보드 웹 어플리케이션을 개발하면서 특정 화면을 보고서 형식으로 PDF로 저장해 전달해야 하는 기능을 개발해야 했습니다요구사항을 자주 마주쳤습니다.
레거시 코드에서는 보고서 양식 이미지 파일 위에 내용을 하나하나 그려가는 방식으로 작성되어 있었고 이 방식에는 다음과 같은 이슈들이 있었습니다:
-
PDF 생성 속도가 느림
-
특정 조건에서 화질 저하 또는 레이아웃 깨짐 현상 발생
당시에는 PoC 단계에서 고객들의 요구사항을 잘 접할 수 있었는데, 가장 큰 이슈중에 하나이기도 했습니다.
이를 해결하기 위해 팀에서는 백엔드에서 미리 PDF를 생성해 서버에 저장해두고 사용자 요청 시 내려주는 방식으로 결정하고 스프린트가 시작됐지만, 저는 이 구조가 화면의 시각적 완성도를 충분히 반영하지 못하고 있다고 느꼈습니다.
짧은 스프린트 일정 속에서도 고민 끝에, 화면을 캡처해 클라이언트에서 직접 PDF를 생성하는 방식을 검토하기 시작했습니다. 관련 기술을 조사하며 html2canvas
와 jsPDF
조합이 속도 문제를 해결하면서도 문서 품질을 높일 수 있다는 확신이 생겼고, 다른 팀 개발자들의 피드백을 통해 실현 가능성도 검토할 수 있었습니다.
백엔드 리소스를 줄이는 동시에 UI 품질도 개선할 수 있다면 충분히 시도해볼 가치가 있다고 판단해, 팀을 설득하고 자투리 시간을 활용해 직접 구현에 나섰습니다.
이 글에서는 해당 기능을 구현하는 과정에서 사용한 html2canvas와 jsPDF의 활용법을 정리하고, 기능 중심으로 커스텀 훅을 리팩토링하여 재사용성을 높인 경험을 함께 공유합니다.
1. 문제 정의
레거시 프로젝트에서는 보고서 이미지 파일 위에 해당 텍스트를 추가해서 보고서를 만들었습니다. 하지만 다음과 같은 이슈들이 있었습니다:
- 해상도 저하 (특히 그래프나 UI 요소들이 흐릿하게 보임)
- 렌더링 시간이 길어 유저가 기다려야 함
- 백엔드 리소스를 소비하는 구조라 부담이 큼
이에 따라 다음과 같은 목표를 세웠습니다:
- 유저가 보는 화면 그대로를 PDF로 저장하고
- 고해상도에서도 깔끔하게 출력되며
- 백엔드 리소스를 소모하지 않는 구조
2. 해결 전략: 프론트엔드에서 직접 PDF 만들기
우선 사용할 기술 스택은 다음과 같습니다:
html2canvas
: DOM 요소를 이미지로 캡처jsPDF
: 이미지를 A4 PDF로 변환 및 저장window.devicePixelRatio
: 고해상도 대응
이 조합은 브라우저 환경에서 DOM 요소를 PDF로 저장할 수 있는 비교적 간단하고 널리 사용되는 방법입니다. 단, 일부 CSS(예: 폰트, 그림자 등)는 완벽하게 렌더링되지 않을 수 있으므로 주의가 필요합니다.
3. 1차 구현 코드: 도메인 종속 훅 useReportDownload
처음에는 특정 도메인(예: 진단 보고서)에 맞춘 형태로 커스텀 훅을 만들었습니다.
const useReportDownload = ({
companyId,
siteId,
assetId,
diagnosisDate,
ref,
}) => {
const reportData = useReportData({ ... });
// ...
const download = async () => {
const canvas = await html2canvas(ref.current);
const pdf = new jsPDF();
pdf.addImage(canvas.toDataURL(), 'JPEG', 0, 0, 210, 297);
pdf.save('보고서.pdf');
};
return { download };
};
사용 예시
const Component = () => {
const { downloadPDF } = usePDFDownload()
const ref = useRef<HTMLDivElement>(null)
return (
<>
<div ref={ref}>
<h1>PDF로 저장할 내용</h1>
<p>이 영역이 PDF로 저장됩니다.</p>
</div>
<button onClick={()=> downloadPDF(ref.current, 'myFile.pdf')}>
PDF 저장
</button>
</>
)
}
companyId
,siteId
,diagnosisDate
와 같은 도메인 파라미터가 훅 내부에 있어 다른 곳에서 재사용이 불가능useReportData
까지 훅 안에서 호출되어 관심사가 뒤섞여 있음
이건 기능과 도메인을 분리하지 못한 구조였고, 다른 곳에선 쓸 수 없는 "보고서 전용 훅"이 되어버렸습니다.
4. 코드 개선 방향 고민
이 훅이 정말 보고서 전용일까?
사실 그렇지 않았습니다. PDF로 저장해야 할 UI는 보고서 외에도 많습니다.
예를 들면:
- 리포트 요약
- 사용자 설정 정보
- 대시보드 스냅샷 등
그래서 다음과 같은 리팩토링 목표를 세웠습니다:
- 도메인 제거
- 기능 중심의 이름 부여 (
usePdfDownload
) - 외부에서
data
,filename
등을 주입받을 수 있도록 유연하게 변경
5. 개선된 훅: usePdfDownload
리팩토링 이후의 훅은 이렇게 바뀌었습니다:
export const usePdfDownload = <T extends HTMLElement>({
ref,
filename = 'document.pdf',
onBeforeDownload,
}: {
ref: RefObject<T>
filename?: string
onBeforeDownload?: () => Promise<void> | void
}) => {
// 캔버스와 jsPDF 객체 생성 및 다운로드 로직 생략
}
사용 예시
const { download, loading } = usePdfDownload({
ref,
filename: '내보내기.pdf',
})
또는 캡처 전에 데이터를 불러오고 싶을 경우:
const { download } = usePdfDownload({
ref,
onBeforeDownload: async () => {
await fetchData() // 데이터 사전 준비
},
})
이제 이 훅은 어떤 UI에도 사용할 수 있는 범용 도구가 되었습니다.
6. 결과 & 정리
최종적으로 구현된 PDF 다운로드 기능은:
- 고해상도 대응 (
devicePixelRatio
) - 화면 UI 그대로 이미지화
- PDF 다중 페이지 지원
- 재사용 가능한 범용 커스텀 훅
으로 구성됐고, 결과적으로 아래와 같은 효과를 얻을 수 있었습니다.
- 시각적 품질 향상: 고해상도에서도 깔끔하게 출력
- 성능 개선: 프론트 단에서 처리해 속도 향상
- 범용성 확보: 다양한 UI에 재사용 가능
- 유지보수 용이: 관심사 분리 구조
데모: 직접 PDF로 저장해보세요
아래 박스를 PDF로 저장하는 버튼을 눌러 직접 테스트해보세요.
PDF 다운로드 기능을 로딩 중입니다...
📄 저장된 PDF에는 이 박스 안의 텍스트와 스타일이 그대로 담깁니다.
usePdfDownload 훅 전체 코드
usePdfDownload.ts 전체 코드 보기
import { RefObject, useCallback, useState } from 'react'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
export const usePdfDownload = <T extends HTMLElement>({
ref,
filename = 'document.pdf',
onBeforeDownload,
}: {
ref: RefObject<T>
filename?: string
onBeforeDownload?: () => Promise<void> | void
}) => {
const [loading, setLoading] = useState(false)
const download = useCallback(async () => {
if (!ref.current) return
try {
setLoading(true)
// 다운로드 전 실행할 작업이 있다면 실행
if (onBeforeDownload) {
await onBeforeDownload()
}
// 고해상도 대응을 위한 scale 설정
const scale = window.devicePixelRatio
const canvas = await html2canvas(ref.current, {
scale,
useCORS: true,
logging: false,
})
// PDF 생성
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
})
// 이미지 크기 계산
const imgWidth = 210 // A4 width in mm
const imgHeight = (canvas.height * imgWidth) / canvas.width
// PDF에 이미지 추가
pdf.addImage(
canvas.toDataURL('image/jpeg', 1.0),
'JPEG',
0,
0,
imgWidth,
imgHeight,
)
// PDF 저장
pdf.save(filename)
} catch (error) {
console.error('PDF 다운로드 중 오류 발생:', error)
throw error
} finally {
setLoading(false)
}
}, [ref, filename, onBeforeDownload])
return { download, loading }
}
7. 마무리하며
사실 대단한 기능을 구현한 것은 아닙니다. 리서치를 충분히 했다면 누구나 쉽게 구현할 수 있는 기능이었습니다. 다만, 이전까지는 레거시 코드를 수정할 생각을 하지 못해 오히려 돌아가는 길을 선택했던 것 같습니다.
이 경험을 통해 저는 문제를 파악하는 방법을 배웠습니다. 주어진 업무에만 매몰되지 않고, 더 나은 해결책이 없는지 끊임없이 고민하는 습관이 생겼습니다. 그 덕분에 동료들과 자연스럽게 문제를 공유하고 의견을 나누는 시간이 많아졌고, 이러한 과정이 더 나은 결과로 이어졌습니다.
이 경험을 통해 크게 느낀 점은 다음과 같습니다. 실제로 기능을 사용할 때에는 환경에 맞춰 확장성을 고려한 코드를 작성해야 한다는 것입니다. 예를 들어, 화면을 캡처할 때 특정 요소를 제외하거나 특정 요소만 선택적으로 캡처해야 할 수 있습니다. 이러한 기능을 추가하며 더 나은 코드를 만들어보고 싶습니다.
마무리를 어떻게 해야 할지 고민되지만, 혹시 클라이언트 기반 PDF 출력 기능을 고민하고 계신다면 html2canvas와 jsPDF 조합에 커스텀 훅 방식을 적용해보시길 추천드립니다.