카테고리 없음

추측하지 말고 측정하라: 엑셀 다운로드 OOM 분석기

ryankang 2026. 5. 22. 17:20

Java 8 / Spring MVC / Tomcat 8.x 레거시 시스템에서 발생한 엑셀 다운로드 OOM을 힙 덤프로 추적하고, 수치로 검증한 과정을 기록한다.


장애 발생

어느 날 운영 서버에서 알람이 울렸다.

java.lang.OutOfMemoryError: Java heap space
  at ....CommonUtil.makeExcel(CommonUtil.java:842)

엑셀 다운로드 요청 하나에 -Xmx2GB JVM이 죽었다. 처리하던 데이터는 외부 상품권 API에서 받아온 약 139,699건이었다.

첫 반응은 "데이터가 너무 많아서 그런 거 아냐?" 였다. 맞다. 근데 그게 전부가 아니었다. 139,699건에서 2GB가 모자란지를 알아야 진짜 해결책이 나온다.


추측 대신 힙 덤프

추측으로 코드를 고치는 건 운이다. 힙 덤프를 떠서 확인하기로 했다.

JVM 옵션에 -XX:+HeapDumpOnOutOfMemoryError가 걸려 있었고, 덤프 파일을 Eclipse MAT으로 열었다.

조치 전 힙 상위 객체:

클래스 인스턴스 수 Shallow Size
Xobj$AttrXobj (XMLBeans) 4,310,844 655 MB
Xobj$ElementXobj (XMLBeans) 3,234,220 517 MB
char[] 6,835,540 241 MB
XSSFCell (POI) 1,385,604 67 MB

XMLBeans 객체 두 종류가 1.17 GB를 먹고 있었다.

이게 왜 생기는지 이해하려면 XSSFWorkbook의 구조를 봐야 한다.

XSSFWorkbook이 왜 이렇게 메모리를 쓰냐

XSSFWorkbook은 xlsx 파일 전체를 XML DOM 트리로 힙에 올린 뒤, wb.write(out)을 호출하는 순간 XMLBeans가 그 DOM을 직렬화한다. 행이 늘어날수록 DOM 크기도 선형으로 늘어난다.

wb.write(out) 호출 순간:
  XSSFSheet.write()
    → XSSFSheet.commit()
      → POIXMLDocument.write()
        → Xobj.setValue()  ← 여기서 OOM

즉, 139,699행이 문제가 아니라 "전체 행을 한 번에 DOM으로 만들고 직렬화하는 구조" 가 문제였다.


해결책: SXSSFWorkbook

Apache POI에는 이미 이 문제에 대한 공식 해결책이 있다. SXSSFWorkbook이다.

동작 방식이 다르다. 슬라이딩 윈도우(기본 100행)만 힙에 유지하고, 나머지는 임시 파일에 스트리밍으로 기록한다. 행이 100만 개라도 힙에 있는 건 항상 100행뿐이다.

변경 전

XSSFWorkbook wb = new XSSFWorkbook();
// ... 엑셀 생성 후 별도 리소스 해제 없음

변경 후

SXSSFWorkbook wb = null;
try {
    wb = new SXSSFWorkbook();
    // ... 엑셀 생성
} finally {
    if (wb != null) {
        wb.dispose(); // 임시 파일 정리 — 이걸 빼먹으면 tmp 디렉토리가 쌓인다
        wb.close();
    }
}

dispose()를 빼먹으면 임시 파일이 계속 쌓인다. finally로 보장해야 한다.


고쳤으면 검증해야지

코드를 바꾼 것만으로는 부족하다. 실제로 메모리가 어떻게 달라졌는지 확인해야 한다.

수정 후 같은 139,699건을 실행하고 힙 덤프를 다시 떴다.

조치 후 힙 상위 객체:

클래스 인스턴스 수 Retained Size
JsonObject (Gson) 139,699 355 MB
LinkedTreeMap$Node (Gson) 1,676,388 340 MB
char[] 3,097,714 111 MB

XMLBeans 객체가 완전히 사라졌다. 힙 1위가 POI에서 Gson(입력 데이터)으로 바뀐 것은 조치가 의도한 대로 동작했다는 뜻이다.


얼마나 달라졌는지 직접 재봤다

"OOM이 안 나는 것"만으로는 충분하지 않다고 생각했다. 얼마나 빨라졌는지, 메모리가 얼마나 줄었는지 수치로 보고 싶었다.

JUnit 4 + 가짜 HttpServletResponse로 실제 makeExcel 코드 경로를 직접 호출하는 테스트를 작성했다. XSSF와 SXSSF를 별도 JVM으로 격리(forkEvery 1)해서 XSSF OOM이 SXSSF 결과에 영향을 주지 않도록 했다.

XSSF 결과 (전환 전, -Xmx5120m)

XSSF를 20만건까지 모두 완료시키려면 5GB 힙이 필요했다. 운영 환경 2GB에서는 10만건부터 OOM이 났다.

건수 Excel 생성(ms) 피크힙 증가 결과
50,000 6,814 638 MB
100,000 12,470 1,276 MB ❌ (2GB 환경)
150,000 16,101 1,699 MB
200,000 21,134 2,092 MB

SXSSF 결과 (전환 후, -Xmx2048m)

건수 Excel 생성(ms) 피크힙 증가 결과
50,000 1,955 153 MB
100,000 1,612 174 MB
150,000 2,234 242 MB
200,000 2,805 291 MB

20만건 기준 비교

지표 XSSF SXSSF 개선
Excel 생성 시간 21,134 ms 2,805 ms 7.5배 빠름
피크힙 증가 2,092 MB 291 MB 7.2배 적음
필요 힙 5 GB 이상 2 GB

SXSSF의 피크힙이 건수와 무관하게 150~290MB로 거의 일정한 것이 핵심이다. 행이 늘어도 Excel 생성 단계의 메모리는 슬라이딩 윈도우(100행) 덕분에 늘지 않는다.


남은 문제

해결했지만 완전하지는 않다. 구조적으로 한 가지 리스크가 남아 있다.

외부 API 전체 조회 → JsonArray 메모리 로드 → SXSSF로 스트리밍 기록

SXSSF로 Excel 생성 단계는 해결됐지만, API에서 받아온 데이터 전체를 메모리에 올려두는 구조는 그대로다. 현재 139,699건에서 Gson 데이터가 약 350MB를 점유한다. 행당 ~2.5KB니까 80만건을 넘어가면 다시 OOM이 난다.

근본적인 해결은 외부 API를 페이징 방식으로 바꾸고, 청크 단위로 SXSSF에 순차 기록하는 구조로 전환하는 것이다. 현재는 API 스펙 협의가 필요한 단계라 별도 태스크로 관리 중이다.


회고

이번 작업에서 확인한 것들:

  1. OOM은 추측하지 않는다. 힙 덤프를 먼저 본다. 덤프를 보지 않으면 진짜 원인을 모르고 "heap 늘리기"나 "데이터 제한"처럼 증상만 건드리게 된다.
  2. 고쳤으면 다시 측정한다. 수정 후 힙 덤프로 XMLBeans 객체가 사라진 것을 확인했다. 눈으로 확인하지 않으면 "아마 됐겠지"로 끝난다.
  3. 벤치마크가 설득력을 만든다. "SXSSF가 좋다"는 말보다 "20만건에서 7.5배 빠르고 메모리가 7.2배 적다"는 수치가 훨씬 강하다. 팀에 설명할 때도, 본인이 확신을 가질 때도.
  4. 해결책이 새 문제를 드러낸다. SXSSF로 바꾸고 나서야 진짜 병목이 API 데이터 로딩 단계라는 게 명확해졌다. 레이어를 하나씩 벗겨야 그 아래가 보인다.

참고

  • Apache POI 공식 문서 — Streaming User API (SXSSF)
  • Eclipse MAT — 힙 덤프 분석 도구
  • 테스트에 사용한 JUnit 4 기반 벤치마크 코드는 별도 포스팅으로 정리할 예정