Java Virtual Thread — 21에서 25까지의 진화

· 15분 읽기
목차

Java 21 Project Loom에서 시작해 25 LTS까지, Virtual Thread의 동작 원리와 Pinning 문제 해결 과정

Java 서버 애플리케이션의 동시성 모델은 오랫동안 스레드 풀에 의존해왔다. 200개 스레드 풀로 10,000개 요청을 처리하면, 8,000개는 큐에서 대기한다. 스레드 하나가 OS 스레드 하나를 점유하고, OS 스레드는 비싸기 때문이다.

Java 21에서 Project Loom의 결과물로 정식 도입된 Virtual Thread는 이 전제를 뒤집었다. JVM 내부에서 스케줄링하는 경량 스레드로, 수백만 개를 동시에 생성해도 메모리와 OS 리소스 부담이 미미하다. “스레드를 아껴 쓰지 않아도 된다”는 패러다임 변화다.

이 글은 Virtual Thread의 동작 원리부터 Java 21의 한계, Java 24에서의 해결, 그리고 Java 25 LTS를 향한 업그레이드 전략까지를 정리한다.

Platform Thread vs Virtual Thread

기존 Platform Thread는 Java 스레드 하나가 OS 스레드 하나에 1:1로 매핑된다. OS 스레드는 약 1MB 스택 메모리를 차지하고, 생성에 OS 커널 호출이 필요하므로 수천 개가 현실적 한계다.

Virtual Thread는 이 구조를 M:N 모델로 바꾼다. 소수의 Carrier Thread(Platform Thread)가 OS 스레드에 매핑되고, 그 위에 수백만 개의 Virtual Thread가 JVM 스케줄러에 의해 번갈아 실행된다. 초기 스택 메모리는 약 1KB에 불과하다.

구분Platform ThreadVirtual Thread
메모리~1MB (스택)~1KB (초기)
생성 비용높음 (OS 호출)낮음 (JVM 내부)
개수 제한수천 개수백만 개 가능
스케줄링OS 커널JVM (User-mode)
블로킹 시스레드 점유자동 양보 (Yield)

핵심 차이는 블로킹 동작에 있다. Platform Thread가 I/O를 기다리면 OS 스레드를 그대로 점유한다. Virtual Thread는 블로킹이 발생하면 Carrier Thread에서 자동으로 분리(Unmount)되어, 다른 Virtual Thread가 그 Carrier를 사용할 수 있다.

사용 방법

Virtual Thread를 생성하는 방법은 여러 가지지만, 실무에서 권장하는 패턴은 Executors.newVirtualThreadPerTaskExecutor()다.

기본 생성

// 방법 1: Thread.startVirtualThread()
Thread vThread = Thread.startVirtualThread(() -> {
    System.out.println("Hello from Virtual Thread!");
});

// 방법 2: Thread.ofVirtual() builder
Thread vThread = Thread.ofVirtual()
    .name("my-virtual-thread")
    .start(() -> {
        System.out.println("Hello!");
    });

ExecutorService 사용 (권장)

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 100만 개의 작업도 문제없음
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return fetchDataFromDB();
        });
    }
} // try-with-resources로 자동 종료 대기

newVirtualThreadPerTaskExecutor()는 작업마다 새 Virtual Thread를 생성한다. 기존 스레드 풀과 달리 크기 제한이 없다. try-with-resources로 감싸면 모든 작업이 완료될 때까지 대기한 뒤 자동으로 종료된다.

기존 코드와의 차이

동일한 10,000개 I/O 요청을 처리하는 두 방식의 차이를 보면 Virtual Thread의 이점이 명확해진다.

// 기존 방식: 200개 스레드 풀
ExecutorService executor = Executors.newFixedThreadPool(200);
// 10,000개 요청 → 200개씩 처리, 나머지 대기
// 결과: ~50초 (10,000 / 200 = 50 배치 × 1초)

// Virtual Thread: 제한 없음
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 10,000개 요청 → 10,000개 동시 실행
    // 결과: ~1초 (거의 동시에 완료)
}

200개 스레드 풀에서 50초 걸리던 작업이 Virtual Thread로는 약 1초로 줄어든다. 각 작업이 1초간 I/O를 기다리는 동안 스레드를 점유하지 않기 때문이다.

핵심 동작 원리 — Continuation

Virtual Thread의 효율성은 Continuation 메커니즘에 기반한다. I/O 블로킹이 발생하면 다음 순서로 동작한다.

  1. Virtual Thread가 Carrier Thread에 마운트(Mount)되어 실행 중이다
  2. I/O 블로킹이 발생한다 (예: DB 쿼리, HTTP 호출)
  3. Virtual Thread가 자동으로 Carrier에서 분리(Unmount)된다. 실행 상태는 힙(Heap)에 저장된다
  4. 비어진 Carrier Thread에 대기 중인 다른 Virtual Thread가 마운트되어 실행된다
  5. I/O가 완료되면 원래 Virtual Thread가 다시 Carrier에 마운트되어 이어서 실행된다

이 Mount/Unmount 과정이 JVM 내부에서 자동으로 일어나므로, 개발자가 비동기 코드를 작성할 필요가 없다. 기존의 동기 코드 스타일 그대로 비동기의 효율을 얻는다.

Java 21의 한계 — Pinning 문제

Java 21에서 Virtual Thread가 정식 출시되었지만, 몇 가지 제약이 있었다. 가장 심각한 것이 Pinning 문제다.

synchronized 블록 내 블로킹

synchronized 블록 안에서 블로킹 I/O가 발생하면, Virtual Thread가 Carrier Thread에서 분리되지 못하고 고정(Pin)된다. 이 상태에서는 Carrier Thread를 점유한 채 I/O를 기다리므로, Platform Thread와 다를 바 없다.

// Java 21에서의 문제
synchronized (lock) {
    Thread.sleep(1000);      // Pinning 발생! Carrier Thread 점유
    httpClient.send(req);    // I/O 블로킹에도 양보 안 됨
}

Carrier Thread가 고갈되면 다른 Virtual Thread도 실행할 수 없게 되어 전체 성능이 급격히 저하된다.

Java 21에서의 대응법

Java 21에서는 synchronized 대신 ReentrantLock을 사용하는 것이 권장되었다.

// ReentrantLock 사용 (Java 21 권장 패턴)
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    Thread.sleep(1000);  // Pinning 없음
} finally {
    lock.unlock();
}

ReentrantLock은 Virtual Thread의 Mount/Unmount를 방해하지 않는다. 하지만 기존 코드베이스에 synchronized가 광범위하게 사용되어 있다면, 일일이 교체하는 것은 현실적으로 어렵다. 라이브러리 내부의 synchronized까지 고려하면 더욱 그렇다.

기타 Java 21 제약

synchronized 외에도 몇 가지 주의 사항이 있었다.

Object.wait() Pinning: synchronized 블록 내에서 monitor.wait()를 호출해도 Pinning이 발생한다.

ThreadLocal 메모리: Virtual Thread마다 ThreadLocal이 복사되므로, 무거운 객체를 ThreadLocal에 넣으면 수백만 Virtual Thread 환경에서 메모리가 폭발할 수 있다.

모니터링 어려움: 수백만 개 Virtual Thread의 Thread dump는 거대해지고, 기존 APM 도구와의 호환성 문제도 있었다.

Pinning 감지

-Djdk.tracePinnedThreads=full JVM 옵션으로 Pinning 발생 지점을 추적할 수 있다.

java -Djdk.tracePinnedThreads=full MyApp

Java 24의 돌파구 — JEP 491

Java 24(2025년 3월)에서 JEP 491이 도입되면서 synchronized Pinning 문제가 근본적으로 해결되었다.

// Java 24+: synchronized 내에서도 Unmount 가능
synchronized (lock) {
    Thread.sleep(1000);      // 양보 가능
    httpClient.send(req);    // I/O 시 다른 Virtual Thread 실행
}

JEP 491은 synchronized 블록과 Object.wait() 안에서도 Virtual Thread가 Carrier Thread에서 분리될 수 있도록 JVM 내부를 개선했다. 이 변경으로 기존 synchronized 기반 레거시 코드에서도 Virtual Thread를 안전하게 도입할 수 있게 되었다. 다만 native 코드 호출이나 FFM(Foreign Function & Memory) API 내부에서는 여전히 Pinning이 발생할 수 있으므로, 이 부분은 계속 주의가 필요하다.

ThreadLocal의 대안 — Scoped Values

Virtual Thread 환경에서 ThreadLocal의 메모리 문제를 해결하기 위해 Scoped Values가 도입되었다. Java 21에서 Preview로 시작하여 JEP 506으로 정식화가 진행 중이다.

private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

ScopedValue.where(CURRENT_USER, user)
    .run(() -> {
        // 이 범위 내에서만 CURRENT_USER 접근 가능
        processRequest();
    });
특성ThreadLocalScopedValue
생명주기명시적 제거 필요범위 벗어나면 자동 정리
상속 방식자식 스레드에 복사불변, 공유
메모리 안전누수 위험범위 종료 시 자동 해제

Scoped Value는 범위를 벗어나면 자동으로 정리되므로 메모리 누수 위험이 없다. 값이 불변이라 Virtual Thread 간에 안전하게 공유할 수 있다. Virtual Thread와 Structured Concurrency 조합에서 컨텍스트를 전달하는 표준 방법으로 자리잡고 있다.

Structured Concurrency

여러 비동기 작업을 구조적으로 관리하기 위한 API다. Virtual Thread per task 모델을 안전하게 구성하는 진입점으로, 역시 Preview를 거쳐 정식화가 진행 중이다.

// Java 24+: Structured Concurrency API
try (var scope = StructuredTaskScope.open()) {
    Subtask<String> user = scope.fork(() -> fetchUser());
    Subtask<List<Order>> orders = scope.fork(() -> fetchOrders());

    scope.join();  // 모든 작업 완료 대기

    return new Response(user.get(), orders.get());
}  // 예외 발생 시 자동으로 모든 subtask 취소

StructuredTaskScope는 fork한 모든 하위 작업의 생명주기를 스코프 단위로 관리한다. 하나가 실패하면 나머지를 자동 취소할 수 있고, try-with-resources로 리소스 누수를 방지한다. “화재 후 망각(fire-and-forget)” 패턴의 위험성을 구조적으로 제거하는 접근이다.

모니터링 개선

Java 23 이후로 JFR(Java Flight Recorder)에 Virtual Thread 전용 이벤트가 강화되었다.

jdk.VirtualThreadStart           // Virtual Thread 시작
jdk.VirtualThreadEnd             // Virtual Thread 종료
jdk.VirtualThreadPinned          // Pinning 발생 시
jdk.VirtualThreadSubmitFailed    // 제출 실패
# JFR로 Pinning 이벤트 모니터링
java -XX:StartFlightRecording=filename=recording.jfr \
     -Djdk.tracePinnedThreads=full \
     MyApp

이벤트 체계가 정교해지면서 수백만 Virtual Thread 환경에서도 Pinning 발생 지점이나 실패 케이스를 체계적으로 추적할 수 있게 되었다.

Spring Boot 3.2+ 적용

Spring Boot 3.2부터 Virtual Thread를 한 줄 설정으로 활성화할 수 있다.

# application.yml
spring:
  threads:
    virtual:
      enabled: true  # 모든 요청을 Virtual Thread로 처리

이 설정 하나로 Tomcat의 요청 처리 스레드가 Virtual Thread로 전환된다. 기존 코드를 수정할 필요가 없다. Bean 설정으로 더 세밀하게 제어할 수도 있다.

@Configuration
public class ThreadConfig {
    @Bean
    public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(
                Executors.newVirtualThreadPerTaskExecutor()
            );
        };
    }
}

언제 쓰고, 언제 쓰지 않을까

Virtual Thread는 모든 상황에 적합한 것은 아니다. 핵심 판단 기준은 워크로드가 I/O 바운드인지 CPU 바운드인지다.

I/O 바운드 작업에서 진가를 발휘한다. 대량의 동시 요청 처리, DB 쿼리, HTTP 호출, 파일 읽기/쓰기, 마이크로서비스 간 통신 같은 워크로드가 적합하다. 대부분의 웹 서버 애플리케이션이 여기에 해당한다.

반면 CPU 바운드 작업에는 이점이 없다. 연산 집약적인 알고리즘, 암호화/압축, 과학 계산 같은 워크로드는 스레드가 양보할 블로킹 지점이 없으므로, Virtual Thread를 써도 기존 Platform Thread와 성능 차이가 없다.

버전별 JEP 현황

JEP제목Java 21Java 24Java 25 (예정)
444Virtual Threads정식--
491Synchronize without Pinning-정식 도입안정화
506Scoped ValuesPreview정식화 진행정식 예정
453/480/499Structured ConcurrencyPreview정식화 진행정식 예정

업그레이드 전략

Java 21 LTS에서 Virtual Thread를 도입하고 있다면, 다음 경로를 권장한다.

Java 21 LTS (현재): synchronized 대신 ReentrantLock을 사용하고, ThreadLocal을 최소화하며, -Djdk.tracePinnedThreads=full로 Pinning을 모니터링한다.

Java 24 (2025년 3월): JEP 491로 synchronized Pinning이 해결되었는지 테스트 환경에서 검증한다. 기존 synchronized 코드를 ReentrantLock으로 교체하지 않아도 되는 범위를 확인한다.

Java 25 LTS (2025년 9월, 목표): 모든 개선사항이 안정화된다. Scoped Values와 Structured Concurrency가 정식 API로 정착하고, Virtual Thread가 Java의 기본 동시성 모델로 자리잡는 버전이다.

마무리

Virtual Thread는 Java의 동시성 프로그래밍을 근본적으로 바꾸는 변화다. Java 21에서 출발하여, 24에서 가장 큰 제약(synchronized Pinning)이 해결되었고, 25 LTS에서 Scoped Values와 Structured Concurrency까지 정식 안정화되면 “스레드를 아껴 쓰는” 시대는 끝난다.

I/O 바운드 워크로드를 다루는 서버 애플리케이션이라면, Java 21에서 Virtual Thread를 시작하되 Pinning 패턴을 주의하고, Java 25 LTS로의 업그레이드를 계획하는 것이 현실적인 전략이다.

문제/항목Java 21Java 24+
synchronized Pinning심각한 제약JEP 491로 해결
Object.wait() Pinning발생해결
ThreadLocal 메모리주의 필요Scoped Value로 대체
Structured ConcurrencyPreview정식 API 예정
모니터링 (JFR)제한적Virtual Thread 이벤트 강화