[Java] Virtual Thread와 Platform Thread(스케줄링과 성능 비교)
개요
자바의 표준 스레드 모델은 운영체제(OS, Operating System) 스레드와 1:1로 매핑되기 때문에 스레드 하나당 약 1MB의 스택 메모리와 시스템 콜 비용이 든다. 스레드는 비싸기 때문에 스레드 풀을 만들고, 적은 수의 스레드로 많은 요청을 돌려쓰며, 블로킹 입출력(I/O, Input/Output)을 만나면 스레드가 일을 못 하는 비효율을 발생했다.
이 비효율을 해결하기 위해 이벤트 루프 기반의 리액티브(WebFlux)와 컴파일러 변환 기반의 코루틴(Kotlin)이 등장했다. 두 접근 모두 효과는 분명하지만 학습 곡선과 디버깅 난이도가 가파르다. 콜백 체인이나 Mono/Flux, suspend 로 코드를 다시 써야 하고, 스택 트레이스도 평소와 달라진다.
가상 스레드는 Java 21에서 정식 도입된 세 번째 접근이다. 코드 스타일을 바꾸지 않고도 수백만 개의 동시성을 다룰 수 있게 한다. 자바 가상 머신(JVM, Java Virtual Machine) 레벨에서 스케줄링되는 경량 스레드라는 점이 핵심이고, 기존 동기 스타일 코드를 거의 그대로 옮길 수 있다.
| 구분 | 플랫폼 스레드 | 가상 스레드 |
|---|---|---|
| 매핑 대상 | 커널 스레드 (1:1) | 캐리어(Carrier) 스레드 (M:N) |
| 스택 위치 | OS 가 관리하는 네이티브 스택 | JVM 힙(Continuation 객체) |
| 생성 비용 | 높음 (시스템 콜, 약 1MB 스택) | 매우 낮음 (수 KB) |
| 스케줄러 | OS 커널 스케줄러 | JVM 레벨 스케줄러 (기본 ForkJoinPool) |
| 블로킹 I/O 시 동작 | 캐리어(커널 스레드)가 그대로 멈춤 | 캐리어에서 언마운트(unmount)되어 다른 작업 수행 |
플랫폼 스레드의 한계
플랫폼 스레드는 java.lang.Thread 로 만들어지는 스레드이며, JVM 내부에서 만들어지지만 OS 의 커널 스레드와 1:1로 매핑된다. 자바 스레드를 하나 만들면 커널 스레드도 하나 생성되어 작업을 수행한다. 이 구조 때문에 스레드 생성·종료·컨텍스트 스위칭이 모두 시스템 콜을 거치고, JVM은 플랫폼 스레드 하나당 기본 1MB의 스택을 잡는다.
8GB 메모리의 머신에서 산술적으로 약 8,000개의 플랫폼 스레드가 한계다. 실제로는 다른 메모리 사용을 빼면 그보다 훨씬 적다. 그래서 톰캣 워커를 200개 정도로 제한하고, 그 200개 안에서 동시 요청을 처리한다.
블로킹 I/O 의 문제
플랫폼 스레드 모델의 문제는 블로킹 I/O이다. 200개의 톰캣 스레드가 모두 데이터베이스(DB, Database) 조회나 외부 응용 프로그램 인터페이스(API, Application Programming Interface) 호출에 블로킹되면 새 요청은 큐에서 대기한다. 유휴 상태의 스레드가 존재하지만, 작업에 사용할 수 없게 된다.
가상 스레드란(Continuation과 M:N 스케줄링)
가상 스레드는 OS 레벨까지 내려가지 않고 JVM 레벨에서 스케줄링되는 경량 스레드다. 커널 스레드와 직접 매핑되지 않고, 캐리어 스레드(Carrier Thread, 플랫폼 스레드) 위에 마운트(mount)되어 실행된다.
수백만 개의 가상 스레드가 소수의 캐리어 스레드 위에서 M:N으로 스케줄링된다. 가상 스레드는 작업을 수행하는 동안만 캐리어 스레드를 점유하고, 블로킹 I/O를 만나면 캐리어에서 언마운트되어 힙으로 옮겨간다. 그 캐리어 스레드는 즉시 다른 가상 스레드를 마운트해 일을 시킨다. 반대로 순수 CPU 연산은 결국 캐리어 위에서 실행되므로, CPU 바운드 작업에는 플랫폼 스레드 대비 이점이 없다.
가상 스레드의 동작은 Continuation 이라는 객체가 책임진다. Continuation은 실행 상태(스택 프레임, 지역 변수, 프로그램 카운터)를 자바 객체로 표현한 것이다. JVM이 스레드 스택을 OS가 아니라 힙에 두는 셈이다.
// VirtualThread.java 의 일부 (개념적 요약)
final class VirtualThread extends BaseVirtualThread {
private final Executor scheduler; // 기본값: ForkJoinPool
private final Continuation cont; // 실행 상태
private final Runnable runContinuation; // 마운트/실행 람다
private volatile int state; // 가상 스레드 상태
private volatile Thread carrierThread; // 현재 마운트된 캐리어
}각 필드의 의미는 다음과 같다. scheduler는 가상 스레드를 캐리어 위에 올리는 스케줄러로 기본은 work-stealing 기반의 ForkJoinPool 이다. cont는 작업의 실행 컨텍스트(스택, 지역 변수)를 담는 객체다. state는 NEW, STARTED, RUNNING, PARKING, PARKED, UNPARKED, YIELDED, TERMINATED 등의 값을 가진다. carrierThread는 현재 어느 캐리어 위에 마운트되어 있는지 가리킨다.
가상 스레드의 라이프사이클은 다음과 같이 흐른다.
sequenceDiagram autonumber participant VT as Virtual Thread participant Carrier as Carrier Thread participant Cont as Continuation (Heap) participant IO as Blocking I/O VT->>Carrier: mount, CPU 점유 Carrier->>Carrier: 비즈니스 로직 실행 Carrier->>IO: 블로킹 I/O 호출 Note over Carrier: park() 호출 Carrier->>Cont: 스택 상태를 힙으로 복사 Carrier->>VT: unmount Note over Carrier: 다른 가상 스레드를 마운트 가능 IO-->>Carrier: I/O 완료, unpark() Cont->>Carrier: 힙에서 스택 복원 Carrier->>VT: 다시 mount Carrier->>Carrier: 작업 재개
핵심은 블로킹 I/O 호출이 캐리어 스레드를 블로킹 시키지 않는다는 점이다. 호출자 코드는 여전히 블로킹 스타일이지만, 캐리어 스레드는 실제로 멈추지 않는다.
// 호출 코드는 동기 스타일 그대로
String response = httpClient.send(request, BodyHandlers.ofString()).body();
process(response);
// 내부적으로는 가상 스레드가 park 한 뒤 캐리어에서 언마운트되고,
// I/O 완료 시 unpark 되어 다시 마운트된다.Pinning: 캐리어가 묶이는 상황
Java 21~23에서는 synchronized 블록 안에서 블로킹 I/O가 발생하면 가상 스레드가 캐리어에서 언마운트되지 못하고 그대로 캐리어를 점유했다. 이를 pinning 이라 한다.
// 잘못된 패턴: synchronized 블록 안의 블로킹 호출이 캐리어를 pinning 한다
public synchronized void doIt() {
httpClient.send(request, BodyHandlers.ofString());
}
// 올바른 패턴: ReentrantLock 은 자바 레벨 락이라 가상 스레드 단위로 잡혀 정상 언마운트된다
private final ReentrantLock lock = new ReentrantLock();
public void doIt() {
lock.lock();
try {
httpClient.send(request, BodyHandlers.ofString());
} finally {
lock.unlock();
}
}synchronized 는 JVM 수준 락이라 캐리어 단위로 잡히지만, ReentrantLock 은 자바 레벨 락이라 가상 스레드 단위로 잡힌다. Java 24 부터는synchronized 블록 안에서도 pinning 없이 정상적으로 언마운트된다.
가상 스레드가 대량으로 생성될 수 있다는 점은 ThreadLocal 사용에도 영향을 준다. 각 가상 스레드마다 ThreadLocalMap 이 할당되면 메모리 사용량이 폭증할 수 있다. ThreadLocal 의 내부 구조와 ScopedValue 로의 대체 방안은 자바 스레드와 동시성.md 에서 다뤘다. 가상 스레드 환경에서는 특히 ScopedValue 가 권장된다.
다른 동시성 모델과 비교
가상 스레드, WebFlux, 코루틴은 모두 적은 자원으로 많은 동시 요청을 처리한다는 같은 목표를 추구한다. 접근 방식이 다를 뿐이다.
| 구분 | 플랫폼 스레드 | 가상 스레드 | WebFlux | Coroutine |
|---|---|---|---|---|
| 런타임 | OS 스레드 | JVM 가상 스레드 | 이벤트 루프와 콜백 | 컴파일러 변환과 디스패처 |
| 동시성 단위 | 수백~수천 | 수백만 | 콜백 | suspend 함수 |
| 코드 스타일 | 동기(블로킹) | 동기(논블로킹처럼 동작) | 리액티브(Mono/Flux) | suspend / async |
| 스레드 생성 비용 | 높음 | 매우 낮음 | 매우 낮음 | 매우 낮음 |
| 학습 곡선 | 낮음 | 낮음 | 높음 | 중간 |
| 디버깅 | 쉬움 | 비교적 쉬움 | 어려움(Reactor 체인) | 어려움(suspend 흐름) |
WebFlux는 Netty와 이벤트 루프를 사용하며, 모든 I/O가 콜백 기반으로 처리된다. 적은 스레드(코어 수만큼)로 많은 요청을 받지만, Mono, Flux, subscribeOn, publishOn, Schedulers 같은 새로운 모델을 배워야 하고 스택 트레이스도 평소와 다르다.
코틀린의 코루틴은 suspend 함수를 컴파일 타임에 상태 머신(state machine)으로 변환한다. 중단(suspend)과 재개(resume)를 컴파일러가 만들어주는 셈이다.
가상 스레드의 장점은 코드를 그대로 사용할 수 있다는 데 있다. Thread.sleep, JDK 의 동기 API 가 가상 스레드 친화적으로 다시 작성되어 있어 기존 동기 코드를 거의 수정 없이 쓸 수 있다. 설정도 간단해서 스프링 부트에서는 한 줄로 톰캣 워커가 가상 스레드로 교체된다.
스프링 부트에서 가상 스레드 적용
spring:
threads:
virtual:
enabled: true # 톰캣 워커, @Async 등을 가상 스레드로 전환이 설정은 톰캣의 요청 처리 스레드 풀을 VirtualThreadPerTaskExecutor 로 교체한다. 결과적으로 모든 하이퍼텍스트 전송 프로토콜(HTTP, HyperText Transfer Protocol) 요청이 새로운 가상 스레드에서 처리된다.
직접 가상 스레드를 만드는 API는 다음과 같다. 다만 가상 스레드는 풀로 관리하지 않는 것이 원칙이다. 생성 비용이 거의 0이라 매 작업마다 새 가상 스레드를 만들고 끝나면 버리는 것이 효율적이다.
// 잘못된 패턴: 플랫폼 스레드의 습관대로 가상 스레드를 고정 풀로 관리
ExecutorService pool = Executors.newFixedThreadPool(1000, Thread.ofVirtual().factory());
// 올바른 패턴: 작업마다 새 가상 스레드를 만들고 끝나면 버린다
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> handle(i));
}
} // try-with-resources 종료 시 모든 가상 스레드 완료 대기newVirtualThreadPerTaskExecutor() 라는 이름 자체가 이 원칙을 반영한다. 단일 가상 스레드나 팩토리가 필요하면 다음처럼 만든다.
// 단일 가상 스레드
Thread vt = Thread.ofVirtual().name("worker").start(() -> {
// 작업
});
vt.join();
// Factory 를 통한 생성
ThreadFactory factory = Thread.ofVirtual().name("vt-", 0).factory();
ExecutorService pool = Executors.newThreadPerTaskExecutor(factory);10만 명의 프로필을 동시에 가져온다면 플랫폼 스레드 풀로는 스레드 풀 크기에 막혀 불가능하지만, 가상 스레드는 10만 개를 만들어도 메모리 부담이 미미하다.
public class FanOutExample {
public List<UserProfile> fetchAll(List<Long> userIds) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<UserProfile>> futures = userIds.stream()
.map(id -> executor.submit(() -> userService.findById(id)))
.toList();
return futures.stream()
.map(f -> {
try {
return f.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList();
} // 모든 가상 스레드 완료 후 try 블록 종료
}
}@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping("/orders/{id}")
public OrderResponse get(@PathVariable Long id) {
Order order = orderService.findById(id);
return OrderResponse.from(order);
}
}가상 스레드가 무제한으로 만들어진다고 DB 커넥션이나 외부 API 호출이 무제한으로 처리되는 것은 아니다. 동시 작업 수를 제어하려면 세마포어(Semaphore)를 쓴다.
private final Semaphore dbPermits = new Semaphore(50); // DB 동시 호출 50 개로 제한
public Order findOrder(Long id) throws InterruptedException {
dbPermits.acquire();
try {
return orderRepository.findById(id);
} finally {
dbPermits.release();
}
}| 도구 | 적합한 상황 |
|---|---|
ForkJoinPool | CPU 바운드 분할 정복. ParallelStream, CompletableFuture 의 기본 풀 |
CompletableFuture | 비동기 결과 조합(thenCompose, thenCombine, allOf, anyOf). 가상 스레드와 함께 써도 좋다 |
Executors.newVirtualThreadPerTaskExecutor() | I/O 바운드 다수 동시 작업 |
| Structured Concurrency | 부모-자식 관계가 있는 동시 작업 |
언제 가상 스레드를 쓸까
가상 스레드는 CPU 바운드 워크로드에는 이득이 없고, pinning이나 ThreadLocal 누수 같은 새로운 고려사항도 있다. 또한 이미 네티와 리액티브로 잘 짜인 시스템에는 추가 이득이 크지 않고, 기존 라이브러리가 가상 스레드 친화적이지 않을 수 있다(특히 네이티브 코드 호출, 오래된 동기 API).
| 상황 | 추천 |
|---|---|
| 새 프로젝트, MVC, 동기 코드 선호 | 톰캣과 가상 스레드 |
| 기존 톰캣 MVC, 동시성 한계 도달 | 가상 스레드 활성화로 즉시 개선 |
| 이미 리액티브로 잘 동작 중 | 그대로 유지 |
| CPU 바운드 배치 | ForkJoinPool 또는 CompletableFuture |
| 부모-자식 작업 구조 | Structured Concurrency |