infra
Platform

모듈 맵

[Infra Ops] Heap/GC/Thread Dump 분석과 OOM 대응 실무

0 / 52 완료

펼치기
0 / 52 완료0%

인프라 운영 & SRE · 18 / 52

[Infra Ops] Heap/GC/Thread Dump 분석과 OOM 대응 실무

JVM 메모리 구조, GC 종류별 특징, Thread Dump/Heap Dump 분석, OOM 대응까지 — Tomcat을 운영하는 인프라 엔지니어가 반드시 알아야 할 JVM 실무

🚨INCIDENT ALERT
HIGH

새벽 2시, 모니터링 알람이 울립니다. "JVM Heap 사용률 90% 초과 — 서비스: order-service." Tomcat 로그에는 java.lang.OutOfMemoryError: Java heap space 에러가 쌓이고, 응답 지연이 길어지고 있습니다.

재시작하면 당장은 해결됩니다. 하지만 같은 알람이 또 울릴 것입니다. 원인을 모른 채 재시작만 반복하는 건 운영이 아닙니다. 힙 덤프를 떠서 무엇이 메모리를 먹는지 찾아야 합니다. 이 모듈에서 그 절차를 배웁니다.

이번 챕터에서 배울 것
  • 1JVM 메모리 구조(Heap, Metaspace, Stack)와 각 영역의 역할을 설명할 수 있다
  • 2GC 종류(Serial/Parallel/CMS/G1GC/ZGC)의 특징과 선택 기준을 구분할 수 있다
  • 3jstat으로 GC 현황을 실시간 모니터링하고 Full GC 발생 여부를 확인할 수 있다
  • 4Thread Dump와 Heap Dump를 생성하고 BLOCKED 스레드·OOM 원인을 분석할 수 있다
  • 5OOM 알람 수신 시 표준 대응 절차를 순서대로 실행할 수 있다
실습 환경 준비
JDK 설치 확인 (jps, jstat, jstack, jmap 포함)
java -version && which jps jstat jstack jmap
실행 중인 Java 프로세스 목록 확인
jps -l
힙 덤프 저장 디렉터리 생성 및 권한 확인
mkdir -p /tmp/jvm-dumps && ls -ld /tmp/jvm-dumps
Tomcat CATALINA_OPTS 현재 설정 확인
cat /etc/tomcat9/tomcat9.conf 2>/dev/null || cat /opt/tomcat/bin/setenv.sh 2>/dev/null || echo 'setenv.sh 없음'

JVM 메모리 구조

💡개념

JVM이 메모리를 나누는 방식: 왜 Heap만 알면 안 되는가

OOM(OutOfMemoryError)이 발생했을 때 단순히 Heap 크기를 늘리는 것은 잘못된 방향일 수 있습니다. OOM은 Heap이 아닌 Metaspace 고갈이나 네이티브 메모리 부족에서도 똑같이 발생하며, 원인 영역을 잘못 짚으면 같은 장애가 반복됩니다. JVM이 메모리를 어떤 영역으로 나누고 각 영역이 무엇을 담는지 이해해야 에러 메시지만 보고도 어느 JVM 옵션을 조정해야 할지 판단할 수 있습니다.

JVM 메모리 구조 — Heap(객체, Young/Old)·Metaspace(클래스 메타데이터)·스레드 스택·네이티브 메모리로 나뉨. OutOfMemoryError는 Heap뿐 아니라 Metaspace 고갈·네이티브 부족에서도 발생하므로, 영역을 구분해야 원인을 정확히 짚어 Heap만 키우는 헛된 대응을 피함확대

OOM이 났을 때 "힙을 늘리면 되지 않나요?"라고 생각하기 쉽습니다. 하지만 OOM은 힙이 아닌 Metaspace에서도, 네이티브 메모리에서도 발생합니다. 영역별로 무엇이 쌓이는지 알아야 올바른 옵션을 고칠 수 있습니다.

Heap — 가장 큰 영역, GC 대상

JVM Heap 구조 — Young Generation(Eden·Survivor 0·1)에서 MinorGC로 객체가 이동하고, 15회 이상 생존하면 Old Generation으로 승격(Full GC 대상)확대

Metaspace (Java 8+, 구버전의 PermGen 대체)

클래스 메타데이터(클래스 구조, 메서드 바이트코드, static 변수 등)가 저장됩니다. 네이티브 메모리를 사용하며 기본적으로 크기 제한이 없어 무한정 증가할 수 있습니다. 클래스 누수(클래스로더 반복 생성)가 있으면 이 영역에서 OOM이 납니다.

로컬 터미널
# Metaspace OOM 메시지
java.lang.OutOfMemoryError: Metaspace

Stack — 스레드별로 존재

각 스레드가 독립적으로 소유합니다. 메서드 호출 정보(로컬 변수, 호출 스택)가 쌓입니다. 재귀 호출이 너무 깊으면 StackOverflowError가 납니다.

Native Memory

JVM 자체와 GC 스레드, JNI(네이티브 라이브러리) 등이 사용하는 OS 메모리입니다. java.lang.OutOfMemoryError: Unable to create new native thread가 이 영역 고갈의 신호입니다.

💡개념

객체 하나가 힙에서 태어나 죽기까지 — Eden부터 Full GC·OOM까지

코드에서 new Order() 한 줄이면 객체가 생깁니다. 그런데 그 객체는 어디에 놓이고, 언제 사라지며, 왜 어떤 객체는 오래 살아남아 Full GC를 유발할까요? 객체의 일생은 Eden 할당 → Minor GC → Survivor 승격 → Old 승격 → Full GC → 회수 또는 OOM의 순서를 따릅니다. 이 흐름을 알면 jstat의 숫자(O·FGC)가 무슨 뜻인지, 왜 응답이 주기적으로 멈추는지, OOM 메시지가 어느 영역을 가리키는지를 단계로 읽어낼 수 있습니다.

TEXT
[객체 생성]  new Order()
   │
   ① Eden에 할당                            (Young Generation, 빠른 bump 할당)
   │
   ② Eden 꽉 참 → Minor GC (STW 짧음)        (살아있는 객체만 Survivor로 복사)
   │
   ③ Survivor S0 ⇄ S1 이동, age +1           (Minor GC마다 생존하면 나이 증가)
   │
   ④ age 임계치 초과 → Old로 승격             (MaxTenuringThreshold, 기본 15)
   │    → 오래 사는 객체(캐시·세션)가 Old에 쌓임
   │
   ⑤ Old 꽉 참 → Major/Full GC (STW 김)       (Old 전체 정리, 전 스레드 정지)
   │
   ⑥ 회수 성공 → 공간 확보 / 실패 → OOM
   ▼
[결과]  회수되면 지속, 못 하면  java.lang.OutOfMemoryError: Java heap space

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일여기서 막히면
① Eden 할당new로 만든 객체는 대부분 Eden에 즉시 할당(포인터만 밀어 빠름)대량 임시객체 폭주 → Eden이 금방 참 → Minor GC 빈발
② Minor GCEden이 차면 Young 영역 GC. 살아있는 객체만 Survivor로 복사, STW는 보통 수ms살아남는 객체가 많으면 YGCT(YGC 누적시간) 증가 → 복사 비용·짧은 멈춤 누적
③ Survivor age살아남은 객체가 S0·S1을 오가며 age가 1씩 증가Survivor가 너무 작으면 조기 승격(premature promotion) → Old를 이르게 압박
④ Old 승격age가 임계치(MaxTenuringThreshold) 초과 or Survivor 넘침 시 Old로 이동누수 객체가 계속 승격 → jstatO(Old 사용률)가 단조 증가
⑤ Full GCOld(또는 Metaspace)가 차면 힙 전체 GC. STW가 수백ms~수초로 김FGC가 잦고 pause가 길면 → 그 순간 전 스레드 정지 → 주기적 응답 멈춤
⑥ 회수 or OOM살릴 객체를 빼고 회수. 회수 못 하면 OOMFull GC 후에도 O가 안 줄면 누수 확정 → Java heap space OOM · 클래스 누수면 Metaspace OOM

OOM 메시지가 어느 단계·어느 영역이 터졌는지를 알려줍니다. java.lang.OutOfMemoryError: Java heap space는 ⑥에서 힙 회수 실패(객체 누수 또는 힙 부족), : Metaspace는 힙이 아니라 클래스 메타데이터 영역(⑤⑥와 별개 영역, 보통 클래스로더 누수), GC overhead limit exceeded는 ⑤가 계속 도는데 ⑥ 회수량이 거의 없는 상태입니다. 그래서 진단의 출발점은 jstat -gcutilO가 Full GC(FGC) 뒤에도 줄지 않는지 보는 것이고, 줄지 않으면 힙 덤프로 ④에서 계속 승격되는 객체가 무엇인지 추적합니다. Full GC가 잦다는 것은 곧 힙 부족·누수의 신호입니다.

💡개념

GC 종류와 선택 기준

배포 직후부터 응답이 주기적으로 3~5초씩 멈추는 현상이 보고됩니다. 에러는 없고, 로그도 정상인데 간헐적으로 느립니다. GC 로그를 보니 Full GC가 30초마다 발생하고 있습니다. Java 8에서 기본 GC(Parallel GC)를 그대로 쓰는 4GB 힙 서버에서 자주 나타나는 패턴입니다. GC 종류를 이해하고 올바른 것을 선택해야 이 문제가 사라집니다.

GC는 "어떤 알고리즘으로 힙을 청소하느냐"의 차이입니다. 처리량(Throughput)과 응답 지연(Latency) 사이의 트레이드오프입니다. 운영 환경에 맞는 GC를 선택하지 않으면 주기적인 응답 지연이 발생합니다.

GC 종류Java 기본값특징적합 환경
Serial GCJava 1~5단일 GC 스레드, STW소규모 배치, 싱글코어
Parallel GCJava 8 기본멀티 GC 스레드, STW처리량 우선, 배치 서버
CMSJava 9에서 deprecatedOld Gen을 백그라운드 GC응답 시간 민감, 소~중간 힙
G1GCJava 9+ 기본Region 분할, 예측 가능한 STW4GB 이상 힙, 웹 서비스
ZGCJava 15+1ms 이하 STW 목표초대형 힙, 초저지연 요구

STW(Stop-The-World): GC가 실행되는 동안 모든 애플리케이션 스레드가 멈추는 현상입니다. 이 시간이 길수록 응답 지연이 발생합니다.

실무 선택 기준 — Tomcat 웹 서비스 기준:

힙 < 4GB, Java 8:  Parallel GC (기본값)
힙 >= 4GB, Java 8: -XX:+UseG1GC 명시적 지정
Java 9+:           G1GC (기본, 별도 옵션 불필요)
Java 15+, 초저지연: -XX:+UseZGC
GC 로그 설정 — 버전별 옵션 선택
Java 8 (구형 옵션 방식)-Xloggc:/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
Java 9+ (통합 로깅 방식)-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=20m
GC 로그 없이 실시간 확인만 필요jstat -gcutil PID 1000 (1초 간격 10회)
GC 발생 패턴 사후 분석 필요GCEasy.io 또는 GCViewer에 gc.log 업로드

JVM 옵션 설정

💡개념

Tomcat setenv.sh에 JVM 옵션 집어넣기

OOM 알람을 받고 Tomcat을 재시작했는데, 힙 덤프가 남아있지 않습니다. "HeapDumpOnOutOfMemoryError 옵션을 설정했었나요?" — 아무도 모릅니다. 현장에서 이런 상황은 생각보다 자주 발생합니다. setenv.sh 하나만 제대로 관리하면 이런 상황을 예방할 수 있습니다. JVM 옵션이 어디에 있는지 모른다는 것 자체가 운영 리스크입니다.

JVM 옵션은 Tomcat 시작 스크립트에 환경변수로 넣습니다. /opt/tomcat/bin/setenv.sh 파일이 없으면 새로 만듭니다. CATALINA_OPTS에 추가하면 Tomcat 프로세스에만 적용됩니다.

로컬 터미널
# /opt/tomcat/bin/setenv.sh

export CATALINA_OPTS="
  -Xms2g
  -Xmx2g
  -XX:MetaspaceSize=256m
  -XX:MaxMetaspaceSize=512m
  -XX:+UseG1GC
  -XX:MaxGCPauseMillis=200
  -XX:+HeapDumpOnOutOfMemoryError
  -XX:HeapDumpPath=/tmp/jvm-dumps/
  -Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=20m
  -Dfile.encoding=UTF-8
  -Duser.timezone=Asia/Seoul
"

옵션별 의미:

옵션값 예시설명
-Xms2g힙 초기 크기
-Xmx2g힙 최대 크기 (같게 설정해 resize 방지)
-XX:MetaspaceSize256mMetaspace 초기 임계값
-XX:MaxMetaspaceSize512mMetaspace 최대 크기 제한
-XX:+UseG1GCG1GC 명시적 활성화 (Java 8용)
-XX:MaxGCPauseMillis200GC 목표 최대 중단 시간(ms)
-Dfile.encodingUTF-8한국어 등 멀티바이트 문자 인코딩
-Duser.timezoneAsia/SeoulJVM 기본 시간대 설정

JVM 메모리 구조 — Heap/Non-Heap 영역과 GC확대

JVM 현황 모니터링

1jstat으로 GC 현황 실시간 확인

jps -l로 Java 프로세스 PID를 확인한 뒤, jstat -gcutil PID 1000 10으로 1초 간격 10회 GC 통계를 출력합니다. Tomcat PID를 직접 지정해도 됩니다. 이 명령은 JVM을 재시작하거나 서비스를 중단하지 않고 실행 중인 프로세스에 attach해 정보를 읽어옵니다.

jps -l && jstat -gcutil $(jps -l | grep -v Jps | awk '{print $1}' | head -1) 1000 10
🔍실행 후 확인할 것
  • jstat 출력에서 먼저 O(Old Gen) 사용률 트렌드를 보고, 그 다음 FGC 증가 속도 확인 — O가 샘플마다 증가하면 메모리 누수, FGC가 분당 1회 이상이면 즉시 힙 분석 필요
  • 수치 기준: O(Old Gen) 80% 이상=Full GC 빈도 증가 시작, 90% 이상=응답 지연 및 OOM 위험 / YGCT/YGC(평균 YGC 시간)가 100ms 이상이면 힙 크기 증설 검토 / FGC가 10분에 3회 이상이면 메모리 누수 의심
  • O(Old Gen)가 계속 증가하는데 FGC 후에도 줄어들지 않으면 → 메모리 누수 확정 — jmap -dump:format=b,file=/tmp/heap.hprof <PID> 로 힙 덤프 생성 후 MAT/VisualVM 으로 분석
OUTPUT
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00  45.23  72.15  38.42  96.74  89.33    145    2.341     2    0.531    2.872
  0.00  45.23  81.20  38.42  96.74  89.33    145    2.341     2    0.531    2.872
  0.00   0.00   8.43  41.15  96.74  89.33    146    2.389     2    0.531    2.920

위 출력에서 YGC가 145→146으로 증가하며 Eden(E)이 리셋됐습니다. 정상적인 MinorGC입니다. Old Gen(O)이 38→41로 증가했다면 장기적으로 모니터링이 필요합니다.

Thread Dump와 Heap Dump

2Thread Dump 생성 및 BLOCKED 스레드 찾기

실행 중인 모든 스레드의 현재 상태를 스냅샷으로 저장합니다. kill -3 PID도 동일한 효과지만 Tomcat 로그(catalina.out)에 출력되므로 파일로 분리하는 jstack을 권장합니다. 응답 지연 또는 행(hang) 상황에서는 5초 간격으로 3회 이상 연속 수집해 변화를 비교합니다.

jstack $(jps -l | grep -v Jps | awk '{print $1}' | head -1) > /tmp/jvm-dumps/threaddump_$(date +%Y%m%d_%H%M%S).txt
🔍실행 후 확인할 것
  • ls -lh /tmp/jvm-dumps/threaddump_*.txt 로 파일 생성 먼저 확인, 그 다음 grep -c "BLOCKED" 로 BLOCKED 스레드 수 확인, 마지막으로 grep "Found.*deadlock" 으로 데드락 자동 감지 — 이 순서로 심각도 판단
  • BLOCKED 스레드 기준: 0개=정상, 1~3개=경미한 경합(모니터링 필요), 5개 이상=서비스 응답 지연 발생 중, 데드락 감지=즉시 조치(재시작 불가피) — WAITING 스레드는 정상 범주이므로 BLOCKED에 집중
  • BLOCKED 스레드 5개 이상인데 모두 "waiting to lock <0x동일주소>" 이면 → 단일 락 객체에 경합 집중 — 해당 객체를 보유한 스레드(RUNNABLE) 스택을 추적해 어떤 메서드가 락을 오래 보유하는지 확인
로컬 터미널
# Thread Dump 내 상태별 스레드 수 빠르게 집계
grep 'java.lang.Thread.State:' /tmp/jvm-dumps/threaddump_*.txt \
  | awk '{print $NF}' | sort | uniq -c | sort -rn
OUTPUT
     42 WAITING
     28 TIMED_WAITING
      9 BLOCKED
      3 RUNNABLE

BLOCKED가 9개라면 특정 락을 9개 스레드가 기다리는 중입니다. 어떤 락인지 waiting to lock 다음 객체 주소를 추적합니다.

💡개념

Heap Dump 생성과 분석 도구

"OOM이 나서 재시작했습니다. 원인을 모르겠습니다." — 이 말이 3번 반복되면 팀장이 묻습니다. "힙 덤프는 남겼습니까?" OOM은 발생 순간 증거를 남기지 않으면 사후 분석이 불가능합니다. 프로세스가 죽기 전 힙 전체를 파일로 떠두는 것이 힙 덤프입니다. 이 파일 하나가 "무엇이 메모리를 먹었는가"를 밝혀주는 유일한 단서가 됩니다.

힙 덤프는 특정 시점 힙 전체를 파일로 복사합니다. OOM의 원인 객체를 찾는 데 사용합니다. 파일 크기가 힙 크기와 비슷하므로(2GB 힙 → 2GB 파일) 디스크 여유를 미리 확인해야 합니다. 힙 덤프 생성 중 JVM은 잠깐 멈춥니다.

로컬 터미널
# 실행 중인 프로세스에서 힙 덤프 수동 생성
jmap -dump:format=b,file=/tmp/jvm-dumps/heap_$(date +%Y%m%d_%H%M%S).hprof PID

# OOM 발생 시 자동 생성 (setenv.sh에 미리 추가)
# -XX:+HeapDumpOnOutOfMemoryError
# -XX:HeapDumpPath=/tmp/jvm-dumps/

# 힙 덤프 파일 크기 및 생성 확인
ls -lh /tmp/jvm-dumps/*.hprof

분석 도구:

도구특징사용법
Eclipse MAT가장 강력, Leak Suspects 자동 분석.hprof 파일 직접 열기
VisualVMJDK 기본 포함, 연결/파일 분석 모두 가능별도 설치 없이 사용
JProfiler유료, 실시간 프로파일링 강력라이선스 필요

힙 공간은 남아있지만 GC가 너무 자주, 너무 오래 실행되는 상황입니다. JVM 기본 정책은 "GC에 전체 시간의 98% 이상을 쓰면서도 힙의 2% 미만을 회수하면 OOM으로 처리한다"입니다. 실질적으로 힙 부족과 같은 의미입니다.

로컬 터미널
# 1. Full GC 빈도 즉시 확인
jstat -gcutil PID 2000 5
# FGC 컬럼이 2초마다 1씩 증가하면 연속 Full GC

# 2. 힙 덤프 즉시 생성 (프로세스 살아있는 동안)
jmap -dump:format=b,file=/tmp/jvm-dumps/heap_oom.hprof PID

# 3. Old Gen 사용률 확인
# O 컬럼이 95% 이상이면 힙 부족이 원인

# 단기 대응: 힙 크기 증가 (Xmx 상향 후 재시작)
# 근본 대응: 힙 덤프를 Eclipse MAT으로 분석해 누수 객체 찾기

-XX:+GCOverheadLimitExceeded 옵션으로 이 제한을 해제할 수 있지만, 원인을 해결하지 않는 임시방편입니다. 반드시 힙 덤프 분석으로 근본 원인을 찾아야 합니다.

CPU 사용률이 갑자기 100%에 근접하면서 응답 지연이 발생하는 경우, 배경에서 Full GC가 반복 실행 중일 가능성이 높습니다. Full GC는 힙 전체를 대상으로 STW를 일으키기 때문에 애플리케이션이 수 초간 멈춥니다.

로컬 터미널
# 1. GC 실시간 모니터링
jstat -gcutil PID 1000
# FGC 컬럼이 빠르게 증가하면 Full GC 반복 확인

# 2. CPU 높은 스레드 찾기
top -H -p PID
# 또는
ps -mo pid,tid,fname,user,pcpu -p PID

# 3. 높은 CPU 스레드가 GC 스레드인지 확인 (Thread Dump)
jstack PID | grep -E 'GC Task|VM Thread'
# "GC task thread" 스레드가 RUNNABLE이면 GC 과부하 확인

# 4. GC 로그로 Full GC 시간 확인 (Java 9+)
grep 'Pause Full' /logs/gc.log | tail -20

# 단기 대응: Old Gen이 꽉 찼으면 jcmd로 수동 GC 유도
jcmd PID GC.run
# 근본 대응: 힙 덤프 분석으로 Old Gen 점유 객체 확인

심화 — 컨테이너 안의 JVM은 힙 덤프도 없이 죽는다

💡개념

심화: cgroup 한도·RSS의 실제 구성·OOMKill vs OutOfMemoryError

지금까지의 OOM 대응은 모두 '자바가 OutOfMemoryError를 던지고 힙 덤프를 남긴다'를 전제로 합니다. 그런데 컨테이너/쿠버네티스에서 도는 JVM은 힙 덤프 한 장 없이 조용히 죽는 방식이 따로 있습니다.

  • 프로세스 RSS는 -Xmx가 아닙니다: JVM이 실제 점유하는 물리 메모리(RSS)는 힙(-Xmx)뿐 아니라 Metaspace, 스레드마다의 스택(스레드 수 × -Xss), 다이렉트 버퍼(off-heap), GC·JIT 자료구조, 코드 캐시의 합입니다. -Xmx2g여도 RSS는 2.5~3g가 되기 쉽습니다. 컨테이너 메모리 한도를 -Xmx와 똑같이 잡으면 off-heap이 한도를 밀어냅니다.
  • JVM은 컨테이너 한도를 알아야 합니다: Java 8 초기 버전(u191 이전)이나 UseContainerSupport가 꺼진 JVM은 /proc/meminfo로 호스트 전체 RAM을 봅니다. 기본 힙을 호스트 RAM의 1/4로 잡으므로, 64g 호스트의 512m 컨테이너에서 힙을 16g로 산정하려다 곧바로 한도를 넘습니다. 요즘 JVM은 UseContainerSupport(u191+ 기본 on)로 cgroup 한도를 읽고, 힙은 -XX:MaxRAMPercentage로 한도의 비율로 지정합니다.
  • OOMKill과 OutOfMemoryError는 다릅니다: 힙이 -Xmx에 닿아 JVM이 던지는 OutOfMemoryError는 자바 예외라 로그와 힙 덤프가 남습니다. 반면 프로세스 RSS가 cgroup 한도를 넘으면 커널 OOM killer가 SIGKILL로 즉시 죽입니다 — 컨테이너는 exit code 137(128+9)로 끝나고, 이건 자바 밖의 일이라 자바 OOM 로그도 힙 덤프도 없습니다. 그냥 파드가 재시작됩니다.

핵심 설계: 컨테이너에서는 (1) UseContainerSupport를 유지하고(구형 JVM이면 업그레이드), (2) -XX:MaxRAMPercentage-Xmx로 힙을 한도의 50~75%만 잡아 off-heap 여유를 남기며, (3) 죽었을 때 자바 OOM 로그가 아니라 커널 로그(dmesg)와 컨테이너 exit code(137)로 OOMKill 여부를 먼저 가립니다.

상황: 쿠버네티스(또는 도커) 환경에서 파드가 주기적으로 재시작됩니다. HeapDumpOnOutOfMemoryError를 켜 뒀는데도 힙 덤프가 생기지 않고, 자바 OOM 로그도 없습니다. 유일한 단서는 컨테이너 종료 코드 137입니다.

원인: 자바가 OutOfMemoryError를 던진 게 아니라, 프로세스 RSS가 cgroup 메모리 한도를 넘어 커널 OOM killer가 SIGKILL(137 = 128+9)로 죽인 것입니다. 힙은 -Xmx 안이었지만 Metaspace·스레드 스택·다이렉트 버퍼 같은 off-heap이 더해져 RSS가 한도를 초과했습니다. 흔한 방아쇠는 컨테이너 한도와 -Xmx를 같게 잡았거나, UseContainerSupport를 모르는 구형 JVM이 호스트 RAM 기준으로 힙을 과다 산정한 경우입니다.

진단: 먼저 자바 OOM인지 커널 OOMKill인지 가립니다. kubectl describe pod의 Last State가 OOMKilled인지, 노드 dmesgOut of memory: Killed process ... java가 찍혔는지 확인합니다. NMT(-XX:NativeMemoryTracking)를 켜 뒀다면 jcmd PID VM.native_memory로 off-heap 내역을, 아니면 한도 대비 RSS 추이를 관측합니다.

해결: 컨테이너 한도보다 힙(-Xmx 또는 MaxRAMPercentage로 계산된 값)을 낮춰 off-heap 여유(한도의 25~50%)를 남깁니다. UseContainerSupport를 유지하고 구형 JVM이면 업그레이드합니다. 스레드 폭증·다이렉트 버퍼 누수가 원인이면 그쪽을 잡습니다. 자바 OOM이 아니므로 힙 덤프가 아니라 커널·NMT를 봐야 원인이 잡힌다는 것이 이 케이스의 핵심입니다(CPU/메모리/디스크 임계치 관리와 logrotate의 컨테이너 메모리 알람과 함께 설계).

💼
실무 맥락
현업 패턴

온콜 시나리오: "JVM Heap 90%" 알람 수신

새벽 2시에 Heap 90% 알람이 오면 당황하지 말고 순서대로 확인합니다.

로컬 터미널
# 1단계: 현재 상태 빠르게 파악 (30초)
jps -l
# → Tomcat PID 확인

jstat -gcutil TOMCAT_PID 2000 5
# → Old Gen(O), Full GC(FGC) 수치 확인
# → Old Gen 95% 이상 + FGC 계속 증가면 위험

# 2단계: Thread Dump 수집 (1분)
jstack TOMCAT_PID > /tmp/jvm-dumps/td_$(date +%H%M%S).txt
jstack TOMCAT_PID > /tmp/jvm-dumps/td_$(date +%H%M%S)_2.txt
# → 5~10초 간격으로 2회 이상 수집
grep 'BLOCKED\|deadlock' /tmp/jvm-dumps/td_*.txt | head -20

# 3단계: Heap Dump 생성 (OOM 직전인 경우)
jmap -dump:format=b,file=/tmp/jvm-dumps/heap_$(date +%H%M%S).hprof TOMCAT_PID
# → 힙 크기만큼 시간 걸림, 디스크 여유 확인 필수

# 4단계: 즉각 조치 판단
# 서비스 영향이 시작됐으면 → Tomcat 재시작 (서비스 복구 우선)
# 아직 여유 있으면 → Heap Dump 먼저 수집 후 재시작

# 5단계: 재발 방지 — setenv.sh에 자동 덤프 옵션 확인
grep 'HeapDumpOnOutOfMemoryError' /opt/tomcat/bin/setenv.sh
# 없으면 추가 후 다음 재시작 때 적용

인계 시 반드시 남길 정보:

[JVM 장애 기록]
발생 시각: 2026-05-30 02:14 KST
대상 서비스: order-service (Tomcat PID: 12345)
증상: Heap 90% 알람 → OOM 발생 → 응답 불가
조치: Heap Dump 수집 후 Tomcat 재시작
파일: /tmp/jvm-dumps/heap_021420.hprof (2.1GB)
복구 시각: 02:22 KST
잔여 조치: Eclipse MAT으로 덤프 분석, 메모리 누수 원인 확인 필요

명령어·단축키 빠른 참조

이 모듈에서 다룬 JVM 진단 명령을 모았습니다. OOM·GC·스레드 문제를 서비스 중단 없이 실행 중 프로세스에서 파악합니다.

명령어/단축키용도자주 쓰는 예
jps -l실행 중 Java 프로세스·PID대상 Tomcat PID 특정
jstat -gcutil <pid> 1000GC 통계 실시간jstat -gcutil <pid> 2000 5 (Old Gen·FGC 추이)
jstack <pid>스레드 덤프jstack <pid> | grep -A5 BLOCKED (락 경합)
jmap -dump:format=b,file=…힙 덤프 생성jmap -dump:format=b,file=/tmp/heap.hprof <pid>
jcmd <pid> GC.run수동 Full GC 유도jcmd <pid> VM.native_memory (off-heap 내역)
kill -3 <pid>스레드 덤프(→catalina.out)jstack 못 쓸 때 대체
top -H -p <pid>스레드별 CPU 확인GC 스레드가 CPU 점유 중인지
which jps jstat jstack jmapJDK 진단 도구 존재 확인JRE만 있으면 도구 없음
grep 'Pause Full' gc.logFull GC 정지시간 확인pause 길면 응답 지연 원인
grep 'Thread.State:' | uniq -c상태별 스레드 집계... | awk '{print $NF}' | sort | uniq -c
kubectl describe pod / dmesg커널 OOMKill 판별exit 137이면 OOMKilled·dmesgKilled process java
grep HeapDumpOnOutOfMemoryError setenv.sh자동 덤프 옵션 확인없으면 추가 후 다음 재시작에 적용

관련 모듈로 더 깊이:

다음 모듈에서는 SSL/TLS 인증서 형식 변환과 Nginx/Tomcat 인증서 교체 절차를 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

Java 9부터 G1GC가 기본 GC로 채택됐습니다. G1GC를 CMS 대신 선택하는 핵심 이유는?

Q2

운영 서버에서 OOM(OutOfMemoryError)이 발생했지만 어떤 객체가 메모리를 잡아먹었는지 파악하기 어렵습니다. 다음 번 OOM 발생 시 원인을 분석할 수 있도록 JVM 옵션에 미리 추가해야 할 것은?

Q3

Thread Dump에서 BLOCKED 상태의 스레드가 다수 발견됐을 때 가장 먼저 의심해야 할 상황은?

Q4

Tomcat JVM 시작 옵션에서 -Xms와 -Xmx를 동일한 값으로 설정하는 이유는?

Q5

JVM 메모리에서 Heap과 Metaspace는 무엇을 저장하며 왜 구분되나?

Q6

응답이 가끔 수 초씩 멈춘다는 신고가 있다. GC 로그에서 무엇을 확인해야 하나?

Q7

[심화] 컨테이너에 메모리 한도 2g를 걸고 JVM에 -Xmx2g를 줬더니 프로세스가 커널에 의해 죽는다(exit 137). 이유로 가장 정확한 것은?

Q8

[심화] 컨테이너 Tomcat이 몇 시간마다 재시작되는데 catalina.out에 OutOfMemoryError가 없고 힙 덤프도 안 생긴다. 종료 코드는 137이다. 원인과 확인 순서로 옳은 것은?

0 / 8 답변

🧪 실습으로 확인하기

Tomcat WAS 응답 지연 — 스레드 풀·GC 진단

중급

CPU는 한가한데 WAS가 느리다. 원인은 보통 둘 중 하나다: 스레드 풀 고갈(maxThreads 부족·느린 외부 호출로 스레드가 묶임) 또는 GC 폭주(힙 부족·풀 GC 빈발). 스레드 덤프와 GC 로그로 어느 쪽인지 가르고, Connector/힙 설정을 근거 있게 조정한다.

45📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요