💡 전체 작동 예제는 GitHub에서 확인할 수 있습니다:
sign-pdf-in-linux-container-fonts-dotnet
예전 방식은 고통스러웠다
서비스는 청구서를 서명합니다. 노트북에 300개의 폰트를 설치한 상태로 실행되며 검토를 통과하고 금요일에 컨테이너화됩니다. 월요일에 클러스터에서 첫 번째 작업이 Sign document error: Font Arial was not found 오류와 함께 비정상 종료되고, 누군가가 mcr.microsoft.com/dotnet/runtime:8.0 이미지에 실제로 어떤 폰트가 포함되어 있는지 묻기 전까지는 아침 내내 스택 트레이스를 읽는 데 시간을 보냅니다.
답은 없습니다. 이미지에 폰트 파일이 전혀 없습니다. 이 글의 샘플이 실행되는 이미지에서 측정한 결과입니다.
다른 런타임이 어떻게 비교되는지 아는 것이 유용합니다. 실패 현상이 각각 다르게 나타나기 때문입니다. eclipse-temurin:17-jre는 AWT용 8개의 DejaVu 파일을, node:18-bookworm은 6개의 파일을 번들링합니다. 그래서 JVM과 Node 이미지에서는 라틴 텍스트는 조용히 서명되지만 일본어 또는 중국어 문자열이 들어오면 오류가 발생합니다. python:3.11-slim은 .NET 런타임 이미지와 마찬가지로 폰트가 전혀 없으므로 첫 번째 서명에서 바로 실패합니다. 어느 이미지에서도 CJK 폰트를 무료로 제공하지 않습니다.
컨테이너 폰트 프로비저닝은 Linux 이미지에서 GroupDocs.Signature for .NET으로 텍스트 서명을 작동하게 하는 단계입니다. 라이브러리는 누락된 폰트 패밀리를 대체하지 않으며, 설치되지 않은 폰트를 지정하면 오류가 발생하고 문서가 생성되지 않습니다. 이 글에서는 폰트가 없는 이미지를 폰트가 있는 이미지와 나란히 두고, 어떤 점이 바뀌었는지 보여주며, 개발 머신에서 동일한 코드를 작동하게 하는 런타임 해결 방식을 다룹니다.
더 나은 방법이 있다
두 가지 조건이 충족되어야 합니다. 이미지에 최소 하나의 폰트가 있어야 하고, 코드가 특정 폰트를 가정하지 않아야 합니다.
첫 번째는 Dockerfile 레이어이며, 두 번째는 해결 단계입니다: Arial을 하드코딩하는 대신 라이브러리에 여러 후보 패밀리 중 실제로 사용할 수 있는 것을 물어보고, 첫 번째로 동작하는 것을 유지합니다. 이렇게 하면 슬림 컨테이너, Windows, CI 어디서든 환경을 확인하지 않은 채로 가정하지 않기 때문에 코드가 그대로 작동합니다.
작동하지 않는 한 가지 방법이 있는데, 이는 사람들이 가장 먼저 시도하는 것이므로 명확히 밝혀두어야 합니다: 폰트를 지정하지 않음. SignatureFont를 사용하지 않으면 GroupDocs.Signature는 기본값인 Times New Roman을 찾으려 하는데, 폰트가 없는 이미지에도 이 폰트가 없습니다. 호출은 동일하게 실패합니다.
새로운 방법: 두 이미지, 하나의 차이점
단계 1 - 이미지에 무엇이 있는지 확인
서명하기 전에 폰트 파일을 나열합니다. 개수를 확인하면 모호한 예외가 진단으로 바뀝니다. 폰트가 전혀 없고 패밀리 이름이 잘못된 경우 각각 다른 해결책이 필요합니다:
string[] roots =
{
"/usr/share/fonts",
"/usr/local/share/fonts",
Path.Combine(home, ".fonts"),
Path.Combine(home, ".local/share/fonts"),
Environment.GetFolderPath(Environment.SpecialFolder.Fonts),
"/System/Library/Fonts",
"/Library/Fonts",
};
부재한 항목에 주목하세요: System.Drawing. .NET 7 이후부터 System.Drawing.Common은 Windows 전용이며 Linux에서는 예외를 발생시킵니다. 따라서 해당 코드를 기반으로 만든 폰트 로직은 컨테이너에서 두 번째, 무관한 이유로 실패합니다.
단계 2 - 폰트 레이어 추가
패키지 4개와 RUN 한 줄만으로 실패가 사라집니다:
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/*
fontconfig는 해결 도구이며 디버깅용 fc-list를 제공합니다. fonts-dejavu-core는 라틴, 그리스, 키릴 문자 최소 세트를 포함합니다. fonts-liberation은 Arial, Times New Roman, Courier New과 메트릭이 호환되는 대체 폰트를 제공하는데, 이는 Windows에서 만든 문서가 실제로 참조하는 폰트입니다. fonts-noto-cjk는 중국어, 일본어, 한국어를 커버합니다.
단계 3 - 패밀리를 지정하지 말고 해결하기
포터블하게 폰트를 선택하는 방법은 후보마다 임시 서명을 시도해보고 예외가 발생하지 않는 첫 번째를 유지하는 것입니다:
foreach (string candidate in candidates)
{
if (TryFamily(sourcePath, candidate).Ok)
{
return candidate;
}
}
return null;
파일명 매칭은 매력적인 단축키처럼 보이지만 잘못된 방법입니다. Debian의 fonts-noto-cjk는 NotoSansCJK-Regular.ttc를 설치하는데, 패밀리 이름은 Noto Sans CJK JP입니다. 파일명 매칭은 실제 존재하는 폰트를 놓치고, SignatureFont에 전달했을 때 해결되지 않을 패밀리를 잘못 선택하게 됩니다.
단계 4 - 해결된 폰트로 서명하고, 서명된 내용을 검증하기
라틴 패밀리는 반드시 해결되어야 하고, CJK 패밀리는 선택 사항이며 없을 경우 건너뛰고 충돌하지 않도록 합니다:
var options = new List<SignOptions>
{
BuildTextOptions(LatinText, latinFamily, top: 50),
};
if (cjkFamily is not null)
{
options.Add(BuildTextOptions(CjkText, cjkFamily, top: 120));
}
SignResult result = signature.Sign(outputPath, options);
그 다음 파일을 다시 읽어야 합니다. CJK 폰트가 없으면 아무것도 표시되지 않는 빈 상자로 렌더링될 수 있으며, 이 경우 전혀 오류가 발생하지 않습니다:
var options = new TextSearchOptions { AllPages = true };
List<TextSignature> found = signature.Search<TextSignature>(options);
나란히 비교: 이전 vs. 이후
Dockerfile.nofonts |
Dockerfile |
|
|---|---|---|
| 이미지에 있는 폰트 파일 | 0 | DejaVu, Liberation, Noto CJK |
| 라틴어 텍스트 서명 | 실패, 종료 3 | 작성되고 읽어올 때 복구됨 |
| CJK 텍스트 서명 | 실패 | 작성되고 복구됨 |
| 표시된 오류 | Font <name> was not found |
없음 |
| 코드 차이 | 없음 - 동일한 바이너리 | 없음 - 동일한 바이너리 |
마지막 행이 핵심입니다. 두 실행 사이에 애플리케이션 코드가 전혀 바뀌지 않았습니다. 샘플 저장소는 두 파일을 모두 제공하므로 비교를 위해 docker build 명령을 두 번 실행합니다. 이후에도 폰트가 없는 변형을 저장소에 남겨두세요. 이는 누군가가 6개월 후에 베이스 이미지를 교체했을 때 서명이 조용히 사라지는 문제를 가장 빠르게 재현할 수 있는 방법입니다.
왜 모든 폰트를 설치하지 않을까?
이미지 크기는 실제 제약이며, 위 네 패키지만으로 대부분 문서가 사용하는 스크립트를 커버합니다. fonts-dejavu-core만으로도 라틴, 그리스, 키릴 서명이 충분합니다; Windows 패밀리 이름을 참조하는 문서에는 Liberation이 필요하고; Noto CJK는 실제로 용량이 크며 동아시아 텍스트를 서명할 때만 필요합니다. 문서에 필요한 폰트만 설치하고, 읽어오는 단계에서 검증하세요.
실제 사례: 배치 서명 워커
큐 워커는 매일 밤 수천 개의 PDF에 서명합니다. 시작 시에 패밀리를 해결하고 사용할 패밀리 이름을 한 줄 로그에 남기며, 해결되지 않으면 큐에 손대기 전에 종료합니다. 이렇게 하면 메시지당 실패가 아니라 한 줄 이유로 컨테이너가 시작 자체를 거부하게 됩니다.
프로빙 비용은 시작 시 무시할 정도로 작고, 문서당 반복하기엔 너무 큽니다. 각 프로빙은 실제 서명을 임시 파일에 기록하므로 라틴 리스트는 최대 네 번, CJK 리스트는 최대 여덟 번의 서명이 필요합니다(모두 한 페이지 PDF 기준). 한 번 해결하고 두 패밀리 이름을 캐시하면, 문서당 경로는 이전과 동일합니다: 옵션을 만들고 Sign을 호출한 뒤 결과 개수를 읽어옵니다.
제가 추측 로직을 사용했을 때 오후 내내 시간을 잃었습니다. 폰트 디렉터리를 스캔해 NotoSansCJK-Regular.ttc를 찾고 CJK가 사용 가능하다고 보고했지만, 파일명에서 파생된 패밀리 이름으로는 모두 실패했습니다. 실제 서명으로 프로빙하는 것이 더 간단하고 정확했습니다.
컨테이너에서 또 무엇이 문제를 일으키나요?
하나 더 있는데, 이는 폰트와는 무관합니다: InvariantGlobalization=true. 이는 .NET 이미지에서 ICU를 제거하기 위한 일반적인 권고사항이며, GroupDocs.Signature와 함께 사용하면 첫 번째 new Signature(...) 호출이 CultureNotFoundException: ... en-US is an invalid culture identifier 예외를 발생시킵니다. 이는 SignatureSettings가 CultureInfo("en-US")를 생성하기 때문입니다. 글로벌화를 활성화하고 ICU를 이미지에 남겨두세요. 시스템 요구 사항 페이지에서 베이스 이미지 선택 전에 플랫폼 지원을 확인할 수 있습니다.
결론
로컬에서는 정상 작동하지만 Docker에서는 실패하는 서명 서비스는 거의 항상 폰트가 누락된 경우이며, 해결 방법은 네 개의 패키지 레이어와 패밀리를 가정하지 않고 해결하는 코드입니다. 샘플에서 두 이미지를 모두 빌드하고 나란히 실행한 뒤 [fonts] 라인을 확인하세요. 전체 논쟁은 그 한 줄 비교에 들어갑니다.