새벽에 알림이 울렸습니다. 몇 달 동안 멀쩡하던 API 서버가 java.lang.OutOfMemoryError: Java heap space를 토하며 죽어 있었습니다. 재시작하면 한동안은 잘 돌아갔습니다. 그런데 며칠에 한 번이던 주기가 점점 짧아지더니, 어느 날부터는 하루에도 몇 번씩 같은 자리에서 무너졌습니다.
힙을 늘리면 임시방편은 되지만 근본 해결은 아닙니다. 메모리가 부족한 것과, 메모리가 새는(누수) 것은 전혀 다른 문제이기 때문입니다. 이 글은 별도 도구 설치 없이 JDK에 기본 포함된 jstat·jcmd·jmap과 VisualVM만으로, 죽은 서버의 힙 덤프에서 누수의 범인을 지목해 나간 추적 기록입니다.
힙 덤프 분석은 어렵게 느껴지지만 순서만 잡으면 의외로 단순합니다. 어떤 OOM인지 구분하고, 누수인지 가른 뒤, 힙 덤프를 떠서 가장 많은 메모리를 쥐고 있는 객체를 찾고, 그 객체가 왜 회수되지 않는지 GC 루트까지 거슬러 올라가면 됩니다.
어떤 OutOfMemoryError인지부터 구분한다
가장 먼저 한 일은 에러 메시지를 정확히 읽는 것입니다. OutOfMemoryError는 한 종류가 아니라, 뒤에 붙는 문구에 따라 원인과 대응이 완전히 달라집니다. 여기서 방향을 잘못 잡으면 엉뚱한 곳을 며칠씩 파게 됩니다.
| 메시지 | 의미 | 흔한 원인 |
|---|---|---|
Java heap space | 힙 영역이 가득 참 | 객체 누수, 대용량 로딩 |
GC overhead limit exceeded | GC만 돌고 회수는 거의 안 됨 | 누수 초기 신호 |
Metaspace | 클래스 메타데이터 영역 부족 | 클래스로더 누수, 동적 클래스 |
unable to create new native thread | 스레드를 더 못 만듦 | 스레드 누수 |
우리 로그에 찍힌 건 **Java heap space**였습니다. 힙에 객체가 계속 쌓여 회수되지 않고 있다는 뜻입니다. 같은 “메모리 부족”처럼 보여도 Metaspace나 native thread였다면 봐야 할 곳이 완전히 달랐을 것입니다. 첫 단추는 항상 메시지를 끝까지 읽는 것입니다.
누수인가 단순 부족인가, jstat로 가른다
방향을 잡았으니 다음 질문은 “정말 새고 있는가”입니다. 트래픽이 순간적으로 몰려 일시적으로 부족했던 거라면 힙을 키우는 게 답이지만, 누수라면 힙을 아무리 키워도 시간만 벌 뿐 결국 같은 자리에서 죽습니다.
이걸 가르는 가장 빠른 방법이 jstat입니다. 살아 있는 프로세스의 세대별 GC 상황을 실시간으로 보여줍니다.
# 프로세스 PID 확인
jcmd -l | grep YourApp
# 1초 간격으로 GC 사용률 관찰 (O 컬럼 = Old 영역 사용률 %)
jstat -gcutil <pid> 1000
관찰 결과는 명확했습니다. Full GC가 돌아도 Old 영역 사용률이 떨어지지 않고 계단처럼 계속 차오르기만 했습니다. 정상이라면 Full GC 직후 Old 사용률이 뚝 떨어져야 합니다. 회수가 안 된다는 건, 어딘가에서 객체에 대한 참조를 놓지 않고 계속 붙들고 있다는 결정적 신호였습니다. 단순 부족이 아니라 누수가 확정된 순간입니다.
이 “쌓이기만 하고 줄지 않는” 패턴은 앞서 겪었던 다른 장애와도 결이 같습니다. 자원이 시간에 따라 누적되다 임계점에서 터지는 구조는 서버 교체 후 1시간마다 죽는 서비스, 원인은 DB 락에서 다룬 커넥션 풀 고갈과도 닮아 있었습니다.
죽기 전에 힙 덤프를 확보하는 법
누수를 잡으려면 힙 덤프, 즉 그 순간 힙에 무엇이 들어 있었는지 통째로 떠낸 스냅샷이 필요합니다. 확보 방법은 두 가지입니다.
첫째, 죽는 순간 자동으로 뜨게 미리 설정해 둡니다. 운영 서버라면 이 옵션은 기본으로 켜 두는 편이 좋습니다.
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
이렇게 해두면 OOM이 터지는 바로 그 순간의 힙이 파일로 남습니다. 사후 분석에는 이게 가장 정확합니다.
둘째, 살아 있는데 의심스러운 프로세스라면 직접 떠냅니다. JDK 기본 도구로 충분합니다.
# 권장: jcmd
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof
# 또는 jmap (live 옵션은 덤프 전에 Full GC를 한 번 돌려 살아있는 객체만 남김)
jmap -dump:live,format=b,file=/tmp/heapdump.hprof <pid>
주의할 점이 있습니다. 힙 덤프를 뜨는 동안 애플리케이션이 잠깐 멈춥니다(stop-the-world). 힙이 큰 운영 서버에서는 수 초 이상 멈출 수 있으니, 트래픽이 적은 시간대를 고르거나 인스턴스를 일시적으로 빼고 작업하는 게 안전합니다.
클래스 히스토그램으로 용의자를 좁힌다
덤프 파일을 바로 GUI로 여는 것도 방법이지만, 그 전에 텍스트 한 줄로 용의자를 좁히면 훨씬 빠릅니다. 클래스별로 인스턴스가 몇 개씩 살아 있는지 집계하는 히스토그램입니다.
# 살아있는 객체 기준으로 클래스별 인스턴스 수·점유 용량 집계
jmap -histo:live <pid> | head -20
출력 상단에는 보통 byte[], String, Object[] 같은 기본 타입이 올라옵니다. 여기까지는 정상입니다. 진짜 단서는 그 사이에 끼어 있는, 있을 이유가 없는 우리 도메인 클래스입니다.
우리 경우, 상위권에 평소라면 잠깐 쓰고 사라져야 할 응답 DTO 클래스가 약 320만 개나 살아 있었습니다. 요청 한 번에 몇 개 만들어지고 버려져야 정상인 객체가 수백만 개씩 힙에 남아 있다는 건, 누군가 이걸 어딘가에 계속 담아두고 있다는 뜻입니다. 범인의 윤곽이 잡혔습니다.
VisualVM으로 범인을 지목하고 GC 루트까지 추적한다
이제 누가 그 객체들을 붙들고 있는지 확인할 차례입니다. JDK에 함께 배포되는(또는 VisualVM 공식 사이트에서 받는) 그래픽 도구로 .hprof 파일을 엽니다.
VisualVM에서 힙 덤프를 열면 클래스별 점유 현황을 용량 순으로 볼 수 있습니다. 앞서 히스토그램에서 지목한 DTO를 찾아, 인스턴스 하나를 골라 참조 경로(References)를 역추적합니다. 이 객체를 누가 참조하고 있는지, 그 참조를 또 누가 붙들고 있는지 거슬러 올라가는 과정입니다.
추적의 끝, 즉 GC 루트에는 정적(static) 필드가 있었습니다. 한 유틸리티 클래스의 static ConcurrentHashMap이었습니다. 외부 API 조회 결과를 “임시로 캐싱”하려고 만든 맵이었는데, 넣기만 하고 비우는 코드가 어디에도 없었습니다. static 필드는 애플리케이션이 살아 있는 한 GC 대상이 되지 않으므로, 그 맵이 잡고 있는 320만 개의 DTO도 영원히 회수되지 않았던 것입니다. 재시작하면 멀쩡했던 이유, 그리고 시간이 갈수록 빨리 죽었던 이유가 한 번에 설명됐습니다.
원인과 수정: 무한정 캐시를 경계 있는 캐시로
원인을 잡고 나니 수정은 단순했습니다. 문제의 본질은 “캐시”가 아니라 경계 없는 캐시였습니다. 크기 제한도, 만료 시간도 없는 맵은 캐시가 아니라 메모리 누수의 다른 이름입니다.
수정 방향은 세 가지였습니다. 첫째, 직접 만든 static 맵을 걷어내고 최대 크기와 TTL(만료 시간)이 있는 캐시로 교체했습니다. Caffeine이나 스프링 @Cacheable 같은 검증된 구현은 오래된 항목을 자동으로 밀어내므로 무한 증가가 구조적으로 차단됩니다.
둘째, 정말 전역 캐시가 필요한지 다시 따졌습니다. 상당수는 요청 범위에서만 쓰면 충분했고, 전역으로 들고 있을 이유가 없었습니다. 캐시는 “있으면 좋은 것”이 아니라 “비우는 전략까지 설계됐을 때” 비로소 캐시입니다.
셋째, 같은 일이 또 터졌을 때 빨리 잡도록 -XX:+HeapDumpOnOutOfMemoryError를 전 인스턴스에 기본 적용하고, Old 영역 사용률이 회수되지 않는 패턴을 알림으로 걸었습니다. 비슷한 운영 안정성 작업은 MySQL 쿼리 지연 해결 3개월 클라우드 DB 분투기에서도 같은 원칙으로 접근한 적이 있습니다.
수정 후 며칠을 지켜봤습니다. Old 영역은 Full GC 때마다 정상적으로 떨어졌고, 서버는 더 이상 죽지 않았습니다.
자주 묻는 질문 (FAQ)
Q1. 힙 덤프는 운영 서버에서 떠도 괜찮은가요?
뜰 수 있지만 주의가 필요합니다. 덤프 중에는 애플리케이션이 잠깐 멈추고(stop-the-world), 힙이 크면 멈춤 시간도 길어집니다. 트래픽이 적은 시간대에 하거나, 로드밸런서에서 해당 인스턴스를 잠시 빼고 작업하는 것이 안전합니다.
Q2. jmap과 jcmd 중 무엇을 쓰는 게 좋나요?
최신 JDK에서는 jcmd가 권장됩니다. jmap은 여전히 동작하지만 일부 기능이 jcmd로 통합되는 추세입니다. 힙 덤프는 jcmd <pid> GC.heap_dump, 히스토그램은 jcmd <pid> GC.class_histogram으로 대부분 대체할 수 있습니다.
Q3. OutOfMemoryError가 떴는데 힙은 누수가 아니라면 무엇을 봐야 하나요?
메시지를 다시 확인하세요. Metaspace라면 클래스로더 누수나 동적 클래스 생성을, unable to create new native thread라면 스레드 풀 설정과 스레드 누수를 봐야 합니다. 같은 OOM이라도 종류에 따라 원인과 해결이 전혀 다릅니다.
다시 돌아보면
이번 장애에서 가장 오래 걸린 건 분석이 아니라 방향 잡기였습니다. 힙을 키워 임시로 버티는 동안, 사실은 메시지 한 줄(Java heap space)과 jstat 그래프 하나(Full GC 후에도 안 줄어드는 Old 영역)면 누수라는 걸 첫날 확정할 수 있었습니다. 도구가 부족해서가 아니라, “부족”과 “누수”를 가르는 질문을 늦게 던진 탓이었습니다.
힙 덤프 분석은 특별한 도구가 필요한 작업이 아닙니다. JDK 안에 이미 다 들어 있고, 순서만 지키면 됩니다. 무엇보다 운영 서버라면 HeapDumpOnOutOfMemoryError를 미리 켜 두는 것 하나만으로도, 다음 장애 때 추적의 출발점을 공짜로 확보할 수 있습니다.
핵심 요약
- OOM은 메시지(
Java heap space/Metaspace/native thread)부터 끝까지 읽고 방향을 잡는다jstat -gcutil에서 Full GC 후에도 Old 영역이 안 줄면 단순 부족이 아니라 누수다jcmd/jmap으로 덤프를 떠histo:live로 용의자를 좁히고, VisualVM으로 GC 루트까지 역추적해 범인을 지목한다