- Published on
Spring Boot 3 가상스레드로 P99 지연 50% 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 지연을 줄일 때 평균(latency avg)보다 더 중요한 지표가 P99입니다. 사용자 경험을 망치는 것은 “가끔” 터지는 느린 요청이고, 이 느린 꼬리(tail)가 P99를 끌어올립니다. Spring Boot 3는 JDK 21 기반에서 가상스레드(virtual threads)를 비교적 손쉽게 도입할 수 있고, 블로킹 I/O가 많은 웹 애플리케이션이라면 P99를 큰 폭으로 낮출 여지가 있습니다.
이 글은 “가상스레드를 켜면 빨라진다” 같은 단정이 아니라, 어떤 조건에서 P99가 내려가고, 어떤 부분에서 오히려 악화될 수 있는지까지 포함해 재현 가능한 형태로 정리합니다.
- 목표: P99 지연 50% 감소를 현실적으로 달성하는 접근
- 전제: Spring MVC(서블릿) 기반, DB/외부 API 호출 등 블로킹 I/O가 지연의 주된 원인
- 핵심: 스레드 고갈(thread starvation)과 큐잉(queueing)을 줄여 tail을 깎는다
관련해서 가상스레드 적용 후 TPS가 떨어지는 경우도 흔합니다. “왜 TPS가 떨어졌는지”까지 포함한 체크리스트는 아래 글이 도움이 됩니다.
P99가 왜 올라가는가: 스레드 풀 큐잉이 만드는 tail
Spring MVC 기본 모델은 “요청 1개 = 플랫폼 스레드 1개”에 가깝습니다. 요청 처리 중 DB 쿼리나 외부 HTTP 호출처럼 블로킹이 발생하면 해당 스레드는 대기 상태로 묶입니다. 동시 요청이 늘면 결국 다음 현상이 생깁니다.
- 톰캣 작업 스레드가 블로킹으로 점점 점유된다
- 남은 스레드가 줄어들면서 신규 요청이 큐에서 대기한다
- 평균은 그럭저럭이어도, 큐 대기가 길어진 일부 요청이 tail을 만든다
- 그 결과 P95/P99가 급격히 상승한다
가상스레드는 “블로킹 동안 플랫폼 스레드를 점유하지 않도록” 설계된 경량 스레드입니다. 즉, 블로킹 I/O가 많은 서비스에서 플랫폼 스레드 고갈로 인한 큐잉을 줄여 tail을 깎는 데 유리합니다.
다만, 모든 블로킹이 자동으로 이득이 되는 것은 아닙니다. 예를 들어 DB 커넥션 풀 크기가 작거나, 외부 API가 병목이면 스레드만 늘려서는 오히려 더 많은 동시 요청이 한꺼번에 병목에 몰려 P99가 악화될 수도 있습니다. 그래서 “가상스레드 + 병목 자원 풀 튜닝”이 같이 가야 합니다.
어떤 워크로드에서 P99 50% 감소가 현실적인가
다음 조건이 맞을수록 P99 50% 감소가 충분히 현실적입니다.
- 요청 처리 시간이 “CPU 계산”보다 “대기(I/O wait)” 비중이 높다
- 톰캣 스레드 풀이 꽉 차는 구간에서 P99가 급등한다
- 스레드 덤프에서
WAITING또는 소켓/DB 대기 상태가 많다 - 외부 API 호출이 있고, 타임아웃/재시도 정책이 제대로 설정되어 있다
반대로 다음이면 가상스레드만으로는 P99가 잘 안 내려갑니다.
- CPU 바운드(압축, 암호화, 이미지 처리, 대규모 JSON 변환 등)
- DB 커넥션 풀이 이미 병목이며 쿼리 자체가 느리다
- 동시성 증가가 다운스트림(rate limit, 큐, 락 경합)을 악화시킨다
외부 API 쪽 병목이 강한 경우에는 재시도 패턴과 타임아웃 설계가 tail에 큰 영향을 줍니다. 특히 429 같은 제한 응답을 다루는 방식에 따라 P99가 널뛰기합니다.
Spring Boot 3에서 가상스레드 활성화
가장 단순한 시작은 Spring Boot 3.2+에서 제공하는 설정을 켜는 것입니다.
1) 설정으로 활성화
application.yml 예시는 다음과 같습니다.
spring:
threads:
virtual:
enabled: true
이 설정은 Spring이 관리하는 실행기(executor) 및 웹 요청 처리에서 가상스레드를 활용할 수 있게 합니다. 다만 실제로 어디까지 적용되는지는 애플리케이션 구성(서블릿 컨테이너, 비동기 처리 방식, 스케줄러, 커스텀 executor 사용 여부)에 따라 달라집니다.
2) JDK 버전 확인
가상스레드는 JDK 21에서 안정적으로 사용합니다. 컨테이너/런타임이 JDK 21인지 먼저 확인하세요.
java -version
출력에 21 계열이 아니면 기대한 효과가 나오지 않거나, 아예 기능을 못 씁니다.
“P99 50% 감소”를 측정 가능하게 만드는 방법
가상스레드는 성능 “체감”이 아니라 숫자로 확인해야 합니다. 특히 P99는 트래픽 패턴에 민감하므로 다음 원칙을 추천합니다.
1) 동일 부하에서 A/B 비교
- A: 기존 플랫폼 스레드 기반
- B: 가상스레드 활성화
- 같은 트래픽 리플레이(가능하면 프로덕션 샘플)
- 같은 인프라(노드 수, CPU, 메모리, JVM 옵션)
2) 지표는 최소 3종을 함께 본다
latency P50/P95/P99throughput(TPS)error rate및timeout rate
P99만 내려가고 에러가 늘면 “지연이 줄었다”가 아니라 “느린 요청이 실패로 바뀌었다”일 수 있습니다.
3) Micrometer로 퍼센타일 기록
Spring Boot Actuator + Micrometer를 사용 중이라면 HTTP 서버 요청 지표에 퍼센타일을 활성화합니다.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
distribution:
percentiles:
http.server.requests: 0.5,0.95,0.99
percentiles-histogram:
http.server.requests: true
프로메테우스/그라파나로 시각화하면, 가상스레드 전환 직후 “tail이 깎이는 구간”이 명확히 보입니다.
P99를 실제로 깎는 핵심 튜닝 포인트 6가지
가상스레드는 “스레드가 부족해서 생기는 큐잉”을 줄이는 도구입니다. 하지만 병목이 다른 곳에 있으면 tail은 그대로입니다. 아래는 P99를 50% 수준으로 줄일 때 실제로 같이 손봐야 하는 지점들입니다.
1) DB 커넥션 풀 크기와 대기시간 관측
가상스레드를 켜면 동시에 더 많은 요청이 “DB 커넥션을 기다리는 상태”로 몰릴 수 있습니다. 이때 P99는 톰캣 큐 대신 커넥션 풀 대기로 이동합니다.
- HikariCP의 풀 크기만 키우는 것은 위험합니다(DB가 버티는지 확인 필요)
- 중요한 것은 “풀 대기시간”과 “쿼리 시간”을 분리해 보는 것입니다
HikariCP 관련 핵심 설정 예시는 다음과 같습니다.
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 1000
validation-timeout: 500
connection-timeout을 무작정 크게 두면 느린 요청이 오래 살아남아 tail을 더 키우기도 합니다. 타임아웃을 짧게 두고 빠르게 실패시키는 것이 P99 관점에서는 유리한 경우가 많습니다(단, 비즈니스 요구사항과 재시도 설계가 받쳐줘야 합니다).
2) 외부 HTTP 호출: 타임아웃과 커넥션 풀
외부 API 호출이 있는 서비스는 가상스레드 적용 후 동시 호출 수가 늘어나고, 다운스트림이 느리면 tail이 폭발할 수 있습니다. 다음을 반드시 점검하세요.
- connect/read timeout이 설정되어 있는가
- HTTP 클라이언트의 커넥션 풀/최대 동시 요청 제한이 있는가
- 재시도는 지수 백오프와 지터(jitter)를 사용하는가
Spring의 RestClient 또는 WebClient를 쓰더라도, 결국은 “타임아웃과 풀”이 tail을 결정합니다.
3) 동기 락 경합: synchronized 와 좁은 임계구역
가상스레드는 많이 만들 수 있지만, 락 경합이 있으면 그 경합이 곧 P99가 됩니다.
- 전역 캐시 갱신
- 단일 큐/단일 맵에 대한 동기화
- 로깅/메트릭에 과도한 동기화
임계구역을 줄이거나, 락을 분할(sharding)하거나, lock-free 구조를 고려해야 합니다.
4) “가상스레드에 올리면 안 되는” CPU 바운드 작업 분리
가상스레드는 I/O 대기에 강점이 있지만, CPU를 많이 쓰는 작업을 무제한으로 동시에 돌리면 런큐(run queue)가 늘고 컨텍스트 스위칭 비용이 커져 P99가 악화될 수 있습니다.
CPU 바운드 작업은 별도의 제한된 executor로 격리하는 것이 안전합니다.
import java.util.concurrent.*;
@Configuration
class ExecutorsConfig {
@Bean
ExecutorService cpuBoundExecutor() {
int n = Math.max(2, Runtime.getRuntime().availableProcessors());
return Executors.newFixedThreadPool(n);
}
}
그리고 CPU 바운드 구간에서는 해당 executor로 작업을 보내 동시성을 제한합니다.
5) 톰캣 설정: 큐를 줄이고 빠르게 실패시키기
가상스레드를 쓰더라도, 프런트 도어에서 무한정 대기시키면 tail이 남습니다.
acceptCount(대기 큐) 과도하게 크면 P99가 길어질 수 있음- 적정 수준에서 빠르게 503으로 떨어뜨리고, 클라이언트 재시도 정책과 맞추는 전략도 고려
application.yml 예시입니다.
server:
tomcat:
threads:
max: 200
accept-count: 100
connection-timeout: 2s
주의할 점은, 가상스레드가 활성화되면 “max threads를 크게”가 정답이 아닐 수 있다는 것입니다. 목표는 스레드 수가 아니라 tail을 만드는 큐잉을 줄이는 것입니다.
6) 관측성: 스레드/락/풀 대기를 분해해서 본다
P99를 50% 줄였다고 말하려면 “어디에서 줄었는지”가 설명되어야 합니다.
- 톰캣 큐 대기 감소인가
- DB 커넥션 대기 감소인가
- 외부 호출 타임아웃/재시도 패턴 개선인가
실무에서는 이 분해가 안 되면, 다음 배포에서 P99가 다시 튀어도 원인을 못 찾습니다.
실전 적용 시나리오: 블로킹 I/O가 많은 API 서버
전형적인 예를 들어보겠습니다.
- 엔드포인트:
GET /api/orders/{id} - 처리: DB 조회 2회 + 외부 결제 상태 조회 1회
- 증상: 피크 시간에 P99가 1.2s까지 상승, P50은 80ms 수준
- 관측: 톰캣 스레드 풀 사용률이 90% 이상, 큐 대기 증가
이 경우 가상스레드를 적용하면, 블로킹 동안 플랫폼 스레드를 점유하지 않으므로 “스레드 고갈로 인한 큐잉”이 줄어들 가능성이 큽니다. 결과적으로 P99가 1.2s에서 600ms 수준으로 줄어드는 그림이 나올 수 있습니다.
하지만 여기서 끝이 아닙니다. 동시에 외부 결제 API 호출이 증가하면서 429나 타임아웃이 늘면, P99는 다시 올라갑니다. 그래서 다음이 같이 들어가야 합니다.
- 외부 API 타임아웃을 300ms~800ms 같은 현실적인 값으로 제한
- 재시도는 제한적으로, 지수 백오프+지터 적용
- 서킷 브레이커로 느린 다운스트림 격리
이 조합이 맞으면 “큐에서 기다리던 시간이 사라지고, 느린 다운스트림은 빨리 실패하거나 격리”되어 P99 tail이 깎입니다.
자주 하는 실수와 체크리스트
가상스레드 도입 후 “P99는 좋아졌는데 다른 게 망가지는” 패턴이 많습니다. 아래를 배포 전 체크리스트로 권합니다.
- JDK 21 런타임이 맞는가
- DB 커넥션 풀 대기시간을 지표로 보고 있는가
- 외부 HTTP 타임아웃이 모두 설정되어 있는가
- 재시도는 무한 재시도가 아닌가(특히 동기 호출에서)
- CPU 바운드 작업은 별도 제한된 executor로 격리했는가
- 락 경합 지점(
synchronized, 단일 세마포어 등)이 없는가 - 부하 테스트에서 P99, TPS, 에러율을 함께 비교했는가
TPS 하락이나 예기치 않은 병목 전이에 대한 더 구체적인 원인 목록은 아래 글을 함께 참고하는 것이 좋습니다.
결론: 가상스레드는 “tail을 만드는 큐”를 없애는 도구다
Spring Boot 3의 가상스레드는 블로킹 I/O 중심 서비스에서 P99를 크게 낮출 수 있는 강력한 옵션입니다. 특히 스레드 풀 고갈로 인한 큐잉이 tail의 주범이라면, P99 50% 감소는 과장이 아니라 충분히 가능한 목표입니다.
다만 성능은 병목의 이동으로 나타납니다. 가상스레드로 톰캣 큐를 줄이면, 다음 병목은 DB 커넥션 풀, 외부 API 제한, 락 경합, CPU 바운드 구간으로 이동합니다. 따라서 “가상스레드 활성화”는 시작일 뿐이고, 병목 자원 풀과 타임아웃/재시도, 락 구조, CPU 격리까지 함께 설계해야 P99가 안정적으로 내려갑니다.
다음 단계로는 부하 테스트에서 “P99가 내려간 이유”를 지표로 분해해 설명할 수 있도록, 커넥션 풀 대기와 외부 호출 지연을 대시보드에 올려두는 것을 추천합니다.