JVM 플래그 최적화

조용현·2026년 3월 27일

문제 해결

목록 보기
6/13

배경

성능 테스트(100 VU, 10분) 진행 중 테스트 시작 약 4분(240s) 시점에 처리량이 95 RPS에서 69 RPS로 급감하고, P99이 1,265ms에서 8,855ms로 7배 이상 치솟는 딥 구간이 나타났습니다.

popping_before_jvm

구간RPSAvgP99
안정 구간 (150~210s)~95~190ms~1,250ms
딥 구간 (240~270s)69567ms8,855ms
회복 이후 (270s~)~97~160ms~1,100ms

Grafana GC pause 그래프에서 동일 시점(13:19)에 300ms 스파이크가 관측됐고, 이 두 현상이 같은 시점에 맞물려 있었습니다. DB 쿼리나 애플리케이션 코드 문제를 먼저 의심했지만, slow query 수와 평균 응답시간만으로는 이 짧은 딥을 설명하기 어려웠습니다. JVM 설정을 함께 확인해봤습니다.


문제 원인

컨테이너 내부에서 jcmd로 실제 적용된 JVM 플래그를 확인했습니다.

# jcmd 1 VM.flags
-XX:InitialHeapSize=16777216        # 16 MiB
-XX:MaxHeapSize=251658240           # 240 MiB
-XX:ReservedCodeCacheSize=251658240 # 240 MiB
-XX:+UseSerialGC
-XX:+UseContainerSupport

build.gradlejib.container.jvmFlags-Xms를 설정하지 않아 JVM이 기본값인 16 MiB의 작은 초기 힙으로 시작하고 있었습니다. 최대 힙은 240 MiB였지만, 재기동 직후에는 훨씬 작은 힙 상태에서 트래픽을 받는 구조였던 겁니다.

테스트 시작 후 부하가 0 → 100 RPS로 점진 증가하면서 힙도 단계적으로 확장됩니다. SerialGC는 힙을 확장할 때마다 힙 전체를 단일 스레드로 스캔하는 Full GC를 실행하며, 힙이 클수록 STW(Stop-The-World) 시간이 늘어납니다.

기동 시: 힙 16 MiB
    │
    ├─ 부하 증가 → Full GC + 힙 확장 → 32 MiB
    ├─ 부하 증가 → Full GC + 힙 확장 → 64 MiB
    ├─ 부하 증가 → Full GC + 힙 확장 → 128 MiB
    └─ 부하 증가 → Full GC + 힙 확장 → 240 MiB  ← 300ms STW, 13:19
                                                    JTL 기준 240s 딥 구간과 일치

힙이 128 MiB 이상으로 확장되던 시점(약 4분)에 300ms 수준의 GC pause가 관측됐고, 같은 시점에 RPS 69, P99 8,855ms의 딥 구간이 함께 나타났습니다. 관측 결과를 종합하면, 초기 힙이 작아 단계적 힙 확장과 GC pause가 발생한 점이 이 구간의 성능 저하에 영향을 준 유력한 원인으로 보였습니다.

코드 캐시는 240 MiB로 설정돼 있었고, RAM 953 MiB 인스턴스에서 MySQL·OS page cache와 메모리를 공유하는 구조에서 SWAP 39.9%에 기여하고 있었습니다.


해결 과정

GC 알고리즘을 바꾸기보다, 먼저 초기 힙을 충분히 크게 잡아 cold start 초반의 힙 확장 부담을 줄이는 방향으로 접근했습니다. -Xms-Xmx에 가깝게 설정해 초기 메모리 부족으로 발생하는 GC/힙 확장 구간을 완화하고, ReservedCodeCacheSize도 240 MiB에서 64 MiB로 줄였습니다.

// build.gradle - jib.container
jvmFlags = [
    '-XX:+UseContainerSupport',
    '-Xms200m',                        // 초기 힙 확대 (기존: 16 MiB)
    '-Xmx240m',
    '-XX:ReservedCodeCacheSize=64m',   // 코드 캐시 축소 (기존: 240 MiB)
    '-XX:MaxMetaspaceSize=128m',
    '-XX:+UseStringDeduplication',
    '-Dspring.profiles.active=dev',
    '-Dfile.encoding=UTF-8',
    '-Duser.timezone=Asia/Seoul'
]

변경 후 jcmd로 재확인했습니다.

-XX:InitialHeapSize=209715200      # 200 MiB ✅
-XX:MinHeapSize=209715200          # 200 MiB ✅
-XX:ReservedCodeCacheSize=67108864 # 64 MiB  ✅
-XX:MaxMetaspaceSize=134217728     # 128 MiB ✅
-XX:+UseStringDeduplication        ✅

테스트 결과

동일 조건(cold start, 100 VU, 10분)에서 JVM 플래그 적용 전후를 비교했습니다.

JTL 수치 비교

구간Before RPSBefore P99After RPSAfter P99
안정 구간 (150~210s)~95~1,250ms~91~1,450ms
딥 구간 (240~270s)698,855ms961,265ms
이후 안정 (270s~)~97~1,100ms~97~1,150ms
Max elapsed9,659ms3,287ms

Grafana 수치 비교

popping_jvm

지표BeforeAfter
GC pause300ms 단일 스파이크최대 30ms, 이후 10~15ms
SWAP39.9%36.9%
Sys Load1381%1129%
처리량 딥RPS 69, P99 8,855ms없음
Slow query QPS최대 12최대 12 (유사)

-Xms200m을 적용한 뒤에는 테스트 구간에서 이전처럼 눈에 띄는 힙 확장과 300ms 수준의 GC pause가 관측되지 않았습니다. 4분 시점의 딥 구간이 제거됐고 최악 응답시간도 9,659ms → 3,287ms로 감소했습니다. ReservedCodeCacheSize 축소(240 MiB → 64 MiB) 이후 SWAP과 Sys Load도 함께 낮아졌습니다. 다만 이 변화는 다른 요인과 함께 나타난 결과일 수 있으므로, 코드 캐시 축소가 완화에 기여한 것으로 해석하는 편이 더 적절합니다.

Slow query 수와 안정 구간 응답시간이 유사한 이유는 DB 쿼리 비용이 JVM 튜닝 범위 밖이기 때문입니다.


트레이드오프

기동 직후 메모리 점유 증가

-Xms200m을 설정하면 JVM이 시작과 동시에 200 MiB를 점유합니다. RAM 953 MiB 인스턴스에서 MySQL(InnoDB Buffer Pool 128 MiB), OS page cache와 메모리를 공유하는 구조이므로, After 테스트에서도 RAM 사용률이 94.2%로 여전히 높습니다. 힙을 고정하는 방식은 반드시 전체 메모리 사용량을 실측으로 확인해야 합니다.

워밍업 구간은 여전히 존재

Full GC STW는 제거됐지만 After 결과에서도 0~240s 구간의 평균 응답시간이 안정 구간 대비 높게 나타납니다(60~90s 구간 Avg 724ms). JIT 컴파일, 캐시 미스, DB 커넥션 풀 초기화 같은 요소는 JVM 힙 튜닝 범위 밖입니다.

CPU 병목은 해소되지 않음

Before 98.9%, After 99.0%로 CPU 사용률은 동일합니다. Sys Load가 1381% → 1129%로 낮아진 것은 GC 스레드 부하 감소 효과지만, MySQL Questions 최대 951 QPS, Peak Threads Running 최대 31개로 DB 처리 자체가 CPU의 주 원인입니다. 인덱스 추가, DTO Projection, 캐시 전략 개선으로 DB 호출 수를 줄이는 방향이 필요합니다.


결론

테스트 당시 JVM은 -Xms 미설정 상태로 16 MiB 초기 힙에서 시작했고, 부하 증가 구간에서 힙 확장과 GC pause가 함께 관측됐습니다. 특히 테스트 시작 약 4분 시점의 300ms GC pause와 성능 딥이 같은 시점에 나타나, 작은 초기 힙과 그에 따른 확장 비용이 주요 원인 중 하나였다고 판단했습니다.

-Xms200m으로 힙을 초기부터 고정하자 힙 확장 조건 자체가 없어졌습니다. 딥 구간이 사라졌고(RPS 96, P99 1,265ms), 최악 응답시간도 9,659ms → 3,287ms로 감소했습니다. ReservedCodeCacheSize 축소(240 MiB → 64 MiB)는 SWAP 39.9% → 36.9%, Sys Load 1381% → 1129% 감소에 기여했습니다.

profile
백엔드 개발자

0개의 댓글