Spring Boot 4.0 가상 스레드 vs WebFlux — 벤치마크로 증명하는 성능 차이와 실무 함정
General

Spring Boot 4.0 가상 스레드 vs WebFlux — 벤치마크로 증명하는 성능 차이와 실무 함정


3줄 요약

  • Spring Boot 4.0(2025.11.20 GA)은 Java 21+ 환경에서 가상 스레드를 자동 구성하며, I/O 바운드 워크로드에서 WebFlux와 동등하거나 우수한 처리량을 보여줍니다.
  • 800+ 동시접속 환경에서 가상 스레드의 p99 레이턴시는 WebFlux 대비 최대 40% 낮고, DB 집약적 워크로드에서 처리량은 40% 높습니다.
  • 다만 synchronized 핀닝, HikariCP 커넥션 풀 고갈, ThreadLocal 오염이라는 3가지 치명적 함정을 반드시 해결해야 합니다.

2025년 11월 20일, Spring Framework 7.0과 함께 출시된 Spring Boot 4.0 GA는 백엔드 생태계에 큰 파장을 일으켰습니다. Java 17을 최소 사양으로 요구하며 Java 21 이상을 권장하는 이번 릴리즈의 핵심은 단연 가상 스레드의 전면적인 도입입니다. Java 21+ 환경에서 Tomcat과 Jetty 핸들러가 가상 스레드를 기본으로 사용하도록 자동 구성되면서, 그동안 비동기 논블로킹 처리를 위해 도입했던 WebFlux의 입지가 흔들리고 있습니다. 서버 자원을 극한으로 활용하기 위해 도입했던 리액티브 패러다임이 가상 스레드의 등장으로 새로운 전환점을 맞이했습니다.

Spring Boot 4.0 가상 스레드와 WebFlux를 상징하는 두 개의 대조적인 기하학적 형태

Spring Boot 4.0에서 달라진 스레드 모델

가장 두드러진 변화는 application.yaml 파일에 spring.threads.virtual.enabled=true 속성을 선언하는 것만으로 스레드 모델이 완전히 달라진다는 점입니다. Java 21 이상 환경에서는 Tomcat 11.0 또는 Jetty 핸들러가 가상 스레드를 기본으로 사용하도록 자동 구성됩니다. 기존 OS 스레드와 1:1로 매핑되던 플랫폼 스레드 모델을 벗어나, 소수의 캐리어 스레드 위에 수백만 개의 가상 스레드가 1:N으로 매핑되는 Project Loom의 구조를 온전히 활용하게 됩니다.

# application.yaml — 가상 스레드 활성화 (이 한 줄이 전부입니다)
spring:
  threads:
    virtual:
      enabled: true

Servlet 6.1 사양과 호환되지 않는 Undertow 지원은 이번 4.0 릴리즈에서 완전히 제거되었습니다. 내장 웹 서버는 Tomcat 11.0이나 Jetty 중에서 선택해야 합니다. 또한 Jackson 2는 deprecated 처리되었으며, Jackson 3.0으로의 마이그레이션이 예정되어 있어 의존성 라이브러리 관리에도 주의가 필요합니다.

Spring Boot 4.0 주요 변경사항 체크리스트

  • Java 17 최소 / Java 21+ 권장 (가상 스레드 자동 구성)
  • Undertow 지원 제거 → Tomcat 11.0 또는 Jetty 사용
  • Jackson 2 deprecated → Jackson 3.0 마이그레이션 필요
  • JDK HttpClient도 spring.threads.virtual.enabled=true 시 가상 스레드 사용

코드로 보는 WebFlux vs MVC + 가상 스레드

동일한 외부 API를 호출하고 데이터를 가공하는 비즈니스 로직을 두 가지 방식으로 비교합니다.

WebFlux와 Virtual Threads 코드 비교 다이어그램

// WebFlux — Mono/Flux 체이닝 기반 비동기 논블로킹
@GetMapping("/products/{id}")
public Mono<ProductResponse> getProduct(@PathVariable String id) {
    return webClient.get()
            .uri("/api/ext/products/{id}", id)
            .retrieve()
            .bodyToMono(ProductDto.class)
            .flatMap(product -> reviewClient.getReviews(product.getId())
                .map(reviews -> new ProductResponse(product, reviews)))
            .onErrorResume(WebClientResponseException.class,
                ex -> Mono.just(ProductResponse.error()));
}
// Spring MVC + 가상 스레드 — 동기식 코드 그대로
@GetMapping("/products/{id}")
public ProductResponse getProduct(@PathVariable String id) {
    try {
        ProductDto product = restClient.get()
                .uri("/api/ext/products/{id}", id)
                .retrieve()
                .body(ProductDto.class);

        List<Review> reviews = reviewClient.getReviews(product.getId());
        return new ProductResponse(product, reviews);
    } catch (RestClientResponseException ex) {
        return ProductResponse.error();
    }
}

가상 스레드를 활용한 MVC 코드는 위에서 아래로 흐르는 직관적인 동기식 흐름을 유지합니다. 예외 처리도 Java 개발자에게 익숙한 try-catch 블록을 그대로 사용합니다. WebFlux의 Mono 체이닝에서 발생하는 예외는 스레드 경계를 넘나들며 실행 컨텍스트를 잃어버리기 쉽지만, 가상 스레드는 호출 스택을 온전히 보존하므로 스택 트레이스만으로 문제 원인을 명확히 파악할 수 있습니다.

성능 벤치마크 — 숫자로 말하는 처리량과 레이턴시

외부 API 응답에 3초의 지연이 발생하는 전형적인 I/O 바운드 워크로드를 가정한 벤치마크 결과입니다.

가상 스레드와 WebFlux의 TPS 성능 벤치마크 차트

동시 접속 수WebFlux TPSWebFlux p99 (ms)가상 스레드 TPS가상 스레드 p99 (ms)
100323,100333,080
5001603,2501653,150
8002204,5002603,400
1,0002506,2003203,800

동시 접속 수가 500명 수준일 때는 두 기술 스택이 매우 유사한 성능을 냅니다. 800명 이상의 고동시성 환경부터 가상 스레드가 확연한 우위를 점합니다. 전체 벤치마크 시나리오의 약 45%에서 Netty 위에 구성된 가상 스레드 모델이 가장 높은 성능을 기록했습니다.

Red Hat에서 진행한 벤치마크에 따르면 DB 집약적 워크로드에서 가상 스레드 모델이 WebFlux 대비 40% 더 높은 처리량을 보여주었습니다. Netflix 내부 테스트에서도 추천 엔진의 테일 레이턴시(p99)가 25% 감소하는 성과를 거두었습니다.

다만, 스트리밍(SSE), WebSocket 통신, 백프레셔(Backpressure) 제어가 필수적이거나 수학적 연산이 주를 이루는 CPU 바운드 환경에서는 WebFlux가 더 안정적인 성능을 유지합니다.

가상 스레드 도입 시 반드시 알아야 할 3가지 함정

가상 스레드 도입 시 주의해야 할 3가지 함정: synchronized 핀닝, 커넥션 풀 고갈, ThreadLocal 오염

1. synchronized 핀닝

가상 스레드 실행 중 synchronized 블록을 만나면 가상 스레드가 OS 캐리어 스레드에 고정(Pinning)됩니다. 캐리어 스레드가 블로킹되면 가상 스레드의 장점이 상쇄되며 전체 애플리케이션의 성능 저하로 이어집니다.

// Before: 핀닝 발생
public synchronized void updateStock(String productId, int qty) {
    stockRepository.update(productId, qty);
}

// After: ReentrantLock으로 교체하면 핀닝 회피
private final ReentrantLock lock = new ReentrantLock();

public void updateStock(String productId, int qty) {
    lock.lock();
    try {
        stockRepository.update(productId, qty);
    } finally {
        lock.unlock();
    }
}

-Djdk.tracePinnedThreads=short JVM 옵션을 추가하면 핀닝이 발생하는 지점을 로그로 확인할 수 있습니다. 레거시 라이브러리에 숨어있는 synchronized 블록까지 점검하시기 바랍니다.

2. HikariCP 커넥션 풀 고갈

가상 스레드는 메모리만 허락한다면 10만 개 이상 생성됩니다. 동시에 10만 개의 스레드가 DB에 접근을 시도하지만 HikariCP 커넥션 풀의 물리적 한계는 보통 수십~수백 개 수준입니다. 커넥션 고갈(Pool Exhaustion) 에러가 장애로 직결됩니다.

# application.yaml — HikariCP 권장 설정
spring:
  datasource:
    hikari:
      maximum-pool-size: 32
      minimum-idle: 8
      connection-timeout: 2000
// Semaphore로 DB 접근 동시성을 커넥션 풀 크기에 맞춰 제한
private final Semaphore dbPermit = new Semaphore(30);

public Product getProduct(String id) {
    if (!dbPermit.tryAcquire()) {
        throw new TooManyRequestsException("DB 커넥션 대기열 초과");
    }
    try {
        return productRepository.findById(id).orElseThrow();
    } finally {
        dbPermit.release();
    }
}

3. ThreadLocal 오염

가상 스레드는 수명이 짧고 생성·소멸이 빈번합니다. 플랫폼 스레드에서 캐싱이나 컨텍스트 전파 목적으로 사용하던 ThreadLocal을 가상 스레드에서 동일하게 쓰면 힙 오염(Heap Pollution)에 의한 메모리 누수가 발생합니다. Java 21에 도입된 ScopedValue를 대안으로 사용하세요.

// ThreadLocal 대신 ScopedValue 사용 (Java 21+)
private static final ScopedValue<UserContext> CURRENT_USER = ScopedValue.newInstance();

public void handleRequest(UserContext user) {
    ScopedValue.runWhere(CURRENT_USER, user, () -> {
        // 이 스코프 내에서만 유효, 자동 정리
        processOrder(CURRENT_USER.get());
    });
}

그래서 WebFlux는 버려야 할까? 상황별 선택 가이드

워크로드 특성에 따라 적합한 기술 스택이 다릅니다. 두 기술은 상호 배타적이지 않습니다.

시나리오권장 기술 스택주된 이유
일반적인 CRUD APIMVC + 가상 스레드직관적인 동기식 코드, 기존 JDBC 생태계 호환
스트리밍 / SSE 응답WebFlux청크 단위 비동기 전송, 메모리 제어 최적화
API 게이트웨이WebFlux다수 하위 시스템 연동 시 백프레셔 필수
CPU 집약 연산WebFlux + 별도 스레드 풀가상 스레드는 I/O 대기 최적화 용도이며 연산 병목 해결에 부적합
DB 집약 마이크로서비스MVC + 가상 스레드Red Hat 벤치마크 기준 WebFlux 대비 처리량 40% 우위

대규모 MSA 환경에서는 비즈니스 로직 계층에 가상 스레드를 적용하고, 외부 트래픽을 분배하는 게이트웨이 영역에 WebFlux를 배치하는 하이브리드 아키텍처 전략이 가장 현실적인 접근입니다.


Spring Boot 4.0의 가상 스레드는 WebFlux의 대체재가 아니라 보완재입니다. I/O 대기가 잦은 일반 웹 애플리케이션의 개발 생산성과 처리량을 크게 끌어올리지만, 리액티브 프로그래밍 고유의 백프레셔나 데이터 스트리밍 영역까지 대체하지는 못합니다. 시스템의 워크로드 특성을 정확히 분석하고 적합한 도구를 선택하시기 바랍니다.

자주 묻는 질문 (FAQ)

Q. 가상 스레드를 쓰면 WebFlux를 완전히 대체할 수 있나요?

가상 스레드는 동기식 코드의 블로킹 구간을 스레드 점유 없이 넘기며 처리량을 높이는 데 목적이 있습니다. 클라이언트의 데이터 소비 속도에 맞춰 메모리 사용량을 제어하는 백프레셔 기능, WebSocket 연결, 대용량 파일 스트리밍 등 리액티브 고유 기능이 필요한 환경에서는 WebFlux를 유지해야 합니다.

Q. 기존 Spring Boot 3.x 프로젝트에서 가상 스레드로 마이그레이션하려면?

Java 21 이상으로 업그레이드한 뒤 application.yamlspring.threads.virtual.enabled=true를 추가하면 즉시 적용됩니다. 다만 서블릿 필터나 인터셉터 내부에 synchronized 블록이나 ThreadLocal 래퍼가 존재하는지 사전 점검이 필수입니다. Undertow를 사용 중이었다면 Tomcat 11.0이나 Jetty로 교체해야 합니다.

Q. 가상 스레드와 코틀린 코루틴은 어떻게 다른가요?

코루틴은 Kotlin 언어 레벨에서 suspend 키워드로 중단점을 직접 제어하는 방식이고, 가상 스레드는 JVM 레벨에서 I/O 블로킹 시 자동으로 캐리어 스레드를 양보하는 방식입니다. 별도의 비동기 키워드 없이 동기식 코드 그대로 동시성을 확보할 수 있다는 점이 가상 스레드의 차별점입니다.