Virtual Thread 마스터 — API·Builder·ExecutorService

2026-05-03확률과 통계 마스터 노트

Java 21 Virtual Thread 마스터 노트 시리즈 3편. Virtual Thread 생성 4가지 방법(직접·startVirtualThread·Builder·ExecutorService), Thread.Builder API의 세밀한 제어, ThreadFactory로 스타일 통일, Executors.newVirtualThreadPerTaskExecutor의 try-with-resources 패턴, Pool vs Per-Task의 결정적 차이까지.

이 글은 Java 21 Virtual Thread 마스터 노트 시리즈의 세 번째 편입니다. 2편(내부)에서 Carrier·Mount를 봤다면, 이번엔 사용자 API — Virtual Thread 만드는 4가지 방법.

직접 생성·startVirtualThread·Builder·ExecutorService. 어느 게 어디? 일반 = ExecutorService, 세밀 제어 = Builder.

처음 API가 어렵게 느껴지는 이유

처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, 생성 방법 4가지가 한 번에 등장합니다. 둘째, Pool vs Per-Task 차이가 막연합니다.

해결법은 한 가지예요. "일반 = ExecutorService, 단순 = startVirtualThread, 세밀 제어 = Builder" 한 줄. 이 매핑만 잡으면 끝.

1. Thread.startVirtualThread() — 가장 단순

Thread vt = Thread.startVirtualThread(() -> {
    System.out.println("Hello from " + Thread.currentThread());
});

vt.join();

한 줄로 시작. 데모·간단 작업에 적합.

2. Thread.ofVirtual() — Builder 패턴

Thread vt = Thread.ofVirtual()
    .name("worker-1")
    .uncaughtExceptionHandler((t, e) -> log.error("Failed", e))
    .start(() -> {
        // 작업
    });

vt.join();

세밀 제어. 이름·예외 핸들러 등.

unstarted

Thread vt = Thread.ofVirtual()
    .name("worker-1")
    .unstarted(() -> {});

// 나중에 시작
vt.start();

생성과 시작 분리.

name with counter

Thread.Builder.OfVirtual builder = Thread.ofVirtual().name("worker-", 0);

Thread vt1 = builder.unstarted(() -> {});   // worker-0
Thread vt2 = builder.unstarted(() -> {});   // worker-1
Thread vt3 = builder.unstarted(() -> {});   // worker-2

자동 카운터.

3. ThreadFactory

ThreadFactory factory = Thread.ofVirtual().factory();

ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);
// 같은 효과
ExecutorService executor2 = Executors.newVirtualThreadPerTaskExecutor();

Factory로 명시 제어. ExecutorService가 자동 사용.

4. ExecutorService — 가장 일반적

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10000; i++) {
        executor.submit(() -> {
            String data = httpClient.get("/api");
            return process(data);
        });
    }
}   // try-with-resources가 모든 작업 완료 대기

가장 권장. try-with-resources 패턴.

여기서 정말 중요한 시험 함정 — newVirtualThreadPerTaskExecutor는 풀 X. 각 작업마다 새 Virtual Thread. 풀링 비효율 — 만들고 버리는 게 더 쌈.

try-with-resources의 의미

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(task1);
    executor.submit(task2);
}
// close() 자동 호출
// 모든 작업 완료까지 대기
// 그 후 다음 코드 실행

ExecutorService (Java 19+)는 AutoCloseable 구현. close = shutdown() + awaitTermination.

Future·CompletableFuture

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> f1 = executor.submit(() -> fetchData());
    Future<String> f2 = executor.submit(() -> fetchOther());
    
    String r1 = f1.get();   // 블로킹 — 그러나 Virtual Thread면 unmount
    String r2 = f2.get();
}

기존 Future 패턴 그대로. Virtual Thread가 효율 처리.

CompletableFuture와 결합

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    CompletableFuture<String> f1 = CompletableFuture.supplyAsync(
        () -> fetchData(), executor
    );
    
    CompletableFuture<String> f2 = CompletableFuture.supplyAsync(
        () -> fetchOther(), executor
    );
    
    String combined = f1.thenCombine(f2, (a, b) -> a + b).join();
}

CompletableFuture에 executor 명시. Virtual Thread가 백엔드.

여기서 시험 함정이 하나 있어요. CompletableFuture 기본 = ForkJoinPool.commonPool(). Virtual Thread 명시 안 하면 일반 풀. 명시 권장.

동일 작업 — 4 방법 비교

// 1. startVirtualThread
Thread.startVirtualThread(this::doWork);

// 2. Builder
Thread.ofVirtual().start(this::doWork);

// 3. ThreadFactory
ThreadFactory f = Thread.ofVirtual().factory();
f.newThread(this::doWork).start();

// 4. ExecutorService
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    ex.submit(this::doWork);
}
방법 사용처
1. startVirtualThread 데모·단순
2. Builder 이름·핸들러 등 세밀 제어
3. ThreadFactory ExecutorService 통합
4. ExecutorService 운영 표준

ScheduledExecutorService

// Virtual Thread + 스케줄?
// Java 21에선 직접 지원 X
// Workaround:
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
ExecutorService vtExecutor = Executors.newVirtualThreadPerTaskExecutor();

scheduler.scheduleAtFixedRate(() -> {
    vtExecutor.submit(() -> {
        // 실제 작업 — Virtual Thread에서
    });
}, 0, 1, TimeUnit.SECONDS);

스케줄러는 Platform Thread, 작업은 Virtual Thread.

Pool vs Per-Task

// Per-Task (Virtual Thread 표준)
Executors.newVirtualThreadPerTaskExecutor();
// 각 작업마다 새 VT, 끝나면 GC

// Pool (Platform Thread만)
Executors.newFixedThreadPool(200);
// 200 스레드 재사용

여기서 정말 중요한 시험 함정 — Virtual Thread를 풀에 두면 의미 X. 풀의 목적 = 비싼 스레드 재사용. VT는 가벼움 = 풀 불필요. Per-Task가 표준.

ExecutorService 메서드

try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    // submit — Future 반환
    Future<String> f = ex.submit(() -> "result");
    
    // execute — Future X
    ex.execute(() -> {});
    
    // invokeAll — 모두 끝까지 대기
    List<Future<String>> all = ex.invokeAll(tasks);
    
    // invokeAny — 가장 빠른 하나
    String first = ex.invokeAny(tasks);
}

Java 21+ 표준 메서드. 모두 Virtual Thread 위에서.

Daemon — Virtual Thread 항상 true

Thread vt = Thread.ofVirtual().start(() -> {});
vt.isDaemon();   // true (강제)

// non-daemon 시도 X
// Thread.ofVirtual().daemon(false);   // 컴파일 에러

Virtual Thread는 daemon만. main 종료 시 즉시 종료.

Thread Group

ThreadGroup group = Thread.currentThread().getThreadGroup();
// VirtualThreads (Virtual Thread 그룹)

모든 Virtual Thread는 VirtualThreads 그룹. 사용자 정의 그룹 X.

Priority — 무시

Thread.ofVirtual()
    .priority(Thread.MAX_PRIORITY)   // 무시됨
    .start(() -> {});

Virtual Thread는 priority 의미 X. 모든 VT 동등.

Interrupt

Thread vt = Thread.ofVirtual().start(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 작업
    }
});

vt.interrupt();   // 정상 동작

Platform Thread와 같음.

호환성 — 기존 Thread API

Thread vt = Thread.ofVirtual().start(task);

// 기존 Thread API 그대로
vt.join();
vt.interrupt();
vt.getName();
vt.getId();
vt.getState();

여기서 시험 함정이 하나 있어요. Virtual Thread = Thread 클래스 그대로. 별도 클래스 X. 기존 코드 호환.

ScopedValue (Preview)

final static ScopedValue<String> USER = ScopedValue.newInstance();

ScopedValue.where(USER, "alice").run(() -> {
    Thread.startVirtualThread(() -> {
        // VT 안에서도 USER 접근
        System.out.println(USER.get());   // alice
    });
});

ThreadLocal 대안. Virtual Thread 친화. 6편에서 자세히.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 3편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • 생성 4 방법 — startVirtualThread (단순) / ofVirtual() (Builder) / ThreadFactory / newVirtualThreadPerTaskExecutor (운영)
  • 운영 표준 = ExecutorService + try-with-resources
  • close() = shutdown + awaitTermination
  • Thread.ofVirtual().name("worker-", 0) — 자동 카운터
  • unstarted(...) — 생성·시작 분리
  • ThreadFactory 통합 — newThreadPerTaskExecutor(factory)
  • Future·CompletableFuture 그대로 사용 OK
  • CompletableFuture executor 명시 권장 (기본 = commonPool)
  • Virtual Thread를 풀에 X — Per-Task 표준
  • ExecutorService — submit·execute·invokeAll·invokeAny
  • Scheduled = Platform Thread + VT 작업 결합
  • Virtual Thread 항상 daemon (강제)
  • non-daemon 시도 X
  • 모든 VT는 VirtualThreads 그룹
  • priority 무시 (모두 동등)
  • interrupt 정상 동작
  • Thread 클래스 그대로 호환 (별도 클래스 X)
  • 기존 join·interrupt·getName·getState OK
  • ScopedValue = ThreadLocal 대안 (Preview, Java 21+)

시리즈 다른 편

공식 문서: Virtual Threads API 에서 더 깊이.

다음 글(4편)에서는 Pinning — Virtual Thread의 가장 큰 함정, synchronized·JNI 한계, ReentrantLock 대안까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!