Java 21 Virtual Thread 마스터 노트 시리즈 5편. Spring Boot 3.2+의 Virtual Thread 기본 활성화, Tomcat·Jetty·Undertow 모두 Virtual Thread Worker, @Async 자동 통합, ThreadPoolTaskExecutor 대체, Spring MVC vs WebFlux 선택의 결정적 변화, 기존 Spring 앱 마이그레이션 패턴까지.
이 글은 Java 21 Virtual Thread 마스터 노트 시리즈의 다섯 번째 편입니다. 4편(Pinning)에서 함정을 봤다면, 이번엔 Spring Boot 통합 — 한 줄 설정으로 시작.
Spring Boot 3.2+ = Virtual Thread 기본 지원. Tomcat·Jetty·Undertow·@Async 모두 자동 통합. 기존 Spring MVC 앱이 WebFlux 비슷한 처리량.
처음 Spring 통합이 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, "기존 Spring MVC를 WebFlux로 바꿔야 하나" 막연합니다. 둘째, @Async·ThreadPool과 어떻게 연동하나 막연합니다.
해결법은 한 가지예요. "Spring MVC + Virtual Thread = 처리량 ≈ WebFlux" 한 줄. WebFlux 전환 강제 X. 한 줄 설정으로 시작.
Spring Boot 3.2+ — 한 줄 설정
spring:
threads:
virtual:
enabled: true
이 한 줄로:
- Tomcat·Jetty·Undertow Worker → Virtual Thread
- @Async 기본 → Virtual Thread Per Task
@Scheduled→ Virtual Thread (스케줄러 자체는 Platform)TaskExecutorBean 자동 → Virtual Thread
여기서 정말 중요한 시험 함정 — Spring Boot 3.2 + Java 21이 표준. 둘 다 필요. 3.1 이하는 수동 설정.
Tomcat Virtual Thread Worker
spring:
threads:
virtual:
enabled: true
이전 (Tomcat 기본):
worker pool 200개 → 200 동시 요청
이후 (Virtual Thread):
Virtual Thread per request → 수만 동시 요청
여기서 정말 중요한 시험 함정 — WebFlux 안 써도 됨. 기존 Spring MVC + Tomcat + Virtual Thread = WebFlux 비슷한 처리량. 코드 변경 X.
@Async 통합
@Service
public class NotificationService {
@Async
public CompletableFuture<Void> sendEmail(User user) {
emailClient.send(user.getEmail(), "..."); // 블로킹 OK
return CompletableFuture.completedFuture(null);
}
}
spring:
threads:
virtual:
enabled: true
자동으로 Virtual Thread 위에서 실행. 별도 풀 설정 X.
명시적 Executor Bean
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskExecutor taskExecutor() {
return new VirtualThreadTaskExecutor("vt-async-");
}
}
VirtualThreadTaskExecutor (Spring 6.1+).
@Scheduled
@Component
public class ReportScheduler {
@Scheduled(fixedRate = 60_000)
public void generateReport() {
// 1분마다, Virtual Thread에서 실행
reportService.generate();
}
}
스케줄링 자체는 Platform Thread (SingleThreadScheduledExecutor), 작업은 Virtual Thread.
ThreadPoolTaskExecutor 대체
// 이전
@Bean
public TaskExecutor taskExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50);
executor.setMaxPoolSize(200);
executor.setQueueCapacity(1000);
return executor;
}
// 이후
@Bean
public TaskExecutor taskExecutor() {
return new VirtualThreadTaskExecutor("worker-");
}
여기서 정말 중요한 시험 함정 — Pool 크기 설정 의미 X (Virtual Thread). Per-Task 자동. 큐도 불필요.
Spring MVC vs WebFlux — 결정적 변화
| 측면 | Spring MVC | WebFlux | MVC + Virtual Thread |
|---|---|---|---|
| 프로그래밍 모델 | 동기·블로킹 | Reactive | 동기·블로킹 |
| 처리량 | 낮음 (Pool 한정) | 매우 높음 | 매우 높음 |
| 학습 곡선 | 낮음 | 높음 | 낮음 |
| 디버깅 | 쉬움 | 어려움 | 쉬움 |
| 라이브러리 | 풍부 | Reactive 한정 | 풍부 |
여기서 정말 중요한 시험 함정 — MVC + Virtual Thread = WebFlux 효율 + MVC 친화. 기존 코드 + 한 줄 설정. 새 프로젝트에선 WebFlux 강제 X.
WebFlux는 여전히 필요?
WebFlux 권장:
- 백프레셔가 결정적 (대용량 스트리밍)
- 함수형 합성 자연스러운 도메인
- Reactive 라이브러리 풍부 (R2DBC·Reactive Mongo)
MVC + Virtual Thread:
- 일반 CRUD·REST API
- 기존 코드베이스
- 학습 곡선 낮춤
Spring Data JDBC·JPA
spring:
threads:
virtual:
enabled: true
datasource:
url: jdbc:postgresql://...
driver-class-name: org.postgresql.Driver
기존 JDBC·JPA 그대로. Virtual Thread가 자동 효율.
여기서 시험 함정이 하나 있어요. JDBC 드라이버 = Pinning 점검. PostgreSQL 42.7+, MySQL 8.x+ 권장. 옛 드라이버 = synchronized 사용·Pinning 위험.
Connection Pool — HikariCP
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
여기서 정말 중요한 시험 함정 — Connection Pool 크기 ≠ Virtual Thread 수. Pool = DB가 견딜 수. VT는 풀 대기 자동. 대량 동시 = DB 부하 폭주 위험. 큐·Rate Limit 필요.
REST 클라이언트
RestTemplate (블로킹)
@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder().build();
}
// 사용
ResponseEntity<User> response = restTemplate.getForEntity(url, User.class);
// Virtual Thread 환경에서 unmount OK
WebClient (비동기·Reactive)
@Bean
public WebClient webClient() {
return WebClient.create();
}
User user = webClient.get().uri(url)
.retrieve()
.bodyToMono(User.class)
.block(); // Virtual Thread에서 OK
여기서 정말 중요한 시험 함정 — WebClient + .block()도 OK (Virtual Thread 환경). 이전엔 .block()이 Platform Thread 점유 = 안티패턴. 이제 Virtual Thread에선 효율적.
RestClient (Spring 6.1+)
@Bean
public RestClient restClient() {
return RestClient.create();
}
// 동기 + 모던 API
User user = restClient.get().uri(url)
.retrieve()
.body(User.class);
WebClient의 동기 버전. Virtual Thread 친화.
Spring Test
@SpringBootTest(properties = "spring.threads.virtual.enabled=true")
class MyTest {
@Test
void test() {
// Virtual Thread 환경에서 실행
}
}
테스트도 동일. 별도 설정 X.
Logback·Spring Logger
Logback 1.4.x+ — Virtual Thread 친화 (synchronized 제거)
옛 버전 = Pinning 위험. 최신 권장.
마이그레이션 체크리스트
✓ Java 21 업그레이드
✓ Spring Boot 3.2+ 업그레이드
✓ spring.threads.virtual.enabled=true
✓ ThreadPoolTaskExecutor → VirtualThreadTaskExecutor
✓ JDBC 드라이버 최신 (PostgreSQL 42.7+ 등)
✓ HikariCP 5+
✓ Logback 1.4+
✓ 모든 의존성 Pinning 점검
✓ -Djdk.tracePinnedThreads=full (개발)
✓ 부하 테스트 (이전·이후 비교)
처리량 비교 (예시)
시나리오: 1초 I/O 대기하는 REST 엔드포인트, 1000 동시 요청
Spring MVC + Tomcat (기본 Pool 200):
→ 처리 시간 ~5초 (200 동시만)
→ 이후 200개씩 순차
Spring MVC + Virtual Thread:
→ 처리 시간 ~1초 (1000 동시)
→ WebFlux와 비슷
이렇게 큰 차이. 한 줄 설정으로.
운영 모니터링
Actuator
management:
endpoints:
web:
exposure:
include: metrics,threaddump
curl /actuator/threaddump
# Virtual Thread 정보 포함
Micrometer 메트릭
jvm.threads.virtual.live # 현재 Virtual Thread 수
jvm.threads.virtual.peak # 최대 도달
Spring Boot 3.2+ 자동 노출.
CompletableFuture + Virtual Thread
@Service
public class ParallelService {
@Autowired
private VirtualThreadTaskExecutor executor;
public CompletableFuture<Combined> fetchAll() {
var f1 = CompletableFuture.supplyAsync(() -> userClient.fetch(), executor);
var f2 = CompletableFuture.supplyAsync(() -> orderClient.fetch(), executor);
var f3 = CompletableFuture.supplyAsync(() -> productClient.fetch(), executor);
return CompletableFuture.allOf(f1, f2, f3)
.thenApply(v -> new Combined(f1.join(), f2.join(), f3.join()));
}
}
병렬 호출 + 합성. Virtual Thread가 백엔드.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 5편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Spring Boot 3.2+ + Java 21 = 표준
spring.threads.virtual.enabled: true한 줄- 자동 — Tomcat·Jetty·Undertow Worker·@Async·@Scheduled·TaskExecutor
- WebFlux 강제 X — MVC + VT = 비슷한 처리량
- @Async — 자동 Virtual Thread (또는 명시
VirtualThreadTaskExecutor) - @Scheduled — 스케줄러 Platform·작업 Virtual
- ThreadPoolTaskExecutor → VirtualThreadTaskExecutor
- Pool 크기 의미 X (Per-Task)
- MVC + VT vs WebFlux — 학습·디버깅·라이브러리 풍부 vs 백프레셔
- 일반 = MVC + VT / 백프레셔 결정적 = WebFlux
- JDBC·JPA — 기존 그대로, 드라이버 최신 권장
- Connection Pool ≠ VT 수 — DB 한계만큼
- 대량 동시 = DB 부하 → 큐·Rate Limit
- WebClient
.block()OK (Virtual Thread 환경) - RestClient (Spring 6.1+) = 동기·모던
- Logback 1.4+ Virtual Thread 친화
- 마이그레이션 — Java 21·Spring Boot 3.2·드라이버·HikariCP·Logback·Pinning 점검
- 메트릭 —
jvm.threads.virtual.live·peak - CompletableFuture + executor 명시 = 병렬 합성
시리즈 다른 편
- 1편 — 동시성 기초·Java Thread
- 2편 — Carrier·Mount·Unmount
- 3편 — API·Builder·ExecutorService
- 4편 — Pinning·synchronized·ReentrantLock
- 5편 — Spring Boot 통합 (현재 글)
- 6편 — Structured Concurrency
- 7편 — Performance·JFR·메모리
- 8편 — Patterns·실전·안티패턴
공식 문서: Spring Boot 3.2 Release Notes 에서 더 깊이.
다음 글(6편)에서는 Structured Concurrency — 여러 Virtual Thread를 단위로 관리, StructuredTaskScope, 자동 취소·실패 전파까지 풀어 갑니다.