Java 21 Virtual Thread — 원리, 적용과 주의사항
1. 핵심 요약
- 개념: JVM이 직접 관리하는 경량 스레드로, OS 스레드(Platform Thread)의 리소스 한계를 극복하기 위해 Java 21에서 정식 도입되었다.
- 핵심 가치: 기존의 직관적인 동기/블로킹(Imperative/MVC) 작성 방식을 유지하면서 많은 I/O 대기 작업을 동시에 다룰 수 있다. 실제 처리량은 DB 커넥션 풀, 하위 시스템, CPU 사용량 등의 병목에 따라 달라지므로 반드시 대상 환경에서 측정해야 한다.
- 적합한 유즈케이스: DB 조회, External API 호출 등 I/O Bound 작업이 많은 백엔드 서비스이다.
- Spring Boot 지원: Java 21 이상과 Spring Boot 3.2 이상에서
spring.threads.virtual.enabled=true를 사용하면 지원되는 자동 구성 실행기와 스케줄러가 가상 스레드를 사용한다. 사용자 정의Executor나AsyncConfigurer가 있으면 적용 범위가 달라질 수 있다.
2. 작동 원리
2.1 Platform Thread와 Virtual Thread
[ 기존 Platform Thread Model ]
Request -> [Platform Thread (1:1)] <---> [OS Thread] (I/O 블로킹 시 OS 스레드 대기 / 낭비)
[ Virtual Thread Model ]
Request -> [Virtual Thread (M)] --(Mount)-- [Carrier Thread (N)] <---> [OS Thread]
|
(I/O 블로킹 발생)
v
(Unmount / Parking) -> Carrier Thread는 다른 Virtual Thread 처리
- Carrier Thread: 가상 스레드를 실행하기 위해 밑바탕이 되는 OS 스레드(Platform Thread)이다. (기본적으로 ForkJoinPool 이용)
- Mount / Unmount:
- 가상 스레드가 I/O 블로킹(예: DB 쿼리, Network 호출)을 만나면, JVM은 가상 스레드를 Carrier Thread에서 **Unmount(Parking)**시킨다.
- 이때 Carrier Thread는 유휴 상태가 되지 않고 바로 다른 가상 스레드를 Mount하여 처리한다.
- I/O 응답이 오면 Unmount 되었던 가상 스레드는 다시 가용 Carrier Thread에 **Mount(Unparking)**되어 재개된다.
3. 적용 방법
3.1 Pure Java 21 사용 예시
package com.example.demo;
import java.util.concurrent.Executors;
public class VirtualThreadExample {
public void runVirtualThread() {
// 1. 단일 가상 스레드 생성 및 실행
Thread vThread = Thread.ofVirtual()
.name("custom-vt")
.start(() -> {
System.out.println("Hello from Virtual Thread: " + Thread.currentThread());
});
// 2. ExecutorService를 활용한 수많은 Task 처리
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int taskId = i;
executor.submit(() -> {
// I/O 블로킹 흉내 (Thread.sleep도 가상스레드에서는 Non-blocking처럼 Unmount됨)
Thread.sleep(1000);
return taskId;
});
}
} // try-with-resources에 의해 모든 Task 완료까지 자동 대기(close)
}
}3.2 Spring Boot 3.2+ 설정 (application.yml)
spring:
threads:
virtual:
enabled: true- 위 설정은 Spring Boot가 자동 구성하는 웹 요청 처리와 작업 실행기·스케줄러에 가상 스레드를 적용하는 출발점이다.
@Async는 자동 구성된AsyncTaskExecutor를 사용할 때,@Scheduled는 자동 구성된 스케줄러를 사용할 때 해당 설정의 영향을 받는다. 사용자 정의 실행기·스케줄러는 별도로 확인해야 한다.
4. 제약과 진단
4.1 JDK 21의 Synchronized 블록과 Pinned Thread
- JDK 21 기준으로
synchronized블록이나 메서드 내부에서 I/O 블로킹이 발생하면, 가상 스레드가 Carrier Thread에서 Unmount되지 못하고 붙잡히는 Pinning 현상이 발생할 수 있다. 후속 JDK에서는 이 동작이 달라질 수 있으므로 실제 운영 JDK 버전을 기준으로 확인한다. - 대응: 빈번하게 실행되며 긴 I/O를 보호하는
synchronized영역은 구조를 조정하거나ReentrantLock으로 대체할지 검토한다. 짧은 메모리 연산을 보호하는 영역까지 일괄적으로 교체할 필요는 없다. - 팁: JVM 옵션
-Djdk.tracePinnedThreads=full을 주어 Pinning이 발생하는 위치를 감지할 수 있다.
4.2 ThreadLocal 사용 최소화
- 가상 스레드는 수십만~수백만 개가 생성될 수 있다.
- 무거운 객체나 대용량 데이터를
ThreadLocal에 담아두면 심각한 메모리 낭비 및 GC 부담을 초래한다. - 해결책: 불필요한
ThreadLocal사용을 줄이고, 정 필요하다면 Java의 최신 기능인 Scoped Values (Preview) 도입을 고려한다.
4.3 CPU-Bound 작업에는 비효율적
- 가상 스레드는 I/O 대기 시간을 효율적으로 활용하는 것이 목적이다.
- 비디오 인코딩, 대용량 연산 등 CPU 코어를 100% 지속 사용하는 작업은 Context Switching 오버헤드만 가중시킬 수 있다.
4.4 스레드 풀링(Pooling) 금지 & Semaphore 활용
- 가상 스레드는 생성 비용이 극도로 적고 파기되는 경량 객체이므로 절대 풀링(Pool)하지 않는다. (생성할 때마다 만들어 쓰고 버림)
- DB Connection Pool처럼 **하위 리소스의 개수를 제한(Throttling)**해야 하는 경우에는 스레드 풀 대신
Semaphore를 사용해야 한다.
5. 면접 Q&A
5.1 Virtual Thread가 도입된 배경과 기존 Platform Thread의 한계는 무엇인가요?
A. 기존 자바의 Platform Thread는 OS 스레드와 1:1로 매핑되는 구조였다. OS 스레드는 생성 비용이 크고 기본 스택 메모리(약 1MB) 소모가 심해 수천 개 이상 생성하기 어려웠다.
이로 인해 Thread-per-request 모델에서는 I/O 블로킹 발생 시 OS 스레드가 유휴 상태로 대기하여 리소스 낭비가 심했고, 이를 극복하기 위한 수평 확장이나 스레드 수 증가에 분명한 한계가 존재했다.
5.2 Virtual Thread는 Blocking I/O 발생 시 어떻게 동작하여 높은 처리량을 유지하나요?
A. 가상 스레드가 I/O 작업 등으로 블로킹되면, JVM은 이 가상 스레드를 하단의 캐리어 스레드(Platform Thread)에서 언마운트(Unmount)하고 대기 상태(Parking)로 전환한다.
그동안 캐리어 스레드는 대기하지 않고 즉시 다른 가상 스레드를 마운트하여 실행시킨다. 이후 I/O 작업이 완료되면 대기 중이던 가상 스레드가 다시 캐리어 스레드에 마운트되어 실행을 재개하는 방식으로, 논블로킹(Non-blocking)과 유사하게 OS 스레드의 효율을 극대화한다.
5.3 Spring WebFlux(Reactive) 대신 Virtual Thread를 도입할 때의 장점은 무엇인가요?
A. Reactive 프로그래밍은 고성능을 제공하지만 코드가 복잡해지고, 콜백 체인으로 인해 디버깅 및 에러 트레이싱(Stack Trace)이 매우 까다롭다는 단점이 있다.
반면 Virtual Thread는 기존의 직관적인 동기/블로킹(Spring MVC 스타일) 코드를 유지하면서 I/O 대기가 많은 작업의 동시성을 높일 수 있다. WebFlux와의 처리량 비교는 응용 구조와 병목에 따라 달라지므로 벤치마크가 필요하지만, 기존 코드 구조를 유지할 수 있어 개발 생산성과 유지보수성 측면에서 장점이 있다.
5.4 Virtual Thread 사용 시 주의해야 할 ‘Pinning’ 현상이란 무엇이며 어떻게 대처해야 하나요?
A. JDK 21에서 Pinning은 가상 스레드가 캐리어 스레드에 고정되어 언마운트되지 못하는 현상이다.
synchronized블록이나 Native 메서드 내부에서 I/O 블로킹이 발생할 때 나타날 수 있다.
빈번하고 오래 지속되는 Pinning이 확인된 구간은 임계 영역을 재구성하거나java.util.concurrent.locks.ReentrantLock을 사용할지 검토한다. 단, 모든synchronized를 기계적으로 교체하지 않고 운영 JDK 버전과 진단 결과를 함께 확인한다.