Java 21에서 정식 도입된 Virtual Threads(가상 스레드)는 Project Loom의 핵심 결과물로, 동시성 프로그래밍의 패러다임을 바꾸고 있다. 기존 플랫폼 스레드 대비 메모리 사용량을 1/100 수준으로 줄이면서도, 기존 코드와의 호환성을 유지한다. 이 글에서는 Virtual Threads의 원리, 사용법, 성능 최적화를 다룬다.

Virtual Threads란?

기존 Java 스레드(Platform Thread)는 OS 스레드와 1:1로 매핑되어 각각 약 1MB의 스택 메모리를 차지한다. 서버에서 1만 개의 동시 요청을 처리하려면 1만 개의 OS 스레드가 필요하고, 이는 약 10GB의 메모리를 소비한다. Virtual Thread는 JVM이 관리하는 경량 스레드로, 수십 바이트~수 KB의 메모리만 사용하며 수백만 개를 생성할 수 있다.

기본 사용법

// 1. Thread.ofVirtual()로 생성
Thread vThread = Thread.ofVirtual().name("worker-1").start(() -> {
    System.out.println(Thread.currentThread());
    // VirtualThread[#21,worker-1]/runnable@ForkJoinPool-1-worker-1
});
vThread.join();

// 2. Thread.startVirtualThread() - 가장 간단한 방법
Thread.startVirtualThread(() -> {
    System.out.println("Hello from virtual thread!");
});

// 3. Executors.newVirtualThreadPerTaskExecutor()
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 10만 개의 작업을 동시 실행 - 각각 가상 스레드에서 실행
    List<Future<String>> futures = new ArrayList<>();
    for (int i = 0; i < 100_000; i++) {
        final int taskId = i;
        futures.add(executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return "Task " + taskId + " completed";
        }));
    }
    
    // 모든 결과 수집
    for (Future<String> future : futures) {
        future.get(); // 블로킹해도 OK - 가상 스레드이므로
    }
}
// executor가 자동으로 닫히며 모든 작업 완료 대기

Structured Concurrency (구조화된 동시성)

Java 21의 StructuredTaskScope는 여러 하위 작업의 생명주기를 부모 작업에 묶어 관리한다.

import java.util.concurrent.StructuredTaskScope;

public record UserProfile(User user, List<Order> orders, int points) {}

public UserProfile fetchUserProfile(long userId) throws Exception {
    // ShutdownOnFailure: 하나라도 실패하면 나머지 자동 취소
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // 3개의 API를 동시에 호출
        var userTask = scope.fork(() -> userService.findById(userId));
        var ordersTask = scope.fork(() -> orderService.findByUserId(userId));
        var pointsTask = scope.fork(() -> pointService.getPoints(userId));
        
        // 모든 작업 완료 대기 (또는 하나라도 실패 시 즉시 반환)
        scope.join();
        scope.throwIfFailed();
        
        // 결과 조합
        return new UserProfile(
            userTask.get(),
            ordersTask.get(),
            pointsTask.get()
        );
    }
}

// ShutdownOnSuccess: 하나라도 성공하면 나머지 취소
public String fetchFromFastestMirror(String path) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
        scope.fork(() -> downloadFrom("mirror1.example.com", path));
        scope.fork(() -> downloadFrom("mirror2.example.com", path));
        scope.fork(() -> downloadFrom("mirror3.example.com", path));
        
        scope.join();
        return scope.result(); // 가장 먼저 성공한 결과 반환
    }
}

Scoped Values (스코프 값)

ThreadLocal의 가상 스레드 친화적 대안인 ScopedValue를 사용하면 불변 값을 스레드 계층에 효율적으로 전파할 수 있다.

import java.lang.ScopedValue;

public class RequestContext {
    // 불변, 상속 가능한 스코프 값
    static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
    static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
    
    public void handleRequest(HttpRequest request) {
        String userId = extractUserId(request);
        String requestId = UUID.randomUUID().toString();
        
        // ScopedValue.where()로 바인딩
        ScopedValue.where(CURRENT_USER, userId)
                   .where(REQUEST_ID, requestId)
                   .run(() -> {
                       // 이 블록과 하위 호출에서 값 접근 가능
                       processRequest(request);
                   });
    }
    
    private void processRequest(HttpRequest request) {
        // 별도 파라미터 없이 컨텍스트 접근
        String user = CURRENT_USER.get();
        String reqId = REQUEST_ID.get();
        logger.info("[{}] User {} processing request", reqId, user);
        
        // 하위 가상 스레드에도 자동 전파
        Thread.startVirtualThread(() -> {
            // 부모의 ScopedValue를 그대로 사용
            auditService.log(CURRENT_USER.get(), "action");
        });
    }
}

성능 비교 벤치마크

// Platform Thread vs Virtual Thread 성능 비교
public class ThreadBenchmark {
    static final int TASK_COUNT = 100_000;
    
    // Platform Thread - OOM 위험
    static void platformThreadTest() throws Exception {
        long start = System.currentTimeMillis();
        try (var executor = Executors.newFixedThreadPool(200)) {
            for (int i = 0; i < TASK_COUNT; i++) {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofMillis(100));
                    return "done";
                });
            }
        }
        System.out.printf("Platform: %dms%n", System.currentTimeMillis() - start);
        // 약 50,000ms (스레드 풀 200개로 10만 작업 순차 처리)
    }
    
    // Virtual Thread - 동시에 모두 실행
    static void virtualThreadTest() throws Exception {
        long start = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < TASK_COUNT; i++) {
                futures.add(executor.submit(() -> {
                    Thread.sleep(Duration.ofMillis(100));
                    return "done";
                }));
            }
            futures.forEach(f -> { try { f.get(); } catch (Exception e) {} });
        }
        System.out.printf("Virtual: %dms%n", System.currentTimeMillis() - start);
        // 약 500ms (10만 개 동시 실행, 100ms 대기)
    }
}

주의사항

  • synchronized 블록 주의: Virtual Thread가 synchronized 블록에서 블로킹되면 캐리어 스레드도 함께 블로킹됨 (pinning). ReentrantLock으로 대체 권장
  • CPU 바운드 작업 비적합: Virtual Thread는 I/O 바운드 작업에 최적화됨. CPU 집약적 작업은 기존 스레드 풀 사용
  • ThreadLocal 주의: 수백만 개의 Virtual Thread에서 ThreadLocal을 사용하면 메모리 낭비. ScopedValue로 대체
  • 풀링 금지: Virtual Thread는 풀링하지 말 것. 매번 새로 생성하는 것이 올바른 사용법

Virtual Thread는 특히 Spring Boot, Quarkus 등의 웹 프레임워크에서 큰 효과를 발휘한다. Spring Boot 3.2+에서는 spring.threads.virtual.enabled=true 한 줄로 모든 요청 처리를 Virtual Thread로 전환할 수 있다.