TestForge Blog
← 전체 포스트

운영 환경 JVM 옵션 튜닝 완벽 가이드 — GC부터 메모리까지

Spring Boot 프로덕션 서버의 JVM 옵션을 단계별로 튜닝하는 방법. GC 알고리즘 선택, Heap 설정, GC 로깅, OOM 대응, 컨테이너 환경 주의사항까지 실전 중심 정리.

TestForge Team ·

왜 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 GCJava 1단일 스레드, STW단일 코어, 임베디드
Parallel GCJava 1.4멀티 스레드, 처리량 우선배치, 대량 처리
G1 GCJava 7 (기본값 9+)예측 가능한 pauseAPI 서버 표준
ZGCJava 11+< 1ms pause, 대용량 Heap초저지연 서비스
ShenandoahJava 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 pauseN/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: 컨테이너에서 -Xmxlimits.memory와 같게 설정하면?
A: JVM Heap 외에 Metaspace, Thread Stack, Native 메모리 등이 추가로 필요합니다. limits.memory의 75~80% 이하로 설정해야 OOMKilled를 방지할 수 있습니다.