개요

Java 21에서 정식 출시된 Virtual Threads(가상 스레드)는 Java 동시성 프로그래밍의 패러다임을 바꾸는 기술입니다. 2026년 3월 출시된 Java 26에서는 Structured Concurrency가 여섯 번째 프리뷰(JEP 525)를 맞이하며 더욱 성숙한 API로 발전했습니다. 이 글에서는 Virtual Threads의 동작 원리부터 Structured Concurrency, Scoped Values까지 실전 코드 예제와 함께 상세히 설명합니다.

Virtual Threads란?

기존 Java의 Platform Thread는 OS 스레드와 1:1로 매핑되어 스레드 수에 한계가 있고, 컨텍스트 스위칭 비용이 컸습니다. Virtual Threads는 JVM이 관리하는 경량 스레드로, 수백만 개를 동시에 생성할 수 있습니다. I/O 대기 시 캐리어 스레드를 반납하고 다른 가상 스레드가 사용할 수 있어 자원 효율이 극적으로 향상됩니다.

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

구분Platform ThreadVirtual Thread
생성 비용높음 (OS 자원 할당)매우 낮음 (JVM 관리)
최대 개수수천 개 수준수백만 개 가능
스택 크기1MB (기본값)필요한 만큼 동적 할당
I/O 블로킹OS 스레드 점유캐리어 스레드 반납

Virtual Threads 기본 사용법

스레드 생성 방법 3가지

// 방법 1: Thread.ofVirtual() 사용
Thread vThread = Thread.ofVirtual()
    .name("virtual-thread-1")
    .start(() -> {
        System.out.println("가상 스레드 실행: " + Thread.currentThread());
    });
vThread.join();

// 방법 2: Thread.startVirtualThread()
Thread t = Thread.startVirtualThread(() -> {
    System.out.println("가상 스레드: " + Thread.currentThread().isVirtual());
});
t.join();

// 방법 3: ExecutorService (권장)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        final int taskId = i;
        executor.submit(() -> {
            // I/O 작업 시뮬레이션
            Thread.sleep(Duration.ofMillis(100));
            System.out.println("Task " + taskId + " 완료");
        });
    }
} // AutoCloseable로 자동 종료

Spring Boot에서의 적용

Spring Boot 3.2 이상에서는 설정 한 줄로 가상 스레드를 활성화할 수 있습니다.

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

# 또는 Java Config
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadsProtocolHandlerCustomizer() {
    return protocolHandler -> {
        protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    };
}

Java 24의 synchronized 핀닝 문제 해결 (JEP 491)

Java 21~23에서 Virtual Threads의 대표적인 단점이 있었습니다. synchronized 블록 내에서 I/O 블로킹이 발생하면 가상 스레드가 캐리어 스레드에 고정(pinning)되는 문제였습니다. Java 24의 JEP 491이 이를 해결했습니다.

// Java 24 이전: synchronized 블록에서 I/O 시 캐리어 스레드 고정 (성능 저하)
public synchronized void processLegacy() throws InterruptedException {
    Thread.sleep(1000); // 캐리어 스레드 점유 문제 발생
}

// Java 24 이후: synchronized 내부 블로킹에서도 캐리어 스레드 반납
// 코드 변경 없이 JVM 레벨에서 자동 해결
public synchronized void processNew() throws InterruptedException {
    Thread.sleep(1000); // 이제 캐리어 스레드를 반납하고 다른 가상 스레드 실행 가능
}

// JFR로 핀닝 이벤트 모니터링
// -Djdk.tracePinnedThreads=short 옵션으로 핀닝 감지 가능

Structured Concurrency (JEP 525 - Java 26 여섯 번째 프리뷰)

Structured Concurrency는 관련된 여러 작업을 하나의 작업 단위로 묶어 관리하는 API입니다. 에러 처리와 취소를 일관성 있게 처리할 수 있고, 태스크 누수를 방지합니다.

기본 패턴: 모두 성공 또는 실패

import java.util.concurrent.StructuredTaskScope;

public record UserInfo(String name, List<Order> orders, CreditScore score) {}

public UserInfo fetchUserInfo(long userId) throws Exception {
    try (var scope = StructuredTaskScope.open()) {
        // 세 가지 I/O 작업을 병렬 실행
        var userName  = scope.fork(() -> fetchUserName(userId));
        var orders    = scope.fork(() -> fetchOrders(userId));
        var credit    = scope.fork(() -> fetchCreditScore(userId));

        scope.join(); // 모든 작업 완료 대기

        // 결과 수집 (예외 발생 시 자동 전파)
        return new UserInfo(
            userName.get(),
            orders.get(),
            credit.get()
        );
    }
}

Java 26 신기능: onTimeout() 메서드

// Java 26 JEP 525: Joiner에 onTimeout() 추가
public String fetchWithTimeout(String url) throws Exception {
    try (var scope = StructuredTaskScope.open(
            Joiner.<String>anySuccessfulOrThrow()
                  .onTimeout(Duration.ofSeconds(5), () -> "TIMEOUT")
    )) {
        var primary  = scope.fork(() -> httpGet(url));
        var fallback = scope.fork(() -> httpGetFallback(url));

        scope.join();
        return scope.result();
    }
}

// allSuccessfulOrThrow()가 이제 Stream 대신 List 반환
try (var scope = StructuredTaskScope.open(
        Joiner.<String>allSuccessfulOrThrow())) {
    scope.fork(() -> task1());
    scope.fork(() -> task2());
    scope.join();
    List<String> results = scope.result(); // List<String> 반환 (변경됨)
}

Scoped Values: ThreadLocal의 현대적 대안

JEP 512에서 최종 확정된 Scoped Values는 불변 데이터를 스레드 간에 안전하게 공유하는 메커니즘입니다. Virtual Threads와 함께 사용할 때 ThreadLocal보다 훨씬 효율적입니다.

// ThreadLocal 방식 (구식, 메모리 누수 위험)
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();

// Scoped Values 방식 (권장)
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

// 값 바인딩 및 자동 해제
public Response handleRequest(User user, Request request) {
    return ScopedValue.where(CURRENT_USER, user)
        .call(() -> processRequest(request)); // 이 범위 내에서만 유효
}

// StructuredTaskScope와 함께 사용 시 자식 스레드에 자동 상속
public void processInScope(User user) throws Exception {
    ScopedValue.where(CURRENT_USER, user).run(() -> {
        try (var scope = StructuredTaskScope.open()) {
            scope.fork(() -> {
                User u = CURRENT_USER.get(); // 부모의 바인딩 자동 상속
                return processForUser(u);
            });
            scope.join();
        }
    });
}

실전 예제: 병렬 API 호출 서비스

@Service
public class ProductAggregatorService {

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

    public ProductDetail getProductDetail(String productId, String reqId) throws Exception {
        return ScopedValue.where(REQUEST_ID, reqId).call(() -> {
            try (var scope = StructuredTaskScope.open(
                    Joiner.<Object>allSuccessfulOrThrow()
                          .onTimeout(Duration.ofSeconds(3), () -> null)
            )) {
                var info    = scope.fork(() -> productService.getInfo(productId));
                var reviews = scope.fork(() -> reviewService.getReviews(productId));
                var stock   = scope.fork(() -> inventoryService.getStock(productId));
                var price   = scope.fork(() -> pricingService.getPrice(productId));

                scope.join();

                log.info("[{}] 모든 서비스 응답 완료", REQUEST_ID.get());

                return new ProductDetail(
                    info.get(),
                    reviews.get(),
                    stock.get(),
                    price.get()
                );
            }
        });
    }
}

// Virtual Thread 기반 ExecutorService 설정
@Configuration
public class ThreadConfig {
    @Bean
    public ExecutorService virtualThreadExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

성능 최적화 및 주의사항

ThreadLocal 사용 주의

Virtual Threads는 수백만 개가 생성될 수 있으므로 무거운 객체를 ThreadLocal에 저장하면 메모리 문제가 발생할 수 있습니다. Scoped Values로 대체하거나, 사용 후 반드시 remove()를 호출해야 합니다.

CPU 집약적 작업 분리

Virtual Threads는 I/O 바운드 작업에 최적화되어 있습니다. 대용량 이미지 처리나 암호화 같은 CPU 집약적 작업은 여전히 Platform Thread 기반 스레드 풀을 사용해야 합니다.

// I/O 바운드: 가상 스레드 사용
try (var ioExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
    ioExecutor.submit(() -> fetchFromDatabase());
    ioExecutor.submit(() -> callExternalApi());
}

// CPU 바운드: 플랫폼 스레드 사용
var cpuExecutor = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);
cpuExecutor.submit(() -> processLargeImage());

정리

Java 26의 Virtual Threads와 Structured Concurrency는 Java 동시성 프로그래밍을 근본적으로 변화시키고 있습니다. 가상 스레드로 Thread-per-Request 모델을 높은 처리량으로 구현할 수 있고, Structured Concurrency로 복잡한 병렬 작업을 안전하게 관리할 수 있습니다. Scoped Values로 불변 컨텍스트 데이터를 효율적으로 전파하는 패턴까지 익히면, 리액티브 프로그래밍의 복잡성 없이도 고성능 서버 애플리케이션을 구축할 수 있습니다.