대용량 데이터를 엑셀로 내려주는 기능을 개발할 때 가장 먼저 신경 쓰게 되는 것은 메모리입니다. 수십만 건에 달하는 데이터를 한 번에 메모리로 올리면 힙이 버티지 못하기 때문에, 데이터를 어느 시점에 얼마나 메모리에 두고 처리할지를 미리 정해 두지 않으면 기능 자체가 성립하지 않습니다.
이번에 담당한 다운로드 기능도 같은 고민에서 출발했습니다. 사용자가 화면에서 다운로드를 신청하면 그 요청을 먼저 쌓아 두고, 주기적으로 실행되는 배치가 파일을 만들어 두면 사용자가 나중에 내려받는 구조로 설계했습니다. 이렇게 나눈 이유는 무거운 작업을 별도의 배치 인스턴스에서 처리하도록 해서, 어드민 서버의 리소스를 점유하지 않게 하기 위해서였습니다.
다만 작업을 옮겨 놓았다고 해서 메모리 부담 자체가 사라지지는 않습니다. 오히려 요청이 쌓인 만큼을 배치가 한 번에 처리하기 때문에, 처리해야 하는 데이터의 양은 그대로 남습니다. 그래서 데이터를 만들어 내는 지점과 읽어 오는 지점 양쪽에 각각 메모리 사용량의 상한을 정해 두는 방식으로 설계했습니다. 엑셀 파일을 생성하는 쪽은 Apache POI의 SXSSF를 사용해 지정한 window size 만큼의 행만 메모리에 유지하고, 그 범위를 벗어난 행은 임시 파일로 흘려보내도록 했습니다. 데이터를 조회하는 쪽은 커서 기반으로 나누어 읽도록 구성해서, 한 번에 메모리로 적재되는 건수를 일정한 수준으로 제한해 두었습니다.
perf 환경에서 실제와 유사한 규모로 돌려 보며 검증을 마쳤고, 그렇게 운영에 배포했습니다.
그런데 얼마 후 운영 중 이 엑셀 다운로드 배치가 비정상 종료되었습니다. 콘솔에는 Killed 만 남아 있고 graceful shutdown 로그는 없었습니다.
Process exited with an error: 137 (Exit value: 137) [2026/05/29 08:21:23] status: ERROR - message: [ERROR condition]=[ExitCode("137") != "0"] [2026/05/29 08:21:23] <END>
메모리를 아끼도록 설계한 로직이 정작 메모리 문제로 죽은 것처럼 보였습니다.
이번 포스트에서는 종료 코드 137에서 출발해 프로파일링으로 애플리케이션을 원인에서 제외하고, 컨테이너 실행 옵션이 SXSSF의 설계 의도를 무력화하고 있었음을 밝혀내기까지의 과정과 해결 방법을 정리해 보겠습니다.
종료 코드 137이 말해주는 것
종료 코드 137은 128 + 9 로 분해됩니다. 뒤의 9는 시그널 번호이고 9번은 SIGKILL 입니다. 즉 프로세스가 외부로부터 SIGKILL을 받고 강제 종료되었다는 뜻입니다.
SIGKILL을 보내는 주체는 여럿입니다.
| 발신 주체 | 상황 |
|---|---|
| 커널 OOM Killer | 호스트 전체 메모리가 고갈되어 프로세스를 골라 종료 |
| 컨테이너 cgroup OOM | 컨테이너에 할당된 메모리 한도 초과 |
docker stop 타임아웃 | grace period 안에 종료되지 않아 강제 종료 |
수동 kill -9 | 사람이 직접 종료 |
따라서 137만으로는 누가 왜 죽였는지를 알 수 없습니다. 다만 graceful shutdown 로그 없이 Killed 만 남은 패턴은 OOM Killer의 전형적인 증상이므로, 메모리 쪽을 먼저 의심하는 것이 합리적인 출발점이었습니다.
그다음으로 확인해야 할 부분은 어느 쪽의 메모리가 부족했는가입니다. 여기서 가설을 두 갈래로 나눴습니다.
가설 1. 엑셀 다운로드 로직 안에 예상하지 못한 메모리 누적 경로가 있어, 애플리케이션 스스로 메모리를 고갈시켰다.
가설 2. 애플리케이션은 정상이지만 호스트 인스턴스 전체의 메모리가 고갈되었고, 커널 OOM Killer가 이 배치를 종료 대상으로 골랐다.
가설 1: 엑셀 다운로드 로직 내 OOM 발생 가능 경로가 존재
먼저 엑셀 다운로드 로직에 메모리 누수 포인트가 있는건 아닌지 확인하기 위해서 IntelliJ Profiler로 프로파일링 세션을 녹화했습니다.
IntelliJ Profiler 기능은 내부적으로 async-profiler와 JFR 기반으로 동작해서 CPU, 메모리 할당, 스레드 상태를 함께 볼 수 있습니다.
측정 조건은 실제 장애 당시 데이터보다 조금 많은 건 수로 잡았습니다
결과는 아래와 같았습니다.

- 40만 건 엑셀 다운로드에 약 30분 소요
- 힙 사용량은 300~400MB 수준을 유지
- 조회 후 엑셀에 쓰는 작업에 누적 124.98GB가 할당되었지만, Stream의 lazy evaluation과 커서 기반 조회 덕분에 동시에 유지되는 양은 300~400MB에 그침

누적 할당량이 124GB라는 숫자만 보면 놀랄 수 있지만, 이는 할당된 총량이지 동시에 살아 있는 양이 아닙니다. 짧게 쓰이고 버려지는 객체가 계속 만들어지면 누적 할당량은 얼마든지 커지고, 그 대부분은 곧바로 GC 대상이 됩니다. 실제로 힙 사용량 그래프는 톱니 모양을 그리며 안정적인 구간에 머물렀습니다.
운영 환경의 JVM 지표(Heap Usage, GC, CPU) 역시 장애 시점에 이상이 없었습니다.
정리하면, 운영 장애 당시보다 큰 부하에서도 힙은 한도 근처에 가지 않았습니다. 따라서 가설 1을 기각하고 두번째 가설을 확인해보았습니다.
가설 2: 호스트 인스턴스 메모리 고갈
애플리케이션이 아니라면 실행 환경을 봐야 합니다. 배치를 띄우는 docker run 명령을 확인했습니다.
docker run --rm --tmpfs /tmp \ -e RUN_ENV=prod,cli \ -e JVM_MEMORY="-XX:InitialRAMPercentage=5 -XX:MaxRAMPercentage=20" \ -e RUN_JVM_PARAM="-Duser.timezone=Asia/Seoul" \ -e APP_PARAM="run-export" \ {registry}/{service}-batch:{tag}
SXSSF는 디스크에 쓰고 있지 않았다
--tmpfs /tmp 가 눈에 들어왔습니다. --tmpfs /tmp 는 컨테이너의 /tmp 를 RAM 기반 파일시스템으로 마운트하는 옵션입니다. 이 경로에 기록되는 모든 내용은 디스크가 아니라 RAM에 올라갑니다.
그런데 SXSSF의 동작 원리가 바로 여기에 걸립니다. SXSSF는 window size만큼만 힙에 유지하고 나머지 행을 임시 파일로 내보내 메모리를 절약하는데, 그 임시 파일이 만들어지는 위치가 java.io.tmpdir, 즉 /tmp 입니다.
즉, SXSSF는 힙에서 덜어낸 데이터를 디스크로 보낸다고 믿고 있었지만, 실제로는 같은 RAM 안의 다른 공간으로 옮기고 있었을 뿐이었습니다.
게다가 tmpfs에 올라간 파일은 GC 대상이 아닙니다. JVM 힙 밖에 있기 때문에 힙 지표에도 잡히지 않고, dispose() 로 임시 파일을 정리하기 전까지 계속 누적됩니다. 힙 사용량이 300~400MB로 안정적이었던 것과 프로세스가 메모리 때문에 죽은 것이 동시에 성립할 수 있었던 이유가 여기에 있습니다.
실제로 얼마나 쌓이는지 측정했습니다. export가 도는 동안 컨테이너 안에서 2초마다 두 값을 찍었습니다.
df -m /tmp # tmpfs 전체 사용량 du -cm /tmp/poi* # SXSSF 임시 파일만 합산
운송장 38만 건 기준으로 SXSSF 임시 파일이 약 1.15GB까지 RAM에 누적되는 것을 확인했습니다.
쓰기는 그대로인데 읽기만 튀어 오른 이유
하지만 여기까지도 아직 결정적이지는 않았습니다. 배치 하나가 1.15GB를 더 쓴다고 해서 32GB(m7i.2xlarge) 인스턴스가 곧바로 고갈되지는 않기 때문입니다.
때문에 호스트 지표를 살펴보았는데, 장애 시점에 흥미로운 패턴이 보였습니다. 배치가 실행된 인스턴스의 EBS 볼륨 중 루트 볼륨에서만 '읽기 작업량', '읽기 처리량', '평균 대기열 길이'가 에러 발생 시점에 급등했습니다.

반면 쓰기 처리량은 평소와 다르지 않았습니다. 읽기 부하만 급증했다는 점이 이상하게 느껴졌고, 이 현상을 이해하기 위해 리눅스가 RAM에 올려두는 데이터를 두 종류로 나누어 살펴보았습니다.
| 구분 | 내용 | 회수하려면 |
|---|---|---|
| Anonymous memory | 힙, 스택처럼 파일과 연결되지 않은 데이터 | 디스크에 원본이 없으므로 swap에 기록해야 함 |
| Page cache | 디스크 파일 내용을 RAM에 캐시한 것 (mmap된 실행 파일, 라이브러리, Docker 레이어) | 원본이 디스크에 있음 |
그리고 page cache는 다시 두 상태로 나뉩니다.
| 상태 | 의미 | 회수 비용 |
|---|---|---|
| Clean | RAM 내용이 디스크 원본과 동일 (읽기만 하고 수정 없음) | 가장 저렴. 그냥 버리면 됨 |
| Dirty | RAM에서 수정되어 디스크와 달라진 상태 | 버리기 전에 디스크로 플러시해야 함 |
메모리가 차오르면 커널은 OOM Killer를 발동하기 전에 회수 가능한 영역부터, 비용이 낮은 순서로 메모리를 반환합니다.
가장 저렴한 대상이 clean page cache이고, 그중 가장 큰 덩어리는 JVM이 mmap한 app.jar, JVM 라이브러리, 그리고 루트 볼륨에 있는 /var/lib/docker 레이어입니다. 코드와 라이브러리는 실행만 될 뿐 수정되지 않으므로 항상 clean 입니다.
여기서 다음 사이클이 반복됩니다.
- 커널이 메모리를 확보하려고 코드, 라이브러리의 clean 페이지를 회수합니다. 원본이 디스크에 있으니 쓰기 없이 그냥 버립니다.
- 그런데 JVM은 그 코드를 계속 실행해야 합니다. page fault가 발생하고 루트 볼륨에서 다시 읽어옵니다. 여기서 읽기가 급증합니다.
- 메모리는 여전히 빡빡하므로 또 버리고 또 읽습니다. thrashing 입니다. 읽기량과 대기열 길이가 함께 올라갑니다.
즉 쓰기 없이 읽기만 튀어 오르는 패턴 자체가 "코드 캐시가 계속 쫓겨나고 있다"는 신호이고, 이는 RAM이 실질적으로 고갈되어 커널이 OOM Killer 직전까지 몰린 상태를 뜻합니다. RAM에 여유가 있었다면 커널이 실행 중인 코드의 캐시를 버릴 이유가 없습니다.
한 가지 방증이 더 있었습니다. 같은 시간대의 컨테이너 모니터링 그래프에 중간중간 데이터가 비어 있는 구간이 있었습니다. 호스트가 thrashing 상태에 빠져 Datadog agent 자체가 CPU 스케줄링을 받지 못했고, 그 사이 지표 보고가 누락된 것으로 보입니다. 지표를 수집하는 프로세스까지 밀려날 정도로 호스트가 압박을 받고 있었다는 뜻입니다.

누가 메모리를 채웠는가
그렇다면 이 배치가 아니라면 무엇이 32GB를 채운 것일까요? 앞서 말한 것처럼 이 배치 혼자서는 RAM에 1.15GB를 더 얹었을 뿐이고, 그것만으로 인스턴스 전체가 고갈될 수는 없습니다. 남는 가능성은 같은 인스턴스에서 동시에 실행되던 다른 잡들과의 메모리 경합이었습니다.
배치 실행 목록을 확인해 보니 장애 시점에 같은 인스턴스에서 배치 잡 28~35개가 동시에 실행되고 있었습니다. EC2 호스트의 메모리 지표에서도 같은 시간대에 가용 메모리가 바닥에 가까웠던 것이 확인되었습니다.

수십 개의 배치가 각자 힙과 임시 파일로 RAM을 나눠 쓰는 상태에서 가용 메모리가 소진되었고, 커널은 clean page cache를 버리며 버티다가 thrashing 상태에 들어갔습니다. 그래도 메모리가 확보되지 않자 남은 수단은 OOM Killer였습니다.
왜 이 배치가 선택되었는가
마지막으로 남은 질문은 수십 개 배치 가운데 왜 이 엑셀 다운로드 배치가 종료 대상이 되었는가였습니다. 호스트의 시스템 로그를 확인해보니, OOM Kill 대상 목록에 해당 배치 컨테이너의 hostname이 그대로 남아 있었습니다.
OOM Killer는 메모리 사용량이 큰 프로세스를 우선 종료 대상으로 고릅니다. 이 배치의 힙은 300~400MB에 머물렀지만, tmpfs에 쌓인 임시 파일 1.15GB까지 합치면 같은 호스트의 다른 배치들보다 큰 쪽에 속했습니다. 힙만 보면 얌전한 프로세스가 커널의 눈에는 가장 무거운 프로세스 중 하나로 보였던 것입니다.
정리하면 원인은 이렇습니다.
호스트 인스턴스의 가용 메모리가 고갈된 상태에서 page cache thrashing이 발생했고, 커널 OOM Killer가 메모리 사용량이 상대적으로 큰 엑셀 다운로드 배치를 종료 대상으로 선택했습니다. 그리고 이 배치의 메모리 사용량을 키운 것은 힙이 아니라 tmpfs에 쌓인 SXSSF 임시 파일 1.15GB 였습니다.
해결
원인을 알았으니 해결은 간단해 보였습니다. --tmpfs /tmp 옵션을 제거하면 SXSSF는 본래 설계대로 임시 파일을 디스크에 spill 하게 되고, 이 배치가 RAM에 얹던 1.15GB는 사라집니다.
그런데 이 옵션은 저희 배치만 쓰는 설정이 아니었습니다. 같은 인스턴스에서 도는 수십 개 배치에 공통으로 적용되는 실행 명령의 일부였고, 인프라팀에서는 배치 하나 때문에 공용 설정을 걷어내기는 어렵다는 답을 주었습니다.
그런데 실행 명령을 한 조각씩 다시 들여다보니 전부가 인프라 소관인 것은 아니었습니다. docker run 이 컨테이너를 띄우면, 컨테이너 안에서는 환경 변수를 조합한 다음 명령이 실행됩니다.
java ${RUN_JVM_PARAM} -jar /usr/local/bin/app.jar ${APP_PARAM}
컨테이너를 띄우는 docker run 옵션은 인프라팀이 관리하지만, 여기에 들어가는 RUN_JVM_PARAM 은 저희 저장소의 배치 배포 설정 파일에서 정해집니다. 배치마다 하나씩 있는 파일이고, 스케줄과 실행 파라미터를 담고 있습니다.
schedule: "*/3 * * * *" environment: APP_PARAM: run-export RUN_JVM_PARAM: "-Xmx1400m -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod" command: - "java ${RUN_JVM_PARAM} -jar /usr/local/bin/app.jar ${APP_PARAM}"
java.io.tmpdir 은 JVM 시스템 프로퍼티이므로 바로 이 자리에서 지정할 수 있습니다. 추가한 것은 옵션 하나였습니다.
RUN_JVM_PARAM: "-Xmx1400m -Djava.io.tmpdir=/var/tmp -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod"
RAM으로 마운트된 경로는 /tmp 뿐이므로 /var/tmp 는 실제 디스크에 놓입니다. 이제 SXSSF의 임시 파일은 RAM이 아니라 디스크로 흘러갑니다.
검증은 원인을 찾을 때 썼던 측정 방법을 그대로 재사용했습니다. 적용 전후를 같은 조건으로 대조해야 하므로 동일하게 38만 건으로 돌리면서, 이번에는 두 경로를 함께 지켜봤습니다.
df -m /tmp # tmpfs 사용량이 더 이상 늘지 않는지 du -cm /var/tmp/poifiles # 임시 파일이 디스크 쪽에 쌓이는지
배치가 도는 동안 /tmp 사용량은 변화가 없었고, 임시 파일은 /var/tmp 쪽에 쌓였습니다. 이 배치가 호스트 RAM에 얹던 1.15GB가 디스크로 옮겨 간 것입니다.
애플리케이션 코드에서 해결하는 방법도 있습니다. Apache POI는
TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(dir))로 임시 파일 위치를 코드에서 재정의할 수 있습니다.java.io.tmpdir을 바꾸면 JVM 전체의 임시 경로가 바뀌므로, 다른 라이브러리가/tmp에 의존하고 있어 부수 효과가 걱정된다면 POI에만 적용되는 이 방식이 안전합니다.다만 이때 공용 엑셀 라이브러리 쪽에 설정을 넣으면 안 됩니다. 이 설정은 JVM 전역 정적 상태라, 라이브러리가 호출하면 그 라이브러리를 쓰는 다른 시스템의 동작까지 함께 바뀝니다. 반드시 애플리케이션 쪽에 두어야 합니다.
이번 건에서는 배포 설정 한 줄로 끝나는 방법을 택했습니다. 코드 변경이 없어 빌드와 릴리스가 필요 없고, 그 파일은 이 배치 전용이라 영향 범위가 명확하며, 문제가 생기면 파일을 되돌려 재배포하는 것으로 즉시 원복됩니다.
마무리
지금까지 종료 코드 137에서 출발해, 프로파일링으로 애플리케이션 로직을 원인에서 제외하고, --tmpfs /tmp 옵션이 SXSSF의 메모리 최적화를 무력화하고 있었음을 확인한 뒤, 배치별 JVM 파라미터로 해결하기까지의 과정을 살펴보았습니다.
이번 일에서 가장 크게 남은 것은 "메모리를 아꼈다"는 말이 어느 층위에서 성립하는지 확인해야 한다는 점입니다. SXSSF를 도입할 때는 힙만 기준으로 생각했지만, 덜어낸 데이터가 어디에 놓이는지는 애플리케이션 코드가 아니라 실행 환경이 정하고 있었습니다. 힙 지표만 보고 있었다면 끝내 보이지 않았을 자리였습니다.
참고 자료