운영 환경 JVM 옵션 튜닝 완벽 가이드 — GC부터 메모리까지
Spring Boot 프로덕션 서버의 JVM 옵션을 단계별로 튜닝하는 방법. GC 알고리즘 선택, Heap 설정, GC 로깅, OOM 대응, 컨테이너 환경 주의사항까지 실전 중심 정리.
왜 JVM 튜닝이 필요한가
Java 애플리케이션은 JVM 기본값으로도 동작하지만, 프로덕션에서는 다음 문제를 자주 마주칩니다.
- Full GC로 인한 수 초~수십 초 STW(Stop-The-World) → 사용자 타임아웃
- OOM(OutOfMemoryError) → 갑작스러운 Pod 재시작
- GC 로그 없음 → 장애 원인 분석 불가
- 컨테이너 메모리 한계 초과 → OOMKilled
튜닝은 무작정 옵션을 추가하는 것이 아닙니다.
측정 → 분석 → 조정 → 재측정 사이클을 반복하는 과정입니다.
JVM 옵션 구조 이해
java [표준 옵션] [비표준 옵션] -jar app.jar
-X : 비표준 옵션 (JVM 구현체마다 다를 수 있음)
-XX : 고급 런타임 옵션 (프로덕션 튜닝의 핵심)
-XX:+옵션명 → 활성화
-XX:-옵션명 → 비활성화
-XX:옵션명=값 → 값 설정
1. Heap 메모리 설정
기본 설정
java -jar app.jar \
-Xms512m \ # 초기 Heap 크기 (최소값)
-Xmx2g # 최대 Heap 크기
Xms = Xmx 로 설정하는 이유
-Xms2g -Xmx2g
Xms < Xmx이면 JVM이 Heap을 동적으로 확장/축소합니다.
확장 시 OS 메모리 할당 비용 + GC 발생 → 레이턴시 스파이크.
프로덕션에서는 Xms = Xmx로 고정해 예측 가능한 동작을 보장합니다.
메모리 크기 산정 공식
전체 JVM 메모리 ≈ Heap + Metaspace + Thread Stack + Off-Heap + JIT 코드 캐시
Heap = 전체의 70~75% 권장
예) 서버 RAM 8GB:
-Xms6g -Xmx6g
나머지 2GB: OS + Metaspace + 기타
Metaspace 설정
Java 8 이전의 PermGen이 Metaspace로 대체됐습니다.
기본값은 무제한 → 클래스 누수 시 서버 전체 메모리 고갈 위험.
-XX:MetaspaceSize=256m # 초기 Metaspace 크기
-XX:MaxMetaspaceSize=512m # 최대 제한 (필수)
2. GC 알고리즘 선택
GC 알고리즘 비교
| GC | 도입 | 특징 | 적합 환경 |
|---|---|---|---|
| Serial GC | Java 1 | 단일 스레드, STW | 단일 코어, 임베디드 |
| Parallel GC | Java 1.4 | 멀티 스레드, 처리량 우선 | 배치, 대량 처리 |
| G1 GC | Java 7 (기본값 9+) | 예측 가능한 pause | API 서버 표준 |
| ZGC | Java 11+ | < 1ms pause, 대용량 Heap | 초저지연 서비스 |
| Shenandoah | Java 12+ | ZGC와 유사, Red Hat | 초저지연 서비스 |
G1 GC 설정 (권장: API 서버)
java -jar app.jar \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \ # 목표 pause 시간 (ms) — SLA 기준으로 설정
-XX:G1HeapRegionSize=16m \ # Region 크기 (1~32MB, Heap/2048 권장)
-XX:G1NewSizePercent=20 \ # Young 영역 최소 비율
-XX:G1MaxNewSizePercent=40 \ # Young 영역 최대 비율
-XX:G1MixedGCCountTarget=8 \ # Mixed GC 횟수 (낮을수록 적극적 회수)
-XX:InitiatingHeapOccupancyPercent=45 \ # Old GC 시작 기준 (기본 45%)
-XX:+ParallelRefProcEnabled \ # 참조 처리 병렬화
-XX:+G1EagerReclaimHumongousObjects # 대형 객체 조기 회수 (Java 12+)
ZGC 설정 (초저지연 / Heap 8GB+)
java -jar app.jar \
-XX:+UseZGC \
-XX:+ZGenerational \ # Java 21+ Generational ZGC (권장)
-XX:MaxGCPauseMillis=10 \ # 목표 pause < 10ms
-XX:ConcGCThreads=4 \ # GC 병렬 스레드 수
-Xms16g -Xmx16g
ZGC는 Heap이 작으면 역효과. 8GB 이상 환경에서 사용하세요.
3. GC 로깅 설정 (필수)
GC 로그 없이는 장애 원인 분석이 불가능합니다. 반드시 활성화하세요.
# Java 9+ 통합 로깅 시스템
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50m
| 옵션 | 설명 |
|---|---|
gc* | GC 관련 모든 태그 출력 |
file=/var/log/app/gc.log | 파일로 저장 |
time,uptime,level,tags | 타임스탬프 + 경과시간 + 레벨 + 태그 포함 |
filecount=10 | 최대 10개 파일 롤링 |
filesize=50m | 파일당 최대 50MB |
GC 로그 해석
[2.345s][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 512M->256M(2048M) 18.234ms
GC(42) : 42번째 GC
Pause Young : Young GC (빠름)
512M->256M : GC 전후 Heap 사용량
(2048M) : 전체 Heap 크기
18.234ms : STW 시간 (목표 200ms 이하)
주의 신호:
Pause Full (G1 Compaction Pause) → Full GC 발생 (심각)
To-space exhausted → Heap 부족, OOM 임박
4. OOM 대응 설정
-XX:+HeapDumpOnOutOfMemoryError \ # OOM 시 Heap Dump 자동 생성
-XX:HeapDumpPath=/var/log/app/heap/ \ # Dump 저장 경로
-XX:+ExitOnOutOfMemoryError \ # OOM 즉시 종료 (좀비 프로세스 방지)
-XX:OnOutOfMemoryError="kill -9 %p" \ # 구버전 JVM 대안
-XX:+ExitOnOutOfMemoryError 가 중요한 이유:
OOM 후에도 JVM이 살아있으면 일부 스레드만 죽은 반쯤 동작하는 상태가 됩니다.
Kubernetes에서는 종료 → 자동 재시작이 OOM 상태 유지보다 안전합니다.
5. 성능 관련 추가 옵션
JIT 컴파일 최적화
-XX:+TieredCompilation \ # 계층형 컴파일 (기본 활성화, Java 8+)
-XX:ReservedCodeCacheSize=256m \ # JIT 코드 캐시 크기 (기본 240MB)
-XX:+UseStringDeduplication \ # 중복 String 객체 메모리 절약 (G1 전용)
스레드 스택 크기
-Xss512k # 스레드당 스택 크기 (기본 512KB~1MB)
# 스레드 수 × Xss = 스택 메모리 합계
# 1000 스레드 × 1MB = 1GB
재귀 깊이가 깊지 않은 일반 API 서버는 256k~512k로 줄여 메모리를 확보할 수 있습니다.
GC 스레드 수
-XX:ParallelGCThreads=8 \ # STW GC 스레드 수 (보통 CPU 코어 수)
-XX:ConcGCThreads=2 # 동시 GC 스레드 수 (ParallelGCThreads / 4)
6. 컨테이너(Kubernetes) 환경 주의사항
컨테이너 메모리 인식 문제
Java 8u191 이전에는 컨테이너 메모리 제한을 인식하지 못해 호스트 메모리를 기준으로 Heap을 설정합니다.
# Java 8u191+, Java 11+ 에서는 자동 인식
-XX:+UseContainerSupport # 컨테이너 메모리 제한 인식 (기본 활성화)
-XX:MaxRAMPercentage=75.0 # 컨테이너 메모리의 75%를 Heap으로
-XX:InitialRAMPercentage=50.0 # 초기 Heap
-XX:MinRAMPercentage=25.0 # 최소 Heap
-Xmx 고정값 대신 MaxRAMPercentage를 사용하면 Kubernetes 메모리 제한에 유연하게 대응합니다.
Kubernetes 배포 예시
containers:
- name: app
image: my-app:latest
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi" # 이 값의 75% = 1.5GB Heap
cpu: "2000m"
env:
- name: JAVA_OPTS
value: >-
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heap/
-XX:+ExitOnOutOfMemoryError
-Xlog:gc*:file=/var/log/gc/gc.log:time,uptime:filecount=5,filesize=20m
OOMKilled vs OutOfMemoryError 차이
OOMKilled → Kubernetes가 컨테이너 종료 (메모리 limit 초과)
대부분 Heap Dump 생성 안 됨
해결: limits.memory 증가 또는 MaxRAMPercentage 감소
OutOfMemoryError → JVM 내부 OOM
Heap Dump 생성됨
해결: 메모리 누수 분석 또는 Heap 크기 증가
7. 전체 프로덕션 권장 옵션
API 서버 (24 vCPU, 48GB RAM)
java -jar app.jar \
-Xms2g \
-Xmx2g \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:+ParallelRefProcEnabled \
-XX:+UseStringDeduplication \
-XX:ReservedCodeCacheSize=256m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heap/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/gc/gc.log:time,uptime:filecount=10,filesize=50m \
-Djava.security.egd=file:/dev/./urandom
고처리량 배치 서버 (8+ vCPU, 16GB+ RAM)
java -jar batch.jar \
-Xms12g \
-Xmx12g \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:+UseParallelGC \ # 처리량 우선 GC
-XX:ParallelGCThreads=16 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heap/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/gc/gc.log:time,uptime:filecount=5,filesize=100m
초저지연 서비스 (Java 21, 8+ vCPU, 32GB+ RAM)
java -jar app.jar \
-Xms24g \
-Xmx24g \
-XX:+UseZGC \
-XX:+ZGenerational \
-XX:MaxGCPauseMillis=10 \
-XX:ConcGCThreads=4 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heap/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/gc/gc.log:time,uptime:filecount=10,filesize=50m
Kubernetes / 컨테이너 (메모리 유동적)
java -jar app.jar \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heap/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/gc/gc.log:time,uptime:filecount=5,filesize=20m
8. 튜닝 전후 확인 지표
측정 도구
# GC 통계 요약 (실행 중인 프로세스)
jstat -gcutil <PID> 1000 10
# S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
# 0.0 0.0 67.5 45.2 96.3 93.1 142 2.123 0 0.000 0 0.000 2.123
# 컬럼 설명
# E : Eden 사용률 %
# O : Old 사용률 %
# YGC: Young GC 횟수
# YGCT: Young GC 누적 시간(초)
# FGC: Full GC 횟수 → 0에 가까울수록 좋음
# JVM 메모리 현황
jcmd <PID> VM.native_memory summary
# GC 로그 분석 도구
# GCViewer: https://github.com/chewiebug/GCViewer
# GCEasy: https://gceasy.io (웹 업로드)
정상 범위 기준
| 지표 | 목표 | 위험 |
|---|---|---|
| Young GC 빈도 | < 1회/초 | > 5회/초 |
| Young GC pause | < 50ms | > 200ms |
| Full GC 빈도 | 0회 | 1회 이상/시간 |
| Full GC pause | N/A | > 1초 |
| Heap 사용률 (GC 후) | < 50% | > 80% |
| Metaspace 사용률 | < 80% | > 95% |
9. 자주 묻는 실전 질문
Q: -server 옵션이 필요한가?
A: Java 9+에서는 기본값이 server 모드입니다. 명시하지 않아도 됩니다.
Q: -XX:+DisableExplicitGC 를 항상 써야 하나?
A: System.gc() 를 막는 옵션입니다. 대부분의 앱에서 유용하지만, DirectByteBuffer를 많이 쓰는 앱(Netty 등)에서는 Off-Heap 메모리 해제를 막을 수 있습니다. 상황에 따라 판단하세요.
Q: G1GC에서 MaxGCPauseMillis=200은 보장되는가?
A: 소프트 목표입니다. JVM이 최대한 맞추려 노력하지만 보장하지는 않습니다. Heap이 꽉 차면 초과합니다.
Q: 컨테이너에서 -Xmx를 limits.memory와 같게 설정하면?
A: JVM Heap 외에 Metaspace, Thread Stack, Native 메모리 등이 추가로 필요합니다. limits.memory의 75~80% 이하로 설정해야 OOMKilled를 방지할 수 있습니다.