Java Virtual Thread - 경량 스레드로 동시성 프로그래밍

2026-01-04 13:22:32
#Java#Concurrency#VirtualThread#JDK21#Loom

Virtual Thread가 필요한 이유

전통적인 Java 동시성 모델에서는 하나의 요청을 하나의 스레드가 처리하는 thread-per-request 방식이 일반적이었다. 코드가 직관적이고 디버깅도 쉽다. 근데 이게 좀 문제가 있다.

Platform Thread(기존 Java 스레드)는 OS 스레드와 1:1로 매핑된다. OS 스레드는 생성 비용이 높고, 스레드당 약 1MB의 스택 메모리를 차지한다. 8GB 메모리 서버에서 스레드만으로 메모리를 다 쓰려면 8,000개가 한계다. 실제로는 다른 자원도 써야 하니까 수천 개 수준에서 병목이 생긴다.

요청 수 증가 → 스레드 수 증가 필요 → OS 스레드 한계 → 처리량 병목

이 문제를 해결하기 위해 Reactive Programming이나 비동기 방식이 등장했지만, 코드 복잡도가 급격히 증가하고 기존 thread-per-request 스타일의 장점을 포기해야 했다.

Virtual Thread는 이 딜레마를 해결한다. thread-per-request의 단순함은 유지하면서 OS 스레드 제약을 없앴다. 수백만 개의 동시 작업도 가능하다.

Virtual Thread란

JDK 21에서 JEP 444로 들어온 Virtual Thread는 경량 스레드다. OS 스레드에 직접 매핑되지 않고 JVM이 자체적으로 스케줄링한다.

핵심은 M:N 스케줄링이다. 쉽게 말해서 여러 Virtual Thread가 적은 수의 OS 스레드를 돌려쓴다. 택시 합승이랑 비슷하다고 보면 된다.

Virtual Thread Architecture Virtual Thread와 OS 스레드의 M:N 관계 - 수많은 Virtual Thread가 소수의 Platform Thread(Carrier)를 공유한다

Platform Thread vs Virtual Thread

구분 Platform Thread Virtual Thread
OS 스레드 관계 1:1 매핑 M:N 스케줄링
생성 비용 높음 (~1ms, ~1MB 스택) 매우 낮음 (수 KB, 동적 확장)
최대 개수 수천 개 수준 수백만 개 가능
스택 메모리 위치 OS 메모리 JVM Heap (GC 대상)
데몬 여부 설정 가능 항상 데몬
우선순위 설정 가능 (1~10) 고정 (NORM_PRIORITY)
풀링 권장됨 권장되지 않음

동작 원리: Mount와 Unmount

Virtual Thread의 핵심 메커니즘은 mount/unmount다.

Virtual Thread가 실행되려면 실제 OS 스레드가 필요하다. 이때 Virtual Thread를 태워서 실행하는 Platform Thread를 Carrier Thread라고 부른다.

Mount/Unmount 상태 전이

상태 설명
Mounted Carrier Thread에 올라타서 실행 중
Unmounted (Ready) 실행 준비 완료, Carrier 대기 중
Unmounted (Blocked) I/O 완료 대기 중
Pinned Carrier에 고정됨 (unmount 불가)
  • Mount: Virtual Thread가 Carrier Thread에 올라타서 실행
  • Unmount: Blocking I/O 발생 시 Carrier Thread에서 내려옴

Blocking I/O(파일 읽기, 네트워크 요청 등)가 발생하면 Virtual Thread는 Carrier Thread에서 unmount된다. 이때 Carrier Thread는 다른 Virtual Thread를 mount해서 실행할 수 있다. Blocking 작업이 완료되면 Virtual Thread는 다시 스케줄러에 의해 Carrier Thread에 mount되어 이어서 실행된다.

그래서 Blocking I/O가 발생해도 OS 스레드가 놀지 않는다. 이게 Virtual Thread의 핵심이다.

내부 구현: Continuation

Virtual Thread의 핵심 구현체는 Continuation이다. Continuation은 실행을 일시 중단(suspend)했다가 나중에 재개(resume)할 수 있는 프로그래밍 구조다.

// 개념적 구조 (실제 API는 내부용)
Continuation cont = new Continuation(scope, () -> {
    System.out.println("시작");
    Continuation.yield(scope);  // 여기서 중단
    System.out.println("재개");  // 나중에 여기서 계속
});

cont.run();  // "시작" 출력 후 중단
cont.run();  // "재개" 출력 후 완료

Virtual Thread가 Blocking I/O를 만나면:

  1. 현재 실행 상태(스택 프레임)를 Continuation에 저장
  2. Continuation을 Heap에 보관 (GC 대상)
  3. Carrier Thread에서 unmount
  4. I/O 완료 시 Continuation 복원 후 재개

Scheduler 구조

Virtual Thread의 스케줄러는 Work-stealing 알고리즘을 사용하는 ForkJoinPool이다.

왜 ForkJoinPool인가?

Work-stealing은 Virtual Thread처럼 많은 수의 짧은 작업을 처리하는 데 최적화되어 있다. 각 Carrier Thread가 자신의 작업 큐를 가지고, 큐가 비면 다른 Carrier Thread의 큐에서 작업을 "훔쳐온다".

설정 기본값 설명
parallelism CPU 코어 수 Carrier Thread 수
maxPoolSize 256 최대 Carrier Thread 수
minRunnable 1 최소 실행 가능 스레드 수
# 스케줄러 병렬성 조정 (기본값: 가용 프로세서 수)
java -Djdk.virtualThreadScheduler.parallelism=16 MyApp

# 최대 풀 사이즈 조정
java -Djdk.virtualThreadScheduler.maxPoolSize=512 MyApp

FIFO 스케줄링

Virtual Thread는 FIFO(First-In-First-Out) 순서로 스케줄링된다. 먼저 준비된 Virtual Thread가 먼저 Carrier Thread에 mount된다. 우선순위 설정은 무시된다.

사용법

Virtual Thread 생성 방법은 여러 가지가 있다.

Thread.startVirtualThread()

가장 간단한 방법이다.

Thread vThread = Thread.startVirtualThread(() -> {
    System.out.println("Virtual Thread 실행: " + Thread.currentThread());
});
vThread.join();

Thread.ofVirtual() Builder

스레드 이름 설정 등 세부 제어가 필요할 때 사용한다.

// 단일 스레드 생성 시 이름 직접 지정
Thread vThread = Thread.ofVirtual()
    .name("worker-1")
    .start(() -> {
        System.out.println("Named Virtual Thread: " + Thread.currentThread().getName());
    });

// 여러 스레드 생성 시 ThreadFactory 활용 - 자동 번호 부여
ThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();
Thread t1 = factory.newThread(() -> {});  // worker-0
Thread t2 = factory.newThread(() -> {});  // worker-1

ExecutorService

실무에서 가장 많이 사용하는 방식이다. try-with-resources와 함께 사용하면 모든 작업이 완료될 때까지 대기한다.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            try {
                Thread.sleep(Duration.ofSeconds(1));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return i;
        });
    });
}  // 모든 작업 완료 후 자동으로 shutdown

위 코드는 10,000개의 Virtual Thread를 생성해서 각각 1초씩 대기하는 작업을 수행한다. Platform Thread였다면 10,000개의 OS 스레드가 필요했겠지만, Virtual Thread는 소수의 Carrier Thread만으로 이를 처리한다.

주의사항

Pinning 문제

Virtual Thread가 Carrier Thread에서 unmount되지 못하고 고정되는 현상을 Pinning이라고 한다. 두 가지 경우에 걸린다.

  1. synchronized 블록 내에서 Blocking I/O 수행
  2. Native 메서드 실행 중
// Pinning 발생 예시 - 피해야 함
synchronized (lock) {
    // Blocking I/O - Carrier Thread가 블록됨
    inputStream.read();
}

// 해결책: ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    inputStream.read();  // Unmount 가능
} finally {
    lock.unlock();
}

Pinning이 발생하면 Carrier Thread 자체가 블록되어 Virtual Thread의 장점이 사라진다. 진단을 위해 다음 JVM 옵션을 사용할 수 있다.

# Pinning 발생 시 스택 트레이스 출력
-Djdk.tracePinnedThreads=full

# 문제 프레임만 간략히 출력
-Djdk.tracePinnedThreads=short

참고로 JDK 24에서는 JEP 491을 통해 synchronized 블록에서의 Pinning 문제가 해결될 예정이다.

ThreadLocal 사용 주의

Virtual Thread는 수백만 개 생성될 수 있으므로, ThreadLocal에 무거운 객체를 저장하면 메모리 문제가 발생한다.

// 피해야 함 - Virtual Thread마다 Connection 생성
private static final ThreadLocal<Connection> connHolder =
    ThreadLocal.withInitial(() -> createConnection());

// 권장 - Connection Pool 사용
private static final DataSource dataSource = createDataSource();

Pooling 금지

Platform Thread는 생성 비용이 높아서 ThreadPool을 사용하는 게 일반적이다. 하지만 Virtual Thread는 생성 비용이 매우 낮으므로 풀링할 필요가 없다. 오히려 풀링하면 Virtual Thread의 장점을 활용하지 못한다.

// 잘못된 사용 - Virtual Thread를 풀링
ExecutorService pool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());

// 올바른 사용 - 작업마다 새 Virtual Thread 생성
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Virtual Thread가 적합한 경우

Virtual Thread는 모든 상황에서 Platform Thread보다 좋은 것이 아니다.

적합한 경우:

  • I/O 바운드 작업이 많은 서버 애플리케이션
  • 동시 요청 수가 수천 개 이상
  • 짧은 작업이 많이 발생하는 경우

부적합한 경우:

  • CPU 바운드 작업 (연산 위주)
  • 동시 작업 수가 적은 경우
  • synchronized를 많이 사용하는 레거시 코드 (JDK 24 이전)

Spring Boot에서의 활용

Spring Boot 3.2부터 Virtual Thread를 쉽게 활성화할 수 있다.

# application.properties
spring.threads.virtual.enabled=true

이 설정 하나로 Tomcat의 요청 처리가 Virtual Thread 기반으로 전환된다. 기존 코드 수정 없이 바로 적용 가능하다.

Connection Pool 설정

Virtual Thread를 쓴다고 Connection Pool 설정을 크게 바꿀 필요는 없다. 다만 주의할 점이 있다.

# HikariCP 설정 예시
spring:
  datasource:
    hikari:
      maximum-pool-size: 10  # Virtual Thread 수만큼 늘리면 안 됨
      connection-timeout: 30000

Virtual Thread가 수만 개 떠 있어도 DB Connection은 여전히 비싼 자원이다. Pool 크기를 Virtual Thread 수에 맞춰 늘리면 DB가 죽는다. 기존 설정 그대로 유지하면 된다.

WebFlux vs Virtual Thread

둘 다 높은 처리량을 목표로 하지만 접근 방식이 다르다.

구분 WebFlux Virtual Thread
프로그래밍 모델 Reactive (Mono/Flux) 동기식 (기존 방식)
학습 곡선 높음 낮음
디버깅 어려움 쉬움
기존 코드 호환 전면 재작성 거의 그대로

이미 WebFlux로 잘 돌아가는 서비스라면 굳이 바꿀 필요 없다. 새 프로젝트거나 동기식 코드를 유지하고 싶다면 Virtual Thread가 좋은 선택이다.

Structured Concurrency (Preview)

JDK 21에서 Preview로 도입된 StructuredTaskScope는 여러 Virtual Thread를 구조적으로 관리한다.

// JDK 21 Preview 기능 활성화 필요
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Supplier<String> user = scope.fork(() -> fetchUser(userId));
    Supplier<String> order = scope.fork(() -> fetchOrder(orderId));

    scope.join();            // 모든 작업 완료 대기
    scope.throwIfFailed();   // 예외 발생 시 전파

    // 두 작업 모두 성공한 경우에만 도달
    return new Response(user.get(), order.get());
}

장점

기존 방식 Structured Concurrency
ExecutorService로 수동 관리 scope 종료 시 자동 정리
예외 처리 복잡 자동 예외 전파
스레드 누수 가능 구조적으로 불가능

ShutdownOnFailure vs ShutdownOnSuccess

// 하나라도 실패하면 나머지 취소
new StructuredTaskScope.ShutdownOnFailure()

// 하나라도 성공하면 나머지 취소 (race 패턴)
new StructuredTaskScope.ShutdownOnSuccess<>()

성능 비교

Virtual Thread의 성능 이점은 I/O 바운드 작업에서 극대화된다.

Virtual Thread Performance CPU 바운드 작업에서는 Platform Thread와 Virtual Thread 차이가 거의 없다

워크로드 Platform Thread Virtual Thread 비고
I/O 바운드 스레드 수 제한으로 병목 수백만 동시 처리 가능 Virtual Thread 유리
CPU 바운드 CPU 코어 수만큼 병렬 CPU 코어 수만큼 병렬 차이 없음
메모리 사용 스레드당 ~1MB 스레드당 수 KB Virtual Thread 유리

디버깅과 모니터링

JFR (Java Flight Recorder)

Virtual Thread 관련 이벤트를 JFR로 수집할 수 있다.

# JFR 녹화 시작
java -XX:StartFlightRecording=filename=recording.jfr,duration=60s MyApp

주요 이벤트:

  • jdk.VirtualThreadStart: Virtual Thread 시작
  • jdk.VirtualThreadEnd: Virtual Thread 종료
  • jdk.VirtualThreadPinned: Pinning 발생

Thread Dump

Virtual Thread도 thread dump에 포함된다. 다만 수백만 개가 있으면 dump가 매우 커질 수 있다.

# JSON 형식 thread dump (JDK 21+)
jcmd <pid> Thread.dump_to_file -format=json threads.json

정리

Virtual Thread는 꽤 쓸만한 기능이다. Reactive 코드 없이도 thread-per-request 모델로 높은 처리량을 낼 수 있다.

핵심 개념을 정리하면 다음과 같다.

  • Virtual Thread는 JVM이 관리하는 경량 스레드로, OS 스레드와 M:N 관계
  • Blocking I/O 발생 시 Carrier Thread에서 unmount되어 스레드 낭비 방지
  • 풀링하지 말고 작업당 하나씩 생성
  • synchronized 블록 내 Blocking I/O는 Pinning 유발 (ReentrantLock 권장)
  • I/O 바운드 작업에 적합, CPU 바운드 작업에는 이점 없음

자주 묻는 질문 (FAQ)

Q1: Virtual Thread를 사용하면 기존 Thread Pool 코드를 다 바꿔야 하나요?

아니다. Executors.newVirtualThreadPerTaskExecutor()로 교체하면 된다. 나머지 코드는 그대로 유지 가능하다.

Q2: Virtual Thread가 CPU 바운드 작업에서는 왜 이점이 없나요?

Virtual Thread의 장점은 Blocking I/O 시 Carrier Thread를 양보하는 것이다. CPU 바운드 작업은 Blocking이 없으므로 unmount가 발생하지 않는다. 결국 Platform Thread와 동일하게 동작한다.

Q3: Pinning이 발생하면 어떻게 되나요?

Carrier Thread가 해당 Virtual Thread에 고정되어 다른 Virtual Thread를 실행할 수 없다. Carrier Thread 수가 부족해지면 전체 처리량이 저하된다.

Q4: Kotlin Coroutine과 뭐가 다른가요?

구분 Virtual Thread Kotlin Coroutine
구현 레벨 JVM 언어/컴파일러
기존 코드 호환 거의 그대로 suspend 함수 필요
생태계 모든 Java 라이브러리 Coroutine 지원 라이브러리

둘 다 경량 스레드지만, Virtual Thread는 JVM 레벨 지원이라 기존 Java 코드와 호환성이 좋다.

Q5: DB Connection Pool과 Virtual Thread를 함께 쓰면 성능이 좋아지나요?

DB 처리량이 병목이라면 Virtual Thread를 써도 성능이 늘지 않는다. Virtual Thread 수만큼 Connection을 늘리면 DB가 죽는다. Connection Pool은 기존대로 유지하고, Virtual Thread는 Connection 대기 시간에 다른 요청을 처리하는 용도로 활용한다.

Q6: JDK 24에서 Pinning 문제가 해결된다던데?

맞다. JEP 491로 synchronized 블록 내에서도 unmount가 가능해진다. 다만 Native 메서드 실행 중 Pinning은 여전히 발생한다.

참고문헌

프로필 이미지
@chani
바둑, 스타크래프트 등 고전 게임을 좋아하는 내향인 개발자입니다

댓글