100만 개의 스레드를 만들 수 있게 된 Java — Virtual Threads와 pinning의 함정

// Java 25
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return null;
        });
    }
}   // 100만 개의 가상 스레드가 1초 만에 완료된다

Java 21 이전에는 불가능했다. 100만 개의 platform thread는 각각 ~1MB 스택을 사용하여 1TB의 메모리가 필요하다. Virtual Thread는 OS 스레드가 아니라 JVM이 관리하는 경량 스레드 — 메모리 소비가 수 KB 수준이다. (JEP-444)

이 글은 Virtual Thread의 작동 원리, platform thread와의 차이, synchronized pinning 함정, 그리고 언제 Virtual Thread를 쓰는지를 풀어간다.

Virtual Thread란 — Project Loom의 결실

Virtual Thread는 JVM이 스케줄링하는 경량 스레드다. OS 스레드(platform thread) 위에서 실행되지만, I/O 대기 시 OS 스레드를 반납하고 다른 virtual thread가 사용할 수 있게 한다.

flowchart TD
    subgraph platform["Platform Thread (OS 스레드, 풀 크기 N)"]
        VT1["Virtual Thread A<br/>(실행 중)"]
        VT2["Virtual Thread B<br/>(대기 중 - parked)"]
        VT3["Virtual Thread C<br/>(대기 중 - parked)"]
    end
    VT1 -.->|"I/O 대기 시<br/>platform thread 반납"| PARKED["Parked<br/>(메모리에만 존재, 수 KB)"]
    PARKED -.->|"I/O 완료 시<br/>platform thread 재획득"| VT2
항목 Platform Thread Virtual Thread
생성 비용 높음 (~1ms, 시스템 콜) 매우 낮음 (~1μs)
메모리 ~1MB (스택) 수 KB (힙)
최대 수 수천 수백만
스케줄링 OS 커널 JVM (ForkJoinPool)
I/O 대기 OS 스레드를 붙잡음 OS 스레드를 반납 (unmount)
용도 CPU 집약적 I/O 집약적

Virtual Thread 생성

// Java 25 — Virtual Thread 생성
// 방식 1: 직접 시작
Thread vt = Thread.ofVirtual().start(() -> {
    System.out.println("Virtual Thread: " + Thread.currentThread());
});

// 방식 2: ExecutorService (권장 — 수백만 개 처리)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofMillis(100));
            return i;
        })
    );
}   // 10,000개의 virtual thread가 ~100ms 만에 완료

// 방식 3: Thread.Builder
Thread vt2 = Thread.ofVirtual()
    .name("my-vt-", 0)
    .start(() -> System.out.println("이름: " + Thread.currentThread().getName()));

Thread.currentThread().isVirtual()으로 현재 스레드가 virtual인지 확인할 수 있다.

왜 Virtual Thread가 I/O에 강한가

Traditional 스레드 모델에서 1,000개의 HTTP 요청을 처리하려면 1,000개의 platform thread가 필요하다 — 각 스레드가 요청을 처리하는 동안 I/O 대기(DB, 외부 API)에서 스레드를 붙잡고 있기 때문이다.

Virtual Thread는 I/O 대기 시 스레드를 반납한다:

  1. Virtual Thread가 HttpClient.send()를 호출 (I/O 블로킹)
  2. JVM이 virtual thread를 parked 상태로 전환, platform thread를 반납
  3. 다른 virtual thread가 반납된 platform thread에서 실행
  4. I/O 완료 시, virtual thread가 다시 platform thread에서 재개(unmount → mount)

→ 100개의 platform thread로 100,000개의 동시 I/O 요청을 처리할 수 있다.

synchronized pinning — Virtual Thread의 치명적 함정

Virtual Thread는 synchronized 블록 안에서 platform thread에 고정(pinning)된다. (JEP-444 - Pinning)

// Java 25 — pinning 발생
public synchronized String fetchData() {   // synchronized 메서드
    return database.query("SELECT ...");   // I/O 블로킹 → platform thread를 놓지 못함!
}

synchronized 안에서 I/O 대기가 발생하면, virtual thread가 platform thread를 붙잡고 있는다 — virtual thread의 이점(경량 스케줄링)이 사라진다. 100개의 platform thread가 모두 pinning되면, 새 요청이 대기한다.

해결 — ReentrantLock으로 대체

// Java 25 — ReentrantLock으로 pinning 회피
import java.util.concurrent.locks.ReentrantLock;

private final ReentrantLock lock = new ReentrantLock();

public String fetchData() {
    lock.lock();
    try {
        return database.query("SELECT ...");   // I/O 대기 시 platform thread 반납
    } finally {
        lock.unlock();
    }
}

ReentrantLock은 virtual thread-friendly하다 — 블로킹 시 platform thread를 반납한다. 기존 코드의 synchronizedReentrantLock으로 교체하면 virtual thread 환경에서 pinning 없이 동작한다. (JEP-491, Java 24 preview)에서 synchronized pinning 문제 자체를 해결하려는 작업이 진행 중이다.

pinning 감지

# Java 25 — pinning 감지를 위한 진단 옵션
java -Djdk.tracePinnedThreads=full Main.java
# 또는 요약
java -Djdk.tracePinnedThreads=short Main.java

이 옵션은 virtual thread가 pinning될 때 스택 트레이스를 출력한다 — 어떤 synchronized 블록이 원인인지 즉시 파악할 수 있다.

Virtual Thread를 쓰면 안 되는 경우

Virtual Thread는 CPU 집약적 작업에 부적합하다:

  • 계산 집약적(암호화, 압축, 이미지 처리) — I/O 대기가 없으므로 virtual thread의 이점 없음
  • platform thread를 계속 점유하므로 virtual thread overhead만 추가
// Java 25 — CPU 집약적 작업에는 platform thread 풀
ExecutorService cpuPool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors());

// I/O 집약적 작업에는 virtual thread
ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor();

Virtual Thread와 기존 코드의 호환성

Virtual Thread는 기존 Thread API를 그대로 사용한다 — Thread.sleep(), Object.wait(), ThreadLocal 모두 동작한다. 코드를 변경하지 않고 스레드 생성 방식만 바꾸면 된다.

// Java 25 — 기존 코드를 Virtual Thread로 전환
// Before: platform thread
// Executors.newFixedThreadPool(200)

// After: virtual thread (코드 변경 최소)
Executors.newVirtualThreadPerTaskExecutor()

Spring Boot 3.2+는 spring.threads.virtual.enabled=true 설정만으로 Tomcat의 요청 처리 스레드를 virtual thread로 전환한다. 코드 변경 없이 처리량을 크게 높일 수 있다.

동시성 모델의 진화 — Virtual Thread가 바꾸는 것

thread-per-request에서 reactive로, 그리고 다시 thread-per-request로

Java 웹 서버의 동시성 모델 진화:

flowchart LR
    M1["thread-per-request<br/>(synchronous blocking)<br/>스레드 풀 한계"] -->|"스레드 부족"| M2["reactive<br/>(CompletableFuture, WebFlux)<br/>콜백 지옥"]
    M2 -->|"복잡성 폭발"| M3["thread-per-request<br/>(Virtual Thread)<br/>동기 코드 + 경량 스레드"]
모델 동시성 코드 스타일 한계
thread-per-request (platform) 스레드 풀 동기, 직관적 스레드 수 한계 (수천)
reactive (Netty, WebFlux) 이벤트 루프 비동기 콜백 체인 복잡한 코드, 디버깅 어려움
thread-per-request (virtual) virtual thread 동기, 직관적 synchronized pinning

Virtual Thread의 가치는 "reactive의 성능을 동기 코드로 얻는 것"이다. 비동기 콜백 체인(CompletableFuture.thenApply().thenCompose()) 없이, 평범한 동기 코드로 수백만 동시 요청을 처리할 수 있다. 코드가 단순하고 디버깅이 쉽다.

Reactive 코드 vs Virtual Thread 코드

// Java 25 — Reactive (WebFlux 스타일)
// public Mono<User> getUser(String id) {
//     return userRepository.findById(id)
//         .flatMap(user -> orderRepository.findByUserId(id)
//             .map(orders -> new UserDTO(user, orders)));
// }

// Java 25 — Virtual Thread (동기 코드)
public UserDTO getUser(String id) {
    User user = userRepository.findById(id);   // 블로킹이지만 virtual thread는 OK
    List<Order> orders = orderRepository.findByUserId(id);
    return new UserDTO(user, orders);
}
// 코드가 단순하고 직관적 — 동기 코드지만 virtual thread가 I/O 대기 시 스레드를 반납

두 접근 모두 장단점이 있다. Reactive는 스레드 전환 비용 없이 고성능을 내지만, 코드 복잡성이 높다. Virtual Thread는 동기 코드의 단순함을 유지하면서 높은 동시성을 제공한다. 기존 동기 코드베이스를 가진 조직에서는 Virtual Thread가 마이그레이션 비용이 훨씬 낮다.

Virtual Thread 사용 시 주의점

주의 이유 해결
synchronized 안의 I/O pinning 발생 ReentrantLock으로 대체
ThreadLocal 남용 메모리 폭발 ScopedValue(Java 25 finalized) 사용
thread pool에 virtual thread 넣지 말 것 풀링 불필요, 의미 없음 newVirtualThreadPerTaskExecutor()
CPU 집약적 작업 platform thread 점유 platform thread 풀 사용

Virtual Thread와 구조적 동시성(Structured Concurrency) 결합

Virtual Thread는 구조적 동시성(장 19 참조)과 결합할 때 가장 강력하다 — 부모 virtual thread가 자식 virtual thread들의 생명주기를 관리하고, 하나라도 실패하면 전체를 취소한다.

// Java 25 (preview — --enable-preview 필요)
// try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
//     Subtask<User> userTask = scope.fork(() -> fetchUser(id));
//     Subtask<Order> orderTask = scope.fork(() -> fetchOrder(id));
//     scope.join();               // 자식 완료 대기
//     scope.throwIfFailed();      // 실패 시 전체 취소 + 예외 전파
//     return new Result(userTask.get(), orderTask.get());
// }
// 각 fork()가 새 virtual thread를 생성 — I/O 대기 시 자동으로 unmount

구조적 동시성 + Virtual Thread는 Go의 goroutine + channel, Erlang의 프로세스 모델에 가까운 경험을 Java에 가져온다. 동기 코드의 단순함 + 경량 스레드의 성능 + 구조적 오류 처리 — 세 가지를 동시에 얻는다. Java 25에서 구조적 동시성은 여전히 5차 preview(JEP-505)지만, Scoped Values는 정식(finalized, JEP-506)으로 승격됐다.

Scoped Values — Virtual Thread 환경의 ThreadLocal 대안 (Java 25 finalized)

Virtual Thread 환경에서 수백만 개의 스레드가 각자 ThreadLocal을 가지면 메모리가 폭발한다. Java 25에서 Scoped Values(JEP-506)가 정식(finalized)으로 도입되어, 이 문제를 해결한다. (JEP-506)

// Java 25 — ScopedValue 사용 (finalized)
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

// ScopedValue.where(USER_ID, "user123").run(() -> processRequest());
// processRequest 내부에서 USER_ID.get()으로 접근

// ThreadLocal과의 차이:
// - 불변(immutable): 한 번 설정하면 범위 내에서 변경 불가
// - 범위 바인딩: 메서드 호출 범위에서만 유효 (자동 정리)
// - 메모리 효율: virtual thread마다 독립 저장소가 아닌 바인딩 체인
// - 스레드 안전: child thread에 자동 전파 (구조적 동시성과 결합)

ThreadLocal은 mutable하고 스레드 수만큼 메모리를 소비한다. ScopedValue는 immutable하고 범위가 종료되면 자동으로 해제된다. Virtual Thread 환경에서 요청 컨텍스트, 사용자 인증 정보 등을 전달할 때 ScopedValue를 우선한다. 자세한 내용은 장 19 참조.

실습 — Virtual Thread vs Platform Thread

// Java 25 — VirtualThreadDemo.java
import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class VirtualThreadDemo {
    public static void main(String[] args) throws Exception {
        // 10,000개의 I/O 시뮬레이션 작업
        Runnable ioTask = () -> {
            try {
                Thread.sleep(Duration.ofMillis(100));   // I/O 대기 시뮬레이션
            } catch (InterruptedException e) { }
        };

        // Platform Thread (풀 크기 200)
        long start = System.currentTimeMillis();
        try (var executor = Executors.newFixedThreadPool(200)) {
            var futures = IntStream.range(0, 10_000)
                .mapToObj(i -> executor.submit(ioTask))
                .toList();
            for (var f : futures) f.get();
        }
        System.out.printf("Platform Thread: %d ms%n", System.currentTimeMillis() - start);

        // Virtual Thread
        start = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var futures = IntStream.range(0, 10_000)
                .mapToObj(i -> executor.submit(ioTask))
                .toList();
            for (var f : futures) f.get();
        }
        System.out.printf("Virtual Thread: %d ms%n", System.currentTimeMillis() - start);
    }
}
java VirtualThreadDemo.java
Platform Thread: ~5000 ms (200개 스레드가 배치 처리)
Virtual Thread: ~120 ms (10,000개가 동시에 실행)

확인할 것: 10,000개의 I/O 대기 작업에서 Virtual Thread가 40배 이상 빠르다. Platform thread는 200개가 10,000개를 나눠 처리하지만, Virtual Thread는 10,000개가 동시에 대기한다.

요약 — 이 글의 결론

  • Virtual Thread는 JVM이 관리하는 경량 스레드다. OS 스레드가 아니라 수 KB 메모리만 사용하며, 수백만 개를 동시에 실행할 수 있다.
  • I/O 집약적 작업에 최적이다. I/O 대기 시 platform thread를 반납하여, 적은 platform thread로 많은 동시 연결을 처리한다. CPU 집약적 작업에는 platform thread를 쓴다.
  • synchronized는 pinning을 일으킨다. synchronized 안에서 I/O 대기 시 platform thread를 붙잡아 virtual thread의 이점이 사라진다. ReentrantLock으로 대체하거나 -Djdk.tracePinnedThreads로 감지한다.
  • 기존 Thread API를 그대로 쓴다. 코드 변경 없이 Executors.newVirtualThreadPerTaskExecutor()로 전환한다. Spring Boot 3.2+는 설정 한 줄로 적용된다.
  • Virtual Thread는 스레드 풀을 만들지 않는다. 각 작업마다 새 virtual thread를 생성한다 — 생성 비용이 매우 낮으므로 풀링할 필요가 없다.

생각해 볼 문제

  1. Virtual Thread에서 synchronized 블록 안에서 I/O를 하면 pinning이 발생한다. 하지만 synchronized 안에서 CPU 연산만 하면 pinning이 문제인가?
  2. ThreadLocal을 virtual thread 환경에서 대량으로 사용하면 어떤 문제가 발생하는가?
  3. Virtual Thread가 platform thread를 반납(unmount)하고 다시 획득(mount)하는 과정에서 스택은 어떻게 관리되는가?
  4. -Djdk.tracePinnedThreads=short를 실행하고 pinning 로그를 분석해 보자. 기존 라이브러리 중 어떤 곳에서 pinning이 발생하는가?
  5. Spring Boot에서 spring.threads.virtual.enabled=true를 설정했을 때 내부적으로 어떤 변화가 일어나는가? Tomcat의 thread pool이 어떻게 바뀌는가?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 22. networking  (0) 2026.07.12
Java - 21. jdbc  (0) 2026.07.12
Java - 19. concurrent  (0) 2026.07.12
Java - 18. thread sync  (0) 2026.07.12
Java - 17. annotation-reflection  (0) 2026.07.12