Spring BootConcurrencyPerformanceArchitecture

Spring Boot Virtual Threads 실무 적용기 — WebFlux 없이 높은 동시성을 달성한 경험

Spring Boot 4에서 기본이 된 Virtual Threads를 실무에 적용하면서, 기존 WebFlux 코드를 블로킹 스타일로 전환하고도 높은 동시성을 달성한 과정과 주의할 점을 정리합니다.

Srue2026년 4월 11일
Spring Boot Virtual Threads 실무 적용기 — WebFlux 없이 높은 동시성을 달성한 경험

처음 WebFlux를 프로젝트에 도입했을 때는 꽤 뿌듯했습니다. MonoFlux로 비동기 파이프라인을 만들면 스레드를 적게 써도 수천 개의 동시 요청을 처리할 수 있다는 매력적인 약속이 있었기 때문입니다.

하지만 시간이 지나면서 현실은 약속과 조금 달랐습니다. 리액티브 코드가 늘어날수록 디버깅이 고통스러워졌고, 새로 합류한 팀원이 flatMap 체인 속에서 길을 잃는 모습을 자주 목격했습니다. 무엇보다, JPA처럼 블로킹 기반의 라이브러리를 함께 쓰려면 별도의 스레드 풀로 감싸야 하는 번거로움이 끝없이 따라왔습니다.

그러던 중 Spring Boot 4에서 Virtual Threads가 기본 활성화되었다는 소식을 접했습니다. "블로킹 코드를 그대로 쓰면서 높은 동시성을 달성할 수 있다"는 말에 처음에는 반신반의했습니다. 하지만 직접 적용해 보니, 결국 이것이야말로 저희 팀이 기다려온 해답이었습니다.

Virtual Threads가 뭐가 다른 걸까

기존 자바 스레드는 OS 스레드(Platform Thread)와 1:1로 매핑되었습니다. 스레드 하나가 I/O 대기 상태에 빠지면 그 OS 스레드도 함께 묶여버립니다. 그래서 Tomcat의 기본 스레드 풀 200개가 전부 DB 응답을 기다리고 있으면, 201번째 요청은 문 앞에서 발만 동동 구르는 신세가 됩니다.

Virtual Threads는 이 구조를 뒤집습니다. JVM이 관리하는 경량 스레드로, I/O 대기가 발생하면 자동으로 캐리어 스레드(Carrier Thread)에서 분리(unmount)됩니다. 놀이공원의 놀이기구에서 잠시 내려 다른 사람이 탈 수 있게 해주는 것과 비슷합니다.

전통적인 Platform Thread 방식
// Tomcat 스레드 200개 = 최대 동시 처리 200개
// 스레드가 DB 응답을 기다리는 동안 아무것도 못 함
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
    return orderService.findById(id); // DB I/O 대기 중 스레드 점유
}

Spring Boot 4에서는 별도 설정 없이 이 코드가 Virtual Thread 위에서 실행됩니다. DB 응답을 기다리는 동안 캐리어 스레드는 다른 Virtual Thread의 작업을 처리할 수 있으니, 동시 처리량이 비약적으로 올라갑니다.

기존 WebFlux 코드가 안고 있던 고통

저희 프로젝트에서 WebFlux를 썼던 대표적인 이유는 외부 API 호출이 많은 서비스였기 때문입니다. 결제 연동, 알림 발송, 외부 시세 조회 등 I/O 바운드 작업이 한 요청에 3~4개씩 엮여 있었습니다.

기존 WebFlux 코드 (before)
public Mono<PaymentResult> processPayment(PaymentRequest request) {
    return webClient.post()
        .uri("/api/pg/approve")
        .bodyValue(request)
        .retrieve()
        .bodyToMono(PgResponse.class)
        .flatMap(pgResponse -> 
            notificationClient.sendAsync(request.getUserId(), "결제 완료")
                .thenReturn(pgResponse)
        )
        .flatMap(pgResponse ->
            Mono.fromCallable(() -> orderRepository.save(Order.from(pgResponse)))
                .subscribeOn(Schedulers.boundedElastic()) // JPA는 블로킹이라 별도 스레드 필요
        )
        .map(PaymentResult::success)
        .onErrorResume(e -> Mono.just(PaymentResult.fail(e.getMessage())));
}

코드 자체가 읽기 어려운 것은 둘째 치고, 실질적인 문제들이 있었습니다.

  • 스택 트레이스가 끊김: 에러가 터졌을 때 flatMap 체인 어딘가에서 발생했다는 것만 알 수 있었습니다. Sentry 도입 이후에도 리액티브 스택 트레이스는 읽기가 고통스러웠습니다.
  • JPA 호환성: @Transactional을 쓰려면 Schedulers.boundedElastic()으로 감싸야 했는데, 이렇게 하면 리액티브의 장점이 반감됩니다.
  • 학습 곡선: 새 팀원이 Mono/Flux에 익숙해지기까지 최소 2~3주가 걸렸습니다.

Virtual Threads로의 전환

Spring Boot 4로 업그레이드하면서 WebFlux 의존성을 걷어내고, 기존의 친숙한 Spring MVC + 블로킹 코드로 돌아왔습니다. spring-boot-starter-web만 있으면 Tomcat이 자동으로 Virtual Threads를 사용합니다.

혹시 Spring Boot 3.2~3.x에서 먼저 시도하고 싶다면 아래 설정 한 줄이면 됩니다.

application.yml (Spring Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true

위의 WebFlux 코드를 블로킹 스타일로 다시 작성하니, 놀라울 정도로 단순해졌습니다.

Virtual Threads 기반 블로킹 코드 (after)
@Transactional
public PaymentResult processPayment(PaymentRequest request) {
    try {
        PgResponse pgResponse = restClient.post()
            .uri("/api/pg/approve")
            .body(request)
            .retrieve()
            .body(PgResponse.class);
 
        notificationService.send(request.getUserId(), "결제 완료");
 
        Order order = orderRepository.save(Order.from(pgResponse));
        return PaymentResult.success(order);
        
    } catch (Exception e) {
        return PaymentResult.fail(e.getMessage());
    }
}

@Transactional이 자연스럽게 동작하고, 스택 트레이스가 완전한 형태로 잡히고, 새 팀원도 코드를 읽는 데 5분이면 충분했습니다. 그런데 정말 성능은 괜찮을까 하는 의구심이 남았습니다.

성능 비교: WebFlux vs Virtual Threads

JMeter로 동시 사용자 1,000명이 10초간 요청을 보내는 시나리오를 구성하고, 외부 API 응답 지연을 200ms로 시뮬레이션했습니다.

항목WebFlux (기존)Virtual Threads (전환 후)
평균 응답 시간약 230ms약 245ms
99퍼센타일 응답 시간약 580ms약 520ms
처리량 (req/sec)약 3,800약 3,650
에러율0.02%0.01%
메모리 사용량 (Heap)약 420MB약 310MB

평균 응답 시간은 근소하게 WebFlux가 빨랐지만, 꼬리 지연(tail latency)에서는 Virtual Threads가 오히려 안정적이었습니다. 무엇보다 메모리 사용량이 눈에 띄게 줄었는데, 수만 개의 Virtual Thread가 Platform Thread보다 훨씬 가벼웠기 때문입니다.

"성능이 비슷하다면, 코드가 단순한 쪽이 이긴다"는 것이 팀 내 결론이었습니다.

실무에서 밟았던 지뢰들

모든 것이 순탄했다면 글을 쓸 이유가 없었겠죠. 전환 과정에서 몇 가지 뼈아픈 교훈을 얻었습니다.

synchronized 블록의 핀닝(Pinning) 문제

Virtual Thread가 synchronized 블록 안에서 I/O를 수행하면, 캐리어 스레드에서 분리되지 못하고 고정(Pinning)되는 현상이 발생합니다. 이러면 사실상 Platform Thread를 점유하는 것과 같아져서 Virtual Threads의 장점이 사라집니다.

이전에 트랜잭션과 동시성 이슈를 정리하면서 synchronized를 고려했던 적이 있는데, Virtual Threads 환경에서는 이 선택이 독이 될 수 있었습니다.

핀닝이 발생하는 코드 (문제)
public synchronized String fetchExternalData() {
    // synchronized 블록 안에서 I/O → 캐리어 스레드 고정(Pinning)
    return restClient.get()
        .uri("/api/external")
        .retrieve()
        .body(String.class);
}

해결책은 synchronized 대신 ReentrantLock을 사용하는 것이었습니다.

ReentrantLock으로 핀닝 회피 (해결)
private final ReentrantLock lock = new ReentrantLock();
 
public String fetchExternalData() {
    lock.lock();
    try {
        return restClient.get()
            .uri("/api/external")
            .retrieve()
            .body(String.class);
    } finally {
        lock.unlock();
    }
}

JDK Flight Recorder에서 jdk.VirtualThreadPinned 이벤트를 모니터링하면 핀닝이 발생하는 지점을 찾아낼 수 있었습니다. 배포 전 이 이벤트를 반드시 확인하는 절차를 배포 체크리스트에 추가했습니다.

커넥션 풀 사이징 재조정

Virtual Threads 덕분에 동시에 수천 개의 요청을 처리할 수 있게 되었지만, HikariCP 커넥션 풀은 여전히 유한한 자원입니다. 기존에는 Tomcat 스레드 200개가 상한이었으니 커넥션 풀 20개로도 어느 정도 버텼는데, Virtual Threads 환경에서는 수천 개의 스레드가 동시에 커넥션을 요청하면서 풀이 순식간에 고갈되었습니다.

application.yml - 커넥션 풀 조정
spring:
  datasource:
    hikari:
      maximum-pool-size: 50       # 기존 20 → 50으로 증가
      connection-timeout: 5000    # 대기 시간도 여유 있게

단순히 풀 사이즈를 늘리는 것만이 답은 아니었습니다. DB 서버가 감당할 수 있는 최대 커넥션 수를 고려해야 했고, Redis 캐시로 DB 접근 자체를 줄이는 전략이 함께 필요했습니다.

ThreadLocal 사용에 대한 주의

Virtual Threads는 생성과 소멸이 매우 빈번하기 때문에, ThreadLocal에 무거운 객체를 저장하면 메모리 누수로 이어질 수 있었습니다. 기존에 MDC(Mapped Diagnostic Context)로 traceId를 남기던 방식도 점검이 필요했습니다.

다행히 SLF4J의 MDC는 내부적으로 InheritableThreadLocal을 사용하고 있어서 Virtual Threads에서도 동작했지만, 커스텀 ThreadLocal을 쓰고 있던 부분은 ScopedValue(JDK 21 프리뷰)로 전환을 검토하게 되었습니다.

전환 후 코드베이스에서 느낀 변화

기술적인 지표보다 체감이 컸던 변화가 있었습니다.

  • 코드 리뷰 시간 감소: 리액티브 체인의 올바른 구성인지 검토하는 시간이 사라졌습니다.
  • 온보딩 속도 향상: 새 팀원이 서비스 코드를 이해하는 데 걸리는 시간이 체감상 절반으로 줄었습니다.
  • 디버깅 효율: 스택 트레이스가 완전하게 잡히니, 에러 원인을 찾는 시간이 눈에 띄게 줄었습니다.

결국 "개발자가 읽고 쓰기 편한 코드"가 장기적으로 유지보수 비용을 낮추고, 팀의 생산성을 올린다는 지극히 당연한 결론에 다시 도달했습니다.

마무리

WebFlux를 걷어내는 결정이 쉽지는 않았습니다. 이미 동작하고 있는 코드를 뜯어내는 것은 언제나 용기가 필요하니까요.

하지만 Virtual Threads를 적용하고 나서 깨달은 것은, 기술 선택의 기준이 "가장 최신인가"가 아니라 "팀이 가장 잘 다룰 수 있는가"여야 한다는 점이었습니다. 리액티브 프로그래밍 자체가 나쁜 것은 아닙니다. 다만 저희 팀의 상황에서는 블로킹 스타일의 단순함이 리액티브의 이론적 성능 우위보다 더 큰 가치를 가졌습니다.

Virtual Threads가 만능은 아닙니다. 핀닝, 커넥션 풀, ThreadLocal 같은 함정이 곳곳에 있고, CPU 바운드 작업에는 여전히 별도의 전략이 필요합니다. 그래도 "I/O가 많은 전형적인 백엔드 서비스"에서는 코드의 단순함과 높은 동시성을 동시에 잡을 수 있는 강력한 선택지라는 확신이 생겼습니다.

결국 좋은 기술이란, 쓰고 있다는 걸 의식하지 않아도 될 만큼 자연스러운 기술이 아닐까 생각합니다.