- Published on
Spring Boot 3.2 가상스레드로 TPS 2배 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 TPS가 한계에 부딪힐 때, 많은 팀이 먼저 떠올리는 건 스케일 아웃이나 캐시입니다. 하지만 Spring Boot 3.2(기반: JDK 21)에서는 가상 스레드(virtual threads) 만으로도, 특히 I/O 바운드 API에서 TPS를 크게 끌어올릴 수 있습니다.
이 글은 “가상 스레드 켜면 빨라진다” 수준이 아니라, TPS 2배를 목표로 어떤 조건에서 효과가 나고, 어디서 망가지며, 무엇을 반드시 측정해야 하는지까지 실전 관점으로 정리합니다.
왜 가상 스레드가 TPS를 올리나
전통적인 서블릿 기반(Spring MVC, Tomcat) 요청 처리는 보통 플랫폼 스레드(커널 스레드) 를 요청 처리 단위로 사용합니다. 요청이 DB/외부 API 호출 같은 블로킹 I/O에서 대기하면, 그 동안 스레드는 놀고 있지만 스레드 풀의 슬롯은 점유됩니다.
가상 스레드는 다음을 바꿉니다.
- 블로킹 I/O 대기 시, 가상 스레드는 캐리어 스레드(플랫폼 스레드) 를 점유하지 않고 양보할 수 있음
- 결과적으로 같은 수의 플랫폼 스레드로도 동시에 더 많은 요청을 처리 가능
- 스레드 수를 과도하게 늘리지 않아도 되므로 컨텍스트 스위칭/메모리 부담이 줄어듦
즉, 병목이 CPU가 아니라 대기 시간(특히 DB/네트워크 I/O) 인 경우에 TPS 상승 폭이 큽니다.
“TPS 2배”가 가능한 조건 체크
가상 스레드로 효과를 보려면 아래 조건을 먼저 확인하세요.
1) 워크로드가 I/O 바운드인가
- DB 쿼리 응답이 느리거나
- 외부 HTTP 호출이 많거나
- 파일/메시지 브로커 I/O 대기가 많다면
가상 스레드가 유리합니다.
반대로 다음이면 TPS 2배가 어렵습니다.
- CPU 바운드(대부분이 JSON 직렬화/암호화/이미지 처리/복잡한 계산)
- GC 압박이 이미 큰 상태(객체 폭증)
2) “동시성 부족”이 병목인가
스레드 풀 고갈/대기열 증가가 보이면 가능성이 큽니다.
- Tomcat
maxThreads근처에서 고정 - 요청 지연이 계단식으로 증가
- APM에서
thread pool queue증가
3) DB 락/데드락이 병목이면 오히려 악화 가능
가상 스레드로 동시성이 늘면 DB에 더 많은 동시 요청이 들어가서 락 경합이 커질 수 있습니다. 락/데드락이 의심되면 먼저 트랜잭션 경계와 쿼리를 점검하세요.
- MySQL이라면: MySQL InnoDB Deadlock 로그로 원인 SQL 찾기
- PostgreSQL이라면: PostgreSQL RDS deadlock_detected(40P01) 원인·해결
Spring Boot 3.2에서 가상 스레드 적용 방법
가장 단순한 적용은 Spring Boot 설정 한 줄입니다.
1) Spring MVC(서블릿)에서 가상 스레드 활성화
application.yml:
spring:
threads:
virtual:
enabled: true
이 설정은 Spring이 내부적으로 요청 처리나 @Async 실행 등에 가상 스레드를 활용할 수 있게 해줍니다(사용 방식은 구성에 따라 달라질 수 있으므로 반드시 부하 테스트로 확인).
2) 명시적으로 Executor를 가상 스레드로 구성하기
특정 비동기 작업(예: 외부 API fan-out)을 확실히 가상 스레드로 돌리고 싶다면, 직접 Executor를 만드세요.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
@Configuration
public class VirtualThreadConfig {
@Bean
public Executor virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
그리고 @Async에 지정합니다.
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class PartnerClient {
@Async("virtualThreadExecutor")
public void callPartner() {
// 블로킹 I/O(HTTP/DB 등) 호출이 있는 작업에 특히 유리
}
}
주의: 가상 스레드는 “무한정 빠름”이 아니라, 동시성 증가로 인해 하위 시스템(DB, 외부 API, 커넥션 풀) 이 먼저 터질 수 있습니다.
TPS 2배 튜닝의 핵심: 스레드가 아니라 “풀”을 다시 맞추기
가상 스레드를 켜고 TPS가 오르다가 어느 순간 더 안 오르거나, 오히려 오류가 늘어나는 패턴이 흔합니다. 이유는 대부분 다른 풀(pool)이 병목이기 때문입니다.
1) DB 커넥션 풀(HikariCP) 재조정
가상 스레드로 요청 동시성이 늘면, DB 커넥션 풀이 먼저 포화됩니다.
대표 증상:
- 응답 시간 증가와 함께
HikariPool-... - Connection is not available류 메시지 - APM에서 DB 대기 시간이 급증
기본적으로는 다음 원칙을 권합니다.
- 커넥션 풀을 무작정 크게 늘리기보다
- DB가 감당 가능한 동시 쿼리 수, 인덱스 상태, 락 경합을 함께 고려
예시 설정:
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 2000
max-lifetime: 1800000
여기서 중요한 건 숫자 자체가 아니라, 부하 테스트로 “DB가 감당 가능한 최대 동시성”을 찾는 것입니다.
PostgreSQL을 쓰고 있고 테이블 bloat로 인해 쿼리가 느려져 있다면, 가상 스레드로 동시성만 올려서는 TPS가 잘 안 오릅니다. 먼저 상태를 정리하세요.
2) HTTP 클라이언트 풀(외부 API 호출)
외부 API 호출이 많다면, 클라이언트 커넥션 풀도 병목이 됩니다.
예를 들어 Apache HttpClient 또는 OkHttp를 쓴다면 다음을 확인하세요.
- 최대 커넥션 수
- 라우트별 커넥션 수
- 타임아웃
WebClient(리액티브)를 쓴다고 해서 무조건 해결되는 문제는 아닙니다. 외부 시스템이 느리면 결국 대기 시간이 생기고, 동시성이 늘면 외부 호출 수가 폭증할 수 있습니다.
3) Tomcat/Jetty 스레드 설정은 “줄이는” 쪽으로도 검토
가상 스레드를 쓰는데도 Tomcat 플랫폼 스레드를 과도하게 크게 잡으면, 오히려 스케줄링/메모리 면에서 손해가 날 수 있습니다.
다만 이 부분은 서비스 특성에 따라 달라서, 아래처럼 단계적으로 접근하는 게 안전합니다.
- 1단계: 가상 스레드 활성화 후, 기존
maxThreads유지 - 2단계: 목표 TPS 달성 여부 확인
- 3단계: CPU 사용률/스케줄링/컨텍스트 스위칭 관찰하며
maxThreads최적화
실전 측정 시나리오: “2배”를 증명하는 방법
가상 스레드 적용은 체감이 아니라 수치로 증명해야 합니다. 다음 시나리오로 측정하세요.
1) 부하 테스트 구성
- 동일한 테스트 스크립트(JMeter, k6 등)
- 동일한 데이터 셋 및 캐시 조건
- 동일한 인프라 스펙
측정 지표:
- TPS(RPS)
- p95/p99 latency
- 에러율(타임아웃, 5xx)
- DB 커넥션 풀 대기 시간
2) 비교군 2개만 명확히
- A: 기존(플랫폼 스레드)
- B: 가상 스레드 활성화
나머지 튜닝(커넥션 풀, 타임아웃)은 “B에서 터지는 병목을 해소하기 위한 최소 조정”만 하되, 변경 사항을 모두 기록합니다.
3) 병목이 어디서 이동하는지 확인
가상 스레드를 켜면 병목이 보통 다음 순서로 이동합니다.
- 웹 서버 스레드 풀 고갈
- DB 커넥션 풀 고갈
- DB 락/슬로우 쿼리
- 외부 API rate limit
- CPU/GC
이 흐름을 이해하면 “TPS가 안 오르는데요?” 상황에서 원인 파악이 빨라집니다.
자주 터지는 함정 5가지
1) 동시성 증가로 DB 락 경합 폭발
동시에 더 많이 때리면 락 경합이 커집니다. 특히 다음 패턴이 위험합니다.
- 넓은 트랜잭션 범위
- Aggregate 경계가 커서 한 row/테이블에 업데이트 집중
이 경우 가상 스레드는 증폭기일 뿐입니다. 트랜잭션을 쪼개거나, 업데이트 경로를 분산하세요.
2) 타임아웃이 없거나 너무 김
가상 스레드로 “대기하는 요청”을 많이 수용할 수 있게 되면, 타임아웃이 긴 호출이 시스템 자원을 오래 붙잡습니다.
- DB 쿼리 타임아웃
- HTTP client connect/read 타임아웃
- 서킷 브레이커/벌크헤드
을 반드시 설정하세요.
3) 관측(Observability) 비용 증가
동시 요청 수가 늘면 로그/트레이싱도 함께 늘어납니다.
- INFO 로그 과다
- JSON 로그 직렬화 비용
- 분산 트레이싱 샘플링 과소/과다
이 비용이 CPU를 잡아먹으면 TPS 이득이 줄어듭니다.
4) 동기 블로킹을 “리액티브”로 착각
가상 스레드는 블로킹 코드를 더 싸게 돌리는 것이지, 무한 확장을 보장하지 않습니다. 하위 시스템이 느리면 결국 전체 지연이 커집니다.
5) 인증/필터 체인에서 병목
요청 수가 늘면 필터 체인의 비용도 그대로 늘어납니다. JWT 검증, DB 조회 기반 인증 등이 있다면 먼저 최적화하세요.
예제: 블로킹 I/O API를 가상 스레드로 안정화하기
아래는 “주문 조회 API”에서 DB 조회와 외부 배송 상태 조회(HTTP)를 함께 하는 전형적인 I/O 바운드 예시입니다.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
private final OrderRepository orderRepository;
private final ShippingStatusClient shippingStatusClient;
public OrderController(OrderRepository orderRepository,
ShippingStatusClient shippingStatusClient) {
this.orderRepository = orderRepository;
this.shippingStatusClient = shippingStatusClient;
}
@GetMapping("/orders/{id}")
public OrderView get(@PathVariable("id") long id) {
var order = orderRepository.findById(id)
.orElseThrow();
// 블로킹 HTTP 호출이라고 가정
var shipping = shippingStatusClient.getStatus(order.getTrackingNo());
return new OrderView(order.getId(), order.getAmount(), shipping);
}
}
이 API는 전통적인 플랫폼 스레드 모델에서는 동시 요청이 늘수록 스레드가 배송 API 대기에서 묶이기 쉽습니다. 가상 스레드를 적용하면 “대기 중인 요청을 더 많이 수용”할 수 있어 TPS 상승이 가능합니다.
단, 그 다음 병목은 보통 다음 중 하나로 이동합니다.
- 배송 API rate limit
- HTTP 커넥션 풀 부족
- DB 커넥션 풀 부족
따라서 가상 스레드 적용과 함께 타임아웃/리트라이/서킷 브레이커를 반드시 설계해야 합니다.
체크리스트: TPS 2배 달성을 위한 단계별 가이드
- 부하 테스트로 현재 병목이 스레드 풀인지 확인
spring.threads.virtual.enabled적용 후 동일 조건으로 재측정- 에러율이 늘면 DB/HTTP 커넥션 풀 포화 여부부터 확인
- DB 락/데드락 징후가 있으면 트랜잭션/쿼리 구조 개선
- 타임아웃/서킷 브레이커/벌크헤드로 대기 요청 상한 설정
- p95/p99 지연과 TPS를 함께 보고, “TPS만 오른 상태”를 경계
결론
Spring Boot 3.2의 가상 스레드는 특히 I/O 바운드 서비스에서 “스레드 풀 고갈”을 완화해 TPS를 크게 끌어올릴 수 있는 강력한 옵션입니다. 다만 TPS가 2배가 되는 순간, 병목은 웹 스레드에서 DB 커넥션 풀, 락 경합, 외부 API 제한, 관측 비용으로 이동합니다.
가상 스레드는 만능이 아니라 동시성을 더 싸게 제공하는 도구입니다. 도구를 제대로 쓰려면, 부하 테스트로 병목 이동을 추적하고, 풀과 타임아웃을 재설계하는 것이 핵심입니다.