견적을 화면에서 비교하는 것까지는 만들었는데, 실제 영업 현장에서는 그 결과를 고객에게 보낼 파일로 만들 수 있어야 의미가 있습니다. 그래서 워크로드 스펙, 벤더별 비교표, 절감액 요약을 담은 A4 1장짜리 PDF 견적서 기능을 추가했습니다.
Java에서 PDF를 생성하는 라이브러리는 몇 가지가 있는데, 상업적 이슈가 없는 오픈소스 라이선스(LGPL/MPL)이면서 표(PdfPTable), 폰트, 색상 제어가 충분한 OpenPDF를 선택했습니다.
@Service
public class QuotePdfService {
public byte[] render(QuoteResponse quote) {
Document document = new Document(PageSize.A4, 42, 42, 48, 42);
ByteArrayOutputStream out = new ByteArrayOutputStream();
PdfWriter.getInstance(document, out);
document.open();
document.add(title("멀티클라우드 견적서"));
document.add(specTable(quote));
document.add(comparisonTable(quote)); // 최저가 강조
savingsSummary(quote).ifPresent(document::add); // 절감액 요약
document.close();
return out.toByteArray();
}
}
PDF에 한글을 넣으려면 폰트가 필요한데, 여기서 트레이드오프를 하나 의식적으로 선택했습니다.
OpenPDF의 CJK 폰트 메트릭(HYSMyeongJo-Medium)을 임베드하지 않고 참조만 하는 방식을 썼습니다.
this.baseFont = BaseFont.createFont(
"HYSMyeongJo-Medium", "UniKS-UCS2-H", BaseFont.NOT_EMBEDDED);
NOT_EMBEDDED가 핵심입니다. 폰트 파일 자체를 PDF 안에 집어넣지 않고, "이 텍스트는 이 이름의 CJK 폰트로 그려라"라는 참조만 남깁니다.
장점은 명확합니다. 폰트를 임베드하면 한글 TTF 하나만 해도 수 MB인데, 이 방식은 PDF가 3KB 수준으로 나옵니다. 저장소에 폰트 파일을 커밋할 필요도 없습니다.
단점도 명확합니다. 이 폰트는 PDF 리더가 시스템에 설치된 한국어 폰트를 찾아서 대신 그려주는 방식이라, 한국어 폰트가 아예 없는 환경의 뷰어에서는 글자가 깨질 수 있습니다. 사내에서 쓰거나 한국어 환경 사용자에게 보내는 용도로는 괜찮지만, 불특정 다수에게 공개 배포한다면 NotoSansKR 같은 폰트를 직접 임베드하는 쪽으로 바꾸는 게 안전합니다. 이건 지금 단계에서 의도적으로 감수한 트레이드오프로 남겨뒀습니다.
PDF 기능을 테스트할 때 흔히 하는 실수가 "파일이 만들어졌는지, 사이즈가 0이 아닌지"만 확인하고 넘어가는 것입니다. 그런데 이건 내용이 맞는지는 전혀 보장하지 않습니다.
그래서 생성한 PDF를 PDFBox로 다시 열어서 텍스트를 추출하고, 그 안의 내용을 검증하는 방식을 택했습니다.
byte[] pdf = quotePdfService.render(quote);
try (PDDocument document = Loader.loadPDF(pdf)) {
String text = new PDFTextStripper().getText(document);
assertThat(document.getNumberOfPages()).isEqualTo(1);
assertThat(text).contains("멀티클라우드 견적서"); // 한글 인코딩이 깨지지 않았는지
assertThat(text).contains("t3.xlarge");
assertThat(text).contains("$148.96"); // 금액이 맞는지
assertThat(text).contains("최저가"); // 배지 텍스트가 들어갔는지
assertThat(text).contains("절감"); // 절감액 요약이 들어갔는지
}
이 테스트를 처음 돌렸을 때 실제로 실패했습니다. 벤더 열의 너비를 좁게 잡아서 "AZURE (최저가)"라는 텍스트가 표 셀 안에서 줄바꿈되며 잘리는 문제였습니다. 화면으로 미리보기를 했다면 눈으로 훑고 지나쳤을 수도 있는데, 텍스트를 그대로 assert하는 테스트라서 바로 걸러졌습니다.
// 벤더 열은 "AZURE (최저가)"가 한 줄에 들어가도록 넉넉히 잡는다.
PdfPTable table = new PdfPTable(new float[]{1.9f, 2.2f, 1.3f, 1.3f, 1.5f});
견적서에 단순히 "AWS $160.96 / Azure $148.96"만 나열하면, 읽는 사람이 직접 차이를 계산해야 합니다. 그래서 최저가 대비 절감액을 월/연 단위로 미리 계산해서 문장으로 보여주도록 했습니다.
BigDecimal monthlyGap = costliest.monthlyCostUsd().subtract(cheapest.monthlyCostUsd());
String text = "%s 선택 시 %s 대비 월 %s, 연 %s 절감됩니다.".formatted(
cheapest.vendor().name(), costliest.vendor().name(),
usd(monthlyGap), usd(monthlyGap.multiply(BigDecimal.valueOf(12))));
작은 부분이지만, 영업 자료라는 목적을 생각하면 "숫자를 보여주는 것"과 "판단을 도와주는 것"은 다른 문제라고 생각합니다.
다음 편에서는 이 프로젝트에서 겪은 버그 중 가장 기억에 남는 것 — 화면에서만 드러난 null과 undefined 사이의 버그를 다룹니다.