실전에서 바로 써먹는 성능·메모리 튜닝 노하우
Spring Boot · Container · Production성급한 튜닝은 문제를 해결하기보다 새로운 문제를 만듭니다. 본격적으로 옵션을 건드리기 전에 이 원칙부터 체화하세요.
"아마 Heap이 부족할 거야"가 아니라 GC 로그·힙덤프·프로파일러로 확인한 데이터를 근거로 움직입니다. 측정 → 가설 → 변경 → 재측정의 순환이 기본입니다.
JVM 기본값은 수많은 실 서비스에서 검증된 것입니다. 문제를 확인하기 전에 옵션을 건드리지 마세요. 블로그에서 봤다는 이유로 옵션을 추가하면 오히려 성능이 떨어집니다.
세 개 옵션을 동시에 바꾸면 무엇이 효과를 냈는지 알 수 없습니다. A/B 테스트처럼 한 변수씩 통제하며 비교하세요.
"빠르게 만들어줘" 같은 막연한 요구는 튜닝할 수 없습니다. 다음 중 무엇이 가장 중요한지 정해야 합니다.
| 목표 | 대표 지표 | 트레이드오프 |
|---|---|---|
| 처리량(Throughput) | TPS, RPS | GC pause는 허용 |
| 응답 속도(Latency) | p99 응답 시간 | 메모리·CPU 더 씀 |
| 메모리 효율 | RSS, Heap 사용률 | 성능 약간 희생 |
| 안정성 | 에러율, 가동률 | 처리량 제한 |
"p99 응답시간 200ms 이하, GC pause 100ms 이하, 동시 사용자 1000명에서 에러율 0.1% 이하" — 이 정도로 구체적이어야 튜닝 방향이 잡힙니다.
Heap은 JVM에서 가장 큰 메모리 영역이자 GC의 주 대상. 크기를 잘못 잡으면 OOM이나 잦은 GC로 서비스가 죽습니다.
# 실제 사용 Heap 측정 (정상 운영 시 GC 직후)
# 이를 "Live Data Size(LDS)"라고 함
권장 Heap 크기 = LDS × 3 ~ 4
# 예: LDS가 400MB라면 Heap은 1.2GB ~ 1.6GB
GC는 Heap에 여유 공간이 많을수록 빠르게 동작합니다. Eden 영역에서 새 객체를 위한 공간, 승격을 위한 Old 영역 여유, GC 작업 공간까지 필요해서 실사용의 3~4배가 황금비입니다.
초기 Heap 크기. 컨테이너에선 -Xmx와 같게 설정해 성장 시 멈춤을 방지.
최대 Heap 크기. 컨테이너 메모리의 70~75%가 적정선.
컨테이너 환경에서 -Xmx 대신 비율로 지정. 컨테이너 메모리 제한에 자동 대응.
Old : Young 비율. 기본 2 (Old가 2배). 짧은 생명 객체가 많으면 낮춰서 Young 영역을 키움.
트래픽 증가 시 Heap이 커지는데, 이 확장 과정에서 Stop-The-World가 길어져 사용자가 느려짐을 체감합니다. 운영 환경에선 두 값을 같게 고정하세요.
Heap은 "넉넉하되 과하지 않게"가 원칙입니다.
| 상황 | 추천 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 | 오버헤드 최소 |
-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 최대 비율
MaxGCPauseMillis가 가장 중요합니다. 낮게 잡으면 Young 영역이 작아져 자주 GC하고, 높게 잡으면 덜 자주 GC하지만 한 번에 오래 멈춥니다. 기본 200ms가 웹 서비스엔 적정선.
-XX:+UseZGC
-XX:+ZGenerational # JDK 21+ 세대별 ZGC (권장)
-Xmx=16g # ZGC는 큰 Heap에서 진가 발휘
-XX:SoftMaxHeapSize=14g # 소프트 최대치 (권장)
pause 시간이 Heap 크기와 무관하게 10ms 미만. 대신 CPU·메모리를 15% 정도 더 씁니다. 실시간 응답성이 돈과 직결되는 서비스에 쓰세요.
# 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 로그 없이 장애 분석은 불가능에 가깝습니다. 용량도 적고 오버헤드도 무시 가능하니 무조건 켜두세요.
# Brian Goetz 공식 (자바 병렬 프로그래밍)
스레드 수 = CPU 코어 수 × (1 + Wait Time / Compute Time)
# 예시
# - 4코어
# - 평균 대기시간 100ms (DB, 외부 API)
# - 평균 연산시간 50ms
스레드 수 = 4 × (1 + 100/50) = 12
스프링부트/톰캣 기본 max-threads=200은 대부분의 서비스에 과합니다. I/O 대기가 많다면 충분하지만, 스레드당 1MB 스택을 쓰므로 200 × 1MB = 200MB가 스택에 잡힙니다. 실제 필요한 만큼만 쓰세요.
스레드당 스택 크기를 1MB → 512KB로. 재귀가 깊지 않은 일반 웹 앱에선 충분. 스레드 200개 기준 100MB 절약.
더 공격적. StackOverflowError 가능성 주의. 반드시 부하 테스트로 검증.
전통적인 OS 스레드는 무겁습니다. Virtual Thread는 스레드당 수 KB만 사용하며, 수백만 개까지 만들 수 있습니다.
# application.yml (Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true
I/O 대기가 많은 작업(DB 쿼리, 외부 API 호출 등)에 특히 효과적. CPU 연산이 주력인 배치 작업엔 기존 스레드가 나을 수 있습니다.
클래스 메타데이터가 저장되는 영역. 기본은 무제한이라 방치하면 계속 커집니다.
초기 크기. 이 값에 도달하면 Full GC 1회 발생. 기본값이 작아 초기 GC가 잦으므로 명시적으로 크게 설정 권장.
최대 크기 상한. 무한 증가 방지. 일반 스프링부트는 200~300MB면 충분.
동적 클래스 생성(CGLib, JSP 리로드, 리플렉션 기반 프록시)이 많으면 누수가 발생합니다. Groovy/Scala 같은 동적 언어 혼용 시 특히 위험. jcmd <pid> GC.class_stats로 점검.
JIT 컴파일 결과가 저장되는 곳. 가득 차면 JIT가 멈춰 성능이 급락합니다.
기본 240MB. 대형 앱이나 마이크로서비스 여러 개 돌리면 늘려야 할 수 있음.
Code Cache가 차면 오래된 컴파일 결과를 자동 flush. 기본 켜져 있음.
CodeCache is full. Compiler has been disabled.
Try increasing the code cache size using -XX:ReservedCodeCacheSize=
이 로그를 본 적 있다면 즉시 Code Cache를 늘리세요.
컨테이너 메모리 (예: 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 명령에 직접 옵션을 붙이지 않아도 자동 적용됩니다. 빌드 시스템이나 래퍼 스크립트가 java -jar를 호출하는 경우에도 안정적으로 동작합니다.
# deployment.yaml
resources:
requests:
memory: "2Gi"
cpu: "500m"
limits:
memory: "2Gi" # requests == limits 권장 (QoS Guaranteed)
cpu: "2000m"
requests만 크게 잡고 limits를 낮게 두면, 노드 메모리 압박 시 컨테이너가 OOMKilled로 강제 종료됩니다. 프로덕션에선 두 값을 같게 설정하세요.
| 명령어 | 용도 |
|---|---|
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 대체 (더 빠름) |
메모리 누수 가능성 높음. 힙덤프 분석 필요.
컨테이너 메모리 한도 초과. Heap + Non-Heap 재계산.
Heap 과대 또는 GC 선택 오류. ZGC 검토.
객체 승격이 과도. Young 영역 확대 검토.
클래스 로더 누수 의심.
ReservedCodeCacheSize 증가 필요.
정상 상태. 현재 설정 유지.
GC 여유 공간 충분. 이상적.
# 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 기준 실제 메모리OutOfMemoryError가 없음.
jcmd <pid> VM.native_memory summary로 Non-Heap 확인 후 해당 영역 제한 또는 컨테이너 메모리 증설. Netty 사용 시 -XX:MaxDirectMemorySize 설정.
-XX:MaxGCPauseMillis=200 + Heap 확대. 또는 -XX:G1NewSizePercent=30으로 Young 영역 확대.
-XX:+TieredCompilation으로 계층 컴파일 활용 (이미 기본 적용).
jmap) → Eclipse MAT로 Dominator Tree 분석 → 누수 근원 코드 수정. 임시 대응으로 주기적 재시작.
top -H -p <pid>로 CPU 먹는 스레드 찾고 jstack으로 해당 스레드 스택 트레이스 확인.
10개 옵션 복붙 후 배포. 효과도 모르고 부작용도 모름. 쓰는 옵션은 왜 쓰는지 설명할 수 있어야 합니다.
-Xmx16g로 잡으면 Full GC 한 번에 수십 초. Heap은 실사용량의 3~4배면 충분하며, 컨테이너 한도 내에 있어야 합니다.
장애가 나도 원인을 알 수 없는 상황. GC 로그는 오버헤드가 거의 없으니 무조건 켜두세요.
CMS는 JDK 14부터 제거된 deprecated GC. 아직도 쓰고 있다면 즉시 G1이나 ZGC로 교체.
컨테이너 스펙이 바뀌어도 JVM은 2GB만 씀. -XX:MaxRAMPercentage로 비율 지정하면 자동 대응.
Full GC를 강제로 유발해 서비스를 멈추게 함. 라이브러리가 호출한다면 -XX:+DisableExplicitGC로 무력화.
설정 변경은 반드시 스테이징에서 실 트래픽과 유사한 부하로 검증한 뒤 프로덕션에 반영.
-XX:MaxRAMPercentage=75 (컨테이너)-XX:MaxMetaspaceSize 명시-XX:+UseG1GC 또는 ZGC-Xlog:gc* GC 로그 활성화-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath 지정 (영속 볼륨)-XX:+ExitOnOutOfMemoryError (컨테이너 재시작 유도)-XX:MaxRAMPercentage=75
-XX:MaxMetaspaceSize=256m
-XX:ReservedCodeCacheSize=128m
-Xss512k
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-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이 효율