"Virtual Threads가 나왔으니까 WebFlux 배울 필요 없는 거 아닌가요?" 이 질문, 2024년부터 지금까지 정말 많이 봤습니다. 결론부터 말하면 — 반은 맞고, 반은 틀립니다.

WebFlux가 해결하려 했던 문제 중 상당 부분을 Virtual Threads가 더 간단하게 해결합니다. 하지만 WebFlux만이 할 수 있는 영역이 여전히 존재해요. 이 글에서는 2026년 기준으로 두 기술을 비교하고, 어떤 상황에서 어떤 선택이 합리적인지 정리해보겠습니다.


왜 이 논쟁이 생겼나

WebFlux가 등장한 이유

Spring MVC는 요청 하나당 스레드 하나를 할당합니다. I/O 대기 중에도 스레드를 잡고 있으니, 동시 요청이 많아지면 스레드 풀이 고갈돼요. WebFlux는 이 문제를 논블로킹 I/O + 이벤트 루프 로 해결했습니다.

하지만 대가가 컸어요:

  • Mono/Flux 기반의 완전히 새로운 프로그래밍 모델
  • JPA, JDBC 같은 블로킹 라이브러리 사용 불가
  • 스택 트레이스가 읽기 어려움
  • 러닝 커브가 가파름

Virtual Threads가 바꾼 판도

Java 21에서 정식 릴리스된 Virtual Threads는 접근 방식이 완전히 다릅니다. 블로킹 코드를 그대로 쓰면서 I/O 대기 시 자동으로 캐리어 스레드를 양보해요. 프로그래머 입장에서는 아무것도 바뀌지 않았는데, 런타임이 알아서 최적화해주는 겁니다.

JAVA
// MVC + Virtual Threads — 기존 블로킹 코드 그대로 사용
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
    // JPA 블로킹 호출이지만, Virtual Thread가 I/O 대기 시 양보
    User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));

    // 외부 API 블로킹 호출 — 역시 자동으로 양보
    CreditScore score = creditService.getScore(user.getSsn());

    return UserResponse.from(user, score);
}
JAVA
// WebFlux — 동일한 로직을 리액티브로 작성
@GetMapping("/users/{id}")
public Mono<UserResponse> getUser(@PathVariable Long id) {
    return userRepository.findById(id)  // R2DBC 사용 필수
            .switchIfEmpty(Mono.error(new UserNotFoundException(id)))
            .flatMap(user ->
                creditService.getScore(user.getSsn())  // 논블로킹 클라이언트 필요
                    .map(score -> UserResponse.from(user, score))
            );
}

같은 기능인데 코드 복잡도의 차이가 느껴지시나요?


Virtual Threads 동작 원리 (짧게)

Virtual Threads의 핵심을 한 줄로 요약하면: JVM이 관리하는 경량 스레드로, 블로킹 I/O를 만나면 자동으로 캐리어 스레드에서 언마운트된다.

PLAINTEXT
캐리어 스레드 (OS 스레드, 소수)
├── Virtual Thread A → DB 쿼리 중 (언마운트, 대기)
├── Virtual Thread B → 실행 중
├── Virtual Thread C → API 호출 중 (언마운트, 대기)
└── Virtual Thread D → 실행 중

// Virtual Thread는 수십만 개 생성 가능
// 캐리어 스레드는 CPU 코어 수만큼만 유지

기존 플랫폼 스레드는 1MB 스택 메모리를 차지하지만, Virtual Thread는 수 KB로 시작하고 필요에 따라 늘어납니다. 그래서 수십만 개를 생성해도 메모리 부담이 적어요.

Virtual Threads가 마법처럼 보이지만, 본질은 ** 커널 스케줄링을 JVM 스케줄링으로 대체 **한 것이다. Go의 고루틴이나 Kotlin의 코루틴과 같은 계열이다.


성능 비교: 처리량, 지연 시간, 메모리

처리량 (Throughput)

I/O 바운드 워크로드 기준으로, 두 방식 모두 ** 비슷한 수준의 동시성 **을 달성합니다. 여러 벤치마크에서 확인된 결과예요:

항목MVC (플랫폼 스레드)MVC + Virtual ThreadsWebFlux
동시 요청 1,000정상정상정상
동시 요청 10,000스레드 풀 고갈정상정상
동시 요청 100,000불가정상 (메모리 여유 시)정상

지연 시간 (Latency)

  • MVC + VT: 블로킹 호출의 실제 I/O 시간에 가까운 지연 시간
  • WebFlux: 이벤트 루프 오버헤드가 있지만 매우 작음 (마이크로초 단위)
  • 실무에서 두 방식의 차이는 ** 대부분 무시할 수 있는 수준**

메모리

  • WebFlux: 이벤트 루프 기반이라 기본 메모리 사용이 적음
  • MVC + VT: Virtual Thread당 수 KB지만, 수십만 개면 누적됨
  • 극한의 메모리 효율이 필요하면 WebFlux가 약간 유리

성능 차이보다 ** 개발 생산성과 유지보수성 **이 대부분의 프로젝트에서 더 중요한 선택 기준이다. 초당 수십만 요청이 아닌 이상, 두 방식 모두 충분하다.


생태계 호환성

이 부분이 실무에서 가장 크게 체감되는 차이입니다.

MVC + Virtual Threads

JAVA
// JPA, JDBC — 그냥 된다
@Transactional
public Order createOrder(OrderRequest request) {
    // JPA 엔티티 저장 — 블로킹이지만 VT가 알아서 처리
    Order order = orderRepository.save(Order.from(request));

    // JDBC 기반 레거시 라이브러리 — 역시 그냥 동작
    legacyBillingSystem.charge(order.getPaymentInfo());

    return order;
}
  • JPA, MyBatis, JDBC 모두 호환
  • 기존 블로킹 라이브러리 전부 사용 가능
  • Spring Security, Spring Batch 등 전체 생태계 활용

WebFlux

JAVA
// R2DBC 필수 — JPA 사용 불가
public Mono<Order> createOrder(OrderRequest request) {
    return r2dbcOrderRepository.save(Order.from(request))
            .flatMap(order ->
                // 레거시 블로킹 라이브러리? Schedulers.boundedElastic()으로 감싸야 함
                Mono.fromCallable(() -> legacyBillingSystem.charge(order.getPaymentInfo()))
                    .subscribeOn(Schedulers.boundedElastic())
                    .thenReturn(order)
            );
}
  • R2DBC, Spring Data Reactive만 지원
  • JPA, MyBatis 사용 불가 (블로킹 I/O)
  • 블로킹 라이브러리를 쓰려면 별도 스케줄러에서 실행해야 함
  • 일부 서드파티 라이브러리가 리액티브 미지원

팀에 리액티브 경험이 부족한 상태에서 WebFlux를 도입하면, 가장 먼저 부딪히는 게 "이 라이브러리가 리액티브를 지원하나?"다. 생태계 호환성 문제는 생각보다 개발 속도를 많이 떨어뜨린다.


학습 곡선과 디버깅

학습 곡선

MVC + Virtual Threads는 사실상 ** 추가 학습이 거의 없습니다.** Spring Boot 설정에 한 줄 추가하면 끝이에요:

YAML
# application.yml — Virtual Threads 활성화 (Spring Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true

반면 WebFlux는:

  • Reactive Streams 스펙 이해
  • Mono/Flux 오퍼레이터 숙지 (수십 개)
  • flatMap, switchIfEmpty, zip, concat 등 조합 학습
  • 에러 처리 패턴 (onErrorResume, onErrorReturn)
  • 테스트 방법 (StepVerifier)

디버깅 경험

JAVA
// MVC + VT — 스택 트레이스가 깔끔하다
java.lang.NullPointerException
    at com.example.UserService.getUser(UserService.java:25)
    at com.example.UserController.getUser(UserController.java:18)
    // 읽기 쉬운 동기식 스택 트레이스
JAVA
// WebFlux — 스택 트레이스가 복잡하다
java.lang.NullPointerException
    at com.example.UserService.lambda$getUser$0(UserService.java:25)
    at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:106)
    at reactor.core.publisher.FluxFlatMap$FlatMapMain.tryEmit(FluxFlatMap.java:543)
    // 리액터 내부 스택이 섞여서 원인 파악이 어려움

Hooks.onOperatorDebug()를 켜면 좀 나아지지만, 프로덕션에서 켜면 성능이 크게 떨어진다. ReactorDebugAgent를 쓰면 오버헤드 없이 디버깅할 수 있지만, 이런 도구를 알아야 한다는 것 자체가 러닝 커브다.


백프레셔: WebFlux의 킬러 피처

여기서부터가 WebFlux를 아직 버릴 수 없는 이유입니다.

** 백프레셔(Backpressure)** 는 소비자가 처리할 수 있는 속도로 생산자에게 데이터를 요청하는 메커니즘이에요. Reactive Streams 스펙에 내장되어 있습니다.

JAVA
// WebFlux — 백프레셔가 자연스럽게 동작
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamEvents() {
    return eventService.getEventStream()
            .onBackpressureBuffer(1000)  // 버퍼 크기 제한
            .map(event -> ServerSentEvent.<String>builder()
                    .data(event.toJson())
                    .build());
    // 클라이언트가 느리면 자동으로 속도 조절
}

MVC + Virtual Threads에서 동일한 패턴을 구현하려면? 직접 큐를 관리하고 흐름을 제어해야 합니다. 불가능은 아니지만, WebFlux가 제공하는 것처럼 선언적으로 할 수는 없어요.


언제 WebFlux를 선택하나

다음 조건 중 하나라도 해당하면 WebFlux가 더 적합합니다:

  1. ** 실시간 스트리밍 **: SSE, WebSocket으로 지속적인 데이터 푸시가 핵심
  2. ** 백프레셔 필수 **: 생산자-소비자 간 속도 차이를 제어해야 함
  3. ** 이벤트 기반 파이프라인 **: 여러 비동기 소스를 조합하는 복잡한 데이터 흐름
  4. ** 이미 리액티브 생태계 **: R2DBC, Reactive Redis, Reactive Kafka 등 이미 사용 중
JAVA
// WebFlux가 빛나는 전형적인 예 — 실시간 대시보드
@GetMapping(value = "/dashboard", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<DashboardData> streamDashboard() {
    // 세 개의 실시간 소스를 결합
    Flux<CpuMetric> cpu = metricsService.streamCpu();
    Flux<MemoryMetric> memory = metricsService.streamMemory();
    Flux<RequestRate> requests = metricsService.streamRequests();

    return Flux.combineLatest(cpu, memory, requests,
            (c, m, r) -> new DashboardData(c, m, r))
            .onBackpressureLatest();  // 느린 클라이언트에게는 최신 값만 전달
}

언제 MVC + Virtual Threads를 선택하나

** 대부분의 프로젝트가 여기에 해당합니다:**

  1. CRUD 중심 API: REST API, 관리 페이지 백엔드
  2. ** 복잡한 비즈니스 로직 **: 트랜잭션, 조건 분기가 많은 도메인
  3. ** 레거시 통합 **: JPA, JDBC, 블로킹 라이브러리 사용 필수
  4. ** 팀 역량 **: 리액티브 경험이 적은 팀
  5. ** 빠른 개발 **: 프로토타이핑, MVP
JAVA
// MVC + VT로 충분한 전형적인 예 — 주문 처리 API
@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid OrderRequest request) {
        // 복잡한 비즈니스 로직도 동기 코드로 깔끔하게
        Order order = orderService.create(request);
        return ResponseEntity.created(URI.create("/orders/" + order.getId()))
                .body(OrderResponse.from(order));
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;        // JPA
    private final InventoryService inventoryService;      // 블로킹 호출
    private final PaymentGateway paymentGateway;          // 외부 API

    @Transactional
    public Order create(OrderRequest request) {
        // 재고 확인 → 결제 → 주문 저장
        // 세 단계 모두 블로킹이지만, VT 덕분에 동시성 문제 없음
        inventoryService.reserve(request.getItems());
        PaymentResult payment = paymentGateway.charge(request.getPaymentInfo());
        return orderRepository.save(Order.from(request, payment));
    }
}

하이브리드 접근: MVC + VT + WebClient

실무에서 가장 현실적인 조합입니다. 전체 아키텍처는 MVC + Virtual Threads로 가져가되, 외부 API 호출이 많은 부분에서만 WebClient를 활용하는 방식이에요.

JAVA
// 하이브리드 — MVC 컨트롤러 + WebClient 비동기 호출
@Service
@RequiredArgsConstructor
public class AggregationService {

    private final WebClient webClient;
    private final UserRepository userRepository;  // JPA — 블로킹

    public AggregatedData aggregate(Long userId) {
        // JPA 블로킹 호출 — VT가 처리
        User user = userRepository.findById(userId)
                .orElseThrow();

        // 여러 외부 API를 병렬 호출 — WebClient로 효율적으로
        CompletableFuture<Profile> profileFuture = webClient.get()
                .uri("/api/profiles/{id}", userId)
                .retrieve()
                .bodyToMono(Profile.class)
                .toFuture();

        CompletableFuture<List<Activity>> activitiesFuture = webClient.get()
                .uri("/api/activities/{id}", userId)
                .retrieve()
                .bodyToFlux(Activity.class)
                .collectList()
                .toFuture();

        // 병렬 결과 취합
        Profile profile = profileFuture.join();
        List<Activity> activities = activitiesFuture.join();

        return new AggregatedData(user, profile, activities);
    }
}

이 방식의 장점:

  • JPA, 트랜잭션 등 기존 코드 유지
  • 외부 호출은 WebClient로 비동기 처리
  • 필요한 곳에서만 리액티브 API 사용
  • 팀 전체가 리액티브를 숙지할 필요 없음

의사결정 매트릭스

빠른 판단 플로차트

PLAINTEXT
새 프로젝트 시작

    ├── 실시간 스트리밍/SSE/백프레셔가 핵심인가?
    │     ├── Yes → WebFlux
    │     └── No ↓

    ├── JPA/JDBC/블로킹 라이브러리를 써야 하나?
    │     ├── Yes → MVC + Virtual Threads
    │     └── No ↓

    ├── 팀에 리액티브 경험이 있나?
    │     ├── Yes → WebFlux 고려 가능
    │     └── No → MVC + Virtual Threads

    └── 기본 선택: MVC + Virtual Threads

항목별 비교표

비교 항목MVC + Virtual ThreadsWebFlux
학습 곡선낮음 (기존 MVC 그대로)높음 (리액티브 패러다임)
디버깅쉬움 (동기식 스택 트레이스)어려움 (비동기 스택)
JPA/JDBC완벽 지원불가 (R2DBC 필요)
서드파티 호환거의 모든 라이브러리리액티브 지원 라이브러리만
백프레셔직접 구현 필요네이티브 지원
스트리밍가능하지만 불편최적화됨
메모리 효율좋음약간 더 좋음
코드 가독성높음익숙해지면 괜찮음
테스트 용이성높음 (JUnit 그대로)보통 (StepVerifier 필요)

VT 사용 시 주의할 점

Virtual Threads가 만능은 아닙니다. 알아둬야 할 함정이 있어요.

synchronized 핀닝

JAVA
// 주의: synchronized 블록 안에서 블로킹 I/O를 하면 캐리어 스레드가 핀된다
synchronized (lock) {
    // 이 안에서 I/O 하면 Virtual Thread가 양보하지 못함
    database.query("SELECT ...");  // 캐리어 스레드 점유!
}

// 해결: ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    database.query("SELECT ...");  // Virtual Thread가 정상적으로 양보
} finally {
    lock.unlock();
}

ThreadLocal 남용

JAVA
// 주의: Virtual Thread는 매우 많이 생성되므로 ThreadLocal이 메모리를 많이 차지
private static final ThreadLocal<ExpensiveObject> cache =
    ThreadLocal.withInitial(ExpensiveObject::new);
// VT 100만 개 = ExpensiveObject 100만 개 생성 가능!

// 대안: ScopedValue (Java 21+ preview, Java 25 정식)
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

synchronized 대신 ReentrantLock, ThreadLocal 대신 ScopedValue — Virtual Threads 시대의 새로운 관용구다. 기존 코드 마이그레이션 시 이 두 가지를 가장 먼저 점검하자.


정리

2026년 기준, 결론은 명확합니다:

  • ** 기본 선택은 MVC + Virtual Threads.** 대부분의 웹 애플리케이션에서 충분한 동시성을 제공하면서, 기존 생태계와 개발 경험을 그대로 활용할 수 있습니다.
  • WebFlux는 전문 도구. 실시간 스트리밍, 백프레셔, 이벤트 기반 파이프라인이 핵심 요구사항일 때 선택합니다.
  • ** 하이브리드가 현실적.** MVC + VT를 기본으로 가져가고, WebClient나 일부 리액티브 컴포넌트를 필요한 곳에서 섞어 쓰는 게 실무에서 가장 많이 보이는 패턴입니다.

WebFlux를 "레거시"로 취급할 단계는 아닙니다. 하지만 "높은 동시성이 필요하니까 WebFlux"라는 등식은 더 이상 성립하지 않아요. Virtual Threads가 그 등식을 깨뜨렸으니까요.

댓글 로딩 중...