대용량 처리를 위해 엑셀 파일 생성 구현체를 변경했습니다. 로컬과 DEV 환경에서 검증을 마치고 운영 서버에 배포했는데, 운영 환경의 엑셀 다운로드 API에서 에러가 발생했습니다.
1. 현상 분석: 스프링 예외로 감싸인 네이티브 에러
로그에서 가장 먼저 확인한 것은 스프링 MVC의 예외였습니다.
org.springframework.web.util.NestedServletException: Request processing failed;
nested exception is org.springframework.web.handler.HandlerDispatchResolverException:
Handler dispatch failed; nested exception is java.lang.UnsatisfiedLinkError:
/usr/local/openjdk-21/lib/libfontmanager.so: libfreetype.so.6: cannot open shared object file: No such file or directory로그 상단의 Handler dispatch failed는 Spring MVC에서 요청을 처리(컨트롤러 실행 등)하는 도중 예외가 발생했을 때 DispatcherServlet이 이를 감싸서 던지는 최상위 래퍼(Wrapper) 에러입니다.
이 문구 자체는 "요청 처리 중에 문제가 생겼다"는 범용적인 신호이므로, 원인을 찾으려면 그 안에 중첩된 실제 예외에 집중해야 합니다.
java.lang.UnsatisfiedLinkError
Java 공식 문서에 따르면, 이 예외는 JVM이 적절한 네이티브 언어 정의(Native-language definition)를 찾지 못하거나, 의존하는 네이티브 라이브러리(.so 또는 .dll 파일)를 로드하는 데 실패했을 때 발생합니다.
이번 로그에서는 JVM이 JDK 내부의 libfontmanager.so를 로드하려다, 이 파일이 의존하는 OS 레벨 공유 라이브러리인 libfreetype.so.6를 찾지 못해 발생한 경우였습니다.
2. openjdk:21 vs openjdk:21-slim
로컬 도커 환경도 Java 21이고 운영도 Java 21인데, 왜 로컬에만 해당 파일이 있는지 확인해야 했습니다. 원인은 Dockerfile의 베이스 이미지 태그 차이였습니다.
- 로컬 개발용 Dockerfile:
FROM openjdk:21 - 운영(Prod)용 Dockerfile:
FROM openjdk:21-slim
Debian 공식 패키지 명세를 보면 libfreetype6는 "FreeType 2 font engine, shared library files"로 정의되어 있습니다.
일반 openjdk:21 이미지에는 Linux 표준 폰트 엔진과 기본 OS 패키지가 포함되어 있는 반면, -slim 버전은 컨테이너 용량을 줄이기 위해 자바 실행에 필요한 최소 패키지만 남기고 폰트 엔진 관련 파일을 제거합니다.
3. 원인: SXSSFWorkbook이 시트 생성 시 폰트를 참조한다
이번에 변경된 코드는 한 줄이었습니다. 엑셀 생성 워크북 구현체를 기존 XSSFWorkbook에서 스트리밍 방식인 SXSSFWorkbook으로 바꾼 것입니다.
// AS-IS
Workbook workbook = new XSSFWorkbook();
// TO-BE
SXSSFWorkbook workbook = new SXSSFWorkbook();코드 어디에도 폰트 스타일을 지정하는 로직은 없습니다. 컬럼 너비를 자동 계산하는 autoSizeColumn()도 사용하지 않았고, 셀에 값만 넣고 write()를 호출할 뿐입니다. 폰트를 직접 다루지 않는데도 폰트 라이브러리를 로드하다 실패한 이유는 SXSSFWorkbook이 시트를 생성하는 순간 내부적으로 기본 폰트를 참조하기 때문입니다. Apache POI 소스 코드를 따라가 보면 이 흐름을 확인할 수 있습니다.
SXSSFSheet는 생성자에서 무조건 AutoSizeColumnTracker를 생성합니다. 스트리밍 방식 특성상 행을 디스크로 흘려보내면서 컬럼 너비를 실시간으로 추적해야 하기 때문입니다.
// org.apache.poi.xssf.streaming.SXSSFSheet
public SXSSFSheet(SXSSFWorkbook workbook, XSSFSheet xSheet) throws IOException {
...
_autoSizeColumnTracker = new AutoSizeColumnTracker(this); // ← 시트 생성 시 무조건 실행
}그리고 이 AutoSizeColumnTracker 생성자는 내부적으로 기본 폰트의 글자 너비를 미리 계산해 둡니다.
// org.apache.poi.xssf.streaming.AutoSizeColumnTracker
public AutoSizeColumnTracker(final Sheet sheet) {
defaultCharWidth = SheetUtil.getDefaultCharWidthAsFloat(sheet.getWorkbook());
}이 getDefaultCharWidthAsFloat()가 바로 자바 그래픽 런타임(AWT)을 이용해 글자 폭을 재는 지점입니다. 텍스트를 가상으로 렌더링하여 픽셀 폭을 구하기 위해 TextLayout과 FontRenderContext를 사용하고, 이 과정에서 JDK 내부 폰트 네이티브 라이브러리(libfontmanager.so)를 로드하게 됩니다.
// org.apache.poi.ss.util.SheetUtil
public static float getDefaultCharWidthAsFloat(final Workbook wb) {
Font defaultFont = wb.getFontAt(0);
...
TextLayout layout = new TextLayout(str.getIterator(), fontRenderContext); // ← AWT 폰트 렌더링 발생!
return layout.getAdvance();
}즉 autoSizeColumn()을 직접 호출하지 않더라도, SXSSFWorkbook.createSheet()를 호출하는 순간 폰트 관련 네이티브 코드가 실행됩니다. 전체 장애 흐름을 요약하면 다음과 같습니다.
# ── 호출이 안으로 파고드는 단계 (call stack ↓) ──
1. 엑셀 다운로드 API 호출
2. └ new SXSSFWorkbook().createSheet()
3. └ SXSSFSheet 생성자가 new AutoSizeColumnTracker(this) 실행
4. └ AutoSizeColumnTracker → SheetUtil.getDefaultCharWidthAsFloat(workbook)
5. └ 기본 폰트 글자 폭 측정 위해 AWT 폰트 렌더링(TextLayout / FontRenderContext) 작동
6. └ JDK libfontmanager.so 로딩 시도 → 의존하는 libfreetype.so.6 호출
7. ✗ 운영은 openjdk:21-slim 이라 libfreetype.so.6 파일이 없음!
# ── 에러가 밖으로 전파되는 단계 (call stack ↑) ──
8. ↑ java.lang.UnsatisfiedLinkError 발생 → 호출 스택을 타고 위로 전파
9. ↑ Spring DispatcherServlet이 Handler dispatch failed로 감싸서 응답XSSF는 왜 멀쩡했을까?
기존에 사용하던 XSSFSheet는 이 AutoSizeColumnTracker를 미리 생성하지 않습니다. autoSizeColumn()을 직접 호출하지 않는 한 폰트 코드가 실행되지 않습니다. 반면 SXSSFSheet는 시트를 만드는 순간 추적기를 생성하면서 폰트를 참조합니다.
동일한 코드(문자열 셀만 채우고 write)를 워크북 구현체만 바꿔서 실행하면 차이가 드러납니다. 다음은 -verbose:class 옵션으로 sun.font.* 클래스 로딩 횟수를 측정한 결과입니다.
| 워크북 구현체 | sun.font.* 클래스 로드 횟수 |
slim 이미지에서의 동작 여부 |
|---|---|---|
XSSFWorkbook |
0회 (폰트를 건드리지 않음) | 정상 작동 |
SXSSFWorkbook |
82회 (FontManagerNativeLibrary 포함) |
네이티브 라이브러리 로드 실패로 UnsatisfiedLinkError |
이는 데이터의 행 수와도 무관합니다. 데이터가 10행이든 0행이든, 시트를 생성하는 순간 발생합니다.
참고: Apache POI에는 이 상황을 대비한 방어 코드가 있습니다.
SXSSFSheet생성자는UnsatisfiedLinkError를catch하지만, 에러 메시지에X11FontManager가 포함된 경우(과거 리눅스 X11 환경)에만 예외를 무시하고 넘어갑니다. 이번 에러는 메시지가libfontmanager.so: libfreetype.so.6...형태였기 때문에 조건에 맞지 않아 예외가 그대로 전파되었습니다.
4. 도커 이미지별 네이티브 라이브러리 특징 비교
도커는 호스트의 커널을 공유할 뿐, 베이스 이미지 내부의 '파일 시스템'을 패키징합니다. 따라서 베이스 이미지를 어떤 것으로 고르느냐에 따라 포함된 네이티브 파일 구성이 완전히 달라집니다. 흔히 쓰이는 자바 베이스 이미지들의 특징을 정리해 보았습니다.
| 이미지 접미사 (Variant) | 기반 OS (Base OS) | 특징 및 네이티브 라이브러리 구성 |
|---|---|---|
| default (기본형) | Ubuntu / Debian 계열 | 기본적인 패키지, glibc, 폰트 및 그래픽 라이브러리가 풍부하게 포함되어 있어 무겁지만 안정적임. |
-slim |
Debian 계열 (경량화) | 용량을 줄이기 위해 fontconfig, freetype 등 다수의 OS 패키지가 제거됨. (이번 장애의 원인) |
-alpine |
Alpine Linux | 극단적인 경량화 이미지. 단, 표준 glibc 대신 musl libc를 사용하여 일부 네이티브 라이브러리와 호환성 문제가 생길 수 있음. |
| distroless | 최소한의 런타임 | 패키지 매니저(apt, apk)나 Shell조차 없음. 애플리케이션 실행을 위한 최소 파일만 존재하여 보안성과 경량성이 매우 높음. |
5. 마무리: dev, stage, prod 환경은 최대한 같게 맞추자
이번 장애의 원인은 코드 버그가 아니라 실행 환경의 차이였습니다. 로컬·DEV는 openjdk:21(full), 운영은 openjdk:21-slim을 사용했고, 같은 자바 코드라도 그 아래의 OS 라이브러리 구성이 달랐기 때문에 사전 검증을 통과한 변경이 운영에서만 실패했습니다.
- 베이스 이미지를 통일한다. 운영을
-slim으로 최적화하기로 했다면, dev와 stage도 같은-slim이미지를 쓰는 게 맞습니다. 그래야 이번 같은 OS 의존성 문제가 운영까지 가기 전에 로컬/스테이지에서 먼저 드러납니다. - 환경 차이를 줄이면 검증의 신뢰도가 올라간다. "로컬에서 통과했다"는 말이 "운영에서도 통과한다"와 같은 의미가 되려면, 두 환경이 충분히 닮아 있어야 합니다.
자바 애플리케이션이 플랫폼 독립적으로 보여도, Apache POI의 폰트 렌더링처럼 OS 네이티브 라이브러리에 의존하는 부분은 환경 차이에 그대로 노출됩니다. 그 차이를 가장 확실하게 없애는 방법은 검증하는 환경과 배포하는 환경을 같게 만드는 것입니다.