Java의 동시성 프로그래밍은 오랫동안 OS 스레드를 직접 매핑한 Platform Thread를 기반으로 발전해왔습니다. 2023년 9월 19일, Java 21 LTS에서는 이러한 전통적인 스레드 모델과 함께 새로운 경량 스레드 모델인 Virtual Thread
가 도입되었습니다.(JEP 444: Virtual Threads) 이는 최근 프로그래밍 언어들의 트렌드를 반영한 것으로, Go의 Goroutine, Kotlin의 Coroutine과 같은 경량 스레드 모델들이 높은 동시성 처리를 위한 해결책으로 주목받고 있는 흐름과 맥을 같이 합니다.
Lightweight Thread(경량 스레드)
는 실행 단위를 더 작은 단위로 나눠 Context switching 비용과 Blocking 타임을 낮추고, Kernel 레벨이 아닌 Runtime 레벨의 Task Scheduling 으로 효율적인 리소스 활용이 가능하다는 장점이 있어 클라우드 네이티브 환경에서의 대규모 동시성 처리에 적합한 해결책으로 떠오르고 있습니다.
전통적인 Java 웹 애플리케이션은 Thread-per-Request
모델을 기반으로 동작해왔습니다. 각 HTTP 요청마다 하나의 스레드가 할당되어 요청을 처리하는 방식은 직관적이고 이해하기 쉽다는 장점이 있습니다. 하지만 이 모델은 동시에 몇 가지 한계점을 가지고 있습니다.
이러한 문제들을 해결하기 위해 Spring WebFlux와 같은 Reactive Programming 모델이 등장했습니다. 이벤트 루프 기반의 non-blocking 모델은 적은 수의 스레드로 높은 처리량을 달성할 수 있었지만, 다음과 같은 새로운 문제들을 야기했습니다.
이러한 배경에서 Java Virtual Thread
는 다음과 같은 목표를 가지고 탄생했습니다.
Virtual Thread는 JVM 내부에서 자체적으로 스케줄링되는 경량 실행 단위로, OS 스레드에 직접 매핑되는 대신 작업이 필요할 때만 Platform Thread(Carrier Thread)에 마운트되어 실행됩니다. 이를 통해 수십만 개의 동시 작업을 효율적으로 처리할 수 있게 되었고, 특히 IO 작업이 많은 워크로드에서 큰 성능 향상을 기대할 수 있게 되었습니다.
Platform Thread (Java의 전통적인 Thread)
Virtual Thread
사용하는 자원 Platform Thread Virtual Thread Metadata size 약 2kb(OS별 차이 있음) 200~300B Memory 미리 할당된 Stack 필요시 마다 Heap Context Switching Cost 1-10us(커널 영역에서 발생) ns (or 1us 미만)
Spring Boot 3.2 이상
# application.yaml
spring:
threads:
virtual:
enabled: true
Spring Boot 3.2 미만
// Web Request 를 처리하는 Tomcat 이 Virtual Thread를 사용하도록 한다.
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer()
{
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
// Async Task에 Virtual Thread 사용
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
// 권장 X (작업 실패 시 다른 작업의 취소나 리소스 정리가 보장되지 않음)
CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<Order> order = CompletableFuture.supplyAsync(() -> fetchOrder());
// 권장
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> user = scope.fork(() -> fetchUser());
Future<Order> order = scope.fork(() -> fetchOrder());
scope.join(); // 모든 작업 완료까지 대기
scope.throwIfFailed(); // 작업 실패 시 모든 작업 취소 및 예외 전파
// 모든 작업이 성공한 경우에만 실행
processUserOrder(user.resultNow(), order.resultNow());
} // scope를 벗어나면 모든 자식 작업이 자동으로 정리됨
Scoped Value
- ThreadLocal을 대체하기 위한 새로운 컨텍스트 전파 메커니즘 (JEP 429, JEP 446)
- Virtual Thread 환경에 최적화된 불변 컨텍스트 전달 방식
- ThreadLocal과 달리 메모리 누수 위험이 없고 명시적인 스코프 관리
- 자식 Virtual Thread로의 자동 전파 지원
final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance(); void processWithUser(User user) { ScopedValue.where(CURRENT_USER, user) .run(() -> { // 이 스코프 내의 모든 Virtual Thread에서 CURRENT_USER 접근 가능 new VirtualThread(() -> { User u = CURRENT_USER.get(); processUserData(u); }).start(); }); }
8 Core 8 G Memory 인스턴스 2대로 성능 테스트를 진행한 결과입니다.
DB IO 보다는 외부 API 호출이 많기 때문에 DB 사용량은 두 케이스 모두 미미하고, Virtual Thread 가 아무리 빨라도 Response Time은 가장 느린 API 의 응답 시간과 동일했습니다.
동일하게 DB 에 대한 리소스는 고려해야할 정도는 아니었고, 외부 API 에 부담이 갈 정도의 성능을 요구하지는 않아 backpressure 처리는 톰캣 스레드만 플랫폼으로 연결하는 정도로 설정했습니다.