💡 전체 작동 예제가 GitHub에 있습니다:
nodejs-docker-signing-with-fonts
소개
폰트 해석은 컨테이너 서명 과정에서 Node 서비스가 문서를 생성할지 예외를 발생시킬지를 결정하는 부분입니다. GroupDocs.Signature은 누락된 패밀리를 대체하지 않으며, 이미지에 존재하지 않는 패밀리를 지정하면 호출이 예외를 발생시키고 아무 것도 쓰지 않습니다. 폰트를 지우는 것도 해결책이 되지 않는데, 라이브러리가 자체 기본값을 요청하면서 동일한 방식으로 실패하기 때문입니다.
패밀리를 선택하는 방법은 세 가지가 있으며, 그 중 하나만 컨테이너 환경에서 살아남습니다. 이 글에서는 세 방법을 비교하고, 프로비저닝 및 바인딩 동작을 다루어 코드가 어떻게 형성되는지 설명합니다. Node.js가 Java를 통해 동작하기 때문에 다른 플랫폼보다 더 많은 바인딩 특성이 존재합니다.
Node.js에서 이것이 더 중요한 이유
이 패키지는 브리지 역할을 합니다: node-java가 프로세스 내에 JVM을 로드합니다. 따라서 Node 서명 이미지에는 JDK, 브리지를 빌드하기 위한 node-gyp 툴체인, 그리고 LD_LIBRARY_PATH가 libjvm.so를 가리키도록 설정되어 있어야 하며, 폰트와 관련된 작업보다 먼저 준비되어야 합니다. node:18-bookworm 이미지에는 AWT용 DejaVu 폰트 6개가 포함되어 있는데, 이는 라틴 문자에는 충분하지만 CJK 문자에는 전혀 없습니다.
이 조합은 애플리케이션 버그처럼 보이는 실패를 일으킵니다. JVM 경로 누락, 폰트 누락, 마샬링 불일치가 모두 Error running instance method 로 표출되는데, 이는 node-java가 Java 측에서 발생한 모든 예외를 이렇게 보고하기 때문입니다.
전제 조건
Node 18 – 브리지는 NAN을 사용해 빌드되며, 이는 Node 20 또는 22의 V8와 호환되지 않아 ('AccessorSignature' is not a member of 'v8') 컴파일되지 않습니다. JDK 8~17: JDK 25에서는 이미지 레이어가 Cannot open an image. The image size can not be 0! 오류로 실패합니다.
설치
npm install @groupdocs/groupdocs.signature
이미지 안에서는 build-essential와 python3가 필요하고, openjdk-17-jdk-headless와 로더 경로도 설정해야 합니다:
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH="${JAVA_HOME}/bin:${PATH}"
# node-java dlopens libjvm.so at run time; it is not on the default loader path.
ENV LD_LIBRARY_PATH="${JAVA_HOME}/lib/server:${LD_LIBRARY_PATH}"
방법 1 - 패밀리 이름을 하드코딩
모두가 가장 먼저 쓰는 버전: Arial을 선택하고, 이미지를 포함시킨 뒤 진행합니다. 개발 머신에서는 동작하지만 첫 컨테이너 실행 시 실패합니다. Debian 이미지에는 Arial이 설치되지 않고, 대신 메트릭 호환성이 있지만 다른 패밀리 이름을 가진 Liberation Sans가 설치되기 때문입니다.
여기에 보여줄 코드가 없다는 것이 요점입니다. 이 방법의 전체 내용은 특정 환경에서만 참인 문자열 리터럴일 뿐입니다.
방법 2 - 파일 시스템에서 폰트 감지
자연스러운 해결책: 폰트 디렉터리를 스캔하고, 존재하는 폰트를 확인한 뒤 선택합니다. 실제로 유용한 부분은 이미지에 폰트가 0개인지 6개인지 알려주는 인벤토리입니다:
const roots = [
'/usr/share/fonts',
'/usr/local/share/fonts',
path.join(home, '.fonts'),
path.join(home, '.local', 'share', 'fonts'),
'/System/Library/Fonts',
'/Library/Fonts',
];
하지만 나머지 절반은 작동하지 않습니다. 폰트 파일은 호출자가 전달해야 할 패밀리 문자열을 거의 포함하지 않기 때문입니다. 예를 들어 Debian의 fonts-noto-cjk는 NotoSansCJK-Regular.ttc를 설치하는데, 이 파일의 패밀리는 Noto Sans CJK JP입니다. 파일명에서 패밀리를 추출하면 NotoSansCJK-Regular가 되며, 이는 아무 것도 찾지 못합니다. 파일명 기반 감지는 실제 존재하는 폰트를 놓치고, 실패할 패밀리를 자신 있게 반환합니다.
인벤토리는 진단용으로만 유지하세요. 선택에 사용하지 마십시오. 카운트는 이미지가 전혀 프로비저닝 되었는지 여부를 알려 주며, 이는 별도이면서도 동일하게 유용한 질문입니다.
방법 3 - 라이브러리에게 문의
각 후보 패밀리마다 임시 서명을 시도하고, 예외가 발생하지 않는 첫 번째 패밀리를 선택합니다. 후보당 PDF를 한 번 쓰게 되지만, 실제 서명 호출과 동일한 호출이므로 답변이 가장 권위 있습니다.
for (const candidate of candidates) {
if (tryFamily(sourcePath, candidate) === null) {
return candidate;
}
}
return null;
Node에서는 탐색에 한 줄이 더 필요합니다. node-java는 모든 Java 예외를 Error running instance method 로 축소하기 때문에, 실제 메시지를 래핑된 스택 트레이스에서 복구해야 합니다:
const stack = err.stack || '';
const match = stack.match(/com\.groupdocs\.signature\.exception\.[^\n]*/);
return match ? match[0].trim() : (err.message || String(err));
이 두 줄이 없으면 폰트가 없는 컨테이너와 JVM 경로가 깨진 경우가 동일한 로그를 출력합니다. 정규식을 추가하기 전까지 전혀 다른 이유로 동일한 오류를 출력하는 두 컨테이너를 비교하는 데 생각보다 오래 걸렸습니다.
탐색 비용
탐색에 대한 반대 의견은 파일을 만든다는 점인데, 실제로 그렇게 됩니다. 후보당 작은 PDF 하나를 만들고 즉시 삭제합니다. 샘플의 라틴 리스트에는 4개의 항목이, CJK 리스트에는 8개의 항목이 있어, 콜드 스타트 시 최대 12개의 1페이지 문서를 임시 디렉터리에 작성한 뒤 서비스가 준비됩니다.
이는 요청당 비용이 아니라 시작 비용이며, 두 패밀리 모두를 로그에 남길 수 있게 해 줍니다. 깨끗하게 시작하고 첫 고객 문서에서 브리지 오류로 실패하는 컨테이너와 비교했을 때, 12개의 임시 파일은 큰 부담이 아닙니다.
방법 비교: 언제 사용할지
| 방법 | 가장 적합한 상황 | 주요 장점 | 제한 사항 |
|---|---|---|---|
| 패밀리 하드코딩 | 단일 제어된 환경 | 간단하고 시작 비용 없음 | 해당 패밀리가 없는 이미지에서는 모두 실패 |
| 파일명 감지 | 이미지에 무엇이 들어있는지 진단 | 빠르고 서명 호출 없음 | 파일명이 패밀리 이름이 아니므로 파생된 선택이 실패 |
| 라이브러리 탐색 | 컨테이너화 혹은 이식 가능한 모든 경우 | 권위 있음, 노트북과 이미지 모두에서 동작 | 후보당 PDF를 하나 작성하므로 시작 시 해결하고 캐시 필요 |
알아두면 좋은 두 가지 바인딩 특이점
패밀리가 해결되면 서명 호출 자체가 Node 전용 형태를 가집니다. Java API는 옵션 리스트를 받지만, JavaScript 배열은 java.util.List 로 마샬링되지 않아 Could not find method "sign(java.lang.String, [Ljava.lang.Object;)" 오류가 발생합니다. 해결 방법은 단일 옵션 오버로드를 체인하고 임시 파일을 통해 단계적으로 호출하는 것입니다:
new signatureLib.Signature(sourcePath)
.sign(firstOutput, buildTextOptions(LATIN_TEXT, latinFamily, 50));
if (stageTwo) {
new signatureLib.Signature(firstOutput)
.sign(outputPath, buildTextOptions(CJK_TEXT, cjkFamily, 120));
}
두 번째 특이점은 읽어오기(read‑back)입니다. TextVerifyOptions는 이 바인딩을 통해 라운드트립되지 않으며, verify 호출이 동일한 일반 브리지 오류를 발생시킵니다. 따라서 샘플은 sentinel 값을 반환하고 unavailable을 출력해 서명이 실패했다고 가장하지 않습니다. npm 패키지는 2024년 12월에 배포된 24.12.0 버전이며, 엔진은 23.6.1을 포함하고 있습니다. 반면 .NET은 26.6, Java는 26.5 버전입니다. 서명 기능은 영향을 받지 않지만, 검증 경로가 누락되었습니다.
프로덕션에서 여전히 Node.js 바인딩을 사용해야 할까?
라틴 문자만 서명한다면 예, 정상적으로 서명되며 누락된 폰트는 조용히 degrade되지 않고 예외를 발생시켜 실패 모드가 명확합니다. 혼합 스크립트 작업의 경우, 읽어오기 기능이 없다는 점을 고려해야 합니다. 프로세스 내에서 CJK 글리프가 실제로 삽입되었는지 확인할 방법이 없기 때문입니다. 같은 파이프라인에 .NET 또는 Java 기반의 작은 검증기를 두면 이 격차를 메울 수 있습니다.
모범 사례 및 팁
- 순서대로 프로비저닝하세요: JDK와 툴체인 → 로더 경로 → 폰트 → 애플리케이션. 각 레이어는 서로 다른 방식으로 실패하므로 섞이면 진단이 느려집니다.
- 시작 시 패밀리를 한 번 해결하고 폰트 수와 함께 로그에 남기세요.
- Node 18과 JDK 8~17 사이 버전을 고정하고, 이를 일상적인 업그레이드가 아닌 고정 인프라로 취급하세요.
- 폰트가 없는 Dockerfile을 저장소에 보관해 두어, 실패가 언제든 한 번의 빌드로 재현될 수 있게 하세요.
결론
폰트를 선택하는 방법은 세 가지, 배포 후에도 살아남는 방법은 하나입니다. 라이브러리를 탐색하고 답을 캐시하며, 인벤토리는 결정이 아니라 진단용으로 활용하세요. 그런 다음 바인딩이 제공하는 대로 작업합니다: 옵션 하나씩 서명하고, 스택 트레이스에서 Java 예외를 추출하며, 검증이 누락된 경우 숨기지 말고 솔직히 보고합니다. 샘플 저장소는 두 이미지를 모두 빌드하므로 여기서 언급한 모든 내용은 두 명령으로 검증할 수 있습니다.