Spring Virtual Thread 실전

Virtual Thread란?

Virtual Thread는 Java 21에서 정식 도입된 경량 스레드로, Project Loom의 핵심 결과물입니다. 기존 Platform Thread(OS 스레드)는 생성 비용이 크고 수가 제한적이었지만, Virtual Thread는 JVM이 관리하는 경량 스레드로 수십만 개를 동시에 실행할 수 있습니다. Spring Boot 3.2부터 공식 지원하며, 한 줄 설정으로 기존 동기 코드의 처리량을 극적으로 향상시킬 수 있습니다.

이 글에서는 Virtual Thread의 동작 원리, Spring Boot 통합, 성능 특성, 그리고 운영 시 반드시 알아야 할 주의사항까지 깊이 있게 다루겠습니다.

Platform Thread vs Virtual Thread

항목 Platform Thread Virtual Thread
관리 주체 OS 커널 JVM
메모리 ~1MB 스택 ~수 KB (동적 확장)
동시 실행 수 수백~수천 수십만~수백만
생성 비용 ~1ms ~1μs
컨텍스트 스위칭 OS 레벨 (비용 높음) JVM 레벨 (비용 낮음)
블로킹 I/O 시 OS 스레드 점유 Carrier Thread 해제

동작 원리: Carrier Thread와 Mounting

// Virtual Thread의 실행 구조
// 
// Virtual Thread (수십만 개)
//     ↓ mount
// Carrier Thread (= ForkJoinPool, CPU 코어 수만큼)
//     ↓ 
// OS Thread
//
// 블로킹 I/O 발생 시:
// 1. Virtual Thread가 Carrier Thread에서 unmount
// 2. Carrier Thread는 다른 Virtual Thread를 mount하여 실행
// 3. I/O 완료 시 Virtual Thread가 다시 Carrier Thread에 mount

// Java 코드로 보면:
Thread vt = Thread.ofVirtual().start(() -> {
    // Carrier Thread에 mount됨
    System.out.println("Running on: " + Thread.currentThread());
    
    // 블로킹 I/O → unmount, Carrier 해제
    var result = httpClient.send(request, bodyHandler);
    
    // I/O 완료 → 다시 mount (다른 Carrier일 수 있음)
    process(result);
});

// Carrier Thread 풀 크기 조정
// -Djdk.virtualThreadScheduler.parallelism=16
// -Djdk.virtualThreadScheduler.maxPoolSize=256

Spring Boot 3.2+ 통합

# application.yml — 한 줄로 활성화!
spring:
  threads:
    virtual:
      enabled: true

# 이 설정 하나로 적용되는 범위:
# - Tomcat 요청 처리 스레드
# - @Async 비동기 실행
# - Spring MVC 컨트롤러 전체
# - TaskExecutor 기본 구현
// 수동 설정이 필요한 경우
@Configuration
@EnableAsync
public class VirtualThreadConfig {

    // Tomcat용 Virtual Thread Executor
    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }

    // @Async용 Virtual Thread Executor
    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(
            Executors.newVirtualThreadPerTaskExecutor()
        );
    }

    // 스케줄러용
    @Bean
    public ScheduledExecutorService scheduledExecutor() {
        return Executors.newScheduledThreadPool(0, 
            Thread.ofVirtual().factory());
    }
}

실전 성능 비교

Virtual Thread는 I/O 바운드 워크로드에서 극적인 성능 향상을 보여줍니다.

// I/O 바운드 API 예시: DB 조회 + 외부 API 호출
@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderRepository orderRepository;
    private final PaymentClient paymentClient;
    private final NotificationService notificationService;

    @GetMapping("/orders/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        // 각 호출이 50~200ms의 I/O 대기 발생
        Order order = orderRepository.findById(id).orElseThrow();
        PaymentInfo payment = paymentClient.getPayment(order.getPaymentId());
        notificationService.markAsRead(id);

        return OrderResponse.from(order, payment);
    }
}

// 부하 테스트 결과 (1000 동시 요청, 각 요청 ~100ms I/O)
// Platform Thread (200 스레드 풀):
//   - 처리량: ~1,800 req/s
//   - p99 지연: ~580ms
//   - 메모리: ~400MB
//
// Virtual Thread:
//   - 처리량: ~8,500 req/s (+370%)
//   - p99 지연: ~120ms
//   - 메모리: ~180MB

Pinning 문제: 가장 큰 함정

Pinning은 Virtual Thread가 Carrier Thread에서 unmount되지 못하는 현상입니다. 이 경우 Virtual Thread의 장점이 완전히 사라집니다.

// ❌ Pinning 발생 케이스 1: synchronized 블록 내 I/O
public class LegacyService {
    private final Object lock = new Object();

    public void process() {
        synchronized (lock) {  // ⚠️ synchronized → Pinning!
            // I/O 작업 중 Carrier Thread가 점유됨
            dbClient.query("SELECT ...");
        }
    }
}

// ✅ 해결: ReentrantLock 사용
public class ModernService {
    private final ReentrantLock lock = new ReentrantLock();

    public void process() {
        lock.lock();  // Virtual Thread 친화적
        try {
            dbClient.query("SELECT ...");
        } finally {
            lock.unlock();
        }
    }
}

// ❌ Pinning 발생 케이스 2: native 메서드/JNI 호출 중
// → 대안이 제한적, 가능하면 JNI 호출 최소화

// Pinning 감지 JVM 옵션
// -Djdk.tracePinnedThreads=full   (상세 스택트레이스)
// -Djdk.tracePinnedThreads=short  (요약)

주의해야 할 라이브러리

라이브러리 Virtual Thread 호환 비고
HikariCP 5.1+ ✅ 호환 synchronized → ReentrantLock 전환 완료
Hibernate 6.2+ ✅ 호환 JDBC 내부 synchronized 제거
Lettuce (Redis) ✅ 호환 Netty 기반, 비블로킹
Jedis ⚠️ 주의 내부 synchronized 사용
구형 JDBC 드라이버 ⚠️ 주의 synchronized I/O 가능성
Logback ⚠️ 주의 AsyncAppender 권장

Virtual Thread vs WebFlux

Spring WebFlux와 Virtual Thread는 모두 높은 동시성을 목표로 하지만 접근 방식이 다릅니다.

// WebFlux: 리액티브 (비동기 + 논블로킹)
@GetMapping("/orders/{id}")
public Mono<OrderResponse> getOrder(@PathVariable Long id) {
    return orderRepository.findById(id)        // R2DBC
        .flatMap(order -> 
            paymentClient.getPayment(order.getPaymentId())
                .map(payment -> OrderResponse.from(order, payment))
        );
}

// Virtual Thread: 동기 코드 그대로 (블로킹 + 경량 스레드)
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    PaymentInfo payment = paymentClient.getPayment(order.getPaymentId());
    return OrderResponse.from(order, payment);
}

// 선택 기준:
// - 기존 MVC 코드 + I/O 바운드 → Virtual Thread (마이그레이션 비용 최소)
// - 스트리밍/SSE/백프레셔 필요 → WebFlux
// - 이미 WebFlux 도입됨 → WebFlux 유지 (혼용 비추천)

ThreadLocal과 Spring 트랜잭션

// Virtual Thread에서 ThreadLocal은 정상 동작하지만,
// 수십만 스레드 × ThreadLocal 변수 = 메모리 폭발 가능

// ❌ 무거운 ThreadLocal
private static final ThreadLocal<HeavyContext> CTX = 
    ThreadLocal.withInitial(HeavyContext::new);  // ⚠️ 스레드당 수 KB

// ✅ ScopedValue (Java 21 Preview) — Virtual Thread 최적화
private static final ScopedValue<RequestContext> CTX = 
    ScopedValue.newInstance();

ScopedValue.where(CTX, new RequestContext(tenantId))
    .run(() -> {
        // 이 스코프 내에서만 유효, 자동 정리
        String tenant = CTX.get().getTenantId();
        processOrder(tenant);
    });

// Spring의 TransactionSynchronizationManager는
// ThreadLocal 기반이지만, Virtual Thread에서도 정상 동작
// (스프링 프레임워크가 내부적으로 호환성 보장)

운영 모니터링

# JFR(Java Flight Recorder)로 Virtual Thread 모니터링
java -XX:StartFlightRecording=filename=recording.jfr,duration=60s 
     -Djdk.tracePinnedThreads=short 
     -jar app.jar

# Micrometer + Actuator 메트릭
management:
  endpoints:
    web:
      exposure:
        include: metrics,threaddump

# 확인할 핵심 메트릭:
# - jvm.threads.live (전체 스레드 수)
# - jvm.threads.daemon (데몬 스레드)
# - jvm.threads.started.total (생성된 총 스레드)
# - tomcat.threads.busy (Tomcat 활성 스레드)

마무리

Spring Boot + Virtual Thread는 기존 동기 코드를 그대로 유지하면서 WebFlux 수준의 동시성을 달성할 수 있는 혁신적 조합입니다. spring.threads.virtual.enabled=true 한 줄이면 즉시 적용되지만, synchronized 기반 PinningThreadLocal 메모리 이슈는 반드시 점검해야 합니다. I/O 바운드 워크로드에서 큰 효과를 발휘하며, CPU 바운드 작업에서는 이점이 제한적이라는 점도 기억하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux