- Published on
Spring Boot 3.x p99 지연 폭증? JVM·GC·Netty 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 지표를 보면 평균 응답시간은 멀쩡한데 p99(또는 p999)만 갑자기 치솟는 경우가 있습니다. Spring Boot 3.x로 올린 뒤 이런 현상이 더 자주 보인다면, 단순히 애플리케이션 로직이 느려진 게 아니라 JVM 런타임(스케줄링, 스레드, 메모리)과 GC, 그리고 Netty(리액티브 스택일 때)의 이벤트 루프/버퍼/백프레셔가 꼬이면서 “꼬리 지연(tail latency)”이 커졌을 가능성이 큽니다.
이 글은 다음 전제를 둡니다.
- Spring Boot 3.x (Jakarta EE 9+, JDK 17 이상)
- 서블릿 스택(Tomcat/Jetty) 또는 리액티브 스택(WebFlux/Netty)
- p50, p95는 정상인데 p99만 튀거나, 특정 트래픽 구간에서만 p99가 급등
아래 순서대로 보면 원인 후보를 빠르게 좁힐 수 있습니다.
1) p99 폭증을 “측정 오류”부터 배제하기
p99는 소수의 느린 요청에 매우 민감합니다. 아래가 섞이면 실제보다 더 나쁘게 보이거나, 반대로 문제를 숨깁니다.
- 히스토그램 버킷 설정 부적절: Prometheus histogram 버킷이 너무 거칠면 p99 계산이 튀는 것처럼 보입니다.
- 클라이언트 타임아웃/재시도: 타임아웃 난 요청이 서버에서는 계속 처리되어 큐를 밀어 p99를 더 악화시킵니다.
- 로드밸런서/게이트웨이 큐잉: 서버 지표가 아니라 앞단에서 대기열이 생긴 경우도 많습니다.
MSA에서 게이트웨이 레벨 문제도 자주 섞입니다. 예를 들어 라우팅/인증 필터가 병목이면 서버 p99가 아니라 게이트웨이 p99가 먼저 튑니다. 필요하면 Kong API Gateway MSA 라우팅·JWT 401 트러블슈팅처럼 앞단 설정도 같이 점검하세요.
2) Spring Boot 3.x에서 tail latency가 커지기 쉬운 지점
2.1 JDK 17 + G1GC 기본값의 “안전하지만 느린” 경향
Boot 3.x는 JDK 17 이상을 권장하고, 대부분 G1GC를 사용합니다. G1은 전반적으로 균형이 좋지만, 다음 상황에서 p99가 튈 수 있습니다.
- 힙이 작거나, 힙 여유가 부족해 Mixed GC가 잦아짐
- 큰 객체 할당(대형 JSON, 압축/암호화 버퍼, 이미지/파일 처리)로 Humongous allocation이 증가
System.gc()또는 라이브러리의 강제 GC 트리거- 컨테이너 환경에서 CPU 제한이 있는데 JVM이 이를 제대로 반영하지 못해 GC 스레드/애플리케이션 스레드 경쟁이 발생
2.2 가상 스레드(virtual threads) 도입 시 착시
Boot 3.2+에서 가상 스레드를 쉽게 켤 수 있습니다. 하지만 p99 관점에서는 “대부분 빨라졌는데 일부는 더 느려진” 형태가 나올 수 있습니다.
- 블로킹 I/O가 섞인 코드에서는 이점이 크지만
- 동기화 락 경합, 커넥션 풀 고갈, 다운스트림 지연이 있으면 더 많은 동시성이 오히려 큐를 키워 tail을 늘릴 수 있습니다.
가상 스레드는 만능이 아니라 “병목을 더 잘 드러내는 도구”에 가깝습니다.
2.3 WebFlux/Netty에서 이벤트 루프 고갈
리액티브 스택이라면 p99 폭증의 흔한 원인이 이벤트 루프 스레드가 블로킹되는 것입니다.
block()호출- JDBC 같은 블로킹 드라이버를 이벤트 루프에서 실행
- 큰 JSON 직렬화/역직렬화, 암호화, 압축 같은 CPU 작업을 이벤트 루프에서 수행
이 경우 평균은 괜찮다가도 특정 순간 이벤트 루프가 잠기면 p99가 급등합니다.
3) 1차 진단: GC와 스레드 덤프로 “꼬리”를 잡기
3.1 GC 로그부터 켜기 (운영에서도 부담 적게)
JDK 17에서 추천하는 GC 로그 옵션 예시는 다음과 같습니다.
JAVA_TOOL_OPTIONS="-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50M"
여기서 보는 핵심은 다음입니다.
Pause Young (Normal)이 자주 길어지는지Pause RemarkPause Cleanup같은 STW 구간이 길어지는지Humongous관련 로그가 반복되는지safepoint로그에서Total time for which application threads were stopped가 튀는지
p99 폭증 시점과 GC pause 시점을 맞춰보면, “GC 때문인지 아닌지”를 빠르게 분리할 수 있습니다.
3.2 스레드 덤프를 “지연 순간”에 채집
p99가 튀는 순간에 3장 정도 연속으로 뜨면(예: 5초 간격) 병목이 보입니다.
jcmd <pid> Thread.print -l > /tmp/threaddump-1.txt
sleep 5
jcmd <pid> Thread.print -l > /tmp/threaddump-2.txt
sleep 5
jcmd <pid> Thread.print -l > /tmp/threaddump-3.txt
주의: 위에서 <pid>는 반드시 인라인 코드로 감싸야 MDX에서 안전합니다.
덤프에서 자주 보이는 패턴은 다음입니다.
BLOCKED가 많으면 락 경합WAITING이 커넥션 풀 대기(HikariCP)라면 DB 풀 고갈- Netty 이벤트 루프 스레드가 JSON 직렬화 같은 CPU 작업에 묶여 있으면 설계 문제
4) JVM 튜닝: “p99 친화적”으로 힙과 스레드를 잡기
4.1 컨테이너 환경 기본값 확인
Kubernetes에서 CPU 제한이 있는데도 JVM이 이를 크게 잡으면, GC 스레드가 과도하게 늘거나 JIT 컴파일 타이밍이 꼬여 p99가 흔들릴 수 있습니다. 다음 플래그를 확인합니다.
-XX:ActiveProcessorCount=...로 JVM이 인식할 CPU를 고정- 메모리 제한에 맞춘 힙 비율 설정
예시:
JAVA_TOOL_OPTIONS="
-XX:ActiveProcessorCount=4
-XX:MaxRAMPercentage=75
-XX:InitialRAMPercentage=75
"
4.2 힙은 “너무 작아도” “너무 커도” p99에 불리
- 힙이 너무 작으면 GC가 잦아져 pause 빈도가 증가
- 힙이 너무 크면 한 번의 GC가 길어질 수 있고, 메모리 압박이 오면 회복이 늦습니다.
경험적으로는 다음 접근이 안전합니다.
- 먼저
-Xms와-Xmx를 같게 두어 힙 리사이즈 비용을 제거 - GC 로그로 pause 분포를 본 뒤, 힙을 단계적으로 조정
JAVA_TOOL_OPTIONS="-Xms4g -Xmx4g"
4.3 Safepoint가 p99를 만드는 경우
GC가 아닌데도 애플리케이션이 멈추는 느낌이 나면 safepoint를 의심합니다.
- 편향 락, 디옵티마이즈, 클래스 언로딩, JIT 컴파일 타이밍 등
-Xlog:safepoint로 관측 가능
이때는 다음도 점검합니다.
- 과도한 동적 프록시/리플렉션 사용
- 런타임 바이트코드 생성 라이브러리의 남용
5) GC 튜닝: G1GC 기준으로 p99를 깎는 실전 옵션
G1GC는 “목표 pause time” 기반이지만, 목표를 너무 공격적으로 잡으면 오히려 throughput이 떨어져 큐가 쌓이고 tail이 늘 수 있습니다. 기본에서 시작하되, 아래 옵션을 단계적으로 실험합니다.
5.1 MaxGCPauseMillis는 과하게 낮추지 않기
JAVA_TOOL_OPTIONS="-XX:MaxGCPauseMillis=200"
- 50ms 같은 값으로 무리하게 낮추면 GC가 자주 돌 수 있습니다.
- p99가 1초 단위로 튄다면, 200ms 수준에서 분포를 먼저 안정화시키는 편이 낫습니다.
5.2 InitiatingHeapOccupancyPercent로 Mixed GC 타이밍 조절
JAVA_TOOL_OPTIONS="-XX:InitiatingHeapOccupancyPercent=30"
- 기본값보다 낮추면 더 일찍 마킹을 시작해 “늦게 한 번 크게”를 피할 수 있습니다.
- 단, 너무 낮추면 백그라운드 GC 비용이 늘 수 있어 GC 로그로 확인해야 합니다.
5.3 Humongous allocation 줄이기: 객체 크기와 버퍼 전략
G1에서 큰 객체는 별도 취급되어 단편화와 회수 지연으로 tail을 만들 수 있습니다. 해결은 옵션보다 코드/설계 쪽이 큽니다.
- 큰 배열/버퍼를 요청마다 새로 만들지 말고 풀링(주의: 풀링 자체가 락 경합을 만들 수 있음)
- JSON 파싱 시 스트리밍 파서 고려
- 압축/암호화는 chunk 단위 처리
6) Netty 튜닝: 이벤트 루프, 버퍼, 백프레셔
WebFlux(기본 Netty)에서 p99가 튄다면, Netty 설정과 “블로킹 격리”가 핵심입니다.
6.1 이벤트 루프 스레드 수를 고정해 변동성 줄이기
CPU 코어 수 대비 이벤트 루프가 과소/과대면 tail이 커질 수 있습니다.
JAVA_TOOL_OPTIONS="-Dreactor.netty.ioWorkerCount=4 -Dreactor.netty.ioSelectCount=1"
ioWorkerCount는 보통 CPU 코어 수 근처에서 시작- 너무 늘리면 컨텍스트 스위칭이 증가해 p99가 나빠질 수 있습니다.
6.2 블로킹 작업은 반드시 별도 스케줄러로 격리
예: 파일 I/O, JDBC, 무거운 암호화/압축, 큰 JSON 변환 등
Mono<Response> mono = Mono.fromCallable(() -> blockingCall())
.subscribeOn(Schedulers.boundedElastic());
이 격리가 안 되어 있으면 “대부분의 요청은 빠른데 일부 요청이 이벤트 루프를 오래 점유”하며 p99가 폭증합니다.
6.3 커넥션 풀/백프레셔: 다운스트림이 느리면 p99는 반드시 튄다
서버가 느린 게 아니라 DB나 외부 API가 느린 경우가 더 흔합니다.
- HikariCP의
maximumPoolSize를 올리기 전에, DB의max_connections및 쿼리 성능을 먼저 확인 - 리액티브 HTTP 클라이언트 풀도 같은 원리
DB 레플리케이션 지연이나 스토리지 병목이 tail을 만들기도 합니다. 관련해서는 RDS PostgreSQL replication lag 폭증 원인·해결도 함께 참고하면 원인 분리가 빨라집니다.
7) Spring Boot 레벨에서 할 수 있는 설정들
7.1 서블릿 스택(Tomcat)에서 p99를 흔드는 큐잉 줄이기
Tomcat은 스레드 풀과 accept 큐가 tail에 직접 영향을 줍니다.
server:
tomcat:
threads:
max: 200
min-spare: 20
accept-count: 100
max-connections: 8192
threads.max를 무작정 올리면 컨텍스트 스위칭과 락 경합으로 p99가 악화될 수 있습니다.- “CPU 사용률이 낮은데 p99가 높다”면 스레드가 부족해 큐잉 중일 수 있고
- “CPU 사용률이 높은데 p99가 높다”면 과도한 동시성으로 경쟁 중일 수 있습니다.
7.2 관측성: Micrometer 히스토그램을 p99 친화적으로
Prometheus histogram을 쓴다면, 서비스 특성에 맞는 버킷을 둬야 p99가 안정적으로 계산됩니다.
management:
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
slo:
http.server.requests: 50ms,100ms,200ms,500ms,1s,2s,5s
버킷이 너무 성기면 p99가 “계단식”으로 튀어 보입니다.
8) 재현 가능한 튜닝 플로우(권장 순서)
- p99 폭증 시점의 GC 로그와
safepoint로그를 확보 - 같은 시점에 스레드 덤프 3장 확보
- (WebFlux면) 이벤트 루프 블로킹 여부 확인
- 다운스트림(DB/외부 API) 지연과 풀 대기 여부 확인
- 힙 크기 고정(
-Xms-Xmx동일) 후 변동성 제거 - G1 옵션은
MaxGCPauseMillis와InitiatingHeapOccupancyPercent부터 소폭 조정 - Netty 워커 수는 CPU에 맞게 고정하고, 블로킹은
boundedElastic로 격리
여기까지 하면 “p99가 왜 튀는지”는 대부분 설명 가능합니다. 그 다음은 서비스 특성에 따라 코드 레벨 최적화(대형 객체 생성 감소, 직렬화 비용 감소, 락 경합 제거, 풀 고갈 방지)로 들어가면 됩니다.
9) 운영에서 바로 써먹는 예시 구성
9.1 Docker/Kubernetes에서 추천 시작점
JAVA_TOOL_OPTIONS="
-XX:ActiveProcessorCount=4
-XX:MaxRAMPercentage=75
-XX:InitialRAMPercentage=75
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50M
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=30
-Dreactor.netty.ioWorkerCount=4
"
이 상태로 1~2일 관측한 뒤, GC pause 분포와 p99 상관관계를 보고 다음 액션을 정합니다.
- GC pause와 p99가 강하게 동조하면 힙/할당 패턴/GC 옵션을 더 파고들고
- 동조하지 않으면 스레드 풀 큐잉, 다운스트림 지연, 이벤트 루프 블로킹을 우선 의심합니다.
10) 마무리: p99는 “한 군데”가 아니라 “연쇄”로 커진다
p99 폭증은 단일 원인보다 연쇄 반응인 경우가 많습니다.
- 작은 GC 지연이 스레드 풀 큐를 만들고
- 큐잉이 타임아웃과 재시도를 부르고
- 재시도가 더 큰 부하를 만들어
- 결국 tail이 폭발합니다.
따라서 튜닝도 단일 옵션으로 해결하려 하기보다, 관측(로그/덤프)으로 병목을 특정하고, 동시성 모델(서블릿 스레드 또는 Netty 이벤트 루프)에서 “막히는 지점”을 제거하는 방식이 가장 빠릅니다.
추가로, 외부 API 호출이 많고 429나 스로틀링이 섞이면 p99는 더 쉽게 튑니다. 그런 경우에는 AWS Bedrock Claude InvokeModel 429·Throttling 해결처럼 재시도/백오프/쿼터 관점도 함께 점검하세요.