한 줄 정의
주요 자바 프레임워크(스프링 부트·쿼커스·자카르타 EE 등)는 JDK 21의 가상 스레드를 저마다 다른 방식—전역 스위치, 선택적 애너테이션, 표준 명세—으로 통합하며, 그 차이는 각 프레임워크의 기존 동시성 모델에서 비롯됩니다.
쉽게 말하면
가상 스레드를 “전기 요금이 거의 안 나오는 신형 냉난방기”라고 해 봅시다. JDK 21이 이 기기를 표준으로 공급하기 시작하자, 건물주(프레임워크)마다 설치 방식이 갈렸습니다.
- 스프링 부트는 이미 방마다 낡은 히터(플랫폼 스레드 풀)를 하나씩 두고 있었기 때문에, 로비에 마스터 스위치 하나(
spring.threads.virtual.enabled=true)를 달아 전 층을 한꺼번에 신형으로 바꿉니다. - 쿼커스는 이미 중앙 관제형 스마트 냉난방(이벤트 루프)이 잘 돌고 있어서, 함부로 전체를 바꾸면 관제 시스템이 마비됩니다. 그래서 “이 방만 신형으로”라는 스티커(
@RunOnVirtualThread)를 필요한 방에만 붙입니다. - 자카르타 EE는 직접 설치하지 않고 “신형 기기는 이런 규격이어야 한다”는 표준 사양서(
virtual = true)만 발행합니다. 실제 설치와 동작은 시공사(런타임)마다 다를 수 있습니다.
이 장의 핵심은 하나입니다. 같은 가상 스레드지만, “어떻게 켜느냐”는 각 프레임워크가 원래 어떤 냉난방 구조였는지에 따라 갈립니다.
왜 중요한가?
가상 스레드가 “명령형 코드를 유지하면서 높은 동시성을 얻는 도구”라는 건 개념입니다. 실무에서 실제로 켜려면 내가 쓰는 프레임워크가 이걸 어떻게 노출하는가를 알아야 합니다.
그런데 프레임워크마다 켜는 방식이 다르고, 이 차이를 모르고 접근하면 사고가 납니다. 예컨대 리액티브 기반 프레임워크에 전역으로 가상 스레드를 적용하려다 이벤트 루프를 블로킹해 버릴 수 있습니다. 반대로 각 방식이 왜 그렇게 설계됐는지 알면, 프레임워크의 동시성 철학에 맞게 안전하게 도입할 수 있습니다.
핵심 내용
스프링 부트: 전역 스위치 하나로
스프링 부트는 전통적으로 요청당 스레드(thread-per-request) 모델을 씁니다. 요청마다 전용 플랫폼 스레드를 하나씩 할당하는데, I/O 집중 요청이 몰리면 유한한 스레드 풀이 병목이 됩니다. @Async·TaskExecutor로 비동기화해도 결국 제한된 플랫폼 스레드 풀에 의존한다는 한계는 그대로입니다.
스프링 부트 3.2(2023년 11월, 스프링 프레임워크 6.1 기반)부터 자바 21을 1급 런타임으로 취급하며, 프로퍼티 한 줄로 가상 스레드를 켤 수 있습니다.
spring.threads.virtual.enabled=true이미 요청마다 스레드를 묶는 구조이므로, 그 스레드를 가상 스레드로 바꾸는 전역 전환이 자연스럽습니다. 켜는 순간 자동 구성이 개입해 이그제큐터·스케줄러 빈을 가상 스레드 기반으로 교체합니다.
| 상태 | Executor (applicationTaskExecutor) | Scheduler (taskScheduler) |
|---|---|---|
| 가상 스레드 ON | SimpleAsyncTaskExecutor | SimpleAsyncTaskScheduler |
| OFF (기본) | ThreadPoolTaskExecutor | ThreadPoolTaskScheduler |
이 전환은 @Async 태스크, Callable 반환 비동기 처리, 웹 MVC 비동기 요청, 스케줄링(@Scheduled) 등 스프링의 동시성 기능 전반에 자연스럽게 적용됩니다. 로그의 스레드 이름이 VirtualThread[#63,tomcat-handler-0]/...로 찍히면 가상 스레드에서 실행 중이라는 뜻입니다.
수동 구성
3.2 이전 버전이거나 이그제큐터를 세밀하게 제어하려면 빈을 직접 정의합니다.
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}Executors.newVirtualThreadPerTaskExecutor()를 그대로 쓰지 않고 TaskExecutorAdapter로 감싸는 이유는, 후자가 스프링의 빈 생애주기(초기화·셧다운) 관리에 더 자연스럽게 통합되기 때문입니다.
같은 방식으로 내장 톰캣의 요청 처리 스레드도 가상 스레드로 바꿀 수 있습니다. TomcatProtocolHandlerCustomizer로 프로토콜 핸들러의 이그제큐터를 교체하면 웹 서버 진입 지점까지 가상 스레드가 적용됩니다.
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadExecutor() {
return handler -> handler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}쿼커스: 이벤트 루프를 지키는 선택적 도입
쿼커스는 버트엑스(Vert.x) 이벤트 루프 기반의 리액티브 프레임워크로, 빠른 기동과 낮은 자원 사용 덕에 마이크로서비스·서버리스에 적합합니다. 스프링처럼 전역으로 켜지 않고, @RunOnVirtualThread를 붙인 메서드만 가상 스레드에서 실행합니다.
@GET
@RunOnVirtualThread
public String process() { ... }이유는 명확합니다. 이벤트 루프 모델에서는 소수의 루프 스레드가 모든 요청을 처리하므로, 여기서 블로킹하면 시스템 전체가 멈춥니다. 전역으로 가상 스레드를 켜는 건 이 모델과 맞지 않습니다. 대신 블로킹이 필요한 특정 엔드포인트만 @RunOnVirtualThread로 이벤트 루프 밖 가상 스레드에 오프로드해서, 익숙한 블로킹 코드를 쓰면서도 루프의 응답성을 지킵니다.
리액티브 타입과도 결합됩니다. 지연 연산(deferred computation)을 나타내는 Uni<String>을 가상 스레드 안에서 블로킹으로 기다릴 수 있습니다.
return helloService.getHello()
.await().atMost(Duration.ofSeconds(5));await().atMost(...)는 5초 타임아웃을 걸고 응답이 올 때까지 가상 스레드를 블로킹합니다. 리액티브의 논블로킹 파이프라인을 유지하면서도, 소비 지점에서는 명령형처럼 값을 꺼내 쓰는 형태입니다.
자카르타 EE: 표준 명세와 런타임 위임
자카르타 동시성(Jakarta Concurrency) 3.1이 가상 스레드 지원을 명세에 넣었습니다. 활성화는 세 애너테이션에 virtual = true를 주는 방식입니다.
@ManagedExecutorDefinition@ManagedScheduledExecutorDefinition@ManagedThreadFactory
@ManagedExecutorDefinition(
name = "java:module/concurrent/virtual-executor",
qualifiers = WithVirtualThreads.class,
virtual = true)자바 21에서 실행되면 가상 스레드를, 가상 스레드를 지원하지 않는 자바 17에서 실행되면 자동으로 플랫폼 스레드를 씁니다. 명세가 폴백을 보장하므로 코드를 바꾸지 않고 런타임만 올려도 됩니다.
명세와 실제 동작은 다를 수 있습니다
명세는 “무엇을”만 정하고 “어떻게”는 런타임(Open Liberty·Payara·WildFly 등)에 맡깁니다. 같은 애너테이션이라도 런타임마다 동작이 다르거나 아예 미지원일 수 있어, 실제 배포 대상 런타임의 문서를 확인해야 합니다. 집필 시점 기준 Open Liberty 24.0.0.6-beta가 3.1을 완전 지원하는 최초 런타임입니다.
그 외: 네이티브 설계와 자동 활용
| 프레임워크 | 방식 |
|---|---|
| 헬리든 니마(Helidon Níma) | 가상 스레드를 사후 추가한 게 아니라, 처음부터 가상 스레드를 중심으로 설계된 마이크로서비스 프레임워크 |
| 마이크로노트(Micronaut) | 자바 21+ JDK에서 실행되면 별도 설정 없이 자동으로 가상 스레드 활용 |
비교 / 트레이드오프
핵심은 “어떻게 켜느냐”가 각 프레임워크의 기존 동시성 모델을 그대로 반영한다는 점입니다.
| 프레임워크 | 활성화 | 기본 성향 | 근거 |
|---|---|---|---|
| 스프링 부트 | spring.threads.virtual.enabled=true (전역) | opt-out (전역 ON) | thread-per-request라 요청 스레드를 가상으로 바꾸는 게 자연스러움 |
| 쿼커스 | @RunOnVirtualThread (메서드) | opt-in (선택 도입) | 이벤트 루프 기반 — 전역 전환 시 루프 블로킹 위험 |
| 자카르타 EE | virtual = true (명세 애너테이션) | 명세 기반 | 표준만 정의, 동작은 런타임에 위임 |
| 헬리든 니마 | 설계 자체 | 네이티브 | 처음부터 가상 스레드 중심 |
| 마이크로노트 | 자동 감지 | 자동 | JDK 21+면 설정 없이 |
스프링(전역 스위치)과 쿼커스(선택 애너테이션)가 양 극단입니다. 전자는 이미 스레드를 요청마다 묶는 구조라 “전부 가상으로”가 안전하고, 후자는 스레드를 안 묶는 게 미덕인 구조라 “필요한 곳만”이 안전합니다.
내 생각
- “한 줄로 켜기”가 “한 줄로 안전”은 아닙니다. 스프링 전역 스위치는 편하지만,
synchronized블록이나 일부 네이티브 호출에서 캐리어 스레드가 고정(pinning)되면 오히려 처리량이 떨어질 수 있습니다. 켜고 끝이 아니라 pinning 지점을 점검해야 합니다. - 활성화 방식의 차이는 곧 마이그레이션 리스크의 차이입니다. 리액티브 코드베이스에 무턱대고 전역 가상 스레드를 적용하면 이벤트 루프를 블로킹합니다. 쿼커스가 굳이 opt-in을 택한 건 이 사고를 구조적으로 막으려는 설계 결정으로 읽힙니다.
- 자카르타 EE의 명세-런타임 분리는 이식성의 장점이자 확인 비용입니다. “내 런타임(Liberty/Payara/WildFly)이 이 기능을 지원하나?”를 매번 확인해야 하는 건 표준의 대가입니다.
관련 개념
- Ch02 쉼 없이 CPU를 활용하는 가볍고 부지런한 가상 스레드 — 프레임워크들이 통합하는 가상 스레드 자체의 동작 원리
- Ch06 가상 스레드 시대에서 리액티브 자바의 의미 — 쿼커스가 리액티브와 가상 스레드를 결합하는 배경, “executor 한 줄 교체”의 의미