sales-5

이지예·2026년 9월 17일

개발

목록 보기
27/58

series: 멀티클라우드 견적 자동화 시스템

왜 PDF가 필요했나

견적을 화면에서 비교하는 것까지는 만들었는데, 실제 영업 현장에서는 그 결과를 고객에게 보낼 파일로 만들 수 있어야 의미가 있습니다. 그래서 워크로드 스펙, 벤더별 비교표, 절감액 요약을 담은 A4 1장짜리 PDF 견적서 기능을 추가했습니다.

라이브러리 선택: OpenPDF

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 사이의 버그를 다룹니다.

profile
github: @dlwldP

0개의 댓글