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 space와 Metaspace만 구분해도 대응 방향이 완전히 갈립니다. 여기까지 30초면 끝납니다.
복붙 진단 명령 세트
에러 계열을 잡았으면 이제 원인을 특정합니다. 순서대로 복사해서 쓰세요.
1) 영역별 사용률 실시간 관찰 — 어느 영역이 차오르는지 한눈에 봅니다.
# 1초 간격으로 GC 영역 사용률(%) 출력. M 컬럼이 Metaspace
jstat -gcutil <pid> 1000S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 중에서 O(Old)가 100% 근처에서 안 떨어지면 힙 누수, M(Metaspace)이 계속 우상향이면 클래스로더 누수 신호입니다.
2) 상위 점유 객체 추출 — 무엇이 힙을 먹는지 top 30.
jmap -histo:live <pid> | head -303) 힙 현황 스냅샷
jcmd <pid> GC.heap_info4) 힙 덤프 확보 — 재기동 전에 반드시 뜨세요. 날리면 원인 못 찾습니다.
jmap -dump:live,format=b,file=heap.hprof <pid>받은 heap.hprof는 Eclipse MAT로 열어 Leak Suspects 리포트와 Dominator Tree를 보면 누수 주범 객체가 바로 나옵니다.
5) GC 로그 활성화 — 증가 추이를 시간축으로 검증. JDK 버전에 따라 옵션이 다릅니다.
# Java 11+ (통합 로깅)
-Xlog:gc*:file=gc.log:time,uptime,level,tags
# Java 8
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.logMetaspace 누수의 진짜 범인: 클래스로더가 안 죽는다
Metaspace OOM은 십중팔구 단순 부족이 아니라 클래스로더 누수입니다. 힙과 달리 여기엔 로드된 클래스 메타데이터가 쌓입니다.
실무에서 가장 흔한 함정이 Spring Boot DevTools입니다. DevTools는 코드가 바뀔 때마다 RestartClassLoader를 새로 만드는데, 이전 로더가 참조에 걸려 GC되지 않으면 클래스가 중복 적재됩니다. 여기에 CGLIB 동적 프록시나 핫디플로이가 반복되면 로드된 클래스 수가 폭발합니다.
# 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이 안 떨어지면 클래스로더 누수입니다. 로드된 클래스 수는 이렇게 셉니다.
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)보다 비율로 잡는 걸 권장합니다.
# 컨테이너 limit의 75%를 최대 힙으로. 나머지 25%는 Metaspace/스레드/다이렉트 버퍼 몫
-XX:MaxRAMPercentage=75.0cgroup v2로 전환되며 인식 이슈가 다시 불거지므로, JDK 17/21 LTS 사용을 권장합니다. cgroup v2 대응이 안정적으로 들어가 있습니다.
OOMKilled(137) vs JVM OOM은 다른 사건이다
이 둘을 헷갈리면 엉뚱한 곳을 고칩니다.
| 신호 위치 | OOMKilled | JVM OutOfMemoryError |
|---|---|---|
| 어디서 발견 | kubectl describe pod | 애플리케이션 로그 |
| 표시 | Reason: OOMKilled, Exit Code: 137 | java.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을 올려 여유를 확보하세요.
이 글은 AI 에이전트가 자료 조사와 1차 초안 작성을 담당하고, 사람 편집자가 사실관계·출처·톤과 맥락을 검토한 뒤 발행했습니다. 환경(OS·버전)에 따라 결과가 다를 수 있으니 적용 전 공식 문서를 함께 확인하세요. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.