- Published on
Spring Boot 3 가상스레드 전환으로 스루풋 2배 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스루풋이 안 나오는 Spring Boot 서비스의 전형적인 패턴은 단순합니다. 요청 수가 늘면 CPU는 한가한데 응답 시간이 튀고, 스레드 덤프를 뜨면 WAITING 과 TIMED_WAITING 이 쌓이며, http-nio-* 워커 스레드가 부족해 큐잉이 발생합니다. 이때 문제는 “CPU가 부족”한 게 아니라 “블로킹 I/O를 기다리는 플랫폼 스레드가 부족”한 경우가 많습니다.
Spring Boot 3(정확히는 Java 21)에서 가상스레드(virtual thread)를 쓰면, 블로킹 호출을 하더라도 커널 스레드(플랫폼 스레드)를 오래 점유하지 않고 가볍게 파킹/언파킹되며 대량 동시성을 더 낮은 비용으로 처리할 수 있습니다. 잘 맞는 워크로드에서는 스루풋이 2배(혹은 그 이상)까지도 현실적으로 가능합니다. 다만 “그냥 켜면 끝”은 아니고, DB 풀/외부 API/관측/부하테스트까지 같이 손봐야 진짜 성능이 나옵니다.
이 글에서는 Spring Boot 3에서 가상스레드로 전환해 스루풋을 끌어올리는 과정을 체크리스트 형태로 정리합니다.
가상스레드가 잘 먹히는 조건
가상스레드는 “블로킹이 많은 요청-응답” 모델에서 특히 효과가 큽니다.
잘 맞는 경우
- JDBC, REST 클라이언트, SDK 호출 등 블로킹 I/O가 많은 서비스
- 트래픽이 증가할수록 Tomcat 워커 스레드가 바닥나며 큐잉이 생기는 서비스
- CPU 사용률은 30~60%인데 p95/p99가 치솟는 패턴
기대만큼 안 나오는 경우
- CPU 바운드(압축/암호화/대규모 JSON 변환/이미지 처리) 위주
- 락 경쟁이 심한 공유 상태(전역 캐시, synchronized, 단일 커넥션 등)
- DB가 이미 병목(슬로우 쿼리, 인덱스 부재, 잠금 경합)인 경우
가상스레드는 “스레드 비용”을 줄여줄 뿐 “DB가 느린 문제”를 해결하진 않습니다. 오히려 동시성이 늘어 DB에 더 많은 압력이 가면서 병목이 더 빨리 드러날 수 있습니다. DB 커넥션 풀 고갈은 특히 흔한 함정이니, 아래 내부 글도 같이 보면 좋습니다.
전환 전: 병목이 “스레드”인지 확인하는 5분 점검
전환 전에 아래를 빠르게 확인하면 헛수고를 줄일 수 있습니다.
- 스레드 수가 상한에 닿는지
- Tomcat이면
maxThreads근처까지 올라가며 대기 큐가 생기는지
- Tomcat이면
- CPU가 충분히 남는지
- CPU 100%면 가상스레드로도 스루풋이 크게 오르기 어렵습니다
- p95/p99가 “계단형”으로 튀는지
- 스레드가 부족해 큐잉이 생기면 지연이 급격히 증가합니다
- 스레드 덤프에서 블로킹 대기가 많은지
- JDBC, HTTP 클라이언트, 락 대기가 주로 보이면 후보입니다
- DB 풀 대기 시간이 있는지
- 가상스레드 전환 후 더 악화될 수 있으니 반드시 같이 봅니다
Spring Boot 3에서 가상스레드 켜기
가상스레드는 Java 21이 필요합니다. 런타임부터 맞추세요.
- JDK: 21
- Spring Boot: 3.2+ 권장(3.1도 가능하지만 운영 안정성 측면에서 최신 권장)
1) 가장 간단한 설정: spring.threads.virtual.enabled
Spring Boot는 가상스레드 기반 Executor를 쉽게 켤 수 있습니다.
spring:
threads:
virtual:
enabled: true
이 설정은 애플리케이션 전반에서 사용하는 일부 Executor에 가상스레드를 적용합니다. 하지만 “웹 요청 처리 스레드”가 실제로 가상스레드가 되려면 서블릿 컨테이너(Tomcat 등) 설정도 함께 봐야 합니다.
2) Tomcat에서 요청 처리에 가상스레드 적용
Spring Boot 3.2+에서는 Tomcat이 가상스레드를 사용하도록 커스터마이징할 수 있습니다. 핵심은 Tomcat의 Executor를 Executors.newVirtualThreadPerTaskExecutor() 로 바꾸는 것입니다.
import org.apache.catalina.Executor;
import org.apache.catalina.core.StandardThreadExecutor;
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.Executors;
@Configuration
public class TomcatVirtualThreadConfig {
@Bean
TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreads() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}
주의할 점은 “Tomcat의 플랫폼 스레드 튜닝(maxThreads)로 해결하던 문제를 가상스레드로 바꾸는 것”이지, 무한 동시성을 공짜로 얻는 게 아니라는 점입니다. DB, 외부 API, 레이트리밋, 다운스트림 용량은 그대로이므로 동시성 폭증이 장애로 이어질 수 있습니다.
3) 요청이 진짜 가상스레드에서 도는지 확인
컨트롤러에서 스레드 정보를 로그로 찍어 확인할 수 있습니다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ThreadCheckController {
private static final Logger log = LoggerFactory.getLogger(ThreadCheckController.class);
@GetMapping("/thread")
public String thread() {
Thread t = Thread.currentThread();
log.info("name={} isVirtual={} state={}", t.getName(), t.isVirtual(), t.getState());
return "ok";
}
}
운영에서는 매 요청 로그는 부담이니, 샘플링하거나 특정 엔드포인트에서만 확인하세요.
스루풋 2배를 만들려면: 같이 튜닝해야 하는 4가지
가상스레드 전환만으로 스루풋이 오르긴 하지만, 2배 수준을 안정적으로 만들려면 보통 아래를 같이 만집니다.
1) HikariCP 풀 크기: “늘리기”보다 “대기 제거”가 목표
가상스레드로 동시 요청이 늘면 DB 커넥션 대기가 먼저 터질 수 있습니다. 이때 단순히 maximumPoolSize 를 키우면 DB가 버티지 못하고 전체가 느려질 수 있습니다.
권장 접근은 다음 순서입니다.
- 슬로우 쿼리/인덱스/락 경합 먼저 해결
- 풀 대기 시간(
hikaricp.connections.acquire)이 p95/p99에서 의미 있게 발생하는지 확인 - DB가 허용하는 동시 커넥션 수와 쿼리 비용을 고려해 풀 크기를 조정
예시 설정:
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 1000
validation-timeout: 500
connection-timeout 을 너무 길게 두면, 커넥션이 부족할 때 요청이 오래 매달려 지연 꼬리가 커집니다. 가상스레드 환경에서는 “대기열이 더 많이 생길 수” 있으므로 타임아웃을 보수적으로 두고 빠르게 실패시키는 설계도 고려해야 합니다.
2) 외부 API 호출: 동시성 폭증에 대한 안전장치
가상스레드는 외부 API 호출을 “더 많이 동시에” 날릴 수 있게 해줍니다. 하지만 다운스트림이 그 동시성을 감당하지 못하면 레이트리밋과 장애가 빨라집니다.
- Bulkhead(격리) 적용: 외부 시스템별 동시 호출 상한
- Timeout/Retry/Backoff 재설계
- Circuit Breaker로 장애 전파 차단
Resilience4j로 bulkhead를 두는 예:
import io.github.resilience4j.bulkhead.Bulkhead;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import java.time.Duration;
Bulkhead bulkhead = Bulkhead.of(
"payment",
BulkheadConfig.custom()
.maxConcurrentCalls(50)
.maxWaitDuration(Duration.ofMillis(0))
.build()
);
레이트리밋 재시도 전략은 아래 글의 설계 포인트가 그대로 적용됩니다.
3) 관측: 스레드가 아니라 “대기 원인”을 계측
가상스레드로 바꾸면 스레드 수 자체는 의미가 달라집니다. 대신 아래 지표를 중심으로 봐야 합니다.
- HTTP 서버
- RPS, p50/p95/p99, 오류율, 큐잉 여부
- DB
- 커넥션 풀 acquire time, active/idle, timeout count
- 쿼리 latency 분포
- 외부 호출
- endpoint별 latency/timeout/429 비율
Micrometer + Actuator 기준으로는 Hikari 메트릭을 반드시 대시보드에 올리세요. “가상스레드 전환 후 빨라졌다”가 아니라 “DB acquire p99가 사라졌다”처럼 원인 기반으로 검증해야 합니다.
4) 부하테스트: 같은 조건에서 A/B 비교
전환 효과를 보려면 부하테스트가 필수입니다.
- 동일한 배포 아티팩트(가상스레드 on/off만 차이)
- 동일한 데이터/캐시 워밍
- 동일한 트래픽 패턴(점증 부하, 스파이크)
- 측정 지표: RPS, p95/p99, 에러율, DB acquire time
k6 예시:
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
scenarios: {
ramp: {
executor: 'ramping-vus',
startVUs: 10,
stages: [
{ duration: '1m', target: 200 },
{ duration: '2m', target: 200 },
{ duration: '1m', target: 400 },
],
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<300'],
},
};
export default function () {
http.get('https://your-service.example.com/api/orders');
sleep(0.1);
}
여기서 중요한 건 “가상스레드로 스루풋이 늘었다”가 아니라, 늘어난 RPS에서 p95/p99와 실패율이 어떻게 변하는지, 그리고 DB/외부호출 대기시간이 어디로 이동했는지를 같이 보는 것입니다.
실전 튜닝 시나리오: 스루풋 2배가 나오는 전형적인 흐름
현장에서 자주 나오는 흐름을 요약하면 이렇습니다.
- 기존(플랫폼 스레드)에서
maxThreads를 올려도 한계가 있음- 컨텍스트 스위칭 비용 증가, 메모리 증가, 여전히 큐잉
- 가상스레드 전환 후 RPS가 즉시 상승
- 하지만 DB 풀 대기/외부 API 429가 새 병목으로 등장
- DB 쿼리 최적화 + HikariCP 풀을 “DB가 감당 가능한 범위”로 재조정
- acquire timeout을 짧게, 실패를 빨리 드러내며 보호
- 외부 API bulkhead/timeout/circuit breaker 적용
- 같은 부하 조건에서 p95/p99가 안정화되며 스루풋이 2배 근접
핵심은 “스레드 병목 제거” 다음에 “다운스트림 병목을 안전하게 드러내고 다듬는 과정”입니다.
주의사항: 가상스레드 전환 시 자주 터지는 함정
블로킹을 숨긴 라이브러리/드라이버
대부분의 블로킹 I/O는 가상스레드에서 잘 동작하지만, 일부 드라이버나 네이티브 호출이 스레드를 오래 붙잡는 경우가 있습니다. 전환 후에도 p99가 이상하게 높다면 해당 구간의 스택 트레이스를 확인하세요.
ThreadLocal 기반 컨텍스트
가상스레드는 요청마다 스레드가 바뀌는 모델과 다르게 보일 수 있고, 프레임워크가 ThreadLocal 컨텍스트를 어떻게 전파하는지에 따라 이슈가 생길 수 있습니다. MDC, 트레이싱 컨텍스트는 프레임워크 권장 방식으로 유지하세요.
동시성 증가로 인한 락 경합
이전에는 스레드가 적어서 안 보이던 락 경합이, 동시성이 늘면서 드러날 수 있습니다.
- synchronized 블록 최소화
- 전역 캐시 갱신 경로에서 락 분산
- DB 트랜잭션 범위 축소
체크리스트: 운영 적용 전 마지막 점검
- JDK 21, Spring Boot 3.2+로 런타임 정렬
- 웹 요청 처리 스레드가 실제로 가상스레드인지 검증
- HikariCP acquire time/p99, timeout count 대시보드 구성
- 외부 API bulkhead/timeout/circuit breaker 적용
- 부하테스트로 A/B 비교 및 회귀 확인
- 장애 시 롤백 플래그(가상스레드 off)를 쉽게 준비
마무리
Spring Boot 3의 가상스레드는 “블로킹 I/O 중심” 서비스에서 가장 비용 대비 효과가 큰 성능 카드 중 하나입니다. 다만 성능이 오르는 만큼 동시성이 늘어 병목이 DB/외부 API로 이동하기 때문에, 커넥션 풀과 다운스트림 보호, 관측 체계를 함께 손보는 것이 스루풋 2배 튜닝의 핵심입니다.
가상스레드 전환을 고려 중이라면, 먼저 현재 병목이 스레드인지 확인하고, 전환 후에는 HikariCP 대기와 다운스트림 레이트리밋을 1순위로 관찰하면서 단계적으로 올리는 방식으로 접근해 보세요.