Java Virtual Threads: 실전 가이드
1. 개요
Virtual threads는 Java 21의 공식 기능(JEP 444)으로, 동기식(blocking style) 코드를 작성하면서도 non-blocking I/O처럼 높은 throughput을 얻을 수 있게 해줍니다. 이 글에서는 개념부터 시작해서 virtual thread 생성 방법, platform thread와의 성능 비교, Spring Boot 통합 방법까지 알아봅니다. 이 글을 읽고 나면 언제 virtual threads를 사용해야 하고 언제 사용하지 말아야 할지 알게 될 것입니다.
2. 전통적인 Platform Thread의 문제점
Java의 각 platform thread는 하나의 OS thread와 1:1로 매핑됩니다. OS thread는 메모리(일반적으로 1MB 스택)를 소비하고, thread 생성 및 context switching 비용이 높습니다. 따라서 수만 개의 request를 동시에 처리하는 애플리케이션(예: I/O 대기 중인 서버)은 만들 수 있는 thread pool의 크기로 인해 제약을 받습니다.
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // I/O 호출 시뮬레이션
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
Pool이 200개의 thread를 가지고 있을 때, 10,000개의 task는 대기해야 합니다. 각 thread는 한 번에 하나의 task만 처리할 수 있기 때문에 전체 완료 시간이 길어집니다. 비록 대부분의 시간이 대기 중(blocked)이고 계산이 아니더라도 말입니다.
3. Virtual Thread란 무엇인가
Virtual thread는 JVM이 관리하는 thread(OS 관리 아님)로, 코드를 실행 중일 때만 platform thread(carrier thread라고 함) 위에 “mount”됩니다. Virtual thread가 blocking operation(I/O, Thread.sleep, lock 등)을 호출하면, JVM은 자동으로 그것을 carrier thread에서 “unmount”하여 carrier thread를 다른 virtual thread를 처리하도록 해제합니다.
결과적으로 리소스 고갈을 걱정하지 않고 수백만 개의 virtual thread를 만들 수 있으며, 코드는 여전히 익숙한 blocking 스타일로 작성됩니다. CompletableFuture도 필요 없고, reactive 스타일도 필요 없습니다.
3.1. 간단한 Virtual Thread 만들기
Thread vThread = Thread.ofVirtual()
.name("order-processor")
.start(() -> {
System.out.println("실행 중: " + Thread.currentThread());
});
vThread.join();
Thread.ofVirtual()은 thread 아노를 위한 빌더를 반환합니다. .start()는 thread를 생성하고 바로 실행합니다. 출력된 thread는 다음과 같은 형태입니다:
실행 중: VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1
@ForkJoinPool-... 부분에 주목하세요. 이것이 현재 이 virtual thread를 “carry”하고 있는 carrier thread입니다.
4. Virtual Thread용 Executor
실제 애플리케이션에서 virtual threads를 사용하는 가장 일반적인 방법은 ExecutorService를 통하는 것입니다.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(1000); // I/O 호출 시뮬레이션 (DB, HTTP 등)
return "Task " + id + " 완료";
}));
}
for (Future<String> f : futures) {
f.get();
}
}
newVirtualThreadPerTaskExecutor()는 각 task마다 새로운 virtual thread를 생성하며, 기존 thread pool처럼 thread를 재사용하지 않습니다. Virtual thread를 생성하는 비용이 매우 낮기 때문(수백 바이트, 1MB가 아님), 이는 완전히 합리적입니다. try-with-resources 블록은 executor가 종료되고, 블록을 나갈 때 모든 task가 완료될 때까지 대기하도록 보장합니다.
섹션 2의 예제(200개 platform thread의 pool)와 비교하면, 이 코드는 전체 10,000개의 task를 거의 동시에 약 1초 안에 완료합니다(pool 제한이 없기 때문에). 여러 번 대기할 필요가 없습니다.
5. 실제 테스트로 성능 비교하기
class VirtualThreadBenchmarkTest {
@Test
void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
int taskCount = 10_000;
CountDownLatch latch = new CountDownLatch(taskCount);
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
}
latch.await();
Duration elapsed = Duration.between(start, Instant.now());
assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
}
}
이 테스트는 10,000개의 task를 생성하고, 각 task는 100ms 동안 sleep합니다. 순차적으로 실행하면 1000초가 걸리지만, virtual threads를 사용하면 task들이 진정한 병렬로 실행되기 때문에 전체 완료가 몇 초 안에 됩니다. CountDownLatch는 assert하기 전에 모든 task가 완료될 때까지 대기하는 데 사용됩니다.
주의: 실제 벤치마크는 시간 계산만 하는 것이 아니라 JFR(Java Flight Recorder)을 사용해서 CPU와 메모리도 측정해야 합니다. 시간은 테스트를 실행하는 머신에도 좌우되기 때문입니다.
6. 주의: synchronized 사용 시 Pinning
Virtual thread는 synchronized 블록 내에 있을 때 carrier thread에서 unmount되지 않습니다. 이 현상을 pinning이라고 합니다. 이 경우, synchronized 내부의 blocking I/O는 carrier thread를 점유하게 되어 virtual thread의 장점을 잃게 됩니다.
public class InventoryService {
private final Object lock = new Object();
public void reserveStock(String sku) {
synchronized (lock) {
callExternalInventoryApi(sku); // synchronized 내의 blocking I/O -> pinning!
}
}
}
synchronized (lock) 줄은 I/O 작업임에도 불구하고 callExternalInventoryApi 호출 전체 시간 동안 carrier thread를 점유합니다. 수천 개의 virtual thread가 모두 pin되면, carrier thread pool(기본값은 CPU core 개수)은 빠르게 고갈됩니다.
해결책: synchronized를 java.util.concurrent.locks.ReentrantLock으로 대체합니다. 이는 virtual thread와 잘 호환되며(pinning을 일으키지 않음).
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
public void reserveStock(String sku) {
lock.lock();
try {
callExternalInventoryApi(sku);
} finally {
lock.unlock();
}
}
}
ReentrantLock은 blocking I/O를 만날 때 virtual thread가 정상적으로 unmount되도록 하여, virtual thread의 “가벼움” 정신을 유지합니다.
7. Spring Boot에 Virtual Thread 통합
Spring Boot 3.2부터는 하나의 property를 활성화하기만 하면 Spring Web(Tomcat)이 각 request마다 virtual thread를 사용하도록 할 수 있습니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true
이 property는 Tomcat(그리고 Spring Boot에서 지원하는 다른 executor)을 위해 virtualThreadExecutor를 활성화합니다. 각 HTTP request는 고유한 virtual thread에서 처리되며, 기본 200개 thread의 고정 pool을 공유하는 대신 처리됩니다.
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.fetchOrderWithDetails(id); // DB 호출 + 다른 API 호출, 일반적인 blocking
}
}
Controller 코드는 전혀 변경할 필요가 없습니다. 여전히 예전처럼 blocking 스타일로 작성합니다. ab 또는 k6 같은 도구로 부하 테스트를 할 때, I/O 지연이 높은 endpoint(DB 호출, 다른 서비스 호출)에서 throughput이 눈에 띄게 증가하는 것을 볼 수 있습니다. Tomcat이 더 이상 기본 200개 thread로 제한되지 않기 때문입니다.
8. Virtual Thread를 사용하면 안 될 때
- CPU-bound task: 작업이 주로 계산(I/O 대기 없음)이라면, virtual thread는 이점을 주지 못합니다. Carrier thread의 수는 여전히 CPU core 개수로 제한됩니다. 이 경우
ForkJoinPool또는parallelStream()이 더 적합합니다. ThreadLocal사용이 많은 코드: Virtual thread는ThreadLocal과 호환되지만, 수백만 개의 virtual thread를 만들 때 각각이 큰ThreadLocal을 보유하면 상당한 메모리를 낭비합니다.ScopedValue(Java 21 preview)로 대체하는 것을 고려해야 합니다.- 많은 곳에서
synchronized를 사용하는 레거시 라이브러리: 섹션 6에서 언급했듯이, pinning은 조용히 성능을 저하시켜 바로 알아차리기 어렵습니다. - 제한된 Connection pool: Virtual thread는 많은 request를 병렬로 만드는 데 도움이 되지만, DB connection pool이 20개의 connection만 가지고 있으면 여전히 그곳에서 병목이 발생합니다. 이 경우 pool 크기를 적절히 증가시키거나 높은 동시성을 잘 지원하는 connection pool을 사용해야 합니다.
주의: Virtual thread는 thread 개수의 문제를 해결하지만, 각 task 처리 속도의 문제는 해결하지 못합니다. CPU-bound 코드를 더 빠르게 만들기를 기대하지 마세요.
9. 결론
Virtual threads는 간단한 blocking 코드를 작성하면서도 수천 개의 동시 request에 대해 잘 확장되도록 해줍니다. 특히 web service나 많은 API/DB를 호출하는 microservice 같은 I/O-bound 애플리케이션에 적합합니다. executor를 바꾸거나 Spring Boot에서 하나의 property를 활성화하기만 하면 됩니다. Reactive 스타일로 로직을 다시 작성할 필요가 없습니다. 하지만 synchronized를 사용할 때 pinning의 함정에 주의하고, virtual thread가 CPU-bound task에는 도움이 되지 않는다는 것을 기억하세요.