- Published on
Spring Boot OOM - 메모리 덤프 분석 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Spring Boot 서비스가 java.lang.OutOfMemoryError 로 죽으면, 로그만으로는 원인을 확정하기 어렵습니다. GC 로그, 메트릭, 스레드 덤프도 중요하지만 결정타는 힙 덤프(Heap Dump) 인 경우가 많습니다. 힙 덤프는 “죽기 직전 JVM 힙에 무엇이 얼마나 쌓였는지”를 정지 화면처럼 보여주기 때문에, 누수(leak)인지 단순한 사이즈 부족인지, 어떤 객체가 잡고 있는지까지 추적할 수 있습니다.
이 글은 Spring Boot 환경에서 OOM을 재현하거나 운영 장애에서 힙 덤프를 수집한 뒤, Eclipse MAT로 분석해 원인 객체, 유지 경로(retained path), 누수 패턴을 찾아내는 과정을 실전 중심으로 정리합니다.
OOM 유형부터 구분하기
OutOfMemoryError 라고 다 같은 OOM이 아닙니다. 먼저 메시지를 분류해야 덤프 분석 방향이 정해집니다.
java.lang.OutOfMemoryError: Java heap space- 가장 흔함. 힙에 객체가 너무 많이 쌓이거나, 큰 객체가 들어오거나, 누수.
java.lang.OutOfMemoryError: GC overhead limit exceeded- GC가 대부분의 시간을 쓰는데도 회수되는 메모리가 미미함. 누수/과도한 캐시/컬렉션 폭증 가능성이 큼.
java.lang.OutOfMemoryError: Metaspace- 클래스 메타데이터 영역 부족. 동적 프록시, 런타임 코드 생성, 클래스 로더 누수 의심.
java.lang.OutOfMemoryError: Direct buffer memory- 네이티브 메모리(DirectByteBuffer). Netty, NIO, 압축/암호화 라이브러리, 잘못된 버퍼 관리.
이 글은 주로 Java heap space 중심으로 설명하되, 마지막에 Metaspace/Direct도 체크 포인트를 덧붙입니다.
1) 운영에서 “죽을 때 자동 힙 덤프” 남기기
가장 먼저 해야 할 일은 OOM 시점에 자동으로 힙 덤프를 남기는 JVM 옵션을 넣는 것입니다.
JVM 옵션 (권장)
아래 옵션을 JAVA_TOOL_OPTIONS 나 런처 스크립트, Helm values, systemd unit 등에 넣습니다.
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/myapp/heapdump.hprof \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/myapp/gc.log:time,uptime,level,tags
-XX:+HeapDumpOnOutOfMemoryError: OOM 순간 힙 덤프 생성-XX:HeapDumpPath=...: 덤프 파일 경로 지정-XX:+ExitOnOutOfMemoryError: OOM 후 불안정 상태로 계속 살아있지 말고 즉시 종료(운영에서는 대체로 유리)-Xlog:gc*: GC 로그(자바 9+). 자바 8은-XX:+PrintGCDetails계열 사용
경로/권한/용량 주의
- 덤프 파일은 수백 MB에서 수 GB까지 커질 수 있습니다.
- 컨테이너라면 덤프가 저장되는 볼륨이 영속 볼륨(PV) 인지 확인하세요.
- 파일명이 고정이면 덮어쓰기가 발생할 수 있으니, 가능하면 디렉터리로 지정하거나 런처에서 타임스탬프를 붙이세요.
Spring Boot에서 자주 하는 실수
-Xmx를 크게 잡았는데도 OOM이 나는 경우- 힙이 아니라 Metaspace/Direct/Native 영역일 수 있습니다.
- 혹은 컨테이너 메모리 제한이 더 작아서 OOMKilled가 먼저 발생할 수 있습니다.
컨테이너 환경에서는 “JVM OOM” 과 “컨테이너 OOMKilled” 를 분리해서 봐야 합니다. 빌드/런타임 메모리 폭증 이슈를 다루는 글로는 Next.js App Router 빌드 OOM·메모리 폭증 해결도 참고할 만합니다. (원인은 다르지만, 제한 메모리와 프로세스 메모리 모델을 함께 보는 관점이 유사합니다.)
2) OOM 직후 확보해야 할 추가 증거 3종
힙 덤프만으로 결론이 나지 않는 케이스가 있습니다. 아래를 함께 확보하면 분석 속도가 급격히 빨라집니다.
- GC 로그
- 스레드 덤프
- 애플리케이션 메트릭(특히 캐시 hit/miss, 요청량, 응답시간, DB 커넥션)
스레드 덤프는 OOM 직전 교착/폭주 스레드가 있었는지 확인하는 데 도움됩니다.
jcmd <pid> Thread.print > /var/log/myapp/threaddump.txt
<pid> 는 반드시 인라인 코드로 감싸 MDX 빌드 에러를 피합니다.
3) 힙 덤프 도구 선택: MAT가 표준
힙 덤프 분석은 보통 다음 중 하나로 합니다.
- Eclipse MAT: 대용량 덤프 분석에 강하고, Retained Size/Leak Suspects 리포트가 유용
- VisualVM: 가볍게 보기 좋지만 대용량에서 버거울 수 있음
jcmd/jmap: 덤프 생성에 사용
이 글은 MAT 기준으로 진행합니다.
4) MAT로 분석하는 표준 워크플로우
4.1 덤프 열기와 기본 리포트
MAT에서 heapdump.hprof 를 열면 인덱싱 후 다음을 바로 수행합니다.
Leak Suspects ReportDominator Tree
Leak Suspects는 “누수가 의심되는 상위 보유자”를 자동으로 뽑아주지만, 항상 정답은 아닙니다. 결론은 Dominator Tree와 Retained Path로 확정합니다.
4.2 Dominator Tree: Retained Size 상위부터 본다
Dominator Tree는 “이 객체가 사라지면 같이 사라지는 메모리(=Retained Size)” 기준으로 정렬합니다.
Retained Heap이 비정상적으로 큰 노드 상위 10개를 확인- 상위 노드가 다음 중 어디에 속하는지 분류
- 캐시(맵/구아바/카페인)
- 컬렉션 폭증(리스트/맵에 요청별 데이터가 계속 쌓임)
- 대형 배열/바이트 버퍼(
byte[],char[]) - JSON/문자열 폭증(
String,StringBuilder) - ORM 영속성 컨텍스트(세션에 엔티티가 과다 적재)
4.3 “누가 잡고 있나”: Path to GC Roots
의심 객체를 선택하고 Path to GC Roots 를 봅니다. 핵심은 “GC가 못 치우는 이유”를 찾는 것입니다.
- 정적 필드(static)에서 잡고 있나
- 스레드 로컬(ThreadLocal)에서 잡고 있나
- 캐시/싱글톤/스케줄러가 참조를 유지하나
- 리스너/콜백/옵저버가 해제되지 않았나
MAT 화면에서 흔히 보는 패턴은 이런 식입니다.
ClassName의static필드cache가ConcurrentHashMap을 잡고 있고, 그 안에 값이 계속 축적Thread의threadLocals가 어떤 컨텍스트 객체를 잡고 있고, 그 컨텍스트가 대형 그래프를 유지
5) Spring Boot에서 자주 나오는 누수 패턴 7가지
이 섹션은 “MAT에서 무엇이 보이면 무엇을 의심해야 하는가”에 초점을 둡니다.
5.1 캐시 무제한 증가: ConcurrentHashMap 과 Caffeine 설정
가장 흔한 케이스는 캐시가 사실상 무제한으로 커지는 경우입니다.
- 키가 요청 파라미터 전체(예: 사용자 입력 문자열)라서 cardinality가 무한대
- TTL이 없거나, eviction 정책이 없음
예: 잘못된 캐시 키 설계
@Cacheable(cacheNames = "search", key = "#query")
public List<Item> search(String query) {
return repository.search(query);
}
query 가 사용자 입력 전체면 키 종류가 무한히 늘어날 수 있습니다. 해결은 보통 아래 조합입니다.
- 캐시 크기 제한(
maximumSize) - TTL 설정(
expireAfterWrite) - 키 정규화(불필요한 변동 제거)
5.2 ThreadLocal 누수: 요청 컨텍스트를 넣고 해제 안 함
서블릿 필터/인터셉터에서 ThreadLocal 을 쓰고 remove() 를 누락하면, 스레드 풀의 스레드가 재사용되면서 객체가 오래 살아남습니다.
public class TraceContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void set(String id) {
TRACE_ID.set(id);
}
public static void clear() {
TRACE_ID.remove();
}
}
필터에서는 반드시 try/finally 로 정리합니다.
try {
TraceContext.set(traceId);
chain.doFilter(request, response);
} finally {
TraceContext.clear();
}
MAT에서는 Thread 객체의 threadLocals 아래로 큰 객체 그래프가 매달려 있는 형태로 자주 관측됩니다.
5.3 대량 로깅/버퍼링: String 과 char[] 폭증
- 예외 스택트레이스/요청 바디를 과도하게 문자열로 누적
- 비동기 로깅 큐가 밀려서 메시지가 메모리에 적체
MAT에서 char[] 가 상위 Retained Size를 차지하면 다음을 확인하세요.
- 로그 레벨/로그량 급증 여부
- Logback async appender 큐 사이즈
- 요청/응답 바디 로깅 필터 적용 여부
5.4 Jackson 직렬화로 인한 임시 객체 폭증
응답에 거대한 컬렉션을 그대로 반환하거나, 순환 참조로 인해 직렬화가 비정상적으로 커지는 경우가 있습니다.
- 페이징 없이 전체 목록 반환
- 엔티티 그래프를 그대로 반환해서 연관 객체까지 전부 직렬화
해결책은 보통 DTO로 제한하고, 페이징을 강제하고, 필요 필드만 내려주는 것입니다.
5.5 JPA 영속성 컨텍스트 비우기 실패
배치/마이그레이션 작업에서 EntityManager 에 엔티티가 계속 쌓이면 힙이 증가합니다.
for (int i = 0; i < items.size(); i++) {
entityManager.persist(items.get(i));
if (i % 1000 == 0) {
entityManager.flush();
entityManager.clear();
}
}
덤프에서는 특정 엔티티 타입 인스턴스가 수십만 개 이상으로 보이고, GC Root로는 트랜잭션/세션 관련 객체가 연결되는 패턴이 나옵니다.
5.6 InputStream 을 메모리로 통째로 읽기
파일 업로드/다운로드 처리에서 byte[] 가 큰 Retained Size로 보이면 의심해야 합니다.
byte[] payload = inputStream.readAllBytes();
큰 파일이면 한 방에 힙을 터뜨릴 수 있습니다. 스트리밍 처리로 바꾸고, 상한을 두세요.
5.7 커넥션 고갈과 동반되는 메모리 적체
DB 커넥션 풀이 고갈되면 요청이 쌓이고, 요청 컨텍스트/버퍼/직렬화 대기 객체가 메모리에 적체되면서 OOM으로 이어질 수 있습니다. 이 경우 힙 덤프만 보면 “대기 중인 요청 관련 객체가 많다”로 보일 수 있어, 근본 원인이 커넥션일 수 있습니다.
관련해서는 Spring Boot HikariCP 커넥션 고갈 원인 8가지도 함께 보면 진단이 빨라집니다.
6) “누수”와 “사이즈 부족”을 구분하는 기준
힙 덤프 한 장으로도 어느 정도 판단할 수 있지만, 가장 정확한 방법은 “시간에 따른 힙 사용 패턴”입니다.
- 누수 가능성이 큰 패턴
- Full GC 이후에도 Old Gen 사용량이 계속 우상향
- MAT에서 특정 컬렉션/캐시가 비정상적으로 커지고, GC Root가 static/ThreadLocal/싱글톤
- 단순 사이즈 부족 패턴
- 트래픽 피크나 특정 배치 시점에만 힙이 치솟고, 평소에는 안정
- 대형 응답/대형 파일/대량 처리 작업이 트리거
결론이 “사이즈 부족”이라면 -Xmx 를 늘리는 것도 방법이지만, 먼저 객체 생명주기와 상한(페이징, 스트리밍, 제한)을 설계로 막는 것이 장기적으로 안전합니다.
7) 힙 덤프 분석 예시 시나리오 (MAT에서 결론 내리기)
가상의 예를 들어보겠습니다.
- Dominator Tree 상위에
java.util.concurrent.ConcurrentHashMap이 있고 Retained Size가 힙의 60%를 차지 - 해당 맵의 값 타입이
com.myapp.dto.SearchResult로 수십만 개 Path to GC Roots를 보면com.myapp.cache.SearchCache의static필드가 이 맵을 보유- 키가 사용자 입력 문자열로 보이고, 유사하지만 조금씩 다른 키가 무한히 증가
이 경우 결론은 “캐시 키 cardinality 폭증 + eviction 부재”입니다. 조치는 다음 우선순위로 진행합니다.
- 캐시
maximumSize설정 - TTL 추가
- 키 정규화(공백/대소문자/정렬 등)
- 캐시 대상 자체를 재검토(정말 캐시해야 하는가)
8) Metaspace/Direct OOM일 때 빠른 체크
힙 덤프는 주로 힙을 보여주므로, 아래 유형은 별도 확인이 필요합니다.
8.1 Metaspace
- 증상:
OutOfMemoryError: Metaspace - 의심: 클래스 로더 누수, 런타임 프록시/바이트코드 생성 과다
- 체크
-XX:MaxMetaspaceSize설정 여부- 재배포/리로드가 반복되는 환경에서 클래스 로더가 누적되는지
8.2 Direct buffer memory
- 증상:
OutOfMemoryError: Direct buffer memory - 의심: Netty/HTTP 클라이언트/압축에서 direct buffer가 해제되지 않거나 상한이 낮음
- 체크
-XX:MaxDirectMemorySize확인- NMT(네이티브 메모리 트래킹) 사용 검토
9) 덤프를 남기기 어려운 환경에서의 대안
- 디스크가 부족하거나, 덤프 생성 시간이 부담인 경우
- 힙 덤프 대신
jcmd <pid> GC.class_histogram으로 클래스 히스토그램을 주기적으로 저장 - 문제 시점 전후의 변화량 비교
- 힙 덤프 대신
jcmd <pid> GC.class_histogram > /var/log/myapp/class-histo.txt
히스토그램만으로도 “어떤 클래스 인스턴스가 폭증했는지”는 빠르게 파악할 수 있습니다.
10) 운영 적용 체크리스트
- OOM 시 힙 덤프 자동 생성 옵션 적용:
-XX:+HeapDumpOnOutOfMemoryError - 덤프 저장 경로가 영속/충분한 용량인지 확인
- GC 로그를 파일로 남기고 보관 정책 수립
- 스레드 덤프를 함께 확보할 수 있는 런북 준비
- MAT 분석 절차 표준화: Leak Suspects, Dominator Tree, Path to GC Roots
- 캐시/ThreadLocal/대형 응답/배치 작업에 상한과 정리 로직 적용
마무리
Spring Boot OOM 대응의 핵심은 “죽지 않게 만드는 튜닝”보다, 죽었을 때 원인을 확정할 수 있는 증거를 남기고(힙 덤프), Retained Size와 GC Root 경로로 범인을 특정하는 것입니다. 힙 덤프 분석이 익숙해지면, 단순히 -Xmx 를 늘리는 임시 처방에서 벗어나 캐시 정책, 객체 생명주기, 배치 처리 방식 같은 구조적 개선으로 연결할 수 있습니다.
추가로, 장애가 연쇄적으로 발생하는 환경(예: 커넥션 고갈로 요청 적체가 생기며 메모리까지 터지는 상황)이라면 힙 덤프 분석과 함께 병목 리소스도 같이 추적해야 합니다. 그런 케이스에서는 앞서 언급한 HikariCP 진단 글을 함께 참고하면 원인 분리가 훨씬 빨라집니다.