운영 중인 자바 애플리케이션이 한밤중에 OutOfMemoryError: Java heap space를 던지고 죽는 일은 드물지 않다. 문제는 다음날 아침 로그를 열어봐도 "힙이 모자랐다"는 사실만 남아 있고, 정작 무엇이 힙을 다 먹었는지는 알 수 없다는 점이다. 그 순간의 메모리 상태를 잡아두지 못하면 재현을 기다리며 똑같은 장애를 반복하게 된다.
결론부터 말하자면, JVM 기동 옵션에 -XX:+HeapDumpOnOutOfMemoryError 하나만 걸어두면 OOM이 터지는 바로 그 순간 힙덤프 파일이 자동으로 떨어진다. 그 .hprof 파일을 Eclipse MAT(Memory Analyzer Tool)로 열어 Leak Suspects 리포트와 Dominator Tree를 보면, 어떤 객체가 메모리를 쥐고 있고 그 객체를 누가(어떤 GC root가) 놓아주지 않는지까지 추적할 수 있다.
1. 개요
힙덤프(heap dump)는 특정 시점에 JVM 힙에 올라와 있던 모든 객체와 그 참조 관계를 통째로 떠낸 스냅샷이다. 보통 HPROF 바이너리 형식(.hprof)으로 저장한다. 이걸 분석하면 어떤 클래스의 인스턴스가 몇 개 있고, 각 객체가 실제로 얼마나 많은 메모리를 붙잡고 있는지 알 수 있다.
여기서 두 가지 크기 개념을 먼저 짚어둔다. Shallow heap은 객체 자신이 직접 차지하는 크기다. Retained heap은 그 객체를 GC가 회수한다고 가정했을 때 함께 사라지는 메모리의 총합이다. 누수 분석에서 실제로 중요한 건 retained heap이다. 혼자 작아 보여도 거대한 컬렉션을 붙들고 있으면 retained heap이 크게 잡힌다.
GC root는 가비지 컬렉터가 "살아있다"고 판단하는 출발점이다. 실행 중인 스레드의 스택 변수, 스태틱 필드, JNI 참조 같은 것들이다. 어떤 객체가 메모리에서 안 지워진다면 반드시 GC root에서 그 객체까지 이어지는 참조 경로가 하나 이상 남아 있다는 뜻이다. 이 경로를 찾는 것이 누수 분석의 핵심이다.
아래에서는 자동 힙덤프 옵션을 거는 방법, OOM이 날 때 실제로 파일이 떨어지는 동작, 그리고 MAT에서 Leak Suspects와 Dominator Tree로 범인을 좁혀가는 순서를 차례로 살펴본다.
2. 자동 힙덤프 옵션 걸기
JVM 기동 옵션에 다음 두 개를 추가한다.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heapdump
-XX:+HeapDumpOnOutOfMemoryError는 OOM이 발생하는 순간 힙덤프를 자동으로 기록하게 한다. -XX:HeapDumpPath는 그 파일을 어디에 쓸지 지정한다. 디렉터리를 주면 그 안에 java_pid<PID>.hprof 형식으로, 파일명을 직접 주면 그 이름으로 쓴다. 생략하면 프로세스의 현재 작업 디렉터리에 떨어진다.
systemd 유닛으로 띄우는 서비스라면 보통 JAVA_OPTS나 유닛 파일의 Environment에 넣는다. Spring Boot 실행 예시는 이렇다.
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heapdump \
-Xmx2g \
-jar app.jar
경로 선정에서 한 번 걸리는 부분이 있다. HeapDumpPath 디렉터리는 미리 만들어 두고 JVM 실행 계정에 쓰기 권한을 줘야 한다. 없는 디렉터리를 가리키면 정작 OOM이 터진 순간 덤프를 쓰지 못하고, stderr에 Dumping heap ... failed만 남긴 채 아무 파일도 안 생긴다. 디스크 여유도 중요하다. 힙덤프 파일 크기는 그 시점 살아있는 힙 크기에 비례하므로, 최대 힙(-Xmx)만큼은 여유를 둔다.
이 옵션은 재기동 없이도 켤 수 있다. 이미 떠 있는 프로세스라면 다음처럼 런타임에 바꾼다.
jcmd <pid> VM.set_flag HeapDumpOnOutOfMemoryError true jcmd <pid> VM.set_flag HeapDumpPath /var/log/app/heapdump
3. OOM이 날 때 실제 동작
옵션이 걸린 상태에서 힙이 바닥나면 stderr에 다음과 비슷한 줄이 찍히고 덤프가 떨어진다.
java.lang.OutOfMemoryError: Java heap space Dumping heap to /var/log/app/heapdump/java_pid12345.hprof ... Heap dump file created [1835417216 bytes in 4.231 secs]
여기서 꼭 알아둬야 할 동작이 하나 있다. HotSpot JVM은 한 프로세스 수명 동안 첫 번째 OutOfMemoryError 1회에만 힙덤프를 남긴다. 내부적으로 OOM 보고 시 플래그를 세우고, 같은 JVM에서 이후에 또 OOM이 나도 추가 덤프는 쓰지 않는다. 덤프 디스크가 OOM 반복으로 꽉 차는 사고를 막기 위한 설계다. 따라서 파일이 하나뿐이라고 당황할 필요 없다. 그 하나가 최초 발생 시점의 스냅샷이고, 원인 분석에는 그걸로 충분하다.
또 하나 흔한 오해가 OOM이 나면 JVM이 자동으로 죽는다는 것이다. OutOfMemoryError 자체는 스레드로 던져지는 에러일 뿐, 그걸로 프로세스가 반드시 종료되지는 않는다. 실무에서는 OOM 이후 프로세스를 깔끔히 내리고 재기동하는 편이 안전하므로, 덤프 옵션과 함께 종료 옵션을 같이 건다.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heapdump -XX:+ExitOnOutOfMemoryError
-XX:+ExitOnOutOfMemoryError를 걸면 첫 OOM에서 덤프를 남긴 뒤 JVM이 즉시 종료된다. 반쯤 망가진 상태로 계속 떠서 엉뚱한 응답을 내보내는 것보다, 죽고 재기동 메커니즘(systemd Restart=on-failure 등)에 맡기는 쪽이 운영상 깔끔하다.
스크립트를 돌리고 싶으면 -XX:OnOutOfMemoryError="..."로 명령을 지정할 수 있다. 이 문자열 안의 %p는 현재 실행 중인 JVM 프로세스의 PID로 치환된다.
-XX:OnOutOfMemoryError="/usr/local/bin/notify-oom.sh %p"
4. OOM을 기다리지 않고 지금 덤프 뜨기
장애가 아직 안 났지만 메모리가 서서히 차오르는 게 의심될 때는 살아있는 프로세스에서 직접 덤프를 뜬다. 권장하는 도구는 JDK에 기본 포함된 jcmd다.
jcmd <pid> GC.heap_dump /var/log/app/heapdump/manual.hprof
여기서 주의할 점. jcmd GC.heap_dump는 기본값이 live 객체만 담는다. 덤프 전에 full GC를 먼저 수행해서 이미 회수 가능한 쓰레기를 걸러내고 살아남은 객체만 기록한다는 뜻이다. 누수 분석에는 오히려 이 편이 깔끔하다. 회수 불가능한(unreachable) 객체까지 전부 보고 싶으면 -all을 붙인다.
jcmd <pid> GC.heap_dump -all=true /var/log/app/heapdump/manual-all.hprof
구버전 호환이 필요하면 jmap으로도 덤프를 뜰 수 있다. 다만 기본 동작이 다르다는 점을 분명히 해둔다. jmap -dump는 live를 지정하지 않으면 전체 객체를 담고, live를 붙여야 살아있는 객체만 담는다. jcmd와 기본값이 반대이므로 혼동하지 말자.
# 살아있는 객체만 (full GC 수반) jmap -dump:live,format=b,file=/var/log/app/heapdump/manual.hprof <pid>
5. Eclipse MAT로 열기
Eclipse MAT는 HPROF 힙덤프를 분석하는 공개 도구다. .hprof 파일을 MAT로 열면 인덱싱을 거친 뒤 분석 화면이 뜬다.
실무에서 한 번 크게 걸리는 부분이 MAT 자신의 힙 크기다. 2GB짜리 덤프를 기본 설정의 MAT로 열면 MAT가 먼저 OOM으로 뻗는다. MAT 설치 디렉터리의 MemoryAnalyzer.ini에서 -Xmx를 덤프 크기보다 넉넉히 올린다.
-vmargs -Xmx6g
GUI를 띄우기 어려운 서버라면 MAT의 CLI로 리포트만 뽑아 가져오는 방법도 있다. 다음은 Leak Suspects 리포트를 HTML로 생성한다.
./ParseHeapDump.sh /var/log/app/heapdump/java_pid12345.hprof \ org.eclipse.mat.api:suspects
실행이 끝나면 덤프 파일 옆에 java_pid12345_Leak_Suspects.zip이 생긴다. 이걸 로컬로 받아 압축을 풀면 브라우저로 볼 수 있는 리포트가 나온다.
6. Leak Suspects 리포트 읽기
MAT를 열면 가장 먼저 봐야 할 것이 Leak Suspects 리포트다. MAT가 retained heap이 비정상적으로 큰 객체나 클래스 로더를 자동으로 추려 "이게 범인일 가능성이 높다"고 지목해 준다.
리포트는 보통 이런 문장으로 시작한다. "One instance of java.util.HashMap occupies 1,450,112,320 (78.65%) bytes." 전체 힙의 78%를 HashMap 하나가 붙들고 있다는 뜻이다. 이 숫자는 덤프마다 다르지만, 한 객체나 한 클래스가 힙의 절반 이상을 차지한다면 누수를 강하게 의심한다.
각 suspect에는 "Details" 링크가 있다. 여기를 열면 그 객체의 Shortest Paths To the Accumulation Point와 Accumulated Objects가 나온다. 전자는 GC root에서 문제 객체까지 이어지는 최단 참조 경로이고, 후자는 그 객체가 쌓아둔 실제 내용물이다. 대개 여기서 "어떤 캐시 맵에 엔트리가 수백만 건 쌓였다" 같은 그림이 바로 보인다.
Leak Suspects는 출발점으로 훌륭하지만 추정이다. 맹신하지 말고 다음 단계인 Dominator Tree로 교차 확인한다.
7. Dominator Tree로 보유 경로 추적
Dominator Tree는 객체들을 retained heap 기준으로 정렬해 보여주는 뷰다. "A를 지우면 B도 반드시 함께 사라진다"는 지배 관계를 트리로 표현한다. 트리 최상단에 retained heap이 가장 큰 객체가 올라오므로, 메모리를 가장 많이 붙들고 있는 주범을 바로 짚을 수 있다.
MAT 상단 툴바나 Overview의 "Dominator Tree"를 연다. Retained Heap 열로 내림차순 정렬하면 꼭대기에 거대한 객체가 보인다. 그걸 펼쳐 내려가면 실제 데이터를 쥐고 있는 컬렉션과 그 안의 엔트리들이 드러난다.
여기서 핵심 조작이 Path To GC Roots다. 의심 객체에서 우클릭하고 Path To GC Roots > exclude all phantom/weak/soft etc. references를 선택한다. 약한 참조(weak/soft/phantom)를 빼는 이유는, 그런 참조는 GC가 필요하면 끊을 수 있어 진짜 누수의 원인이 아니기 때문이다. 강한 참조(strong reference)만 남겨야 "GC가 왜 이걸 못 지우는가"의 진짜 경로가 보인다.
경로를 따라가면 대개 끝에 스태틱 필드나 살아있는 스레드가 나온다. 예를 들어 어떤 유틸 클래스의 static Map CACHE가 키를 계속 넣기만 하고 지우지 않는 전형적 누수라면, 경로 맨 위에 그 스태틱 필드가 GC root로 찍힌다. 이 지점이 코드에서 고쳐야 할 자리다.
한 가지 보조 기법으로, 같은 애플리케이션의 정상 시점 덤프와 OOM 시점 덤프 두 개를 떠서 MAT의 비교 기능으로 클래스별 인스턴스 증가분을 보면, 시간이 지날수록 수가 단조 증가하는 클래스가 누수 후보로 바로 드러난다.
8. 주의사항
- HeapDumpPath 디렉터리는 미리 만들고 쓰기 권한과 디스크 여유(최소
-Xmx만큼)를 확보한다. 없으면 정작 OOM 순간에 덤프가 안 남는다. - 자동 덤프는 한 프로세스에서 첫 OOM 1회만 기록된다. 재기동하면 플래그가 초기화되어 다음 첫 OOM에서 다시 남는다.
- 힙덤프에는 메모리에 올라와 있던 실제 데이터(비밀번호, 개인정보, 토큰 등)가 그대로 담긴다. 덤프 파일은 민감 정보로 취급해 접근을 제한하고 분석 후 폐기한다.
jcmd GC.heap_dump(기본 live)와jmap -dump(기본 all)는 기본 동작이 반대다. 누수 분석에는 live 기준이 깔끔하다.OutOfMemoryError: Java heap space외에GC overhead limit exceeded,Metaspace,unable to create new native thread는 원인과 처방이 다르다. 이 글은 힙 space 누수에 한정된다.