견적서 PDF 버튼을 처음 만든 날, 가장 간단한 기능이라고 생각했다. html2canvas + jsPDF. npm 패키지 두 개면 끝이라고 생각했다.
두 달 뒤 관련 커밋이 열다섯 개를 넘겼다.
먼저 CORS와 폰트 문제부터
첫 번째 문제는 이미지였다. 견적서 안에 있는 Vercel Blob 이미지가 캡처되지 않고 흰 박스로 나왔다.
const canvas = await html2canvas(element, {
useCORS: true, // CORS 이미지 허용
scale: 2, // Retina 2× 해상도
onclone: (doc) => {
// 클론된 DOM에 폰트 강제 적용
doc.fonts.ready.then(() => { /* ... */ });
}
});
useCORS: true로 CORS 이미지 허용, onclone으로 폰트 로드 완료 후 캡처. 이 두 가지로 기본 렌더 문제는 해결됐다. PNG 이미지 저장도 여기서 추가했다. 카카오톡으로 견적서를 공유할 때 PDF보다 이미지가 더 편리하다.
들여쓰기 한 줄 때문에 7번을 수정했다
주의사항 박스에 이런 표시가 필요했다.
· 본 견적금액은 현장 정밀 실측 결과에 따라
변경될 수 있으며 최종 금액은 실측 후 확정됩니다.
두 번째 줄이 · 다음에 맞춰 들여써져야 한다. CSS에서는 text-indent + padding-left 조합(행잉 인덴트)이 표준적인 방법이다. html2canvas에서는 작동하지 않았다.
1차: text-indent + padding-left → html2canvas 무시
2차: display: flex → 정렬 계산 오류
3차: table 구조 → 레이아웃 깨짐
4차: 인라인 스타일 + flex → 여전히 오류
5차: tableLayout: fixed + colgroup → 개선 없음
6차: float → 위치 계산 오류
7차: 단순 <p> + margin → 성공
<!-- 최종 해결 -->
<p style="margin: 0 0 0 10px">· 본 견적금액은 현장 정밀 실측 결과에 따라</p>
<p style="margin: 0 0 0 18px">변경될 수 있으며 최종 금액은 실측 후 확정됩니다.</p>
html2canvas는 CSS 클래스 기반 스타일보다 인라인 스타일을 더 안정적으로 처리한다. display: flex, text-indent 같은 복잡한 레이아웃 속성은 무시되거나 오계산된다. 이걸 알고 시작했으면 1번에 해결됐을 거다.
html2canvas vs window.print() 이중 전략
html2canvas 품질에 한계가 보여서 window.print()로 전환한 적이 있다. 브라우저 인쇄 다이얼로그를 띄우는 방식인데, 벡터 기반이라 품질이 훨씬 좋다.
그런데 UX가 문제였다. "PDF로 저장" 버튼을 클릭하면 인쇄 다이얼로그가 뜨고, 거기서 다시 "PDF로 저장"을 선택해야 한다. 두 단계다.
결국 두 경로를 다 살렸다.
버튼 클릭 → html2canvas + jsPDF (즉시 다운로드)
인쇄 버튼 → window.print() (브라우저 인쇄 → PDF 저장)
html2canvas는 편리하지만 해상도 한계가 있고, window.print()는 UX가 복잡하지만 벡터 품질이다. 둘 다 제공하면 사용자가 선택한다.
배경색이 안 나온다는 피드백
고객이 견적서를 Chrome에서 인쇄했더니 파란 배경의 표 헤더가 흰색으로 출력됐다.
브라우저 기본 설정이 "배경 그래픽 인쇄 안 함"이다. 잉크를 아끼는 목적으로 기본값이 OFF다.
@media print {
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
}
!important가 필요한 이유는 일부 브라우저에서 사용자 설정이 CSS보다 우선하는 경우가 있어서다.
가장 영리한 해결책: margin-top: auto
"입금계좌와 주의사항이 항상 페이지 하단에 붙어있었으면 좋겠다."
처음엔 position: fixed; bottom: 0을 썼다. 모든 인쇄 페이지에 반복 출력됐다. 탈락.
JavaScript로 페이지 수를 계산해서 마지막 페이지에만 표시하는 방법도 생각했다. A4 경계 계산이 부정확해서 탈락.
Flexbox margin-top: auto가 정답이었다.
.doc {
display: flex;
flex-direction: column;
min-height: 269mm; /* A4 가용 높이 = 297mm - 여백 14mm × 2 */
}
.doc-page-foot {
margin-top: auto; /* 남은 공간을 위로 밀어냄 */
}
동작 방식은 이렇다.
- 내용이 짧을 때:
min-height: 269mm로 공간 확보 →margin-top: auto가 footer를 하단으로 밀어냄 - 내용이 길어 2페이지가 될 때: flex가 자연히 늘어나 마지막 페이지 하단 근처에 위치
JS 없이 CSS만으로 요구사항을 충족했다.
Next.js 파비콘 덫
이 시기에 작은 트러블이 하나 더 있었다. 파비콘 관련이다.
layout.tsx에 metadata.icons: { icon: "/pwa-icon-192.png" }를 설정했더니 네이비 SVG 파비콘 대신 흰 배경 PNG 아이콘이 표시됐다.
Next.js App Router는 app/icon.svg를 자동으로 파비콘으로 등록하지만, metadata.icons 명시적 설정이 이 자동 감지를 덮어쓴다.
// metadata.icons 키 자체를 제거
export const metadata: Metadata = {
title: "아토즈 | 프리미엄 맞춤제작",
// icons 키 없음 → App Router가 app/icon.svg를 자동 파비콘으로 사용
};
문서에 잘 안 나오는 동작이라 한동안 원인을 몰랐다.
다음 편에서는 PWA 푸시 알림과, 3개월 단독 개발을 마무리하면서 돌아본 것들을 정리한다.