이 글에서 살펴볼 내용
SXSSF를 사용해도 입력 List와 출력 버퍼에서 OOM이 발생할 수 있다. 같은 데이터로 네 가지 구현을 비교하며 메모리가 필요한 지점을 살펴본다.
6개월치 매출을 다운로드하면 서버가 멈췄다
이전에 운영하던 CMS에서 OOM 문제가 발생했다. 애플리케이션 로그에는 엑셀 다운로드 실행 이후 오류가 남았고, 다른 부서에서도 다운로드 이후 서버가 반응하지 않았다는 이야기를 들었다.
짧은 기간의 다운로드는 문제가 없었지만 6개월 이상 매출을 내려받을 때 문제가 나타났다. 금요일 저녁과 주말 결제 집중 시간대에도 OOM이 발생했다.
당시에는 리전별 EC2 사양을 확인해 JVM 메모리 설정을 조정하고, GPT의 도움으로 다운로드 코드를 분석해 XSSF를 SXSSF로 변경했다. 두 조치 이후 퇴사 시점까지 OOM은 재발하지 않았다.
다만 두 조치를 함께 적용했기 때문에 각각의 효과를 분리해서 측정한 것은 아니다. 이번 글에서는 당시 경험에서 출발해, SXSSF를 사용해도 OOM이 발생할 수 있는 두 지점을 살펴본다.
실험 코드 작성과 로컬 실행·결과 확인에는 AI 도구의 도움을 받았다. 운영 코드나 실제 매출 데이터 대신 별도의 가상 데이터를 사용했다.
엑셀 객체 앞뒤에도 데이터가 있다
다운로드 코드를 세 단계로 나누어 보았다.
SXSSF는 일정 범위의 행을 메모리에 유지하고 오래된 행을 임시파일로 넘기는 방식이다. 그러나 입력 List나 출력 버퍼까지 관리해 주는 것은 아니다.
따라서 new SXSSFWorkbook(100)의 100은 애플리케이션 전체가 보관하는 데이터 수나 메모리 용량의 상한이 아니다. Apache POI 공식 안내
비교할 지점은 두 가지다.
- 전체 입력을 먼저 List에 담으면 SXSSF를 사용해도 입력 단계에서 실패할 수 있다.
- 입력을 나누어 처리하더라도 출력 파일 전체를 메모리에 모으면 출력 단계에서 실패할 수 있다.
실험 조건
실제 매출이나 운영 DB 대신 가상 매출 데이터를 사용했다. 같은 id에는 같은 메모 문자열이 생성되므로 모드가 달라도 입력값은 같다. 메모는 영문과 숫자를 섞어 생성했고, 행별로 동일한 문자를 반복해 파일이 지나치게 잘 압축되는 상황을 피했다.
| 항목 | 조건 |
|---|---|
| 실행 환경 | 로컬 Java 21.0.1, Apache POI 5.2.3 |
| GC 및 힙 | G1 GC, -Xms96m -Xmx96m |
| 데이터 | 50,000행, 행당 메모 2,048자 |
| 열 | id, 매장, 금액, 메모 |
| SXSSF 행 윈도 | 100 |
| 입력 페이지 | 1,000건 |
| 실행 방식 | 모드마다 별도 JVM, 순차 실행 |
POI 버전은 로컬에 있던 의존성을 사용해 고정했다. 최신 버전을 권장하기 위한 선택은 아니다. DB 조회는 Java 객체 생성으로 모사했으므로 JDBC 스트리밍이나 DB 성능을 검증한 실험도 아니다.
1. 전체 List를 만들면 엑셀 생성 전에 실패할 수 있다
전체 입력 모드는 다음 구조다. 아래 코드는 실습의 핵심 부분을 발췌한 것이다. Sale은 id·매장·금액·메모를 가진 가상 매출 객체, page(start, end, width)는 해당 범위의 객체를 List로 만드는 함수, append()는 객체 하나를 시트의 한 행에 쓰는 함수다.
page(0, rows, width)가 모든 행의 객체와 문자열을 생성한다. 이 시점에는 아직 append()로 엑셀 행을 쓰기 전이다.
reachabilityFence는 실험에서 행 생성이 끝날 때까지 List가 도달 가능한 상태를 유지하도록 넣었다. GC를 실행하거나 객체를 해제하는 함수는 아니다.
5만 행 조건에서는 XSSF와 SXSSF 모두 materialize-all-input 단계에서 OOM으로 종료됐다. 엑셀 구현을 바꿔도 그 전에 만드는 입력 데이터의 크기는 그대로라는 점을 확인할 수 있었다.
이 결과로 XSSF와 SXSSF 자체의 메모리 차이를 비교할 수는 없다. Workbook 차이를 보려면 먼저 입력 List가 힙에 들어가는 조건을 만들고, 행 생성 단계까지 진행되는 별도 실험이 필요하다.
2. 입력 보관 범위도 함께 줄이기
페이지 모드는 다음과 같이 구현했다.
한 번에 1,000건을 만들고 시트에 쓴 뒤 다음 범위로 넘어간다. 이전 페이지를 별도 List에 계속 추가하지 않는다.
이전 입력 객체는 더 이상 참조되지 않으면 회수 대상이 될 수 있다. 반복이 끝날 때마다 즉시 GC된다는 의미는 아니다.
출력도 완성 파일 전체를 앱의 byte 배열에 모으지 않고 파일 스트림에 직접 쓴다.
이 모드는 같은 5만 행 조건에서 정상 완료됐다. 입력과 행 생성 양쪽의 보관 범위를 제한하고, 출력 전체를 별도 메모리 버퍼에 쌓지 않은 구현이다. 내부 버퍼나 다른 객체의 메모리 사용까지 없어지는 것은 아니다.
3. 마지막에 파일 전체를 모으면 다시 실패한다
출력 버퍼 모드는 페이지 처리와 SXSSF를 그대로 사용하고 마지막 부분만 바꿨다.
ByteArrayOutputStream은 완성 파일을 메모리에 모은다. 이어서 toByteArray()를 호출하면 별도 배열로 복사한다. 따라서 앞 단계가 제한된 메모리로 동작해도 마지막 출력 방식에 따라 부담이 다시 커질 수 있다.
실제 큰 실험에서는 write-output 단계에서 OOM이 발생했고 buffer-plus-copy 로그는 남지 않았다.
즉, 이번 실패는 toByteArray() 복사 시점이 아니라 그 전에 wb.write(buffer)를 실행하는 도중에 발생했다. 복사본은 추가 부담이 될 수 있는 코드상의 요소지만 이번 실패 지점과는 구분해야 한다.
결과와 검증 범위
| 모드 | 결과 | 확인된 실패 단계 |
|---|---|---|
| 전체 List + XSSF + 파일 출력 | OOM | 입력 List 생성 |
| 전체 List + SXSSF + 파일 출력 | OOM | 입력 List 생성 |
| 페이지 처리 + SXSSF + 파일 출력 | 성공 | — |
| 페이지 처리 + SXSSF + 메모리 출력 버퍼 | OOM | 출력 버퍼에 쓰기 |
성공한 페이지 모드의 프로그램 내부 시간은 9.348초, 50ms 간격으로 관찰한 힙 사용량 최대는 63.10MiB였다.
출력 파일은 78,691,392바이트였고 정리 전 임시파일의 크기 합은 112,465,860바이트였다. 메모리와 함께 임시 디스크 사용도 살펴볼 필요가 있었다.
이 수치는 단일 실행 결과다. 샘플링 사이의 순간 최대값은 놓칠 수 있고, 힙 사용량은 프로세스 전체 메모리가 아니다. 시간을 일반적인 성능 수치로 제시하려면 반복 측정이 필요하다.
작은 조건인 2,000행·메모 256자·힙 128MB에서는 네 모드가 모두 성공했다. 생성된 XLSX의 XML에서 셀 값을 추출해 비교한 결과, 모두 2,000행이며 값 기준 SHA-256이 같았다.
큰 조건의 성공 파일도 50,000행임을 확인했다. 파일을 끝까지 생성했는지와 같은 데이터를 썼는지를 함께 확인한 것이다.
성공 여부만큼 실패한 단계도 중요했다
실행 기록에는 입력 생성, 엑셀 행 생성, 파일 출력의 시작 지점을 남겼다. 같은 OOM이어도 입력 List를 만드는 중에 발생했는지, 출력 버퍼를 채우는 중에 발생했는지에 따라 살펴볼 코드가 달랐다.
마지막 단계 로그는 실패 범위를 좁히는 단서다. 어떤 객체가 얼마의 메모리를 붙잡고 있는지까지 알려주지는 않는다. 이번 실험에서는 힙 덤프를 분석하지 않았으므로 객체별 점유량이나 참조 경로를 확인했다고 말할 수는 없다.
후속으로는 힙 덤프의 Dominator Tree와 GC Root 참조 경로를 살펴보면 좋겠다. 코드에서 예상한 List·문자열·출력 버퍼의 생존 범위가 실제로도 그렇게 나타나는지 확인할 수 있기 때문이다.
다음에 다운로드 코드를 점검한다면
이번 결과를 바탕으로 다음에는 다운로드 코드의 세 부분을 먼저 확인하려고 한다.
- 입력: 엑셀을 만들기 전에 조회 결과 전체를 List에 담고 있지는 않은가?
- 생성: SXSSF를 사용하면서도 처리한 입력 데이터를 다른 컬렉션에 계속 누적하고 있지는 않은가?
- 출력: 마지막에 완성 파일 전체를
ByteArrayOutputStream이나byte[]로 모으고 있지는 않은가?
한 요청이 성공하더라도 동시에 여러 다운로드가 실행되는 상황은 별도 검증이 필요하다. 이번 실험은 단일 요청에 해당하는 독립 프로세스에서 실행했으므로 동시 요청 처리 능력까지 확인한 것은 아니다.
당시 운영에서는 JVM 설정 조정과 SXSSF 전환 이후 재발이 없었다. 이번 실험은 그 결과의 원인을 소급해서 입증하는 것이 아니라, 비슷한 문제를 다시 만났을 때 어디를 확인할지 이해하기 위한 후속 학습이다.
'Programming > Trouble Shooting' 카테고리의 다른 글
| [log4j] org.apache.log4j.Logger cannot be resolved to a type 에러 (0) | 2021.11.25 |
|---|---|
| [mac os] Oracle 에러 "로케일을 인식할 수 없습니다.(locale not recognized)" (0) | 2021.08.28 |





















