- Published on
Spring Boot 3 가상스레드 후 지연 폭증 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스레드가 부족해서 느린 줄 알았는데, Spring Boot 3에서 가상스레드(virtual thread)를 켠 뒤 p95, p99 지연이 오히려 튀는 경우가 있습니다. 가상스레드는 "스레드 수" 문제를 크게 완화하지만, 블로킹이 어디서 발생하는지, 어떤 풀과 큐가 병목인지, 동시성 폭증이 외부 의존성을 어떻게 압박하는지를 정리하지 않으면 지연은 더 나빠질 수 있습니다.
이 글은 "가상스레드 적용 후 지연 폭증"을 실무에서 가장 자주 만나는 원인 7가지로 분해하고, 각각을 어떻게 확인하고 어떻게 고치는지까지 연결합니다.
가상스레드의 기대효과와 착각
가상스레드는 OS 스레드(플랫폼 스레드)를 적게 쓰면서도 많은 동시 요청을 처리할 수 있게 해줍니다. 특히 블로킹 I/O(DB, HTTP 호출, 파일 I/O 등)가 많을 때 효과가 큽니다.
하지만 다음 착각이 자주 발생합니다.
- "가상스레드면 무조건 빨라진다"가 아니라, 스레드 대기 비용을 줄여 더 많은 동시성을 허용합니다.
- 동시성이 늘면 외부 시스템(DB, Redis, downstream API)의 한계에 더 빨리 닿습니다.
- 일부 블로킹은 가상스레드에서도 **carrier(플랫폼 스레드) 고정(pinning)**을 유발하거나, 다른 풀(커넥션 풀, HTTP 풀, 락)로 병목이 이동합니다.
즉, 가상스레드는 병목을 "없애는" 게 아니라 병목의 위치를 이동시키는 경우가 많습니다.
적용 방식 체크: 진짜로 가상스레드가 쓰이고 있나
우선 "켰다"고 생각했는데 실제로는 일부만 적용되는 경우가 많습니다.
Spring Boot 3 설정
Spring Boot 3.2+ 기준으로 Tomcat/Jetty/Undertow 등 서블릿 스택에서 다음 설정을 사용합니다.
spring:
threads:
virtual:
enabled: true
그리고 로그/스레드 덤프에서 요청 처리 스레드 이름이나 타입을 확인합니다. 가상스레드는 보통 VirtualThread로 보입니다.
log.info("thread={}", Thread.currentThread());
또한 MVC 컨트롤러 내부에서 Thread.currentThread().isVirtual()로도 확인 가능합니다.
@GetMapping("/health/thread")
public String thread() {
return "isVirtual=" + Thread.currentThread().isVirtual();
}
여기서 false가 나오면, 실제 요청 처리가 가상스레드가 아닐 수 있습니다(프록시/필터/서블릿 컨테이너 설정 미적용 등).
원인 1) DB 커넥션 풀 고갈: 동시성만 늘고 병목은 Hikari에 걸림
가상스레드를 켜면 동시에 더 많은 요청이 "DB 호출 직전"까지 도달합니다. 그러면 가장 흔하게 HikariCP 풀 대기가 폭증합니다.
- 증상: p95, p99 지연 급증, 타임아웃 증가
- 로그:
Connection is not available, request timed out또는 Hikari pool wait 증가
확인 방법
Micrometer를 쓰면 다음 지표를 봅니다.
hikaricp.connections.activehikaricp.connections.pendinghikaricp.connections.timeout
pending이 급증하면 가상스레드가 DB를 더 빨리 압박하는 상황입니다.
해결 방향
풀을 무턱대고 키우기 전에 DB가 감당 가능한지 확인합니다.
트랜잭션 범위를 줄여 커넥션 점유 시간을 줄입니다.
요청당 쿼리 수를 줄이거나 N+1을 제거합니다.
필요하면 풀을 조정하되, 애플리케이션 인스턴스 수와 DB max connection을 함께 계산합니다.
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 2000
가상스레드 환경에서는 "스레드 수"가 병목이 아니므로, 커넥션 풀은 더 쉽게 병목이 됩니다. 이 현상은 Kubernetes에서 리소스가 부족할 때 "Pending"이 늘어나는 것과 유사하게, 대기열이 다른 곳으로 이동한 케이스입니다. (리소스 병목 관점은 EKS Pod Pending - Insufficient cpu·taint 해결 글의 접근법과도 닮았습니다.)
원인 2) Downstream HTTP 커넥션 풀/동시성 제한: Apache HttpClient, OkHttp, Reactor Netty
외부 API를 호출하는 클라이언트는 대부분 커넥션 풀과 동시성 제한을 갖습니다.
- Apache HttpClient:
maxConnTotal,maxConnPerRoute - OkHttp: dispatcher의
maxRequests,maxRequestsPerHost - WebClient(Reactor Netty): connection pool, pending acquire queue
가상스레드로 요청이 많이 들어오면, 애플리케이션은 더 많은 동시 호출을 시도하고 HTTP 풀 획득 대기가 늘어 지연이 폭증합니다.
OkHttp 예시: 제한이 낮으면 대기가 곧 지연
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(200);
dispatcher.setMaxRequestsPerHost(100);
OkHttpClient client = new OkHttpClient.Builder()
.dispatcher(dispatcher)
.build();
해결 방향
- 풀/동시성 제한을 "무작정" 올리기 전에 downstream의 QPS/동시성 한계를 확인
- 호출에 타임아웃을 명확히 설정하고, 실패 시 빠르게 폴백
- bulkhead(격벽)로 기능별 동시성 상한을 둬서 한 기능 폭주가 전체 지연을 망치지 않게 함
원인 3) 동시성 폭증으로 인한 GC/메모리 압박: 더 많은 in-flight가 더 많은 객체를 만든다
가상스레드 자체는 가볍지만, 요청 처리 중 생성되는 객체는 그대로입니다. 동시 요청 수가 늘면 다음이 늘어납니다.
- 요청/응답 바디 버퍼
- JSON 직렬화/역직렬화 객체
- ORM 엔티티, 컬렉션
- MDC/로그 이벤트
그 결과 GC pause가 늘고 tail latency가 튈 수 있습니다.
확인 방법
- GC 로그에서 pause time 증가
jvm.gc.pause(Micrometer)jvm.memory.used가 톱니처럼 크게 출렁
해결 방향
- 요청당 할당량을 줄이기: 큰 DTO/엔티티 변환 최소화
- 로깅을 구조화하되 payload 전체를 남기지 않기
- 필요 시 힙/GC 튜닝보다 먼저 "동시 처리량 상한"을 둬서 in-flight를 제한
원인 4) carrier 스레드 pinning: synchronized, native call, 일부 I/O 패턴
가상스레드는 블로킹 시 언마운트되어 carrier 스레드를 양보하는 게 핵심입니다. 그런데 특정 상황에서는 가상스레드가 carrier에 **고정(pinning)**되어, 플랫폼 스레드가 부족해지고 지연이 폭증할 수 있습니다.
대표적인 원인:
synchronized블록 안에서 블로킹 I/O- 일부 네이티브 호출, 오래 잡는 모니터 락
나쁜 예: 락 안에서 블로킹
private final Object lock = new Object();
public String bad() throws Exception {
synchronized (lock) {
// 여기서 DB/HTTP 같은 블로킹이 발생하면 pinning 위험
Thread.sleep(100);
return "ok";
}
}
개선 예: 락 범위를 최소화
public String better() throws Exception {
// 락은 상태 업데이트처럼 짧게
synchronized (lock) {
// quick state change
}
// 블로킹은 락 밖에서
Thread.sleep(100);
return "ok";
}
확인 방법
- JDK Flight Recorder(JFR)에서 virtual thread pinning 이벤트 확인
- 스레드 덤프에서 carrier 스레드가 특정 모니터에 오래 묶여 있는지 확인
원인 5) 서블릿 비동기/스프링 비동기와의 조합 문제: @Async, DeferredResult, WebFlux 혼합
가상스레드를 켜면 "MVC는 가상스레드로 충분"한데도 기존에 쓰던 @Async나 별도 executor를 그대로 유지해 스레드 홉이 늘어날 수 있습니다.
- 요청 스레드(virtual) →
@Asyncexecutor(플랫폼 스레드 풀) → 다시 합류 - 컨텍스트 전파, MDC 복사 비용 증가
- executor 큐 대기 때문에 지연 증가
점검 포인트
@Async가 정말 필요한지(병렬 처리 목적 vs 단순 블로킹 회피)TaskExecutor가 작은 풀/짧은 큐로 설정되어 병목이 되는지
개선 방향
- MVC + 가상스레드라면 "블로킹 I/O는 그냥 호출"하고, 진짜 병렬화만 남깁니다.
- 꼭
@Async가 필요하면 executor도 가상스레드 기반으로 명시합니다.
@Bean
Executor taskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
원인 6) 타임아웃/재시도 정책이 동시성에서 증폭: 느려질수록 더 느려지는 피드백 루프
가상스레드로 동시성이 늘면, downstream이 느려지는 순간 다음 루프가 생깁니다.
- 응답이 느려짐
- 타임아웃 직전까지 대기하는 요청이 늘어 in-flight 증가
- 커넥션 풀/큐 대기가 늘어 더 느려짐
- 재시도까지 켜져 있으면 트래픽이 2배, 3배로 증폭
해결 방향
- 타임아웃을 "긴 단일 타임아웃" 하나로 두지 말고 단계별로 설정
- connect timeout
- read timeout
- overall deadline
- 재시도는 멱등성, 에러 타입, 지터를 엄격히 제한
- bulkhead + rate limit으로 폭주를 차단
원인 7) 관측 공백: 어디서 기다리는지 모르면 가상스레드가 원인처럼 보인다
가상스레드 적용 후 지연이 튀면 "가상스레드가 문제"라고 결론내리기 쉽지만, 대부분은 대기 지점이 바뀐 것입니다.
필수로 갖춰야 할 관측:
- HTTP 서버: 요청 수, 처리 시간 히스토그램, in-flight
- DB: 커넥션 풀 pending/active, 쿼리 latency
- HTTP 클라이언트: pool acquire time, downstream latency
- JVM: GC pause, allocation rate
- 스레드/락: JFR로 pinning, monitor contention
이런 접근은 프론트엔드에서 렌더링 폭증의 원인을 캐시/메모이제이션/워터폴로 쪼개 진단하는 방식과도 비슷합니다. 병목을 "가상스레드"로 뭉뚱그리지 말고, 단계별로 분해해 측정해야 합니다. 참고로 진단 사고방식은 Next.js 14 RSC fetch waterfall 끊는 캐시·prefetch 최적화 같은 글에서 설명하는 "워터폴 분해"와도 통합니다.
재현 가능한 체크리스트: 지연 폭증을 빠르게 좁히는 순서
1) 지연이 늘어난 구간을 나눈다
- 서버 내부 처리 시간 vs 외부 호출 시간
- DB vs HTTP downstream vs 내부 락
Spring MVC라면 인터셉터/필터에서 전체 시간을 재고, 외부 호출 구간도 별도 타이머로 측정합니다.
long t0 = System.nanoTime();
try {
return downstream.call();
} finally {
long ms = (System.nanoTime() - t0) / 1_000_000;
metrics.timer("downstream.latency").record(ms, TimeUnit.MILLISECONDS);
}
2) 풀 대기(pending)가 있는지 본다
- Hikari pending
- HTTP pool pending acquire
- executor queue size
pending이 보이면, 가상스레드는 "원인"이 아니라 "압박을 더 빨리 드러낸 트리거"일 가능성이 큽니다.
3) pinning/락 경합을 본다
- JFR pinning 이벤트
- monitor contention
4) 동시 처리량 상한을 둬서 안정화한다
가상스레드는 무한 동시성을 의미하지 않습니다. 외부 시스템이 감당하는 수준으로 in-flight를 제한해야 tail latency가 안정화됩니다.
예: 세마포어로 기능별 동시성 제한
Semaphore bulkhead = new Semaphore(50);
public String callWithLimit() throws Exception {
if (!bulkhead.tryAcquire(10, TimeUnit.MILLISECONDS)) {
throw new RuntimeException("busy");
}
try {
return downstream.call();
} finally {
bulkhead.release();
}
}
권장 아키텍처 패턴: 가상스레드 시대의 "적정 동시성"
정리하면, Spring Boot 3 가상스레드 적용 후 지연 폭증은 보통 아래 중 하나입니다.
- 스레드 병목이 사라지며 DB/HTTP 커넥션 풀 병목이 표면화
- 동시성 증가로 메모리/GC가 tail latency를 오염
synchronized와 블로킹이 섞여 carrier pinning 발생@Async등 기존 비동기 구조가 남아 불필요한 큐/홉 추가- 타임아웃/재시도가 폭주를 증폭
가상스레드의 핵심 운영 원칙은 간단합니다.
- 블로킹 I/O는 가상스레드에서 자연스럽게 처리하되
- 외부 의존성에는 bulkhead/timeout으로 "적정 동시성"을 강제하고
- 풀 대기와 pinning을 관측해 병목을 수치로 확인한다
이 3가지만 지키면, 가상스레드는 "지연 폭증의 원인"이 아니라 지연을 낮추고 용량 계획을 단순화하는 도구로 돌아옵니다.