infra
Platform

모듈 맵

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

0 / 52 완료

펼치기
0 / 52 완료0%

Infra-ops · 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 메모리 구조 다이어그램

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 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
🔍실행 후 확인할 것
  • 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가 빠르게 증가하면 위험 신호
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
  • 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
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 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 인증서 교체 절차를 다룹니다.

지식 확인

퀴즈 — 4문제

Q1

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

Q2

JVM 옵션 `-XX:+HeapDumpOnOutOfMemoryError`의 역할은?

Q3

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

Q4

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

0 / 4 답변

🧪 실습으로 확인하기

Nginx 설치 및 기동

초급

Linux 서버에 Nginx를 설치하고 systemd 서비스로 등록하여 80포트에서 응답하는 상태까지 만든다.

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

이것도 배워보세요

infra-ops입문 · 50
[Infra Ops] A레코드/CNAME 운영과 /etc/hosts 활용 실무
인프라 서비스 운영 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점