⚡ JVM 튜닝 가이드

실전에서 바로 써먹는 성능·메모리 튜닝 노하우

Spring Boot · Container · Production

1튜닝의 3대 원칙

성급한 튜닝은 문제를 해결하기보다 새로운 문제를 만듭니다. 본격적으로 옵션을 건드리기 전에 이 원칙부터 체화하세요.

원칙 1. 측정 없는 튜닝은 추측일 뿐이다

"아마 Heap이 부족할 거야"가 아니라 GC 로그·힙덤프·프로파일러로 확인한 데이터를 근거로 움직입니다. 측정 → 가설 → 변경 → 재측정의 순환이 기본입니다.

원칙 2. 기본값이 이유가 있어 기본값이다

JVM 기본값은 수많은 실 서비스에서 검증된 것입니다. 문제를 확인하기 전에 옵션을 건드리지 마세요. 블로그에서 봤다는 이유로 옵션을 추가하면 오히려 성능이 떨어집니다.

원칙 3. 한 번에 하나씩만 바꾼다

세 개 옵션을 동시에 바꾸면 무엇이 효과를 냈는지 알 수 없습니다. A/B 테스트처럼 한 변수씩 통제하며 비교하세요.

2튜닝 프로세스

📊
1. 측정
현재 메트릭 수집
🎯
2. 목표 설정
SLO / 처리량 정의
🔍
3. 병목 분석
원인 지점 특정
🛠️
4. 변경 적용
가설 기반 수정
5. 검증
부하 테스트로 비교

튜닝 목표를 먼저 구체화하라

"빠르게 만들어줘" 같은 막연한 요구는 튜닝할 수 없습니다. 다음 중 무엇이 가장 중요한지 정해야 합니다.

목표 대표 지표 트레이드오프
처리량(Throughput) TPS, RPS GC pause는 허용
응답 속도(Latency) p99 응답 시간 메모리·CPU 더 씀
메모리 효율 RSS, Heap 사용률 성능 약간 희생
안정성 에러율, 가동률 처리량 제한
예시 SLO

"p99 응답시간 200ms 이하, GC pause 100ms 이하, 동시 사용자 1000명에서 에러율 0.1% 이하" — 이 정도로 구체적이어야 튜닝 방향이 잡힙니다.

3Heap 크기 튜닝

Heap은 JVM에서 가장 큰 메모리 영역이자 GC의 주 대상. 크기를 잘못 잡으면 OOM이나 잦은 GC로 서비스가 죽습니다.

Heap 크기 정하는 공식

# 실제 사용 Heap 측정 (정상 운영 시 GC 직후)
# 이를 "Live Data Size(LDS)"라고 함

권장 Heap 크기 = LDS × 3 ~ 4

# 예: LDS가 400MB라면 Heap은 1.2GB ~ 1.6GB
왜 3~4배인가?

GC는 Heap에 여유 공간이 많을수록 빠르게 동작합니다. Eden 영역에서 새 객체를 위한 공간, 승격을 위한 Old 영역 여유, GC 작업 공간까지 필요해서 실사용의 3~4배가 황금비입니다.

핵심 옵션

-Xms <size>

초기 Heap 크기. 컨테이너에선 -Xmx같게 설정해 성장 시 멈춤을 방지.

-Xmx <size>

최대 Heap 크기. 컨테이너 메모리의 70~75%가 적정선.

-XX:MaxRAMPercentage=75.0

컨테이너 환경에서 -Xmx 대신 비율로 지정. 컨테이너 메모리 제한에 자동 대응.

-XX:NewRatio=2

Old : Young 비율. 기본 2 (Old가 2배). 짧은 생명 객체가 많으면 낮춰서 Young 영역을 키움.

-Xms와 -Xmx를 다르게 주면?

트래픽 증가 시 Heap이 커지는데, 이 확장 과정에서 Stop-The-World가 길어져 사용자가 느려짐을 체감합니다. 운영 환경에선 두 값을 같게 고정하세요.

Heap이 너무 큰 것도 문제

Heap은 "넉넉하되 과하지 않게"가 원칙입니다.

4GC 선택과 튜닝

상황별 GC 선택 가이드

상황 추천 GC 이유
일반 서버 (Heap 2~8GB) G1 GC (기본) 균형 잡힌 성능, JDK 9+ 기본
저지연 중요 (실시간 API) ZGC / Shenandoah pause 10ms 이하
처리량 중요 (배치) Parallel GC throughput 최적화
대용량 Heap (32GB+) ZGC Heap 크기 무관 일정한 pause
작은 앱 (Heap < 1GB) Serial GC 오버헤드 최소

G1 GC 튜닝 (가장 많이 쓰는 케이스)

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200            # 목표 pause 시간 (ms)
-XX:G1HeapRegionSize=16m           # region 크기 (1~32m, 2의 거듭제곱)
-XX:InitiatingHeapOccupancyPercent=45  # Mixed GC 시작 임계값
-XX:G1NewSizePercent=20          # Young 최소 비율
-XX:G1MaxNewSizePercent=40       # Young 최대 비율
G1 튜닝 팁

MaxGCPauseMillis가 가장 중요합니다. 낮게 잡으면 Young 영역이 작아져 자주 GC하고, 높게 잡으면 덜 자주 GC하지만 한 번에 오래 멈춥니다. 기본 200ms가 웹 서비스엔 적정선.

ZGC 튜닝 (저지연 서비스)

-XX:+UseZGC
-XX:+ZGenerational                   # JDK 21+ 세대별 ZGC (권장)
-Xmx=16g                           # ZGC는 큰 Heap에서 진가 발휘
-XX:SoftMaxHeapSize=14g            # 소프트 최대치 (권장)
ZGC 특징

pause 시간이 Heap 크기와 무관하게 10ms 미만. 대신 CPU·메모리를 15% 정도 더 씁니다. 실시간 응답성이 돈과 직결되는 서비스에 쓰세요.

GC 로그 활성화 (필수!)

# JDK 9+ (통합 로깅)
-Xlog:gc*:file=/var/log/gc-%t.log:time,uptime,level,tags:filecount=5,filesize=10m

# 추가로 유용한 옵션
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heap-dump.hprof
-XX:+ExitOnOutOfMemoryError          # OOM 시 프로세스 종료 (컨테이너 재시작 유도)
프로덕션에 GC 로그는 필수

GC 로그 없이 장애 분석은 불가능에 가깝습니다. 용량도 적고 오버헤드도 무시 가능하니 무조건 켜두세요.

5스레드 · 스택 튜닝

스레드 풀 크기 공식

# Brian Goetz 공식 (자바 병렬 프로그래밍)
스레드 수 = CPU 코어 수 × (1 + Wait Time / Compute Time)

# 예시
# - 4코어
# - 평균 대기시간 100ms (DB, 외부 API)
# - 평균 연산시간 50ms

스레드 수 = 4 × (1 + 100/50) = 12
톰캣 기본값은 왜 200인가?

스프링부트/톰캣 기본 max-threads=200은 대부분의 서비스에 과합니다. I/O 대기가 많다면 충분하지만, 스레드당 1MB 스택을 쓰므로 200 × 1MB = 200MB가 스택에 잡힙니다. 실제 필요한 만큼만 쓰세요.

스택 크기 조정

-Xss512k

스레드당 스택 크기를 1MB → 512KB로. 재귀가 깊지 않은 일반 웹 앱에선 충분. 스레드 200개 기준 100MB 절약.

-Xss256k

더 공격적. StackOverflowError 가능성 주의. 반드시 부하 테스트로 검증.

Virtual Thread (JDK 21+) 활용

전통적인 OS 스레드는 무겁습니다. Virtual Thread는 스레드당 수 KB만 사용하며, 수백만 개까지 만들 수 있습니다.

# application.yml (Spring Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true
언제 Virtual Thread가 좋은가?

I/O 대기가 많은 작업(DB 쿼리, 외부 API 호출 등)에 특히 효과적. CPU 연산이 주력인 배치 작업엔 기존 스레드가 나을 수 있습니다.

6Metaspace · Code Cache 튜닝

Metaspace

클래스 메타데이터가 저장되는 영역. 기본은 무제한이라 방치하면 계속 커집니다.

-XX:MetaspaceSize=128m

초기 크기. 이 값에 도달하면 Full GC 1회 발생. 기본값이 작아 초기 GC가 잦으므로 명시적으로 크게 설정 권장.

-XX:MaxMetaspaceSize=256m

최대 크기 상한. 무한 증가 방지. 일반 스프링부트는 200~300MB면 충분.

Metaspace 누수 주의

동적 클래스 생성(CGLib, JSP 리로드, 리플렉션 기반 프록시)이 많으면 누수가 발생합니다. Groovy/Scala 같은 동적 언어 혼용 시 특히 위험. jcmd <pid> GC.class_stats로 점검.

Code Cache

JIT 컴파일 결과가 저장되는 곳. 가득 차면 JIT가 멈춰 성능이 급락합니다.

-XX:ReservedCodeCacheSize=256m

기본 240MB. 대형 앱이나 마이크로서비스 여러 개 돌리면 늘려야 할 수 있음.

-XX:+UseCodeCacheFlushing

Code Cache가 차면 오래된 컴파일 결과를 자동 flush. 기본 켜져 있음.

경고 메시지
CodeCache is full. Compiler has been disabled.
Try increasing the code cache size using -XX:ReservedCodeCacheSize=

이 로그를 본 적 있다면 즉시 Code Cache를 늘리세요.

7컨테이너 환경 튜닝

컨테이너 + JVM 메모리 배분 공식

컨테이너 메모리 (예: 2GB)
  ├─ Heap            1.5GB  (75%)  ← -XX:MaxRAMPercentage=75
  ├─ Metaspace       256MB         ← -XX:MaxMetaspaceSize=256m
  ├─ Code Cache      128MB         ← -XX:ReservedCodeCacheSize=128m
  ├─ Thread Stack    100MB         ← -Xss512k × 200 스레드
  ├─ Direct Buffer   ~50MB
  └─ GC/기타          ~50MB
                    ──────
                    ~2.0GB  ✅ 컨테이너 한도 내

도커 실전 설정 예시

# Dockerfile
FROM eclipse-temurin:21-jre

ENV JAVA_TOOL_OPTIONS="\
  -XX:InitialRAMPercentage=50.0 \
  -XX:MaxRAMPercentage=75.0 \
  -XX:MaxMetaspaceSize=256m \
  -XX:ReservedCodeCacheSize=128m \
  -Xss512k \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/heap-dump.hprof \
  -XX:+ExitOnOutOfMemoryError \
  -Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=10m"

COPY app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
JAVA_TOOL_OPTIONS를 쓰는 이유

이 환경변수로 설정하면 java 명령에 직접 옵션을 붙이지 않아도 자동 적용됩니다. 빌드 시스템이나 래퍼 스크립트가 java -jar를 호출하는 경우에도 안정적으로 동작합니다.

쿠버네티스 리소스 설정

# deployment.yaml
resources:
  requests:
    memory: "2Gi"
    cpu: "500m"
  limits:
    memory: "2Gi"        # requests == limits 권장 (QoS Guaranteed)
    cpu: "2000m"
memory requests ≠ limits 위험

requests만 크게 잡고 limits를 낮게 두면, 노드 메모리 압박 시 컨테이너가 OOMKilled로 강제 종료됩니다. 프로덕션에선 두 값을 같게 설정하세요.

8모니터링 · 진단 도구

주요 진단 명령어

명령어 용도
jps 실행 중인 JVM 프로세스 목록
jstat -gc <pid> 1000 GC 실시간 모니터링 (1초 간격)
jstack <pid> 스레드 덤프 (데드락, 느린 요청 분석)
jmap -dump:live,format=b,file=heap.hprof <pid> 힙 덤프 (메모리 누수)
jcmd <pid> VM.native_memory summary Non-Heap 메모리 상세 (NMT 활성화 필요)
jcmd <pid> GC.heap_info Heap 상태 요약
jcmd <pid> Thread.print jstack 대체 (더 빠름)

이 신호가 보이면 즉시 대응

🚨 Full GC가 5분 내 2회 이상

메모리 누수 가능성 높음. 힙덤프 분석 필요.

🚨 OOMKilled 반복 발생

컨테이너 메모리 한도 초과. Heap + Non-Heap 재계산.

🚨 GC pause > 1초

Heap 과대 또는 GC 선택 오류. ZGC 검토.

⚠️ Old 영역 사용률 지속 증가

객체 승격이 과도. Young 영역 확대 검토.

⚠️ Metaspace 계속 증가

클래스 로더 누수 의심.

⚠️ Code Cache 95% 도달

ReservedCodeCacheSize 증가 필요.

✅ GC pause p99 < 200ms

정상 상태. 현재 설정 유지.

✅ Heap 사용률 50~70%

GC 여유 공간 충분. 이상적.

Spring Boot + Prometheus 연동

# build.gradle
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'org.springframework.boot:spring-boot-starter-actuator'

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health, prometheus, metrics
  metrics:
    tags:
      application: ${spring.application.name}
자동 노출되는 핵심 메트릭
  • jvm_memory_used_bytes - 영역별 사용량
  • jvm_gc_pause_seconds - GC pause 히스토그램
  • jvm_threads_live_threads - 활성 스레드 수
  • jvm_classes_loaded_classes - 로딩된 클래스 수
  • process_resident_memory_bytes - OS 기준 실제 메모리

9실전 장애 시나리오

Case 1 컨테이너가 계속 OOMKilled로 재시작
증상 — Heap 사용률은 50%인데 컨테이너가 죽음. 자바 로그엔 OutOfMemoryError가 없음.
원인 — Non-Heap 영역(Metaspace, Direct Buffer 등)이 컨테이너 제한 초과.
해결jcmd <pid> VM.native_memory summary로 Non-Heap 확인 후 해당 영역 제한 또는 컨테이너 메모리 증설. Netty 사용 시 -XX:MaxDirectMemorySize 설정.
Case 2 특정 시간대에 응답 지연 스파이크
증상 — 평소 응답 50ms인데 10분마다 5초씩 멈춤. GC 로그에 Full GC 기록.
원인 — Young 영역이 작아 객체가 곧바로 Old로 승격 → Old 꽉 차서 Full GC.
해결 — G1GC로 변경 + -XX:MaxGCPauseMillis=200 + Heap 확대. 또는 -XX:G1NewSizePercent=30으로 Young 영역 확대.
Case 3 배포 직후 응답이 매우 느림
증상 — 배포 직후 1~2분간 응답이 10배 느리다가 정상화.
원인 — JIT 워밍업 미완료. 헬스체크가 트래픽을 받자마자 부하가 몰림.
해결 — 롤링 배포 + 워밍업 스크립트 실행 (주요 API 호출 반복) 후 트래픽 투입. 또는 -XX:+TieredCompilation으로 계층 컴파일 활용 (이미 기본 적용).
Case 4 며칠 운영하면 메모리가 점점 증가
증상 — 배포 직후 400MB → 3일 후 1.5GB → Full GC해도 안 줄어듦.
원인 — 메모리 누수. 정적 컬렉션, 캐시, 리스너 미해제 등.
해결 — 힙덤프 채집 (jmap) → Eclipse MAT로 Dominator Tree 분석 → 누수 근원 코드 수정. 임시 대응으로 주기적 재시작.
Case 5 CPU가 갑자기 100%로 치솟음
증상 — 정상이던 서비스에 특정 시점 CPU 100%. 요청 처리는 멈춤.
원인 후보
  • 무한 루프 코드 (스레드 덤프로 확인)
  • GC가 계속 돌며 pause 반복 (Heap 누수)
  • 정규식 폭발 (ReDoS)
해결top -H -p <pid>로 CPU 먹는 스레드 찾고 jstack으로 해당 스레드 스택 트레이스 확인.

10흔한 안티패턴

"블로그에서 봤으니 다 넣자"

10개 옵션 복붙 후 배포. 효과도 모르고 부작용도 모름. 쓰는 옵션은 왜 쓰는지 설명할 수 있어야 합니다.

"Heap은 무조건 크게"

-Xmx16g로 잡으면 Full GC 한 번에 수십 초. Heap은 실사용량의 3~4배면 충분하며, 컨테이너 한도 내에 있어야 합니다.

"프로덕션에 GC 로그 안 켬"

장애가 나도 원인을 알 수 없는 상황. GC 로그는 오버헤드가 거의 없으니 무조건 켜두세요.

"-XX:+UseConcMarkSweepGC"

CMS는 JDK 14부터 제거된 deprecated GC. 아직도 쓰고 있다면 즉시 G1이나 ZGC로 교체.

"컨테이너에 -Xmx2g 고정"

컨테이너 스펙이 바뀌어도 JVM은 2GB만 씀. -XX:MaxRAMPercentage로 비율 지정하면 자동 대응.

"System.gc() 호출"

Full GC를 강제로 유발해 서비스를 멈추게 함. 라이브러리가 호출한다면 -XX:+DisableExplicitGC로 무력화.

"부하 테스트 없이 본서버 적용"

설정 변경은 반드시 스테이징에서 실 트래픽과 유사한 부하로 검증한 뒤 프로덕션에 반영.

11프로덕션 배포 체크리스트

필수 옵션 체크

모니터링 체크

운영 준비 체크

12빠른 참조 - 상황별 설정

Spring Boot 웹 서비스 (2GB 컨테이너)

-XX:MaxRAMPercentage=75
-XX:MaxMetaspaceSize=256m
-XX:ReservedCodeCacheSize=128m
-Xss512k
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

저지연 API 서비스 (8GB 컨테이너)

-XX:MaxRAMPercentage=70
-XX:MaxMetaspaceSize=512m
-XX:+UseZGC
-XX:+ZGenerational
-Xlog:gc*:file=/var/log/gc.log

배치 처리 서비스

-XX:MaxRAMPercentage=80
-XX:+UseParallelGC
-XX:ParallelGCThreads=8
-XX:GCTimeRatio=19    # 처리량 95% 목표

마이크로서비스 / 서버리스

-XX:MaxRAMPercentage=75
-XX:TieredStopAtLevel=1   # JIT 1단계만 (빠른 시작)
-Xshare:auto             # CDS 활용
-XX:+UseSerialGC         # 작은 Heap은 Serial이 효율