- Published on
Spring Boot 3 가상 스레드로 TPS 2배 올리기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 TPS를 올리는 가장 흔한 방법은 더 빠른 알고리즘, 캐시, 쿼리 튜닝, 수평 확장입니다. 그런데 Spring MVC 기반의 전통적인 동기 처리 모델에서는, 결국 요청 하나당 플랫폼 스레드(커널 스레드) 하나를 오래 붙잡는 구조가 병목이 되기 쉽습니다. 특히 외부 API 호출, DB I/O, 파일 I/O처럼 대기 시간이 긴 블로킹 작업이 섞이면, CPU는 놀고 있는데 스레드가 모자라서 TPS가 막히는 상황이 자주 발생합니다.
Spring Boot 3(정확히는 Java 21의 Project Loom 기반)에서 제공하는 가상 스레드(virtual thread)는 이 문제를 정면으로 겨냥합니다. 핵심은 "블로킹 코드를 유지하면서도" 동시성을 크게 늘릴 수 있다는 점입니다. 이 글에서는 Spring Boot 3에서 가상 스레드를 적용해 TPS를 2배 수준으로 끌어올릴 때의 접근법을, 설정과 벤치마킹, 그리고 반드시 같이 만져야 하는 DB 풀/외부 호출/관측 포인트까지 묶어서 설명합니다.
가상 스레드가 TPS를 올리는 원리
플랫폼 스레드의 비용
플랫폼 스레드는 OS 스레드에 1:1로 매핑됩니다. 그래서 다음과 같은 비용이 큽니다.
- 스레드 생성/컨텍스트 스위칭 비용
- 스레드 스택 메모리(기본 수 MB 단위)로 인한 메모리 압박
- 동시 요청이 늘면 스레드 풀 큐 대기가 늘고, tail latency가 급증
동기 Spring MVC에서 흔한 구성은 Tomcat의 요청 처리 스레드 풀이며, 기본적으로 요청은 이 풀의 스레드를 점유한 채로 DB/외부 I/O를 기다립니다.
가상 스레드의 핵심: 블로킹을 "싼" 것으로 만들기
가상 스레드는 JVM이 관리하는 경량 스레드입니다. 블로킹 I/O에서 가상 스레드는 캐리어(carrier) 플랫폼 스레드를 점유하지 않고, 적절한 지점에서 파킹(parking)되어 다른 가상 스레드가 캐리어를 사용할 수 있습니다.
정리하면:
- 코드 스타일은 동기(블로킹) 그대로 유지 가능
- 동시성을 크게 늘려도 OS 스레드가 폭증하지 않음
- I/O 대기 시간이 많은 워크로드에서 TPS와 지연이 개선될 여지가 큼
다만 "모든 상황에서 무조건 2배"는 아닙니다. CPU 바운드 작업이 대부분이거나, DB 커넥션 풀이 이미 병목이면 효과가 제한됩니다. 즉, 가상 스레드는 "스레드 부족"을 해결해주지만 "DB, 락, 외부 시스템" 병목은 그대로입니다.
적용 전 체크리스트: 내 병목이 스레드인가?
가상 스레드 적용 전에 아래를 확인하면 성공 확률이 높습니다.
- 트래픽 증가 시 CPU 사용률이 낮은데 TPS가 더 안 오름
- Tomcat
maxThreads를 올리면 TPS가 오르지만 메모리/컨텍스트 스위칭이 급증 - APM에서 요청 시간의 상당 부분이 DB/HTTP 호출 대기
- 스레드 덤프에서 RUNNABLE보다 WAITING/TIMED_WAITING 상태가 많음
반대로 다음 케이스는 가상 스레드만으로는 한계가 큽니다.
- DB 커넥션 풀이 꽉 차서 대기(예: Hikari pool wait time 증가)
- JPA N+1, 비효율 쿼리로 DB가 병목
- synchronized/락 경합이 심함
특히 JPA 쿼리 병목이 섞여 있다면 가상 스레드 적용과 함께 쿼리 최적화를 병행하는 편이 좋습니다. 관련해서는 Spring Boot 3 JPA N+1 폭발을 끝내는 법도 같이 참고하면 전체 TPS가 더 잘 오릅니다.
Spring Boot 3에서 가상 스레드 켜는 방법
1) Java 21 사용
가상 스레드의 안정적인 사용을 위해 Java 21 LTS를 권장합니다.
- 로컬/CI/JDK 모두 Java 21로 통일
- 컨테이너라면 base image도 21 기반으로
2) 가장 쉬운 설정: Spring Boot 프로퍼티
Spring Boot 3.2+에서는 다음 설정으로 요청 처리에 가상 스레드를 사용할 수 있습니다.
spring:
threads:
virtual:
enabled: true
이 설정은 Spring MVC의 요청 처리 스레드(서블릿 컨테이너 요청 스레드)와 @Async 실행 등에서 가상 스레드 기반 실행기를 사용하도록 돕습니다. 단, 사용 중인 내장 컨테이너(Tomcat/Jetty/Undertow) 버전과 조합에 따라 동작 범위가 다를 수 있으니, 적용 후 반드시 실제 스레드 이름과 스레드 덤프로 확인하세요.
3) 명시적으로 Executor 구성(권장)
서비스 내에서 별도 스레드 풀을 사용하거나, @Async를 많이 쓰는 경우에는 명시적으로 가상 스레드 실행기를 주입하는 편이 안전합니다.
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 applicationTaskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
그리고 @Async를 쓴다면:
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class ReportService {
@Async
public void generateReport() {
// 블로킹 I/O가 있어도 가상 스레드에서 처리되도록 유도
}
}
주의할 점은, 기존에 ThreadPoolTaskExecutor로 큐잉/스로틀링을 하던 곳을 무작정 가상 스레드로 바꾸면 "너무 많은 동시 작업"이 한 번에 몰릴 수 있다는 점입니다. 가상 스레드는 싸지만 무한정 공짜는 아니며, 특히 DB/외부 API를 과도하게 때리면 downstream이 먼저 터집니다.
TPS 2배를 만들려면 같이 손봐야 하는 것들
가상 스레드로 요청을 더 많이 동시에 처리할 수 있게 되면, 다음 병목이 앞으로 당겨집니다. 여기서 튜닝을 같이 해야 TPS가 의미 있게 상승합니다.
1) DB 커넥션 풀(HikariCP) 재점검
가상 스레드의 대표적인 함정은 "스레드가 많아졌으니 DB도 더 빠르겠지"라는 착각입니다.
- DB 커넥션 풀은 여전히 제한 자원
- 동시 요청이 늘면 커넥션 대기가 늘어 tail latency가 악화될 수 있음
점검 포인트:
- 풀 사이즈(
maximumPoolSize)를 CPU 코어 수만큼 단순히 늘리지 말고, DB가 감당 가능한 QPS와 쿼리 비용 기준으로 결정 connectionTimeout이 자주 터지면 풀 병목이거나 누수 가능성- Micrometer로 pool metrics를 반드시 수집
예시 설정:
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 2000
max-lifetime: 1800000
풀을 올리기 전에 쿼리 최적화를 먼저 하는 것이 정석입니다. N+1이나 불필요한 eager 로딩이 있으면, 가상 스레드로 동시성을 늘린 만큼 DB가 더 빨리 병목이 됩니다.
2) 외부 HTTP 호출은 타임아웃과 커넥션 풀을 명확히
외부 API 호출이 블로킹이라면 가상 스레드와 궁합이 좋습니다. 하지만 다음을 하지 않으면 동시 요청이 늘면서 장애가 더 커질 수 있습니다.
- connect/read timeout 필수
- HTTP 클라이언트 커넥션 풀 사이즈 점검
- 재시도는 제한적으로(지수 백오프, 서킷 브레이커)
Java HttpClient 예시:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class ExternalCaller {
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(300))
.build();
public String call() throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api"))
.timeout(Duration.ofSeconds(1))
.GET()
.build();
return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
}
3) 동기 락(synchronized)과 ThreadLocal 남용 줄이기
가상 스레드는 동시성을 크게 늘리므로, 락 경합이 있으면 더 빨리 드러납니다.
synchronized구간이 긴지 확인- 전역 캐시 갱신, 싱글톤 상태 변경 로직이 병목인지 확인
- ThreadLocal 기반 컨텍스트가 과도하게 커지지 않는지 확인
특히 "요청당 ThreadLocal에 큰 객체를 넣는" 패턴은 동시 요청이 늘수록 메모리 압박이 커집니다.
벤치마크로 TPS 2배를 검증하는 방법
가상 스레드는 "적용했다"가 아니라 "측정해서 이득이 있는지"가 중요합니다. 다음 순서로 측정하면 재현성이 좋습니다.
1) 워크로드를 두 가지로 나누기
- I/O 바운드 엔드포인트: DB 조회 + 외부 API 호출 포함
- CPU 바운드 엔드포인트: JSON 파싱, 암호화, 압축 등
가상 스레드는 보통 I/O 바운드에서 이득이 큽니다.
2) k6로 부하 테스트
예시 k6 스크립트:
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
scenarios: {
steady: {
executor: 'constant-arrival-rate',
rate: 500,
timeUnit: '1s',
duration: '2m',
preAllocatedVUs: 200,
maxVUs: 2000,
},
},
};
export default function () {
http.get('http://localhost:8080/api/orders');
sleep(0.01);
}
측정 지표:
- TPS(성공 응답 기준)
- p95, p99 latency
- 에러율(타임아웃, 5xx)
- DB 커넥션 풀 대기 시간
3) JVM/스레드 관측 포인트
가상 스레드 적용 후에는 플랫폼 스레드 수가 과도하게 늘지 않는지 확인해야 합니다.
jcmd로 스레드/클래스/GC 상태 확인- APM에서 "스레드 풀 큐 대기"가 줄었는지 확인
- GC 빈도와 힙 사용량 변화 확인
운영 환경이 Kubernetes라면, 동시성이 늘면서 메모리/FD 사용량이 변할 수 있으니 리소스 리밋과 종료 시그널 처리도 같이 점검하세요. 장애 상황에서 Pod가 종료되지 않고 매달리는 케이스는 Azure AKS에서 Pod가 Terminating에 멈출 때 해결법처럼 런타임 종료 훅/프리스트롭과도 연결됩니다.
실전 구성 예시: MVC + JPA에서 안전하게 올리기
아래는 "가상 스레드로 동시성 확장"과 "DB 병목 방지"를 같이 고려한 전형적인 접근입니다.
spring.threads.virtual.enabled로 요청 처리 가상 스레드 활성화- Hikari 풀 메트릭 수집 및 풀 사이즈 보수적으로 조정
- 느린 쿼리/빈번 쿼리부터 튜닝(N+1 제거, 인덱스, 불필요한 트랜잭션 범위 축소)
- 외부 호출 타임아웃/서킷 브레이커 적용
- k6로 동일 조건 A/B 테스트(가상 스레드 off vs on)
JPA 트랜잭션 범위 예시(불필요하게 길지 않게):
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderQueryService {
private final OrderRepository orderRepository;
public OrderQueryService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional(readOnly = true)
public OrderDto getOrder(Long id) {
var order = orderRepository.findById(id)
.orElseThrow();
return OrderDto.from(order);
}
}
트랜잭션이 길어지면 DB 커넥션 점유 시간이 늘고, 가상 스레드로 동시 요청을 늘릴수록 커넥션 풀이 먼저 고갈될 수 있습니다.
자주 겪는 문제와 해결 방향
TPS는 올랐는데 p99가 나빠졌다
- 원인: DB 풀 대기, 외부 API 타임아웃 누락, 락 경합
- 대응: 풀 메트릭에서 wait time 확인, 타임아웃 설정, 병목 구간 프로파일링
CPU가 오히려 올라갔다
- 원인: 동시성이 늘면서 JSON 직렬화/역직렬화, 로깅, 암호화 등 CPU 작업이 병렬로 더 많이 수행
- 대응: 로깅 샘플링/비동기 로깅, 핫패스 최적화, 필요 시 rate limit
장애 시 연쇄적으로 타임아웃이 늘었다
- 원인: 가상 스레드로 더 많은 요청이 동시에 downstream을 때림
- 대응: bulkhead(격리), 서킷 브레이커, 큐잉/백프레셔 도입
Kubernetes에서 이런 상황이 Ingress 503으로 보일 수도 있으니, 인프라 관점 점검은 EKS Ingress 503인데 Pod 정상일 때 점검 가이드도 함께 보면 원인 분리가 빨라집니다.
결론: 가상 스레드는 "동기 코드의 동시성 확장"을 쉽게 만든다
Spring Boot 3의 가상 스레드는 "동기 MVC + 블로킹 I/O"라는 현실적인 코드베이스에서 TPS를 올리는 가장 비용 대비 효과가 좋은 카드 중 하나입니다. 특히 I/O 대기가 많은 서비스라면, 요청 처리 스레드 부족으로 생기던 큐 대기와 컨텍스트 스위칭 비용을 줄여 TPS를 2배 수준까지도 현실적으로 노려볼 수 있습니다.
다만 성공의 조건은 명확합니다.
- 내 병목이 스레드인지 먼저 확인
- 가상 스레드 적용 후, DB 풀/외부 호출/락 경합이 다음 병목으로 올라오는 것을 전제로 튜닝
- 벤치마크로 p95, p99까지 포함해 검증
이 순서로 접근하면 "가상 스레드를 켰더니 빨라졌다"가 아니라, 운영에서 재현 가능한 성능 개선으로 연결할 수 있습니다.