Published on

Spring Boot OOM 원인추적과 힙덤프 분석 실전

Authors

운영 중인 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. 이벤트/메시지 적체로 큐가 커짐

내부 이벤트를 메모리 큐에 쌓아두고 소비가 밀리면 힙이 점진적으로 증가합니다. 특히 재처리/중복 방지 로직이 꼬이면 “처리 못한 이벤트가 계속 재큐잉” 되며 폭증합니다.

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) “원인추적”을 끝내는 체크리스트

힙덤프 분석을 하고도 재발하는 경우는 보통 “조치가 가설 수준”에서 끝났기 때문입니다. 아래를 끝까지 닫아야 합니다.

  1. OOM 유형을 로그로 분류했는가 (heap, metaspace, direct, native thread)
  2. 동일 조건에서 재현하거나, 최소한 GC 로그로 증가 패턴을 확인했는가
  3. 힙덤프에서 Retained Heap 상위 객체를 확인했는가
  4. 해당 객체의 Path to GC Roots 로 “누가 잡고 있는지” 확인했는가
  5. 코드 수정 또는 설정 변경이 “객체 생명주기”를 실제로 끊는가
  6. 캐시/큐/입력 크기에 상한이 생겼는가
  7. 배포 후 메모리 지표(Old Gen, GC pause, RSS)를 대시보드로 추적하는가

10) 마무리: 힙덤프는 ‘증상’이 아니라 ‘관계’를 본다

OOM 대응의 핵심은 “누가 누구를 참조해서 못 죽는지”를 찾는 것입니다. MAT의 Dominator Tree와 GC Roots는 그 관계를 보여주는 가장 빠른 도구입니다. 여기에 GC 로그와 컨테이너 메모리 한계를 함께 보면, 단순 힙 부족인지, 구조적 누수인지, 오프힙 문제인지가 분리됩니다.

운영 장애는 늘 단일 원인처럼 보이지만, 실제로는 입력 폭발, 큐 적체, 캐시 정책 부재 같은 여러 층이 겹쳐서 터지는 경우가 많습니다. 한 번의 힙덤프 분석으로 끝내지 말고, 상한 설정과 관측(대시보드/알람)까지 묶어 “재발 불가능한 상태”로 만드는 것이 목표입니다.