- Published on
Spring Boot 3.2 가상 스레드로 스루풋 2배 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스루풋이 잘 안 오르는 Spring MVC 애플리케이션을 보면, CPU가 바쁘지 않은데도 응답 지연이 늘고 동시 요청이 쌓이는 경우가 많습니다. 원인은 대개 명확합니다. HTTP 요청을 처리하는 스레드가 DB, 외부 API, Redis 같은 블로킹 I/O를 기다리며 묶여 있고, 플랫폼 스레드(커널 스레드) 풀의 크기 자체가 병목이 됩니다.
Spring Boot 3.2는 JDK 21 기반에서 가상 스레드(virtual thread)를 비교적 손쉽게 활성화할 수 있게 해줍니다. 가상 스레드는 블로킹 구간에서 스레드를 “주차”시키고 플랫폼 스레드를 반환하기 때문에, 같은 하드웨어에서 더 많은 동시 요청을 처리할 수 있습니다. 이 글은 “가상 스레드를 켠다”에서 끝나지 않고, 실제로 스루풋을 2배 수준까지 올릴 때 함께 손봐야 하는 설정과 함정, 측정 방법을 단계별로 다룹니다.
또한 DB 락/데드락이 스루풋을 갉아먹는 케이스가 매우 흔하므로, 가상 스레드 적용 전후로 DB 병목을 같이 점검하는 것이 좋습니다. 관련해서는 MySQL InnoDB 데드락 폭증 원인·튜닝 7단계도 함께 참고하면 좋습니다.
가상 스레드가 스루풋을 올리는 조건
가상 스레드는 만능이 아닙니다. 효과가 큰 조건은 다음과 같습니다.
- 요청 처리 시간이 블로킹 I/O 대기 위주일 때
- 동시 요청 수가 많고, 플랫폼 스레드 풀 고갈이 자주 발생할 때
- 코드가 전통적인 블로킹 스타일(JDBC, RestTemplate, 동기 Redis 클라이언트 등)일 때
반대로 아래 상황에서는 이득이 제한적이거나 오히려 악화될 수 있습니다.
- CPU 바운드 작업(이미지 처리, 암호화, 대규모 JSON 변환 등)이 대부분일 때
- 매우 짧은 요청만 초고속으로 처리하는데 컨텍스트 스위칭 비용이 더 커질 때
- 내부적으로 가상 스레드 친화적이지 않은 락 경합이 심할 때
핵심은 “블로킹 대기 시간을 플랫폼 스레드 점유로 지불하지 않게” 만드는 것입니다.
Spring Boot 3.2에서 가상 스레드 활성화
1) JDK 21 확인
Spring Boot 3.2는 JDK 21 조합이 일반적입니다. 런타임이 JDK 21인지 먼저 확인합니다.
java -version
2) 설정 한 줄로 활성화
Spring MVC(서블릿 스택) 기준으로는 아래 설정이 가장 간단합니다.
spring:
threads:
virtual:
enabled: true
이 설정은 내부적으로 요청 처리에 사용하는 실행기(executor)를 가상 스레드 기반으로 전환해, 블로킹 I/O가 많은 API에서 동시성 한계를 크게 늘려줍니다.
3) Tomcat 스레드 수를 무작정 키우지 않기
가상 스레드를 켠 뒤에도 server.tomcat.threads.max를 과거처럼 크게 늘리는 튜닝은 보통 필요 없습니다. 오히려 플랫폼 스레드 수를 과하게 늘리면 컨텍스트 스위칭과 메모리 사용량이 증가합니다.
가상 스레드 적용 후에는 다음 관점으로 재조정합니다.
- 플랫폼 스레드는 “CPU 코어 수와 작업 특성”에 맞춘 합리적 수준 유지
- 동시성 확장은 가상 스레드가 담당
스루풋 2배를 위해 같이 손봐야 하는 5가지
가상 스레드만 켜면 지연이 줄어드는 경우도 있지만, “2배” 같은 눈에 띄는 개선은 주변 병목을 같이 정리할 때 나옵니다.
1) DB 커넥션 풀 크기 재설계(HikariCP)
가상 스레드는 동시 요청 수를 늘려주지만, DB 커넥션은 무한정 늘릴 수 없습니다. 즉, 병목이 “서버 스레드 풀”에서 “DB 커넥션 풀”로 이동합니다.
증상은 다음과 같습니다.
- 응답 시간이
HikariPool-1 - Connection is not available근처에서 치솟음 - DB CPU는 낮은데 애플리케이션에서 대기 증가
권장 접근:
- 커넥션 풀은 “DB가 감당 가능한 동시 쿼리”에 맞춘다
- 애플리케이션 동시성(가상 스레드)과 DB 동시성(커넥션)은 분리해서 생각한다
예시 설정:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 2000
max-lifetime: 1800000
풀 사이즈는 정답이 없지만, 대략적으로는 다음 순서로 찾습니다.
- 목표 TPS와 평균 쿼리 시간을 기준으로 필요한 동시 쿼리 수 추정
- DB CPU, 락 대기, 버퍼풀 히트율 관측
- 커넥션 풀을 늘리기보다 “쿼리 시간 단축”과 “락 경합 감소”를 우선
락 경합이 튀는 경우는 가상 스레드로 동시성이 늘면서 더 빨리 드러납니다. 이때는 DB 튜닝이 먼저입니다. 앞서 언급한 MySQL InnoDB 데드락 폭증 원인·튜닝 7단계를 같이 체크하세요.
2) 외부 API 호출에 타임아웃을 “짧게, 명시적으로”
가상 스레드는 “많이 기다릴 수 있게” 해주지만, 무제한 대기를 허용하라는 뜻이 아닙니다. 외부 API가 느려지면 가상 스레드가 대량으로 쌓이고, 결국 메모리와 다운스트림(외부 시스템) 과부하로 이어집니다.
RestClient(Spring 6) 예시:
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Configuration
class HttpClientConfig {
@Bean
RestClient restClient() {
var rf = new SimpleClientHttpRequestFactory();
rf.setConnectTimeout((int) Duration.ofMillis(300).toMillis());
rf.setReadTimeout((int) Duration.ofMillis(800).toMillis());
return RestClient.builder()
.requestFactory(rf)
.build();
}
}
여기에 더해 회로 차단기(resilience4j)나 bulkhead를 적용하면, 가상 스레드가 “장애 전파를 더 빨리 확대”하는 상황을 막는 데 도움이 됩니다.
3) 동기 로그/추적이 병목이 되지 않게
동시성이 늘면 로그 I/O도 같이 늘어납니다. 특히 JSON 로그를 동기로 파일에 쓰거나, 원격 로그 수집이 지연되면 요청 처리 시간이 늘어납니다.
점검 포인트:
- 비동기 로깅(Logback AsyncAppender) 고려
- 과도한 DEBUG 로그 제거
- 분산 추적 샘플링 비율 조정
가상 스레드 도입 후 “CPU는 여유인데 p99가 안 내려가는” 케이스에서 로그가 원인인 경우가 꽤 있습니다.
4) 캐시 스탬피드와 동시성 폭발
가상 스레드로 동시성이 늘면, 캐시 미스 순간에 DB로 몰리는 트래픽도 동시에 늘어납니다. 그 결과 캐시 스탬피드(동시 미스 폭발)가 더 심해질 수 있습니다.
Redis 캐시를 사용 중이라면 Spring Boot 3 Redis 캐시 스탬피드 해결법에서 소개하는 락/싱글플라이트 패턴을 함께 적용하는 것이 안전합니다.
5) “요청당 새 가상 스레드”를 불필요하게 중첩 생성하지 않기
가상 스레드는 생성이 가볍지만, 아무 곳에서나 Executors.newVirtualThreadPerTaskExecutor()를 만들어 남발하면 관측/제어가 어려워지고, 작업이 중첩되며 병목이 흐려집니다.
원칙:
- Spring이 제공하는 실행기 전환을 우선 사용
- 별도 실행기가 필요하면 “용도별로 명확히” 분리
예시(명시적 가상 스레드 실행기 Bean):
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class VirtualThreadExecutorConfig {
@Bean(destroyMethod = "close")
ExecutorService virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
그리고 이 실행기는 “정말로 요청 처리 스레드와 분리해야 하는 작업”에만 제한적으로 씁니다. 예를 들어, 요청 라이프사이클과 독립적인 백그라운드 작업, 혹은 다운스트림 보호를 위한 bulkhead 전용 작업 큐 등에 사용합니다.
측정: 2배 개선을 확인하는 벤치마크 설계
튜닝에서 가장 흔한 실수는 “부하를 걸어봤더니 빨라진 것 같음” 수준에서 끝나는 것입니다. 최소한 아래 지표는 전후 비교가 가능해야 합니다.
- TPS(throughput)
- 평균/
p95/p99지연 - 에러율(타임아웃,
5xx) - JVM 메모리, GC 시간
- DB 커넥션 풀 대기 시간, DB 락 대기
k6로 간단한 재현
import http from "k6/http";
import { sleep } from "k6";
export const options = {
scenarios: {
load: {
executor: "constant-vus",
vus: 200,
duration: "60s",
},
},
};
export default function () {
http.get("http://localhost:8080/api/orders/123");
sleep(0.1);
}
테스트 시에는 반드시 다음을 고정합니다.
- 같은 데이터셋, 같은 쿼리 플랜
- 같은 인스턴스 스펙, 같은 JVM 옵션
- 워밍업 후 측정
가상 스레드의 효과는 “동시 요청이 높아질수록” 뚜렷해지므로, vus를 낮게 잡으면 차이가 거의 안 보일 수 있습니다.
자주 겪는 함정과 디버깅 포인트
가상 스레드를 켰는데도 스레드 풀이 고갈되는 느낌
- 실제 병목이 DB 커넥션 풀일 가능성이 큽니다.
- 외부 API 타임아웃이 길어 가상 스레드가 대량으로 쌓였을 수 있습니다.
확인:
- Hikari metrics에서
pending threads또는 커넥션 획득 대기 시간 - 외부 호출 타임아웃/재시도 설정
성능이 좋아졌는데 p99가 더 나빠짐
- 동시성이 늘면서 락 경합이 증가했을 수 있습니다.
- 로그/추적/직렬화 비용 같은 “비즈니스 외 비용”이 꼬리 지연을 만들 수 있습니다.
이 경우는 DB 락과 애플리케이션 내부 락을 같이 점검해야 합니다.
CPU가 갑자기 바빠짐
- 동시 요청이 늘어 “이전에는 대기 중이던 작업들이 실제로 실행”되기 시작한 결과일 수 있습니다.
- JSON 직렬화, 템플릿 렌더링, 암호화 같은 CPU 바운드 구간이 드러납니다.
대응:
- CPU 프로파일링으로 핫스팟 확인
- 무거운 연산은 별도 워커로 분리하거나 캐싱
실전 적용 체크리스트
spring.threads.virtual.enabled활성화- HikariCP 풀 크기와 타임아웃 재조정(무작정 증설 금지)
- 외부 API 호출에 연결/읽기 타임아웃을 명시
- 캐시 스탬피드 방지(동시 미스 폭발 대비)
- 로그/추적 비용 점검(비동기 로깅, 샘플링)
- k6 등으로 전후 TPS,
p95/p99, 에러율, DB 대기 지표 비교
결론: “스레드 병목 제거” 이후의 병목을 준비하라
Spring Boot 3.2 가상 스레드는 블로킹 I/O 기반 Spring MVC 서비스에서 가장 비용 대비 효과가 큰 성능 카드 중 하나입니다. 특히 플랫폼 스레드 풀 고갈로 처리량이 막혀 있던 서비스라면, 가상 스레드 전환만으로도 동시성 한계가 크게 올라가며 스루풋이 2배 수준까지 개선되는 케이스가 충분히 나옵니다.
다만 그 순간 병목은 DB 커넥션, 락 경합, 캐시 미스 폭발, 외부 API 타임아웃 같은 “다음 단계”로 이동합니다. 가상 스레드는 출발점이고, 진짜 튜닝은 그 다음 병목을 얼마나 빨리 찾아 제거하느냐에 달려 있습니다.