새벽 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 알람 수신 시 표준 대응 절차를 순서대로 실행할 수 있다
java -version && which jps jstat jstack jmapjps -lmkdir -p /tmp/jvm-dumps && ls -ld /tmp/jvm-dumpscat /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 옵션을 조정해야 할지 판단할 수 있습니다.

OOM이 났을 때 "힙을 늘리면 되지 않나요?"라고 생각하기 쉽습니다. 하지만 OOM은 힙이 아닌 Metaspace에서도, 네이티브 메모리에서도 발생합니다. 영역별로 무엇이 쌓이는지 알아야 올바른 옵션을 고칠 수 있습니다.
Heap — 가장 큰 영역, GC 대상
Heap
├── Young Generation (Young Gen)
│ ├── Eden Space ← 새로 생성된 객체가 최초로 들어오는 곳
│ ├── Survivor 0 (S0) ← MinorGC 생존 객체 1차 이동
│ └── Survivor 1 (S1) ← MinorGC 생존 객체 2차 이동
└── Old Generation (Old Gen / Tenured)
← Young Gen에서 일정 횟수(15회) 이상 생존한 객체
← GC 빈도 낮지만, 발생 시 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가 이 영역 고갈의 신호입니다.
GC 종류와 선택 기준
배포 직후부터 응답이 주기적으로 3~5초씩 멈추는 현상이 보고됩니다. 에러는 없고, 로그도 정상인데 간헐적으로 느립니다. GC 로그를 보니 Full GC가 30초마다 발생하고 있습니다. Java 8에서 기본 GC(Parallel GC)를 그대로 쓰는 4GB 힙 서버에서 자주 나타나는 패턴입니다. GC 종류를 이해하고 올바른 것을 선택해야 이 문제가 사라집니다.
GC는 "어떤 알고리즘으로 힙을 청소하느냐"의 차이입니다. 처리량(Throughput)과 응답 지연(Latency) 사이의 트레이드오프입니다. 운영 환경에 맞는 GC를 선택하지 않으면 주기적인 응답 지연이 발생합니다.
| GC 종류 | Java 기본값 | 특징 | 적합 환경 |
|---|---|---|---|
| Serial GC | Java 1~5 | 단일 GC 스레드, STW | 소규모 배치, 싱글코어 |
| Parallel GC | Java 8 기본 | 멀티 GC 스레드, STW | 처리량 우선, 배치 서버 |
| CMS | Java 9에서 deprecated | Old Gen을 백그라운드 GC | 응답 시간 민감, 소~중간 힙 |
| G1GC | Java 9+ 기본 | Region 분할, 예측 가능한 STW | 4GB 이상 힙, 웹 서비스 |
| ZGC | Java 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
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
"
옵션별 의미:
| 옵션 | 값 예시 | 설명 |
|---|---|---|
-Xms | 2g | 힙 초기 크기 |
-Xmx | 2g | 힙 최대 크기 (같게 설정해 resize 방지) |
-XX:MetaspaceSize | 256m | Metaspace 초기 임계값 |
-XX:MaxMetaspaceSize | 512m | Metaspace 최대 크기 제한 |
-XX:+UseG1GC | — | G1GC 명시적 활성화 (Java 8용) |
-XX:MaxGCPauseMillis | 200 | GC 목표 최대 중단 시간(ms) |
-Dfile.encoding | UTF-8 | 한국어 등 멀티바이트 문자 인코딩 |
-Duser.timezone | Asia/Seoul | JVM 기본 시간대 설정 |

JVM 현황 모니터링
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- S0, S1: Survivor 영역 사용률 (%) — 번갈아 가며 채워짐
- E: Eden 영역 사용률 — 빠르게 찼다 줄면 MinorGC 정상 동작
- O: Old Gen 사용률 — 지속적으로 증가하면 메모리 누수 의심
- M: Metaspace 사용률 — 100% 근접하면 -XX:MaxMetaspaceSize 확인
- YGC/YGCT: Young GC 횟수/누적 시간 — YGCT/YGC로 평균 GC 시간 계산
- FGC/FGCT: Full GC 횟수/누적 시간 — FGC가 빠르게 증가하면 위험 신호
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
실행 중인 모든 스레드의 현재 상태를 스냅샷으로 저장합니다. 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
- BLOCKED 스레드 수 확인: grep -c 'BLOCKED' /tmp/jvm-dumps/threaddump_*.txt
- 데드락 자동 감지: grep -A 10 'Found.*deadlock' /tmp/jvm-dumps/threaddump_*.txt
- WAITING 스레드: 락 해제 또는 조건 신호를 기다리는 상태 (정상 범주일 수 있음)
- 'waiting to lock <0x' 패턴이 여러 스레드에 동일한 주소이면 락 경합
# Thread Dump 내 상태별 스레드 수 빠르게 집계
grep 'java.lang.Thread.State:' /tmp/jvm-dumps/threaddump_*.txt \
| awk '{print $NF}' | sort | uniq -c | sort -rn
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 파일 직접 열기 |
| VisualVM | JDK 기본 포함, 연결/파일 분석 모두 가능 | 별도 설치 없이 사용 |
| 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 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으로 덤프 분석, 메모리 누수 원인 확인 필요
다음 모듈에서는 SSL/TLS 인증서 형식 변환과 Nginx/Tomcat 인증서 교체 절차를 다룹니다.