Spring Boot 4 + Java 21 Virtual Threads: 코드 변경 없이 I/O 처리량이 극적으로 달라지는 원리
동시성 문제를 해결하려고 WebFlux를 도입했다가 팀 전체가 리액티브 패러다임에 적응하느라 몇 달을 고생한 경험, 혹시 있으신가요? 저도 그랬습니다. 콜백이 콜백을 부르고, 스택 트레이스는 도무지 읽을 수가 없고, "이게 진짜 더 나은 건가?" 싶었던 순간들이 생생합니다. 그 고생을 다시 하지 않아도 될 현실적인 대안이 드디어 왔습니다.
2025년 11월, Spring Boot 4.0이 공식 릴리스되면서 Java 21에서 정식 도입된 Virtual Threads(가상 스레드) 를 가장 쉽게 쓸 수 있는 환경이 갖춰졌습니다. application.properties에 한 줄을 추가하면, 기존 MVC 코드를 거의 건드리지 않고도 I/O 처리량이 극적으로 개선됩니다. 이 글을 읽고 나면 기존 Spring MVC 프로젝트에서 설정 파일 수정과 선택적인 Lock 패턴 조정만으로 가상 스레드를 실제로 적용할 수 있습니다.
이 글에서는 가상 스레드가 왜 동작하는지, Spring Boot 4에서 어떻게 쓰는지, 그리고 놓치면 조용히 당하는 함정들을 함께 살펴봅니다. 특히 흔히 잘못 알려진 코드 패턴까지 짚어드리니, 끝까지 읽어보시면 좋겠습니다.
핵심 개념
플랫폼 스레드의 한계, 그리고 JVM이 선택한 해법
그러면 왜 이게 가능한 건지, 잠깐 JVM 레벨로 내려가 보겠습니다.
기존 Java의 스레드(플랫폼 스레드)는 OS 스레드와 1:1로 매핑됩니다. OS 스레드 하나는 메모리를 수 MB 차지하고, 컨텍스트 스위칭 비용도 적지 않습니다. 그래서 전통적인 Spring MVC 앱은 스레드 풀 크기(보통 200개 내외)가 곧 동시 처리 한계였습니다.
데이터베이스 쿼리 하나에 100ms가 걸린다고 생각해보면, 그 100ms 동안 스레드는 그냥 멈춰서 기다립니다. OS 입장에서는 리소스가 묶여있는 것이고, 새 요청이 들어와도 빈 스레드가 없으면 큐에서 대기하게 됩니다. I/O 바운드 환경에서의 고전적인 병목이 여기서 생깁니다.
플랫폼 스레드(Platform Thread): OS 스레드와 직접 연결된 Java의 기존 스레드. 생성 비용이 높고, 수천 개 이상 만들면 메모리와 스케줄링 오버헤드가 급증합니다.
가상 스레드는 이 문제를 JVM 레벨에서 해결합니다. 핵심 아이디어는 단순합니다.
- I/O 블로킹이 발생하는 순간, JVM이 해당 가상 스레드를 자동으로 캐리어 스레드에서 분리(unmount) 합니다.
- 캐리어 스레드는 즉시 다른 가상 스레드를 처리하러 갑니다.
- I/O가 완료되면 가상 스레드가 남은 캐리어 스레드 중 하나에 재연결(remount) 되어 실행을 이어갑니다.
t=0ms 요청 A → 가상 스레드 A ─── DB 쿼리 시작 → unmount
↓ 캐리어 스레드 해제
t=5ms 요청 B → 가상 스레드 B ─── 캐리어 스레드 재사용 → DB 쿼리 시작 → unmount
t=10ms 요청 C → 가상 스레드 C ─── 캐리어 스레드 재사용 → ...
t=100ms 요청 A의 DB 응답 도착 → 가상 스레드 A remount → 처리 재개 → 응답 반환
t=105ms 요청 B의 DB 응답 도착 → 가상 스레드 B remount → ...개발자 입장에서는 async/await도, Mono/Flux도 필요 없습니다. 기존에 쓰던 try { ... } catch 스타일 명령형 코드가 그대로 비동기적으로 동작합니다.
캐리어 스레드(Carrier Thread): 가상 스레드를 실제로 실행하는 OS 스레드. CPU 코어 수에 맞춰 소수만 유지되며, 수백만 개의 가상 스레드를 스케줄링합니다.
Spring Boot 4에서의 통합 — "공식적으로 완전히 지원"의 의미
Spring Boot 4.0은 가상 스레드를 언어와 프레임워크 수준에서 공식적으로 완전히 지원하는(일반적으로 "first-class citizen"이라 부릅니다) 첫 번째 Spring Boot 메이저 버전입니다. 이 한 줄이면 끝납니다.
# application.properties
spring.threads.virtual.enabled=true이 설정을 켜면 Spring MVC의 Tomcat 요청 처리, @Async 태스크, @Scheduled 작업이 모두 가상 스레드 위에서 돌아갑니다. Spring Security의 SecurityContextHolder, JPA의 트랜잭션, Spring의 다양한 per-thread state 메커니즘도 코드 변경 없이 그대로 동작합니다. Spring 팀이 내부적으로 이미 가상 스레드를 고려해 인프라를 정비해뒀기 때문입니다.
Java 베이스라인은 17이지만, 가상 스레드 최적 성능을 위해서는 Java 21 이상을 권장합니다. Java 24라면 더 좋은데, 그 이유는 아래 함정 섹션에서 설명하겠습니다.
실전 적용
예시 1: 기본 설정 — 설정 한 줄로 MVC 전환
가장 빠른 적용 방법입니다. 기존 Spring MVC 프로젝트에 pom.xml(또는 build.gradle) 의존성 버전만 올리고, 설정 한 줄을 추가하면 됩니다.
<!-- pom.xml -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version>
</parent>
<properties>
<java.version>21</java.version>
</properties># application.properties
spring.threads.virtual.enabled=true
# HikariCP — 가상 스레드 환경에서는 풀 크기를 과도하게 늘릴 필요가 없습니다
spring.datasource.hikari.maximum-pool-size=50maximum-pool-size=50이라는 숫자는 임의적인 게 아닙니다. DB 커넥션 풀은 가상 스레드 수와 별개로 DB 서버가 감당할 수 있는 동시 연결 수에 맞게 잡아야 합니다. 일반적으로 (DB 서버 코어 수 × 2) + 여유분 정도가 출발점이 되고, 실측으로 조정하는 것을 권장합니다.
| 설정 항목 | 역할 |
|---|---|
spring.threads.virtual.enabled=true |
MVC 요청 스레드, @Async, @Scheduled를 가상 스레드로 전환 |
hikari.maximum-pool-size |
가상 스레드 수가 늘어도 DB 연결은 별도 풀로 제한, 과도한 연결 방지 |
예시 2: @Async 태스크 — 명시적 실행기 설정 + 예외 처리
Spring Boot 4에서 spring.threads.virtual.enabled=true를 켜면 기본 @Async 실행기도 자동으로 전환됩니다. 기존 Spring Boot 3.x 환경이거나 실행기를 직접 제어하고 싶다면 다음처럼 명시할 수 있습니다.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
// 태스크마다 새 가상 스레드를 생성합니다
return Executors.newVirtualThreadPerTaskExecutor();
}
}@Service
public class NotificationService {
@Async
public CompletableFuture<Void> sendEmail(String recipient) {
try {
// 기존 블로킹 코드 그대로 — I/O 대기 시 캐리어 스레드를 자동으로 해제합니다
emailClient.send(recipient);
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
// @Async에서 예외는 반환된 Future를 통해 전파됩니다
// 호출부에서 .exceptionally()나 .handle()로 처리할 수 있습니다
return CompletableFuture.failedFuture(e);
}
}
}@Async 메서드에서 발생한 예외는 반환된 CompletableFuture 안에 담겨 전파됩니다. 예외 처리를 빠뜨리면 호출부에서 Future.get()이나 .join()을 호출할 때까지 오류가 조용히 묻힐 수 있으니, 실무에서는 예외 경로를 반드시 처리하는 것을 권장합니다.
예시 3: 병렬 DB 쿼리 — 가상 스레드로 동시 I/O 활용
I/O 집약적인 대표 사례입니다. 이 부분에서 흔히 오해가 생기는데, 솔직히 저도 처음엔 헷갈렸습니다. 여러 상품의 상세 정보를 동시에 조회할 때 parallelStream()을 쓰고 싶은 충동이 생기는데, 이건 가상 스레드와 무관합니다.
⚠️
parallelStream()은ForkJoinPool.commonPool()(ForkJoinPool은 CPU 병렬 연산을 위한 스레드 풀로, 가상 스레드와 별개입니다)을 사용합니다. 가상 스레드가 켜져 있어도parallelStream()내부 작업이 가상 스레드로 실행되지는 않습니다.
가상 스레드로 DB 쿼리를 동시에 실행하려면 아래처럼 Executors.newVirtualThreadPerTaskExecutor()와 CompletableFuture를 조합하는 것이 올바른 방법입니다.
@Service
public class ProductService {
private final ProductRepository productRepository;
public List<ProductDetail> fetchAllDetails(List<Long> ids) {
// newVirtualThreadPerTaskExecutor: 각 작업을 새 가상 스레드에서 실행합니다
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(
() -> productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("id: " + id)),
executor))
.toList();
// 모든 Future가 완료될 때까지 기다립니다
return futures.stream()
.map(CompletableFuture::join)
.toList();
}
}
}각 findById가 DB 응답을 기다리는 동안 캐리어 스레드를 놔주고, 다른 쿼리들이 그 사이를 메웁니다. N개의 쿼리가 거의 동시에 진행됩니다.
| 방식 | 10개 쿼리 × 각 100ms | 비고 |
|---|---|---|
| 순차 플랫폼 스레드 | ~1,000ms | 한 번에 하나씩 |
| 병렬 플랫폼 스레드 | ~100ms (스레드 10개 필요) | 스레드 생성 비용 발생 |
| 가상 스레드 (위 코드) | ~100ms | 스레드 생성 비용 거의 없음 |
예시 4: @Scheduled 작업 — 스케줄러 교체
@Configuration
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
// setScheduler()는 ScheduledExecutorService를 요구합니다
// newVirtualThreadPerTaskExecutor()의 반환 타입은 ExecutorService이므로
// 가상 스레드 팩토리를 지정한 ScheduledThreadPool을 사용합니다
taskRegistrar.setScheduler(
Executors.newScheduledThreadPool(1, Thread.ofVirtual().factory())
);
}
}배치성 스케줄 작업에서 외부 API를 다량 호출하거나 DB I/O가 많은 경우, 이 설정만으로도 실행 시간이 크게 줄어드는 것을 경험할 수 있습니다. newVirtualThreadPerTaskExecutor()를 그냥 넘기면 컴파일 오류가 나니 주의가 필요합니다.
장단점 분석
장점
이 숫자가 처음엔 그냥 숫자처럼 보일 수 있는데, TechEmpower 벤치마크 plaintext 시나리오(2025년) 기준으로 16코어 머신에서 가상 스레드 기반 서버가 초당 약 120만 req/s를 달성했습니다. 같은 조건의 WebFlux가 약 90만 req/s였으니, 단순한 I/O 집약 시나리오에서는 오히려 앞서는 결과입니다. 단, 같은 벤치마크라도 DB 쿼리나 JSON 직렬화 시나리오에서는 수치가 크게 달라지므로 여러분 앱의 성격에 맞는 실측 비교를 직접 해보시는 것을 권장합니다.
| 항목 | 내용 |
|---|---|
| 코드 단순성 | 기존 명령형 스타일 유지, 리액티브 패러다임 전환 불필요 |
| 높은 처리량 | I/O 대기 중 OS 스레드 낭비 없이 수백만 동시 요청 처리 가능 |
| 디버깅 용이성 | 스택 트레이스가 직관적, 리액티브 콜백 체인 추적 불필요 |
| 기존 호환성 | Spring Security, JPA, 트랜잭션 등 코드 수정 없이 동작 |
| 낮은 메모리 오버헤드 | 플랫폼 스레드 대비 메모리 점유 최소화 (수 KB vs 수 MB) |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Pinning 문제 | synchronized 블록 내 I/O 발생 시 캐리어 스레드가 블로킹됨 (Java 21~23) |
ReentrantLock으로 교체, 또는 Java 24 이상 사용 |
| CPU 바운드 무효 | 암호화, 이미지 처리 등 CPU 집약 작업은 이점 없음 | 해당 작업은 ForkJoinPool 유지 |
| ThreadLocal 메모리 | 가상 스레드 수 증가 시 ThreadLocal 메모리 사용 급증 가능 |
ScopedValue(Java 23 정식)로 대체 고려 |
| 연결 풀 튜닝 | 요청이 폭발적으로 늘면 DB 연결 풀이 병목될 수 있음 | HikariCP 풀 크기를 I/O 패턴에 맞게 조정 |
Pinning(고착): 가상 스레드가
synchronized블록이나 JNI 코드 내에서 블로킹 I/O를 만났을 때, 캐리어 스레드에서 unmount되지 못하고 그대로 묶이는 현상. JEP(Java Enhancement Proposal, Java 기능 제안서) 491을 통해 Java 24에서synchronized모니터 구현이 재설계되어 근본적으로 해소되었습니다.
Pinning이 발생하고 있는지 확인하고 싶다면 다음 JVM 플래그로 진단할 수 있습니다.
-Djdk.tracePinnedThreads=full문제가 발견됐다면 synchronized를 ReentrantLock으로 교체하는 것이 가장 확실한 방법입니다.
// ❌ pinning 유발 — synchronized 블록 내에서 I/O가 발생하면 캐리어 스레드가 묶입니다
synchronized (lock) {
result = dbRepository.findAll(); // I/O 블로킹
}
// ✅ 가상 스레드 친화적 — ReentrantLock은 unmount/remount를 허용합니다
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
result = dbRepository.findAll();
} finally {
lock.unlock();
}ScopedValue는 Java 21에서 프리뷰(preview)로 도입되어 Java 23에서 정식(finalized)으로 확정되었습니다. Java 21에서 그냥 쓰려면 --enable-preview 컴파일 옵션이 필요하니, 안정적으로 쓰려면 Java 23 이상 환경을 권장합니다.
ScopedValue:
ThreadLocal의 현대적 대안(Java 23 정식). 불변(immutable)이고 스택이 내려갈수록 전파되는 방식으로, 수만 개의 가상 스레드가 각자ThreadLocal값을 들고 다니는 것보다 메모리 효율이 훨씬 좋습니다. 사람으로 치면 개인 서랍 대신 공용 게시판에 필요한 정보를 붙여두는 격입니다.
실무에서 가장 흔한 실수
-
"CPU 바운드 작업도 가상 스레드로 바꾸면 빨라지겠지" — 가상 스레드의 이점은 I/O 대기 시간 동안 캐리어 스레드를 다른 작업에 활용하는 데 있습니다. CPU를 계속 점유하는 연산(해시 계산, 이미지 리사이징 등)에는 효과가 없고, 오히려 스케줄링 오버헤드만 늘어날 수 있습니다.
-
HikariCP 풀 크기를 무한정 늘리는 것 — 가상 스레드 수천 개가 동시에 DB에 연결하려 하면 DB 서버가 감당하지 못합니다. 가상 스레드는 DB 연결을 기다리는 동안에도 캐리어 스레드를 놔주므로 작은 풀로도 많은 요청을 처리할 수 있습니다. DB 서버 스펙에 맞게 보수적으로 유지하는 것을 권장합니다.
-
parallelStream()을 가상 스레드로 착각하는 것 — 예시 3에서 설명한 것처럼,parallelStream()은ForkJoinPool.commonPool()을 씁니다. 가상 스레드 설정과 무관하게 동작합니다. 병렬 I/O가 필요하다면Executors.newVirtualThreadPerTaskExecutor()+CompletableFuture.supplyAsync()를 사용하는 것이 맞습니다.
마치며
Spring Boot 4 + 가상 스레드의 핵심은 "코드는 그대로, 동시성은 극적으로"입니다. 리액티브 패러다임을 팀 전체가 학습하지 않아도, 기존 명령형 코드베이스에서 I/O 처리량을 수 배 끌어올릴 수 있는 현실적인 경로가 열렸습니다. 실제로 I/O 집약 환경에서 분당 처리량이 수 배 향상된 사례도 있는 만큼, TechEmpower 수치(동일 하드웨어에서 WebFlux 대비 약 33% 우위)보다 여러분 앱에서 직접 측정한 숫자가 더 설득력 있는 근거가 됩니다.
지금 바로 시작해볼 수 있는 3단계:
- Java 21 이상으로 업그레이드한 뒤 Spring Boot 4.0으로 마이그레이션합니다.
spring-boot-starter-parent버전을4.0.0으로 올리고,application.properties에spring.threads.virtual.enabled=true를 추가하면 됩니다. 기존 코드는 대부분 수정 없이 동작합니다. -Djdk.tracePinnedThreads=full플래그로 기존 코드베이스의 Pinning 발생 지점을 점검합니다.synchronized블록 내에 DB 쿼리나 외부 HTTP 호출이 포함돼 있다면ReentrantLock으로의 교체를 고려해볼 수 있습니다. Java 24 이상이라면 이 걱정이 상당 부분 해소됩니다.- k6나 Gatling으로 전후 로드 테스트를 진행합니다. 동시 사용자 수를 점진적으로 늘리며 응답 시간과 처리량이 어떻게 바뀌는지 직접 확인해보시면, 가상 스레드의 효과를 가장 설득력 있게 팀에 공유할 수 있습니다.
참고 자료
- Spring Boot 4 and Virtual Threads: A Practical, High-Impact Upgrade | Medium
- Virtual Threads in Spring Boot 4: What Actually Changes for Your Code | Javarevisited
- Virtual Threads and Reactive Evolution in Spring Boot 4 | CodeToDeploy
- Unlocking Peak Performance: Java Virtual Threads with Spring Boot 4.0 | Code Mill Hub
- Spring Boot Performance with Java Virtual Threads | Java Code Geeks
- Java Virtual Threads: The Pinning Problem, the Deadlock, and the Fix in Java 24
- Virtual Threads vs WebFlux: Java Concurrency in 2026 Guide
- Virtual Threads + HikariCP: The 2025 Formula | Medium
- Spring Boot 4 Migration Guide | OpenLogic
- Embracing Virtual Threads | Spring.io 공식 블로그
- Virtual Threads Performance in Spring Boot | FastThread Blog