- Published on
Spring Boot 3 가상스레드 적용 후 p99 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 지연을 평균으로만 보면 가상스레드(virtual thread) 적용 효과가 과대평가되기 쉽습니다. 실제 운영에서 문제는 대부분 p95, p99 같은 꼬리 지연(tail latency)로 나타납니다. Spring Boot 3에서 가상스레드를 켠 뒤 p99가 기대만큼 내려가지 않거나, 오히려 악화되는 케이스도 흔합니다. 이유는 간단합니다. 가상스레드는 스레드 생성/블로킹 비용을 크게 줄여주지만, 외부 I/O, 커넥션 풀, 락, GC, 큐잉, 타임아웃 설계 같은 병목을 자동으로 해결해주지는 않기 때문입니다.
이 글은 Spring Boot 3에서 가상스레드를 적용한 뒤 p99를 목표로 튜닝할 때, 무엇을 어떻게 측정하고 어떤 순서로 손대야 하는지 실전 체크리스트 형태로 정리합니다.
1) p99 튜닝의 출발점: “가상스레드가 빨라지는 구간”을 분리하기
가상스레드가 특히 잘 먹히는 구간은 아래 조건을 만족할 때입니다.
- 요청 처리 중 블로킹 I/O(DB, HTTP, 파일, 메시지 브로커 등) 비중이 큼
- 기존에는 플랫폼 스레드가 부족해 스레드 풀 큐잉이 발생했음
- 동시성 증가로 인해 스레드 수를 늘리면 컨텍스트 스위칭/메모리 문제가 커졌음
반대로 p99가 잘 안 내려가는 전형적인 패턴은 다음과 같습니다.
- DB 커넥션 풀(
HikariCP)이 병목이라 동시 요청이 늘수록 대기열이 길어짐 - 외부 API가 느리거나 간헐적으로 튀어 꼬리 지연을 전염시킴
- 로깅/메트릭/트레이싱이 동기 경로에 붙어
p99에 영향을 줌 - 락 경합(특히 캐시 갱신, 세션/토큰, 싱글톤 상태)이 tail을 만듦
- GC pause가 짧게라도 주기적으로 발생해
p99를 끌어올림
핵심은 “가상스레드를 켜서 동시성은 늘었는데, 병목 자원은 그대로라서 대기만 늘어난다”는 구조적 문제를 먼저 의심하는 것입니다.
2) Spring Boot 3에서 가상스레드 적용 포인트
2.1 Tomcat 기반 MVC에서의 기본 설정
Spring Boot 3.2+ 기준으로 가장 쉬운 방법은 아래 설정입니다.
spring:
threads:
virtual:
enabled: true
이 설정은 기본적으로 요청 처리용 스레드에 가상스레드를 사용하도록 도와줍니다. 다만 실제 효과는 애플리케이션이 어떤 모델을 쓰는지에 따라 다릅니다.
- Spring MVC + Tomcat: 블로킹 I/O 코드가 많을수록 유리
- WebFlux(리액티브): 이미 이벤트 루프 기반이라 가상스레드 효과가 제한적일 수 있음
2.2 @Async, 스케줄러, 커스텀 Executor도 함께 점검
가상스레드를 켰는데도 p99가 안 내려가면, 실제 병목이 요청 스레드가 아니라 @Async나 스케줄러, 내부 작업 큐에서 발생하는 경우가 많습니다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
주의할 점은 “모든 곳에 무작정 가상스레드”가 답이 아니라는 것입니다. CPU 바운드 작업(암호화, JSON 대량 변환, 이미지 처리 등)은 가상스레드로 늘려봤자 CPU 경합만 커져 p99가 악화될 수 있습니다.
3) p99 측정: 평균보다 먼저 “대기 시간”을 쪼개라
p99를 낮추려면 총 시간을 다음처럼 분해해야 합니다.
- 애플리케이션 처리 시간
- DB 대기 시간(커넥션 획득 대기 + 쿼리 실행)
- 외부 API 대기 시간
- 내부 락/큐 대기 시간
- GC/스톱더월드 영향
Micrometer를 쓰고 있다면 최소한 아래 지표는 확인해야 합니다.
http.server.requests의p95,p99hikaricp.connections.pending(커넥션 대기)hikaricp.connections.active,idle,max- JVM GC pause 관련 지표
추가로, 운영에서 로그 폭주가 디스크/IO를 잠식해 tail latency를 만든 경험이 있다면, 시스템 로그 레벨과 저널 설정도 같이 봐야 합니다. (로그가 tail latency를 만드는 케이스는 생각보다 흔합니다.)
4) 가상스레드 적용 후 p99가 악화되는 1순위: DB 커넥션 풀
가상스레드는 “동시 요청을 더 많이 처리할 수 있게” 만들어줍니다. 그런데 DB 커넥션 풀 크기가 그대로면 어떻게 될까요?
- 동시에 더 많은 요청이 DB에 접근 시도
- 커넥션 풀에서 대기(pending)가 증가
- 대기열이 길어져
p99가 상승 - 심하면 타임아웃으로 오류율까지 상승
4.1 HikariCP에서 꼭 확인할 설정
maximumPoolSizeconnectionTimeout- DB 서버의 최대 커넥션, CPU, IOPS
예시:
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 1000
validation-timeout: 500
튜닝 순서 권장:
hikaricp.connections.pending가p99시간대에 같이 치솟는지 확인- pending이 높으면 풀을 늘리기 전에 쿼리 최적화/인덱스/슬로우 쿼리를 먼저 확인
- DB가 여유가 있을 때만 풀을 늘림
- 늘린 뒤에는 DB CPU/락/버퍼캐시 미스가 tail을 만들지 재확인
풀만 늘리면 DB가 버티지 못해 tail latency가 더 커질 수 있습니다. p99는 “애플리케이션이 빨라져서 DB에 더 빨리 몰려온 결과”로 악화되는 경우가 많습니다.
5) 외부 API 호출이 p99를 만들 때: 타임아웃, 벌크헤드, 재시도
가상스레드 환경에서는 블로킹 호출을 더 많이 병렬로 날릴 수 있습니다. 하지만 외부 API의 p99가 나쁘면 그 tail이 그대로 우리 서비스의 p99로 전염됩니다.
5.1 타임아웃을 “짧게, 계층적으로”
- 연결 타임아웃(connect timeout)
- 읽기 타임아웃(read timeout)
- 전체 요청 타임아웃(deadline)
예시(Apache HttpClient 5 기반 RestClient 구성):
@Bean
RestClient restClient() {
var requestConfig = org.apache.hc.client5.http.config.RequestConfig.custom()
.setConnectTimeout(500, java.util.concurrent.TimeUnit.MILLISECONDS)
.setResponseTimeout(800, java.util.concurrent.TimeUnit.MILLISECONDS)
.build();
var httpClient = org.apache.hc.client5.http.impl.classic.HttpClients.custom()
.setDefaultRequestConfig(requestConfig)
.build();
return RestClient.builder()
.requestFactory(new org.springframework.http.client.HttpComponentsClientHttpRequestFactory(httpClient))
.build();
}
5.2 재시도는 p99를 악화시키는 방향으로 작동하기 쉽다
특히 동시성이 커진 상태에서 무분별한 재시도는 외부 시스템과 우리 시스템 모두에 부하를 증폭시켜 tail을 키웁니다. 재시도는 반드시 아래를 포함해야 합니다.
- 지수 백오프
- 지터(jitter)
- 최대 재시도 횟수 제한
- 오류 코드 기반(예:
429,503)으로만 제한적 적용
재시도 설계는 아래 글의 패턴을 그대로 가져와도 좋습니다.
5.3 벌크헤드로 tail 전염 차단
외부 API별로 동시 호출 수를 제한하지 않으면, 느린 외부 API 하나가 전체 스레드(가상스레드라 해도)와 커넥션을 점유해 p99를 끌어올립니다.
Resilience4j의 bulkhead나 세마포어로 “외부 의존성별 동시성 상한”을 두는 것이 효과적입니다.
6) 락 경합과 동기화: 가상스레드에서 더 잘 드러난다
가상스레드로 동시성이 커지면, 이전에는 잘 안 보이던 락 경합이 p99에서 폭발합니다.
체크 포인트:
synchronized로 보호된 캐시 갱신 로직ConcurrentHashMap.computeIfAbsent내부에서 무거운 작업 수행- 싱글톤 리소스(예: 단일 파일, 단일 세션) 접근
- DB에서
SELECT ... FOR UPDATE같은 락 기반 설계
개선 방향:
- 락 범위를 최소화하고, I/O를 락 밖으로 이동
- 캐시 미스 시 “동기 단일 로더” 패턴을 쓰더라도 타임아웃과 실패 전략을 둠
- 가능하면 락 대신 메시지 큐/비동기 파이프라인으로 분리
7) 플랫폼 스레드가 남아있는 구간 찾기: 핀(pin)과 블로킹 포인트
가상스레드는 블로킹 시 캐리어 스레드를 양보(unmount)할 수 있지만, 특정 상황에서는 핀(pin)되어 캐리어 스레드를 붙잡을 수 있습니다. 대표적으로 모니터 락과 특정 네이티브 호출이 섞일 때 문제가 됩니다.
운영에서 의심 신호:
- CPU는 낮은데 처리량이 안 나오고
p99만 늘어남 - 스레드 덤프에서 캐리어 스레드가 특정 구간에 오래 머묾
대응:
- 오래 잡는
synchronized블록 제거 또는 범위 축소 - JDBC 드라이버/네이티브 라이브러리 버전 업데이트
- 관측 도구로 스레드 상태를 확인하고, 블로킹 원인을 코드 레벨로 제거
8) GC와 메모리: p99의 “주기적 스파이크”를 잡아라
가상스레드 자체는 가볍지만, 동시성이 늘면 다음이 같이 늘 수 있습니다.
- 요청당 객체 생성량(특히 JSON 직렬화/역직렬화)
- 버퍼/바이트 배열 할당
- 로깅 이벤트 객체
그 결과 GC가 더 자주 돌거나 pause가 길어져 p99가 스파이크 형태로 튑니다.
실전 팁:
p99타임라인과 GC pause 타임라인을 겹쳐서 상관관계를 먼저 확인- 큰 응답/요청 바디 처리 시 스트리밍, 압축, 제한(업로드/다운로드 상한)을 검토
업로드 바디가 커서 처리 시간이 늘고 tail이 커지는 케이스는 Nginx 단계에서 먼저 잘라내는 것이 더 비용 효율적일 때가 많습니다.
9) p99 튜닝을 위한 부하 테스트 설계: “동시성”보다 “대기열”을 재현
가상스레드 적용 후 성능 테스트를 할 때 흔한 실수는 “동시 사용자 수만 올리고 평균 응답만 본다”입니다. p99를 보려면 다음을 만족해야 합니다.
- 충분한 샘플 수(요청 수가 적으면
p99는 통계적으로 의미가 약함) - 워밍업 구간 분리(캐시/클래스 로딩/JIT)
- DB/외부 API를 실제와 유사하게(가능하면 스테이징 의존성) 구성
- 목표는 처리량이 아니라 대기열이 생기는 지점을 찾는 것
권장 접근:
- 목표
SLO를 먼저 정함 (예:p99250ms) - 요청 비율(읽기/쓰기), 페이로드 크기, 외부 호출 비율을 현실적으로 맞춤
- 부하를 올리며
p99가 꺾이는 지점에서- DB pending
- 외부 API latency
- GC pause
- CPU saturation 을 동시에 확인
10) 적용 순서 체크리스트: 가장 싼 것부터 p99를 깎기
운영에서 효과가 큰 순서로 정리하면 보통 아래 흐름이 안전합니다.
- 관측:
p99를 구간별로 분해(HTTP, DB pending, 외부 호출) - 타임아웃: 외부/DB 타임아웃을 합리적으로 단축하고 데드라인 도입
- 커넥션 풀: Hikari pending이 보이면 쿼리 최적화 후 풀 조정
- 벌크헤드: 느린 의존성이 전체
p99를 오염시키지 않게 격리 - 락 제거: 동기화 구간 축소, 캐시/공유자원 경합 제거
- GC/할당량: 큰 객체/버퍼/로그로 인한 주기적 스파이크 제거
- 가상스레드 확장 적용:
@Async, 스케줄러, 내부 Executor까지 일관성 있게 정리
11) 결론: 가상스레드는 “p99를 깎을 기회”를 만든다
Spring Boot 3 가상스레드는 블로킹 기반 서버에서 동시성 비용을 낮추고, 스레드 풀 큐잉으로 생기던 tail latency를 줄일 강력한 도구입니다. 하지만 적용 직후 p99가 기대만큼 내려가지 않는다면, 그건 실패가 아니라 “진짜 병목이 드러난 것”일 가능성이 큽니다.
p99 튜닝은 결국 대기열을 줄이는 작업입니다. DB 커넥션 풀 대기, 외부 API tail 전염, 락 경합, GC 스파이크 같은 요인을 계측으로 분해하고, 타임아웃과 격리(bulkhead)로 폭발 반경을 줄이면 가상스레드의 장점이 비로소 p99로 환산됩니다.
다음 단계로는, 실제 서비스의 아키텍처(동기 MVC, 혼합형, 배치/비동기 파이프라인)에 맞춰 p99의 1등 기여 요인을 하나씩 제거하는 방식으로 반복 튜닝을 권장합니다.