- Published on
Spring Boot 3.x 가상 스레드로 TPS 2배 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 TPS가 특정 지점에서 더 이상 늘지 않을 때, 의외로 병목은 CPU가 아니라 스레드인 경우가 많습니다. Spring MVC(서블릿) 기반에서 DB/외부 API 호출처럼 블로킹 I/O가 많으면, 요청당 플랫폼 스레드를 오래 점유하게 되고 컨텍스트 스위칭 비용과 스레드 메모리(스택) 부담이 커지면서 처리량이 제한됩니다.
Spring Boot 3.x는 Java 21을 기준으로 가상 스레드(virtual threads)를 공식적으로 활용할 수 있는 길이 열렸고, “블로킹 코드를 크게 바꾸지 않고도” 동시성을 확장해 TPS를 크게 올릴 수 있습니다. 이 글에서는 단순히 설정만 켜는 수준을 넘어, 실제로 TPS를 2배 수준까지 끌어올리기 위해 무엇을 확인하고 어떤 함정을 피해야 하는지 단계별로 정리합니다.
가상 스레드가 TPS를 올리는 메커니즘
가상 스레드는 JVM이 관리하는 경량 스레드입니다. 핵심은 다음 두 가지입니다.
블로킹 시 플랫폼 스레드를 점유하지 않도록 “언마운트(unmount)”
- 가상 스레드가
Socket read,Lock,park같은 블로킹 지점에 들어가면, JVM이 해당 작업을 “대기 상태”로 전환하고 플랫폼 스레드를 반환합니다. - 결과적으로 같은 수의 플랫폼 스레드로 더 많은 동시 요청을 처리할 수 있습니다.
- 가상 스레드가
스레드 생성/전환 비용 감소
- 플랫폼 스레드를 무한히 늘리면 메모리와 스케줄링 비용이 급증합니다.
- 가상 스레드는 생성이 훨씬 저렴하고 대량의 동시성을 현실적으로 다룰 수 있습니다.
즉, CPU가 남아 있는데도 스레드가 부족해서 대기열이 쌓이던 상황에서 효과가 큽니다. 반대로 CPU가 이미 90% 이상 꽉 찬 순수 연산 워크로드라면, 가상 스레드가 TPS를 “마법처럼” 올리지는 않습니다.
어떤 서비스에 특히 효과적인가
다음 조건이 많을수록 TPS 상승 폭이 커집니다.
- Spring MVC + JPA/JDBC + 외부 HTTP 호출 등 블로킹 I/O 비중이 높음
Tomcat maxThreads를 늘려도 TPS가 잘 안 오르고, 지연시간만 증가- 스레드 덤프에서
WAITING,TIMED_WAITING,BLOCKED상태가 많음 - APM에서 DB/외부 API 대기 시간이 큰 비중을 차지
반대로 아래는 “가상 스레드만으로” 해결이 어렵습니다.
- N+1 쿼리, 비효율 쿼리, 인덱스 부재 등 DB 자체 병목
- 커넥션 풀 크기 제한으로 인한 대기
- synchronized 과다 사용, 전역 락 등 락 경합
DB 병목이 의심된다면, 가상 스레드 적용 전에 쿼리/로딩 전략부터 정리하는 편이 효과가 큽니다. 관련해서는 Spring Boot 3 JPA N+1 해결 - EntityGraph·BatchSize도 함께 참고하면 좋습니다.
적용 전 체크리스트 (이거 안 하면 TPS가 안 오른다)
1) Java 21 + Spring Boot 3.2 이상 권장
가상 스레드는 Java 21에서 안정적으로 쓰는 것이 안전합니다. Spring Boot는 3.2부터 가상 스레드 설정이 더 매끄럽습니다.
- 런타임:
Java 21 - Boot:
3.2+권장
2) DB 커넥션 풀(HikariCP) 상한이 실제 병목이 아닌지
가상 스레드로 동시 요청이 늘면, DB 커넥션 풀 대기가 병목으로 “이동”할 수 있습니다.
- 기존에
maximumPoolSize=10같은 값이면, TPS는 금방 커넥션 풀 대기로 막힙니다. - 단, 풀을 무작정 키우면 DB가 먼저 죽습니다. DB가 감당 가능한 동시 연결 수, 쿼리 비용, 락 경합을 함께 봐야 합니다.
3) 외부 API 호출 타임아웃/재시도 정책 정비
동시성이 늘면 외부 시스템에 가해지는 압력도 커집니다. 타임아웃이 길면 “가상 스레드가 많아도” 요청이 오래 쌓여 tail latency가 나빠질 수 있습니다.
- 연결/읽기 타임아웃을 현실적으로 설정
- 재시도는 제한적으로, 지수 백오프 권장
- 서킷 브레이커(Resilience4j) 고려
Spring Boot 3.x에서 가상 스레드 켜는 방법
방법 A: 설정으로 Tomcat 요청 처리에 가상 스레드 적용
Spring MVC(서블릿)에서 가장 간단한 접근입니다.
# application.yml
spring:
threads:
virtual:
enabled: true
이 설정은 “요청을 처리하는 실행 모델”에 가상 스레드를 활용하도록 돕습니다. 다만, 실제 적용 범위는 내장 컨테이너/스프링 버전에 따라 달라질 수 있으니 반드시 부하 테스트로 검증하세요.
방법 B: 명시적으로 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 taskExecutor() {
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로 제한된 풀을 쓰고 있었다면 그 제한이 그대로 병목이 될 수 있다는 것입니다. 가상 스레드의 장점은 “대량 동시성”인데, 기존 고정 크기 풀로 감싸면 효과가 줄어듭니다.
TPS 2배를 노리는 실전 튜닝 포인트
가상 스레드를 켠 뒤 “TPS가 오르긴 하는데 2배까지는 안 간다”면, 보통 아래에서 발목이 잡힙니다.
1) 커넥션 풀 대기: active가 항상 꽉 차는지 확인
가상 스레드 적용 후 가장 흔한 병목 이동입니다.
증상
- TPS는 소폭 상승
- 응답 지연이 증가
- HikariCP metrics에서
pending증가,active가 상한에 고정
대응
- 풀 크기를 DB가 감당 가능한 범위에서 조정
- 쿼리 최적화, 인덱스 보강, 트랜잭션 범위 축소
- 불필요한 Lazy 로딩 제거(N+1 제거)
2) 동기화/락 경합: synchronized와 전역 락 제거
가상 스레드가 많아질수록 락 경합이 더 빨리 드러납니다.
- 전역 캐시 갱신, 단일 Map에 대한 과도한 동기화
- 로깅/메트릭에서 단일 락 사용
가능하면 락 범위를 줄이거나 ConcurrentHashMap, ReadWriteLock, lock-free 구조를 고려하세요.
3) 외부 호출 fan-out: 동시에 너무 많이 때리지 않기
가상 스레드로 동시성이 확 늘면, 외부 API에 대한 동시 호출도 폭증합니다. 외부 시스템이 버티지 못하면 오히려 장애를 키웁니다.
- 세마포어로 동시 호출 상한을 두는 패턴
import java.util.concurrent.Semaphore;
public class ExternalClient {
private final Semaphore bulkhead = new Semaphore(50);
public String call() throws InterruptedException {
bulkhead.acquire();
try {
// HTTP call
return "ok";
} finally {
bulkhead.release();
}
}
}
이건 “가상 스레드의 장점을 포기”하는 게 아니라, 시스템 전체 안정성을 위한 상한선입니다. TPS를 2배로 올리더라도, 외부 의존성이 받쳐주지 못하면 의미가 없습니다.
4) 관측(Observability): 스레드 수가 늘수록 지표가 더 중요
가상 스레드는 스레드 모델이 달라지므로, 예전 감각으로 thread count만 보고 판단하면 오해가 생깁니다.
추천 지표:
- p50/p95/p99 latency
- DB 커넥션 풀
active,pending - GC pause time
- CPU usage, load average
- 에러율(특히 타임아웃)
추가로, 배포 파이프라인에서 설정이 꼬여 성능 테스트 결과가 흔들리는 경우도 많습니다. GitOps를 쓴다면 배포 상태 불일치도 함께 점검하세요. 관련해서는 Argo CD Sync 실패 - OutOfSync 무한 반복 해결도 도움이 됩니다.
벤치마크 설계: “2배”를 증명하는 방법
가상 스레드 적용 전후를 비교하려면, 동일 조건의 부하 테스트가 필수입니다.
1) 워크로드를 분리
- DB만 때리는 API
- 외부 HTTP 호출이 포함된 API
- 둘 다 포함된 API
이렇게 나누면 “어디서 TPS가 늘었는지”가 명확해집니다.
2) 목표는 TPS만이 아니라 tail latency
가상 스레드는 동시성을 크게 늘릴 수 있지만, 무제한 동시성은 tail latency를 악화시키기도 합니다.
- TPS 2배 + p99가 3배면 운영에서는 실패일 수 있습니다.
3) 예시: 간단한 부하 테스트 커맨드
wrk 기준 예시입니다.
wrk -t8 -c400 -d60s --latency http://localhost:8080/api/orders
-c400같은 높은 동시성에서 플랫폼 스레드 기반은 쉽게 한계가 오고,- 가상 스레드 적용 시 대기열이 줄면서 TPS가 유의미하게 오르는 패턴이 자주 나옵니다.
흔한 함정과 운영 팁
함정 1) “가상 스레드면 WebFlux보다 무조건 낫다”는 오해
WebFlux는 논블로킹 체인으로 설계된 모델이고, 가상 스레드는 블로킹 코드를 더 잘 스케일시키는 모델입니다. 둘은 경쟁이라기보다 선택지입니다.
- 이미 MVC + 블로킹 I/O로 잘 돌아가는 서비스라면, 가상 스레드가 비용 대비 효과가 좋습니다.
- 초고성능 스트리밍, 백프레셔가 중요한 경우는 WebFlux가 더 적합할 수 있습니다.
함정 2) ThreadLocal 기반 코드/라이브러리 점검
대부분의 경우 가상 스레드에서도 ThreadLocal은 동작하지만, 스레드 수가 폭증하면 ThreadLocal 메모리 사용 패턴이 달라질 수 있습니다.
- 요청 스코프 정보는 가능하면 Spring의 컨텍스트/필터에서 정리
- 대형 객체를
ThreadLocal에 오래 붙잡지 않기
함정 3) 로깅이 병목이 되는 순간
TPS가 두 배가 되면 로그도 두 배가 됩니다. 동기 파일 I/O 로깅, 과도한 JSON 직렬화 로그는 병목이 됩니다.
- 비동기 로깅(Logback AsyncAppender) 검토
- 샘플링/레벨 조정
결론: TPS 2배는 “가능하지만 조건부”다
Spring Boot 3.x에서 가상 스레드는 블로킹 I/O 중심 API의 동시성 한계를 크게 완화해, 충분히 TPS 2배 수준의 개선을 만들 수 있습니다. 다만 그 전제는 명확합니다.
- 병목이 CPU가 아니라 스레드/대기열이어야 함
- DB 커넥션 풀/쿼리 효율이 받쳐줘야 함
- 외부 의존성에 대한 동시성 상한과 타임아웃을 갖춰야 함
추천 진행 순서는 다음입니다.
- 현재 병목이 스레드인지(DB인지) 지표로 확인
- Java 21 + Boot 3.2+로 올리고 가상 스레드 적용
- 커넥션 풀/쿼리(N+1 포함) 최적화로 병목 이동 대응
- 외부 호출 동시성 상한과 타임아웃 정비
- 동일 조건 부하 테스트로 TPS와 p99를 함께 검증
이 과정을 밟으면 “설정 한 줄로 끝”은 아니더라도, 코드 대수술 없이도 의미 있는 처리량 개선을 안정적으로 달성할 수 있습니다.