왜 Virtual Threads인가

Java의 전통적인 스레드 모델은 OS 스레드와 1:1로 대응되는 플랫폼 스레드(Platform Thread)를 기반으로 했습니다. 플랫폼 스레드는 하나당 약 1~2MB의 스택 메모리를 소비하고, 생성 및 컨텍스트 스위칭 비용이 높아 수만 개 이상의 동시 요청을 처리하기 어렵습니다. Java 21 LTS에서 정식 출시된 Virtual Threads(가상 스레드)는 이 근본적인 한계를 해결합니다.

Virtual Threads의 동작 원리

가상 스레드는 JVM이 직접 관리하는 경량 스레드입니다. 수백만 개를 동시에 생성해도 메모리가 고갈되지 않으며, 소수의 OS 스레드(캐리어 스레드) 위에서 다수의 가상 스레드가 M:N 방식으로 스케줄링됩니다.

// 플랫폼 스레드 생성 (기존 방식)
Thread platformThread = new Thread(() -> {
    System.out.println("Platform thread: " + Thread.currentThread());
});
platformThread.start();

// 가상 스레드 생성 (Java 21+)
Thread virtualThread = Thread.ofVirtual().start(() -> {
    System.out.println("Virtual thread: " + Thread.currentThread());
});

// ExecutorService로 가상 스레드 풀 사용
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // I/O 작업 — 블로킹 시 캐리어 스레드를 다른 가상 스레드에 양보
            Thread.sleep(Duration.ofMillis(100));
            return "done";
        });
    }
} // try-with-resources로 자동 종료

플랫폼 스레드 vs 가상 스레드 성능 비교

// 성능 비교 실험 코드
import java.util.concurrent.*;
import java.time.*;

public class ThreadBenchmark {
    static void simulateIO() throws InterruptedException {
        Thread.sleep(10); // I/O 시뮬레이션
    }

    public static void main(String[] args) throws Exception {
        int taskCount = 10_000;

        // 플랫폼 스레드 풀 (최대 200개 제한)
        long start = System.currentTimeMillis();
        try (var executor = Executors.newFixedThreadPool(200)) {
            var futures = new ArrayList>();
            for (int i = 0; i < taskCount; i++) {
                futures.add(executor.submit(() -> { simulateIO(); return null; }));
            }
            for (var f : futures) f.get();
        }
        System.out.printf("Platform threads: %dms%n", System.currentTimeMillis() - start);
        // 결과: 약 500ms (200개 스레드 * 10ms * 10,000/200 순서)

        // 가상 스레드 (제한 없음)
        start = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var futures = new ArrayList>();
            for (int i = 0; i < taskCount; i++) {
                futures.add(executor.submit(() -> { simulateIO(); return null; }));
            }
            for (var f : futures) f.get();
        }
        System.out.printf("Virtual threads: %dms%n", System.currentTimeMillis() - start);
        // 결과: 약 15ms (동시 실행)
    }
}

Spring Boot에서의 가상 스레드 활성화

Spring Boot 3.2 이상에서는 단 한 줄의 설정으로 Tomcat의 요청 처리 스레드를 모두 가상 스레드로 전환할 수 있습니다.

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

# application.yml
spring:
  threads:
    virtual:
      enabled: true
// 또는 Java Config로 명시적 설정
@Configuration
public class VirtualThreadConfig {

    @Bean
    public TomcatProtocolHandlerCustomizer protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

Java 25의 추가 개선사항

Structured Concurrency (구조화된 동시성)

가상 스레드를 기반으로 하는 Structured Concurrency는 여러 서브태스크를 병렬로 실행하면서 생명주기와 오류 처리를 일관되게 관리하는 현대적 접근법입니다.

import java.util.concurrent.StructuredTaskScope;

public class OrderService {
    record OrderDetails(User user, Product product, Inventory inventory) {}

    public OrderDetails fetchOrderDetails(long orderId) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // 세 API 호출을 병렬 실행
            var userFuture = scope.fork(() -> userService.findById(orderId));
            var productFuture = scope.fork(() -> productService.findByOrder(orderId));
            var inventoryFuture = scope.fork(() -> inventoryService.checkStock(orderId));

            scope.join(); // 모두 완료 대기
            scope.throwIfFailed(); // 하나라도 실패하면 나머지 취소

            return new OrderDetails(
                userFuture.get(),
                productFuture.get(),
                inventoryFuture.get()
            );
        }
    }
}

Scoped Values

ThreadLocal의 단점을 개선한 Scoped Values는 가상 스레드 환경에서 메모리 오버헤드 없이 컨텍스트 데이터를 효율적으로 전달합니다.

// ThreadLocal (기존): 가상 스레드 수백만 개에서 메모리 문제
static final ThreadLocal CURRENT_USER = new ThreadLocal<>();

// ScopedValue (Java 25): 불변, 자동 정리, 낮은 메모리 사용
static final ScopedValue CURRENT_USER = ScopedValue.newInstance();

public void handleRequest(User user, Runnable handler) {
    ScopedValue.where(CURRENT_USER, user).run(handler);
    // handler 실행 중에만 CURRENT_USER 사용 가능, 이후 자동 해제
}

public void someDeepMethod() {
    User user = CURRENT_USER.get(); // 어디서든 안전하게 접근
    // ...
}

Record Patterns과 Pattern Matching for Switch

Java 21에서 완성된 패턴 매칭은 복잡한 타입 체크와 캐스팅을 간결하게 표현합니다.

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

// Java 17 이전 방식
double getArea_old(Shape shape) {
    if (shape instanceof Circle c) {
        return Math.PI * c.radius() * c.radius();
    } else if (shape instanceof Rectangle r) {
        return r.width() * r.height();
    } else if (shape instanceof Triangle t) {
        return 0.5 * t.base() * t.height();
    }
    throw new IllegalArgumentException();
}

// Java 21 패턴 매칭 for switch
double getArea(Shape shape) {
    return switch (shape) {
        case Circle(var r)         -> Math.PI * r * r;
        case Rectangle(var w, var h) -> w * h;
        case Triangle(var b, var h)  -> 0.5 * b * h;
    };
}

// 가드 조건 추가
String classify(Object obj) {
    return switch (obj) {
        case Integer i when i < 0  -> "음수";
        case Integer i when i == 0 -> "영";
        case Integer i             -> "양수";
        case String s when s.isEmpty() -> "빈 문자열";
        case String s              -> "문자열: " + s;
        case null                  -> "null";
        default                    -> "기타: " + obj.getClass().getSimpleName();
    };
}

마이그레이션 시 주의사항

가상 스레드는 I/O 중심(I/O-bound) 작업에서 탁월하지만, CPU 집약적(CPU-bound) 작업에서는 기존 스레드 풀이 더 효율적입니다. 또한 synchronized 블록 내에서 블로킹 I/O가 발생하면 가상 스레드가 캐리어 스레드를 고정(pinning)하는 문제가 Java 24에서 해결되었습니다. Java 25 환경에서는 이 제약이 없으므로, 가상 스레드 마이그레이션을 고려 중이라면 Java 25 LTS를 타겟으로 설정하는 것을 권장합니다.