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 처리
  1. Carrier Thread: 가상 스레드를 실행하기 위해 밑바탕이 되는 OS 스레드(Platform Thread)이다. (기본적으로 ForkJoinPool 이용)
  2. 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 버전과 진단 결과를 함께 확인한다.