💡 전체 작동 예제는 GitHub에서 확인할 수 있습니다: sign-documents-in-docker-fonts-java

9개월 동안 작동한 계약 서명 서비스

컨테이너 폰트 프로비저닝은 Java 서명 서비스가 실제 운영 환경에서 동작할지, 아니면 우연히 작성한 테스트에서만 동작할지를 결정하는 단계입니다. 이는 실패가 예정되어 있기 때문에 중요합니다: JRE 이미지가 충분한 폰트 커버리지를 제공해 보기에 정상적으로 보이지만, 특정 문서가 도착할 때까지 나머지는 보류됩니다.

그 흐름을 살펴보면, 문서 워크플로우가 계약서에 서명하고 eclipse-temurin:17-jre에 배포되며 정상적으로 동작합니다. 9개월 차에 회사가 일본 고객과 첫 계약을 체결하고, 이름이 서명 텍스트에 들어가면서 Specified font file was not found 오류가 발생합니다. 서비스 자체는 변경되지 않았습니다. 이미지에는 CJK 커버리지가 전혀 없었고, 이전까지는 해당 폰트를 요구하는 문서가 없었습니다.

기술적인 원인은 간단합니다. eclipse-temurin:17-jre는 AWT용 DejaVu 폰트 8개만 번들링하는데, 이는 라틴, 그리스, 키릴 문자를 지원합니다. GroupDocs.Signature은 누락된 패밀리를 대체하지 않으므로, 일본어를 지원하는 폰트를 요청하면 폰트가 없다는 오류가 발생하고, 폰트를 지정하지 않으면 라이브러리가 Times New Roman을 찾게 되는데 이것도 존재하지 않아 실패합니다.

폰트가 전혀 없는 이미지보다 왜 더 나쁜가

.NET 및 Python 베이스 이미지는 폰트를 전혀 포함하지 않습니다. 이는 더 나은 실패 형태입니다: 첫 번째 서명이 첫 테스트 실행 시 바로 실패하고, 누군가가 서비스가 배포되기 전에 이를 수정합니다.

JVM 이미지는 부분적으로만 실패하므로 비용이 많이 듭니다. 버그가 이미 운영 중인 코드에 존재하고, 배포와는 무관하게 고객 데이터에 의해 트리거되며, 담당자는 몇 달 동안 건드리지 않은 서비스에서 폰트 오류를 보게 됩니다. 사고 비용은 수정 자체가 아니라—수정은 Dockerfile 레이어 하나일 뿐—폰트가 원인이라는 사실을 누구도 믿기 전까지의 한 시간입니다.

이러한 비대칭성은 폰트 커버리지를 시작 시점에 검증해야 한다는 주장의 근거가 됩니다.

또한 비용 부담 주체가 바뀝니다. 폰트가 없는 이미지는 개발자가 설정 단계에서 20분 정도만 소요됩니다. 부분적으로 커버된 이미지는 호출 대기 엔지니어가 비생산적인 시간에 1시간을 소비하고, 계약 지연 비용과 사고 후 리뷰까지 발생합니다. 두 경우의 기술적 차이는 Dockerfile에 추가되는 네 개의 패키지에 불과합니다.

프로비저닝 실제 비용

런타임 단계에서 설치하는 Debian 패키지 네 개:

RUN apt-get update && apt-get install -y --no-install-recommends \
        fontconfig \
        fonts-dejavu-core \
        fonts-liberation \
        fonts-noto-cjk \
    && fc-cache -f \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

이미지 크기가 흔히 제기되는 이슈이며, 구체적으로 말하자면 CJK 패키지가 가장 큰 용량을 차지하고, 나머지 세 개는 작으며, 문서에 라틴이 아닌 이름이 포함될 경우 어느 하나도 선택 사항이 아닙니다. 실제 문서 세트가 필요로 하는 폰트만 설치하고, 직감에 따라 트리밍하기보다 읽어보는 방식으로 검증하세요.

fontconfig는 폰트 해결자이며 디버깅용 fc-list와 함께 사용됩니다. fonts-dejavu-core는 JRE가 이미 번들하는 폰트를 중복 설치하는데, 이는 베이스 이미지가 바뀔 경우 이미지를 정직하게 유지하기 위함입니다. fonts-liberation은 Windows에서 작성된 문서가 Arial 및 Times New Roman을 이름으로 참조하고 메트릭 호환 렌더링을 기대하기 때문에 중요합니다. fonts-noto-cjk는 위 사고에서 필요했던 패키지입니다.

패밀리를 지정하는 대신 찾기

프로비저닝만으로는 충분하지 않습니다. 코드가 실제 존재하는 패밀리를 지정해야 하기 때문입니다. 포터블한 방법은 라이브러리에 물어보는 것입니다: 후보마다 임시 서명을 시도하고, 예외가 발생하지 않는 첫 번째 후보를 반환합니다.

for (String candidate : candidates) {
    if (tryFamily(sourcePath, candidate) == null) {
        return candidate;
    }
}
return null;

위 탐색 자체는 임시 디렉터리에서 일반적인 서명 호출을 수행하며, 실패를 예외가 아니라 값으로 변환합니다:

SignatureFont font = new SignatureFont();
font.setFamilyName(familyName);
font.setSize(10);
options.setFont(font);
signature.sign(scratch.getAbsolutePath(), options);
return null;

파일명 기반 탐지는 겉보기와 달리 작동하지 않습니다. Debian의 fonts-noto-cjk는 NotoSansCJK-Regular.ttc를 설치하는데, 이 패밀리 이름은 Noto Sans CJK JP이며, 파일명 매칭은 폰트를 놓치고 해결되지 않을 패밀리를 보고합니다.

정직하게 디그레이드하기

해결 로직이 갖춰지면 두 종류의 실패를 명확히 구분할 수 있습니다. 라틴 패밀리가 없으면 이미지 자체가 서명할 수 없으므로 컨테이너를 중단해야 합니다. CJK 패밀리가 없으면 하나의 서명을 건너뛰고 경고와 함께 진행합니다:

List<SignOptions> options = new ArrayList<>();
options.add(buildTextOptions(LATIN_TEXT, latinFamily, 50));

if (cjkFamily != null) {
    options.add(buildTextOptions(CJK_TEXT, cjkFamily, 120));
}

SignResult result = signature.sign(outputPath, options);

운영상 차이는 중요합니다. “사용 가능한 폰트 패밀리가 없음”으로 시작 시점에 컨테이너가 종료되면 배포 문제이며, 배포 담당자가 바로 확인합니다. 반면 전달된 문서에서 서명이 조용히 누락되면 수신자가 발견하는 컴플라이언스 문제입니다. 치명적인 경우를 비정상 종료 코드와 연결하면 첫 번째 카테고리의 실패를 명확히 구분할 수 있습니다.

그 다음 결과를 읽어보세요. CJK 커버리지가 없는 상태에서 CJK 서명을 쓰면 빈 상자(□)가 나타날 수 있으며, 예외는 발생하지 않습니다:

TextSearchOptions options = new TextSearchOptions();
options.setAllPages(true);
List<TextSignature> found = signature.search(TextSignature.class, options);

이미 배포된 팀은 어떻게 해야 할까?

폰트 레이어를 추가하고, 시작 시점에 해결 로직을 넣으며, 서비스 첫 줄에 두 결과를 로그로 남기세요. 다음 엔지니어가 실제로 확인할 수 있습니다. 변경 내용은 Dockerfile 수정과 약 30줄 정도의 코드 추가이며, 고객이 트리거한 사고를 “알려진 커버리지를 가진 컨테이너가 시작하거나, 시작 자체를 거부”하도록 바꿉니다. 기존에 서명된 문서는 영향을 받지 않으며, 새 문서만 CJK 경로를 얻게 됩니다.

이미 실행 중인 이미지 확인하기

무언가를 바꾸기 전에 현재 이미지에 어떤 폰트가 있는지 확인하는 것이 좋습니다. 외부에서 확인할 수 있는 두 명령어:

docker run --rm your-image sh -c "ls -R /usr/share/fonts | head"
docker run --rm your-image sh -c "fc-list : family | sort -u | head -20"

첫 번째는 폰트 파일 목록을, 두 번째는 해결자가 반환할 패밀리 이름 목록을 보여주며, 두 결과 사이의 차이가 파일명 매칭이 실패하는 이유입니다. fc-list가 없으면 그 자체가 답입니다: fontconfig가 설치되지 않았으며, 어떤 패밀리 조회도 눈을 가리고 수행됩니다.

서비스 내부에서는 동일한 검사를 시작 로그에 넣어 해결된 패밀리와 함께 출력합니다. fonts on disk: 8, latin: DejaVu Sans, cjk: (none)와 같은 라인은 다음 담당자에게 이 컨테이너가 무엇을 서명할 수 있고 없는지를 정확히 알려주며, 새벽 3시에 예외를 읽는 것보다 훨씬 유용합니다.

JVM 디테일, 예상치 못한 부분

Java와 관련해서 또 하나의 함정이 있는데, 이는 폰트와는 무관합니다. GroupDocs Maven 아티팩트는 서명된 fat jar입니다. 이를 shaded jar로 재패키징하면 NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions가 발생하고, 일반적인 META-INF/*.SF|RSA|DSA 삭제만으로는 해결되지 않습니다: MANIFEST.MF에 19 MB에 달하는 엔트리 다이제스트가 포함되어 있어 메인 섹션만 남기도록 잘라야 합니다. 샘플은 dependency/ 디렉터리를 사용한 일반 클래스패스로 실행함으로써 이 문제를 회피합니다.

이 부분을 언급하는 이유는 부분 폰트 커버리지와 서명된 jar가 모두 “JVM 경로가 코드처럼 보이지만 실제는 다르다”는 형태를 공유하기 때문입니다. 두 문제 모두 일단 원인을 알면 저비용으로 방어할 수 있습니다: 정상 동작하는 클래스패스 레이아웃을 고정하고, 베이스 이미지에 의존하지 말고 시작 시점에 폰트 커버리지를 검증하세요. 재설계가 필요하지 않으며, 두 문제 모두 애플리케이션 버그와 구분하기 어려운 사고 유형을 제거합니다.

결론

컨테이너 안의 Java 서명 서비스는 한 레이어의 Dockerfile과 한 번의 시작 시점 검증만으로 예측 가능해집니다. fontconfig, DejaVu, Liberation, Noto CJK를 설치하고, 패밀리를 추측이 아니라 탐색으로 해결하며, 삽입할 수 없는 서명은 건너뛰고, 읽어보는 방식으로 검증하세요. 샘플 레포지토리는 두 이미지 모두 제공하므로, 커버리지가 있는지 없는지를 확인하려면 두 번의 빌드가 아니라 한 번의 사고로 알게 됩니다.

추가 자료