- Published on
Spring Boot OOM 원인추적과 힙덤프 분석 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Spring Boot 서비스가 갑자기 java.lang.OutOfMemoryError 로 죽으면, 단순히 힙을 늘리는 것만으로는 재발을 막기 어렵습니다. OOM은 보통 (1) 실제 메모리 누수, (2) 순간 트래픽/배치로 인한 메모리 스파이크, (3) JVM/컨테이너 메모리 설정 불일치, (4) 힙이 아니라 네이티브 메모리 고갈 중 하나로 귀결됩니다.
이 글은 “어떤 OOM인지 분류”하고, 힙덤프를 안전하게 뜬 뒤, MAT(또는 VisualVM)로 원인을 좁혀, 코드/설정 레벨에서 재발 방지까지 가는 실전 흐름을 다룹니다.
1) OOM 종류부터 정확히 분류하기
로그에 찍히는 OOM 메시지에 따라 접근이 달라집니다.
1-1. 대표적인 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- Netty, NIO, 대용량 ByteBuffer 등 오프힙(Direct) 고갈.
java.lang.OutOfMemoryError: unable to create new native thread- 스레드가 너무 많거나, 컨테이너/OS 제한으로 스레드 생성 실패.
1-2. “힙을 늘리면 해결”이 위험한 이유
힙을 늘려서 증상이 늦게 나타날 수는 있지만, 누수라면 결국 다시 터집니다. 또한 컨테이너에서는 힙을 늘리면 네이티브 메모리(스레드 스택, Direct, Metaspace, JIT, libc 등) 를 먹을 공간이 줄어 더 빨리 OOMKill 로 이어질 수 있습니다.
2) 컨테이너 환경에서 먼저 확인할 것
Kubernetes/ECS/Docker에서 Spring Boot가 죽을 때는 “JVM OOM”과 “컨테이너 OOMKill”이 섞여 보입니다.
- JVM 로그에 OOM이 남고 프로세스가 종료되면 JVM OOM 가능성
- 파드 이벤트에 OOMKilled가 찍히면 컨테이너 메모리 한계 초과 가능성
JDK 10+는 기본적으로 컨테이너 메모리 인식을 하지만, 여전히 힙만 보고 안심하면 안 됩니다. 운영에서는 보통 -Xmx 를 컨테이너 메모리의 60~75% 정도로 두고, 나머지를 네이티브에 남겨두는 전략을 씁니다.
3) 재현과 증거 수집: 힙덤프와 GC 로그
3-1. OOM 시 자동 힙덤프 생성 옵션
가장 먼저 해야 할 일은 “OOM이 났을 때 힙덤프가 남도록” 설정하는 것입니다.
JAVA_TOOL_OPTIONS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heapdumps -XX:ErrorFile=/var/log/app/hs_err_pid%p.log" \
java -jar app.jar
-XX:+HeapDumpOnOutOfMemoryError: OOM 시 힙덤프 생성-XX:HeapDumpPath=...: 덤프 저장 경로-XX:ErrorFile=...: JVM 크래시 로그 저장
주의: 덤프 파일은 크기가 매우 큽니다. 컨테이너 디스크가 작으면 덤프가 쓰이다가 실패할 수 있으니, 볼륨 마운트 또는 호스트 경로 를 고려하세요.
3-2. 실행 중 수동 힙덤프 뜨기
OOM 직전이 의심될 때는 프로세스가 살아있는 동안 덤프를 뜨는 편이 분석에 더 유리합니다.
jcmd <pid> GC.heap_dump /tmp/heap.hprof
또는
jmap -dump:format=b,file=/tmp/heap.hprof <pid>
jcmd 가 일반적으로 더 권장됩니다.
3-3. GC 로그도 같이 남기기
힙덤프만으로는 “왜 이런 패턴으로 커졌는지”가 부족할 수 있습니다. GC 로그는 증가 추세(누수)와 순간 스파이크(버스트)를 구분하는 데 도움이 됩니다.
JAVA_TOOL_OPTIONS="-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,level,tags" \
java -jar app.jar
4) 힙덤프 분석 도구 선택: MAT vs VisualVM
- Eclipse MAT: 대용량 덤프 분석에 강함. Leak Suspects, Dominator Tree가 강력.
- VisualVM: UI가 직관적. 라이브 프로파일링과 결합하기 좋음.
운영 OOM은 덤프가 수 GB인 경우가 많아 MAT가 더 안정적입니다.
5) MAT로 원인 좁히는 분석 절차
5-1. Leak Suspects Report부터 시작
MAT에서 덤프를 열면 “Leak Suspects Report”를 먼저 돌립니다.
- 누수가 의심되는 큰 객체 집합
- 해당 객체를 붙잡고 있는 GC Root 경로
- 특정 컬렉션에 쌓인 객체 수
이 리포트는 100% 정답은 아니지만, “첫 30분 방향”을 잡는 데 매우 유용합니다.
5-2. Dominator Tree로 진짜로 메모리를 잡아먹는 것 찾기
Dominator Tree는 “이 객체가 해제되면 같이 해제될 수 있는 메모리”를 보여줍니다.
Retained Heap이 큰 노드를 우선 확인byte[],char[],java.util.HashMap$Node[]같은 배열이 상위에 많으면- 문자열/캐시/역직렬화/버퍼 누적 가능성이 큼
5-3. GC Roots로 “왜 안 죽는지”를 확인
누수의 핵심은 “객체가 살아남는 이유”입니다. MAT에서 의심 객체를 선택하고 “Path to GC Roots”를 봅니다.
자주 나오는 패턴:
static필드가 잡고 있는 컬렉션- Spring 싱글톤 빈이 들고 있는 캐시
- ThreadLocal에 쌓인 값
- Executor 스레드가 참조하는 큐
6) Spring Boot에서 흔한 메모리 누수 패턴과 처방
6-1. 무한 캐시: ConcurrentHashMap 에 계속 쌓임
예: 사용자별 결과를 캐싱한다고 해놓고 만료 정책이 없음.
@Component
public class UserCache {
private final ConcurrentHashMap<String, byte[]> cache = new ConcurrentHashMap<>();
public byte[] getOrLoad(String userId) {
return cache.computeIfAbsent(userId, this::loadBigBlob);
}
private byte[] loadBigBlob(String userId) {
// 큰 데이터를 메모리에 적재
return new byte[10 * 1024 * 1024];
}
}
처방:
- Caffeine 같은 캐시를 사용하고
maximumSize또는expireAfterWrite를 필수로 둠 - 캐시 값이 크면 “메모리 캐시” 대신 Redis/S3 같은 외부 저장소 고려
@Bean
public com.github.benmanes.caffeine.cache.Cache<String, byte[]> userCache() {
return com.github.benmanes.caffeine.cache.Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(java.time.Duration.ofMinutes(10))
.build();
}
6-2. ThreadLocal 누수: 요청 스코프 정보를 정리하지 않음
서블릿 필터나 인터셉터에서 ThreadLocal을 쓰고 remove() 를 안 하면, 스레드풀 스레드가 재사용되면서 값이 계속 남거나 예상보다 오래 살아남습니다.
public final class TraceContext {
private static final ThreadLocal<String> traceId = new ThreadLocal<>();
public static void set(String id) { traceId.set(id); }
public static String get() { return traceId.get(); }
public static void clear() { traceId.remove(); }
}
@Component
public class TraceFilter extends org.springframework.web.filter.OncePerRequestFilter {
@Override
protected void doFilterInternal(
jakarta.servlet.http.HttpServletRequest request,
jakarta.servlet.http.HttpServletResponse response,
jakarta.servlet.FilterChain filterChain) throws java.io.IOException, jakarta.servlet.ServletException {
try {
TraceContext.set(java.util.UUID.randomUUID().toString());
filterChain.doFilter(request, response);
} finally {
TraceContext.clear();
}
}
}
6-3. 대용량 응답/요청 바디를 한 번에 메모리에 올림
- 큰 JSON을
String으로 통째로 만들기 - 로그에 바디를 그대로 찍기
- 파일 업로드를 메모리로 버퍼링
처방:
- 스트리밍 처리
- 압축/청크
- 로그는 길이 제한과 샘플링
이런 “입력 크기 폭발” 문제는 메모리만의 문제가 아니라 트래픽/보안 이슈로도 이어집니다. 비슷한 방식의 진단 접근은 다른 장애 글에서도 도움이 됩니다. 예를 들어 Responses API 400 context_length_exceeded 해결법 처럼 “입력 상한과 방어 로직”을 먼저 세우는 것이 재발 방지의 핵심입니다.
6-4. 이벤트/메시지 적체로 큐가 커짐
내부 이벤트를 메모리 큐에 쌓아두고 소비가 밀리면 힙이 점진적으로 증가합니다. 특히 재처리/중복 방지 로직이 꼬이면 “처리 못한 이벤트가 계속 재큐잉” 되며 폭증합니다.
- 처방: 큐 길이 상한, backpressure, 드롭/디스크 스풀, 소비자 확장
- 이벤트 재처리/순서 문제는 아키텍처적으로도 연결됩니다. 이벤트 소싱/재처리 관점은 DDD 이벤트 소싱 마이그레이션 - 중복·순서·재처리 도 참고할 만합니다.
7) 힙이 아닌 경우: Metaspace, Direct, Native thread
7-1. Metaspace OOM
- 동적 프록시가 과도하거나, 클래스 로더가 해제되지 않는 경우
- 스프링 DevTools, 리로딩, 스크립트 엔진, 바이트코드 생성 라이브러리 사용 시 가능
대응:
-XX:MaxMetaspaceSize로 상한을 두고 관측- 클래스 로더 누수 의심 시 덤프에서 클래스 로더별 점유 확인
7-2. Direct buffer memory OOM
Netty 기반 클라이언트/서버나 NIO에서 DirectBuffer를 많이 쓰면 발생합니다.
대응:
-XX:MaxDirectMemorySize로 상한을 명시- 버퍼 풀링/해제 패턴 점검
- 큰 파일 다운로드를 메모리에 누적하지 않도록 스트리밍
7-3. unable to create new native thread
원인 후보:
- 스레드풀을 무분별하게 생성
- 블로킹 호출로 스레드가 반환되지 않음
- 컨테이너
ulimit또는 PID 제한
대응:
- 스레드풀 개수 제한 및 공유
- 블로킹 I/O 제거 또는 타임아웃 설정
- 컨테이너 리소스/OS 제한 점검
8) 운영에서의 권장 JVM 옵션 템플릿
아래는 “덤프/로그를 남기고, 컨테이너에서 너무 공격적으로 힙을 잡지 않는” 쪽의 예시입니다.
JAVA_TOOL_OPTIONS="\
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heapdumps \
-XX:ErrorFile=/var/log/app/hs_err_pid%p.log \
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,level,tags \
-XX:MaxRAMPercentage=70 \
" \
java -jar app.jar
-XX:MaxRAMPercentage는 컨테이너 메모리 기준 힙 비율을 잡습니다.- 이미
-Xmx를 명시한다면MaxRAMPercentage는 보조로만 두거나 제거하세요.
9) “원인추적”을 끝내는 체크리스트
힙덤프 분석을 하고도 재발하는 경우는 보통 “조치가 가설 수준”에서 끝났기 때문입니다. 아래를 끝까지 닫아야 합니다.
- OOM 유형을 로그로 분류했는가 (
heap,metaspace,direct,native thread) - 동일 조건에서 재현하거나, 최소한 GC 로그로 증가 패턴을 확인했는가
- 힙덤프에서
Retained Heap상위 객체를 확인했는가 - 해당 객체의
Path to GC Roots로 “누가 잡고 있는지” 확인했는가 - 코드 수정 또는 설정 변경이 “객체 생명주기”를 실제로 끊는가
- 캐시/큐/입력 크기에 상한이 생겼는가
- 배포 후 메모리 지표(Old Gen, GC pause, RSS)를 대시보드로 추적하는가
10) 마무리: 힙덤프는 ‘증상’이 아니라 ‘관계’를 본다
OOM 대응의 핵심은 “누가 누구를 참조해서 못 죽는지”를 찾는 것입니다. MAT의 Dominator Tree와 GC Roots는 그 관계를 보여주는 가장 빠른 도구입니다. 여기에 GC 로그와 컨테이너 메모리 한계를 함께 보면, 단순 힙 부족인지, 구조적 누수인지, 오프힙 문제인지가 분리됩니다.
운영 장애는 늘 단일 원인처럼 보이지만, 실제로는 입력 폭발, 큐 적체, 캐시 정책 부재 같은 여러 층이 겹쳐서 터지는 경우가 많습니다. 한 번의 힙덤프 분석으로 끝내지 말고, 상한 설정과 관측(대시보드/알람)까지 묶어 “재발 불가능한 상태”로 만드는 것이 목표입니다.