/개발/OutOfMemoryError 5계열 30초 진단 런북: heap space vs Metaspace 복구 명령
개발OutOfMemoryErrorJVM 튜닝

OutOfMemoryError 5계열 30초 진단 런북: heap space vs Metaspace 복구 명령

java.lang.OutOfMemoryError는 에러 원문 한 줄로 계열을 구분하면 진단의 90%가 끝납니다. 5계열 분기표, jstat·jmap heap dump 분석, MaxRAMPercentage 튜닝, K8s OOMKilled(137)까지 복붙 명령으로 정리한 실전 런북.

OutOfMemoryError 5계열 30초 진단 런북: heap space vs Metaspace 복구 명령

OutOfMemoryError 5계열 30초 진단 런북: heap space vs Metaspace 복구 명령

새벽 3시, 알림이 울리고 로그에는 java.lang.OutOfMemoryError. 여기서 급하게 -Xmx부터 올리는 분들이 많은데, 절반은 헛수고입니다. OutOfMemoryError는 다 같은 OOM이 아니기 때문입니다. Metaspace 누수인데 힙만 키우면 재발하고, 컨테이너 cgroup 문제인데 JVM 옵션만 만지면 또 OOMKilled로 죽습니다.

핵심은 이겁니다. 에러 원문 한 줄이 진단의 90%를 결정합니다. 이 글은 장애 대응 중에 스크롤하면서 바로 복붙할 수 있게 구성했습니다.

30초 분기 진단표: 에러 원문으로 5계열 구분

먼저 에러 메시지 뒤에 붙은 문구부터 보세요. OutOfMemoryError 다음에 오는 텍스트가 계열을 결정합니다.

에러 원문발생 영역대표 원인30초 1차 조치다음 단계 명령
Java heap space힙 (Eden/Old)객체 누수 or 단순 힙 부족힙 dump 확보 후 재기동jmap -dump:live + MAT 분석
Metaspace메타스페이스클래스로더 누수, 동적 프록시/핫디플로이 폭증로드된 클래스 수 확인jstat -gcutil의 M 컬럼 추이
GC overhead limit exceeded힙 (GC 시간)힙이 거의 찼는데 GC로 조금씩만 회수사실상 힙 부족/누수jstat -gcutil FGC 폭증 확인
unable to create new native thread네이티브(스레드 스택)스레드 누수, ulimit/스택 크기 과다스레드 수 카운트jstack <pid> 스레드 덤프
Direct buffer memory힙 밖 다이렉트 메모리NIO/Netty ByteBuffer 미해제MaxDirectMemorySize 확인jcmd VM.native_memory (NMT)

Java heap spaceMetaspace만 구분해도 대응 방향이 완전히 갈립니다. 여기까지 30초면 끝납니다.

복붙 진단 명령 세트

에러 계열을 잡았으면 이제 원인을 특정합니다. 순서대로 복사해서 쓰세요.

1) 영역별 사용률 실시간 관찰 — 어느 영역이 차오르는지 한눈에 봅니다.

Bash
# 1초 간격으로 GC 영역 사용률(%) 출력. M 컬럼이 Metaspace
jstat -gcutil <pid> 1000

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 중에서 O(Old)가 100% 근처에서 안 떨어지면 힙 누수, M(Metaspace)이 계속 우상향이면 클래스로더 누수 신호입니다.

2) 상위 점유 객체 추출 — 무엇이 힙을 먹는지 top 30.

Bash
jmap -histo:live <pid> | head -30

3) 힙 현황 스냅샷

Bash
jcmd <pid> GC.heap_info

4) 힙 덤프 확보 — 재기동 전에 반드시 뜨세요. 날리면 원인 못 찾습니다.

Bash
jmap -dump:live,format=b,file=heap.hprof <pid>

받은 heap.hprofEclipse MAT로 열어 Leak Suspects 리포트와 Dominator Tree를 보면 누수 주범 객체가 바로 나옵니다.

5) GC 로그 활성화 — 증가 추이를 시간축으로 검증. JDK 버전에 따라 옵션이 다릅니다.

Bash
# Java 11+ (통합 로깅)
-Xlog:gc*:file=gc.log:time,uptime,level,tags

# Java 8
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log

Metaspace 누수의 진짜 범인: 클래스로더가 안 죽는다

Metaspace OOM은 십중팔구 단순 부족이 아니라 클래스로더 누수입니다. 힙과 달리 여기엔 로드된 클래스 메타데이터가 쌓입니다.

실무에서 가장 흔한 함정이 Spring Boot DevTools입니다. DevTools는 코드가 바뀔 때마다 RestartClassLoader를 새로 만드는데, 이전 로더가 참조에 걸려 GC되지 않으면 클래스가 중복 적재됩니다. 여기에 CGLIB 동적 프록시나 핫디플로이가 반복되면 로드된 클래스 수가 폭발합니다.

CODE
# jstat -gcutil 로 관찰한 전형적 누수 패턴 (M 컬럼만 발췌)
  M      CCS    YGC   FGC
 71.20  68.11    12     0    <- 정상
 84.55  79.03    18     2
 93.87  88.40    25     6
 99.12  95.71    31    14    <- FGC 폭증하는데 M이 안 내려감 = 누수 확정

정상이라면 클래스 언로드로 M이 오르내려야 합니다. FGC가 늘어도 M이 안 떨어지면 클래스로더 누수입니다. 로드된 클래스 수는 이렇게 셉니다.

Bash
jcmd <pid> VM.classloader_stats   # 클래스로더별 로드 수/차지 바이트

실무 팁: 저는 예전에 스테이징에서만 DevTools를 켜뒀다가 장시간 무중단 테스트 중 Metaspace OOM을 겪었습니다. 운영 이미지에서 DevTools 의존성을 반드시 제거하고, 상한을 -XX:MaxMetaspaceSize=256m처럼 명시해 무한 증식을 조기에 잡는 게 안전합니다. 상한을 안 걸면 네이티브 메모리를 계속 먹다가 컨테이너째 OOMKilled 됩니다.

컨테이너에서 JVM이 메모리를 잘못 인식할 때

컨테이너 배포에서 흔한 사고가 JVM이 cgroup 제한이 아니라 호스트 전체 메모리를 인식하는 경우입니다.

구분UseContainerSupport 미적용적용 (JDK 10+ 기본 ON)
인식 메모리호스트 전체 (예: 64Gi)cgroup limit (512Mi)
기본 힙 산정호스트 기준 과다 할당limit 기준 비율 할당
결과힙이 limit 초과 → OOMKilled안전하게 힙 상한 결정

JDK 8u191+, JDK 10+ 는 -XX:+UseContainerSupport가 기본 활성화입니다. 힙은 절대값(-Xmx)보다 비율로 잡는 걸 권장합니다.

Bash
# 컨테이너 limit의 75%를 최대 힙으로. 나머지 25%는 Metaspace/스레드/다이렉트 버퍼 몫
-XX:MaxRAMPercentage=75.0

cgroup v2로 전환되며 인식 이슈가 다시 불거지므로, JDK 17/21 LTS 사용을 권장합니다. cgroup v2 대응이 안정적으로 들어가 있습니다.

OOMKilled(137) vs JVM OOM은 다른 사건이다

이 둘을 헷갈리면 엉뚱한 곳을 고칩니다.

신호 위치OOMKilledJVM OutOfMemoryError
어디서 발견kubectl describe pod애플리케이션 로그
표시Reason: OOMKilled, Exit Code: 137java.lang.OutOfMemoryError 스택트레이스
죽인 주체커널이 SIGKILL(9)JVM 스스로 예외 발생
원인컨테이너 총 메모리 > limit특정 JVM 영역 한도 초과

핵심은 컨테이너 총 사용량은 힙만이 아니라는 것입니다. 힙 + Metaspace + 스레드 스택 + Direct buffer + 코드 캐시를 다 합친 값이 limit을 넘으면 커널이 OOMKilled 시킵니다. 그래서 -Xmx를 limit에 딱 맞추면 힙 밖 메모리 때문에 반드시 죽습니다. MaxRAMPercentage=75처럼 여유를 두는 이유입니다.

배포 전 체크리스트

  • -Xmx/MaxRAMPercentage가 컨테이너 limit 대비 여유(70~75%)를 두었는가
  • -XX:MaxMetaspaceSize 상한을 명시했는가
  • 운영 이미지에서 Spring DevTools를 제거했는가
  • GC 로그(-Xlog:gc*)를 켜 두어 추이를 남기는가
  • OOM 시 자동 힙 덤프(-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump)를 설정했는가
  • JDK 17/21 LTS + cgroup v2 환경에서 메모리 인식을 검증했는가

참고: 공식 문서

이 글에서 다루는 동작·설정·에러의 1차 출처는 다음 공식 문서입니다. 버전별 옵션과 정확한 동작은 여기서 확인하세요.

자주 묻는 질문 (FAQ)

Q. -Xmx를 올렸는데 며칠 뒤 또 heap space OOM이 납니다. A. 단순 부족이 아니라 힙 누수일 가능성이 큽니다. jstat -gcutil에서 Full GC 후에도 Old 영역이 안 내려가면 누수입니다. jmap -dump:live로 덤프를 떠 MAT의 Dominator Tree로 원인 객체를 찾으세요. 힙을 키우면 장애 주기만 늘어날 뿐 재발합니다.

Q. Metaspace OOM인데 클래스로더가 왜 GC되지 않나요? A. 클래스로더는 그가 로드한 클래스/인스턴스가 하나라도 GC 루트에서 참조되면 통째로 살아남습니다. DevTools 리로드, 캐시된 CGLIB 프록시, ThreadLocal에 걸린 인스턴스가 흔한 원인입니다. jcmd <pid> VM.classloader_stats로 로더 수 폭증을 확인하세요.

Q. Pod가 Exit Code 137로 죽는데 애플리케이션 로그엔 OOM이 없습니다. A. JVM OOM이 아니라 컨테이너 OOMKilled입니다. 힙 밖 메모리(Metaspace·스레드·Direct buffer)까지 합친 총량이 limit을 넘은 것이니 -Xmx가 아니라 MaxRAMPercentage를 낮추거나 limit을 올려 여유를 확보하세요.

✦ ✦ ✦
편집 검토 · Editorial Review

이 글은 AI 에이전트가 자료 조사와 1차 초안 작성을 담당하고, 사람 편집자가 사실관계·출처·톤과 맥락을 검토한 뒤 발행했습니다. 환경(OS·버전)에 따라 결과가 다를 수 있으니 적용 전 공식 문서를 함께 확인하세요. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

초안 · AI (Content Reviewer)·검토 · Nodelog 편집자·발행 ·
관련 공식 문서Oracle Java 공식 문서

댓글

첫 번째 댓글을 남겨보세요.