Published on

Spring Boot OutOfMemoryError 덤프 분석·튜닝 7단계

Authors

운영 중인 Spring Boot 서비스에서 java.lang.OutOfMemoryError가 발생하면 가장 위험한 실수는 “일단 메모리만 늘리자”로 끝내는 것입니다. 증상(에러)은 잠깐 사라질 수 있지만, 누수(leak)·캐시 폭주·오프힙(native) 고갈·컨테이너 메모리 제한(OOMKill) 같은 근본 원인은 남습니다.

이 글은 덤프 확보 → 원인 분류 → 힙/오프힙/GC/컨테이너/코드 튜닝까지 이어지는 7단계 체크리스트 형태로 정리했습니다. 단계별로 “무엇을 확보하고”, “어떤 지표로 판단하며”, “어떤 옵션/수정으로 끝내는지”를 실전 관점에서 다룹니다.

> 참고: 장애 원인 분석은 재현이 어려운 경우가 많습니다. 그래서 사후 분석 가능한 증거(덤프/로그/메트릭)를 남기는 체계가 중요합니다. EKS 같은 쿠버네티스 환경에서의 운영 이슈와 연결해서 보고 싶다면 EKS HPA 폭주를 KEDA 큐기반 오토스케일링으로 안정화도 함께 참고하면 좋습니다.

1단계: OOM 종류부터 분류한다 (힙 vs 오프힙 vs 메타스페이스 vs 스레드)

OutOfMemoryError는 “힙이 꽉 찼다”만 의미하지 않습니다. 메시지를 보고 메모리 영역을 먼저 분류해야 다음 액션이 정확해집니다.

  • Java heap space: 힙 부족(객체/컬렉션 폭증, 캐시, 누수)
  • GC overhead limit exceeded: GC가 대부분의 시간을 쓰는데 회수 실패(힙 압박, 단편화, 과도한 객체 생성)
  • Metaspace: 클래스 메타데이터 부족(동적 프록시/클래스 로딩 폭증, classloader leak)
  • unable to create new native thread: 스레드 생성 실패(스레드 수 폭증, 스택 메모리, OS 제한)
  • Direct buffer memory: Netty/ByteBuffer 등 Direct(오프힙) 메모리 부족

Spring Boot에서는 특히 다음 조합이 자주 나옵니다.

  • WebFlux/Netty 사용 + 대용량 응답/버퍼링 → Direct buffer memory
  • 동적 프록시/리로드/플러그인 구조 → Metaspace
  • 잘못된 캐시 정책 + 트래픽 증가 → Java heap space

2단계: “죽기 전에” 증거를 남기는 JVM 옵션을 넣는다

OOM은 재현이 어려우므로, 운영/스테이징에 최소한 아래 옵션은 넣어두는 것이 좋습니다.

필수: 힙 덤프/에러 로그 자동 생성

JAVA_TOOL_OPTIONS="\
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heapdump.hprof \
-XX:ErrorFile=/var/log/app/hs_err_pid%p.log \
-Xlog:gc*:file=/var/log/app/gc.log:time,level,tags \
"
  • HeapDumpOnOutOfMemoryError: OOM 시점의 힙 스냅샷
  • hs_err_pid*.log: JVM 크래시/내부 오류 정보(스레드/네이티브/메모리 맵)
  • gc.log: OOM 직전 GC 패턴(Full GC 연속, Promotion 실패 등)

Kubernetes/EKS라면: 덤프 파일이 사라지지 않게

컨테이너가 죽으면 로컬 파일이 날아갈 수 있습니다. 다음 중 하나를 권장합니다.

  • PVC 마운트(덤프/로그 저장)
  • sidecar/daemonset로 로그 수집
  • 종료 훅(preStop)로 S3 업로드(단, OOM은 훅이 실행되지 않을 수 있음)

쿠버네티스에서 리소스/볼륨이 꼬여 장애 대응이 어려웠던 경험이 있다면 Kubernetes PV 멈춤 - finalizer로 삭제 안될 때처럼 운영 이슈가 분석 파이프라인을 막는 케이스도 함께 대비해두면 좋습니다.

3단계: 컨테이너 OOMKill과 JVM OOM을 구분한다

운영에서 자주 헷갈리는 포인트입니다.

  • JVM OOM: 애플리케이션 로그에 OutOfMemoryError가 남고, 힙 덤프가 생성될 수 있음
  • 컨테이너 OOMKill(ExitCode 137): 커널이 프로세스를 강제 종료 → JVM이 덤프를 남길 기회가 없음

확인 방법

kubectl describe pod <pod>
# Last State: Terminated
# Reason: OOMKilled
# Exit Code: 137

컨테이너 OOMKill이라면 다음을 먼저 점검합니다.

  • resources.limits.memory가 너무 낮지 않은가
  • JVM이 컨테이너 메모리를 제대로 인지하는가(JDK 10+는 기본적으로 cgroup 인지)
  • 힙 외 메모리(메타스페이스, 스레드 스택, direct, 코드캐시)가 충분한가

컨테이너에서 힙을 “적당히” 잡는 기본값

고정 -Xmx 대신 비율 기반이 운영에 유리한 경우가 많습니다.

JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=70 \
-XX:InitialRAMPercentage=40 \
-XX:+UseG1GC \
"
  • 컨테이너 메모리 중 힙이 100%를 먹지 않게(네이티브 영역 여유 확보)
  • 프레임워크/네이티브 라이브러리/스레드 증가에도 버틸 공간 확보

4단계: 힙 덤프를 MAT로 열고 “Dominator Tree”부터 본다

힙 OOM이라면 가장 빠른 길은 Eclipse MAT(Memory Analyzer Tool)에서 Dominator Tree로 “누가 메모리를 지배하는지”를 보는 것입니다.

MAT에서 가장 먼저 볼 것

  • Leak Suspects Report: 후보를 자동으로 뽑아줌(정확도는 케이스마다 다름)
  • Dominator Tree: Retained Heap 기준 상위 객체
  • Path to GC Roots: 왜 수거되지 않는지(참조 체인)

전형적인 원인 패턴

  • java.util.HashMap/ConcurrentHashMap이 상위: 캐시/맵 누수
  • byte[]가 상위: 파일/응답/압축/이미지 처리, 버퍼링
  • char[]/String이 상위: 로그/JSON 파싱/템플릿/대용량 문자열
  • org.hibernate.engine.spi.PersistenceContext: 세션 범위 과대, 영속성 컨텍스트 누수

예시: 무제한 캐시(누수에 준함)

// 나쁜 예: 만료/최대 사이즈 없음
private final Map<String, Object> cache = new ConcurrentHashMap<>();

public Object get(String key) {
    return cache.computeIfAbsent(key, this::load);
}

개선 방향은 최대 크기 + 만료 + 계측입니다.

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

Cache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(50_000)
        .expireAfterWrite(java.time.Duration.ofMinutes(10))
        .recordStats()
        .build();

5단계: 오프힙/네이티브 메모리는 NMT로 추적한다

힙 덤프가 “정상”처럼 보이는데도 메모리가 터지면, 범인은 종종 오프힙입니다.

  • Direct ByteBuffer(특히 Netty)
  • Thread stack(스레드 수 * -Xss)
  • Metaspace
  • JNI/native 라이브러리

Native Memory Tracking(NMT) 활성화

NMT는 비용이 있으니 상시보단 “장애 대응용”으로 켜는 편이 일반적입니다.

JAVA_TOOL_OPTIONS="\
-XX:NativeMemoryTracking=summary \
-XX:+UnlockDiagnosticVMOptions \
"

실행 중인 프로세스에서 확인:

jcmd <pid> VM.native_memory summary

출력에서 Java Heap이 아니라 Thread, Class, Internal, NIO 등이 비정상적으로 큰지 확인합니다.

Direct 메모리 의심 시

  • Netty 사용 시: 버퍼 누수 로그(-Dio.netty.leakDetection.level=paranoid는 과격, 일시적으로만)
  • -XX:MaxDirectMemorySize를 무작정 키우기 전에 왜 direct가 증가하는지(대용량 버퍼링/백프레셔 부재)를 먼저 확인

6단계: GC 로그로 “힙이 작은가, 객체 생성이 미친 건가”를 판단한다

OOM 직전 GC 로그는 매우 많은 힌트를 줍니다.

흔한 시그널

  • Full GC가 연속으로 발생 + 회수량이 미미 → 누수/라이브셋 과다
  • Young GC가 과도하게 빈번 → 객체 생성 속도 과다(파싱/매핑/임시 컬렉션)
  • Promotion failed / to-space exhausted → Survivor/Old 압박, 힙 구성 문제

G1GC 기본 튜닝의 출발점

Spring Boot 서버 워크로드에서 G1은 무난한 선택입니다.

JAVA_TOOL_OPTIONS="\
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=30 \
-Xlog:gc*:file=/var/log/app/gc.log:time,level,tags \
"
  • MaxGCPauseMillis: 목표 지연(절대 보장 아님)
  • IHOP: Old 점유율이 낮은 시점부터 마킹을 시작해 Full GC 위험을 줄임

GC 튜닝은 “옵션 몇 개”로 끝내기 어렵고, 트래픽 패턴/객체 생명주기/힙 크기가 함께 봐야 합니다. 그래서 4단계의 힙 덤프(라이브셋)와 6단계의 GC 로그를 같이 보며 결론을 내리는 것이 안전합니다.

7단계: 코드·설정 레벨에서 재발을 막는 튜닝 체크리스트

마지막 단계는 “원인 제거”입니다. 덤프 분석 결과에 따라 아래를 적용합니다.

7-1. 대용량 처리: 스트리밍으로 바꾸기(버퍼링 금지)

대용량 파일/응답을 메모리에 올리는 순간 OOM 가능성이 커집니다.

// 좋은 예: StreamingResponseBody로 스트리밍
@GetMapping("/export")
public org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody export(
        javax.servlet.http.HttpServletResponse response) {

    response.setContentType("text/csv");
    return outputStream -> {
        try (var writer = new java.io.BufferedWriter(new java.io.OutputStreamWriter(outputStream))) {
            for (int i = 0; i < 1_000_000; i++) {
                writer.write("row," + i);
                writer.newLine();
            }
        }
    };
}

7-2. 캐시/컬렉션: 상한, 만료, 관측(Stats) 필수

  • maximumSize/maximumWeight
  • expireAfterWrite/expireAfterAccess
  • 캐시 히트율/eviction을 메트릭으로 노출

7-3. DB/ORM: 영속성 컨텍스트와 배치 처리

  • JPA 대량 처리 시 flush/clear 주기적으로
  • 페이징/커서 기반 처리로 한 번에 로딩하지 않기

7-4. 스레드 폭증 방지

  • @Async/스케줄러/웹 요청 처리에서 풀 사이즈 제한
  • 외부 API 호출에 타임아웃/리트라이 상한(무한 대기/무한 재시도는 스레드를 잡아먹음)

7-5. 관측성: “증거 자동 수집”을 상시 파이프라인에

  • OOM 발생 시 힙 덤프 업로드 자동화
  • GC 로그/메모리 메트릭 대시보드화
  • 배포마다 -Xlog:gc* 경로/권한 검증

운영에서 장애 대응은 보통 “원인 하나”가 아니라, 리소스/오토스케일/네트워크/스토리지 같은 주변 요소와 함께 터집니다. 예를 들어 스케일링이 흔들리면 인스턴스당 부하가 급증해 메모리도 같이 터질 수 있는데, 이런 경우는 EKS HPA 폭주를 KEDA 큐기반 오토스케일링으로 안정화처럼 트래픽 흡수 구조까지 함께 보는 게 재발 방지에 효과적입니다.

마무리: 7단계 한 장 요약

  1. OOM 메시지로 영역 분류(heap/metaspace/direct/thread)
  2. 덤프/로그 자동 생성 옵션을 미리 심어둔다
  3. OOMKill vs JVM OOM을 구분해 컨테이너 한계를 먼저 확인한다
  4. 힙 OOM이면 MAT Dominator Tree/GC Roots로 지배 객체를 찾는다
  5. 힙이 정상이면 NMT로 오프힙/네이티브를 추적한다
  6. GC 로그로 라이브셋 과다 vs 생성률 과다 vs 힙 구성 문제를 판단한다
  7. 스트리밍/캐시 상한/스레드 제한/ORM 배치 등으로 재발을 막는다

원하시면, 실제 덤프/GC 로그 일부(민감정보 제거)를 기준으로 MAT에서 어떤 화면을 봐야 하는지, 그리고 -Xmx, MaxRAMPercentage, MaxDirectMemorySize, -Xss를 어떤 근거로 산정할지까지 케이스 스터디 형태로 이어서 정리해드릴 수 있습니다.