백엔드 면접 용어 사전
Spring·JPASpring WebFlux · 리액티브

WebFlux

적은 이벤트 루프 스레드로 동시성을 처리하는 Spring의 논블로킹 웹 스택. 가상 스레드 등장 후 기본 선택지에서 내려왔다.

Spring의 논블로킹 웹 스택. 요청마다 스레드를 붙이는 대신, 소수의 이벤트 루프 스레드가 완료된 작업만 골라 처리한다. Mono(값 0~1개)와 Flux(값 0~N개)로 "값"이 아니라 "값이 도착할 파이프라인" 을 조립한다.

원래의 계산식

  • [전통 MVC] — 요청 1개 = 스레드 1개 점유

    • DB 대기 중에도 스레드가 묶여 있음
    • 스레드 풀 200개 = 동시 200건
  • [WebFlux] — 요청 1개 = 파이프라인 1개 (스레드 아님)

    • 대기는 콜백으로 등록하고 스레드는 다른 요청 처리
    • 스레드 8개로 수만 건

대가는 복잡도였다. 중첩된 flatMap 체인, 끊긴 스택 트레이스, 디버거로 따라가기 어려운 흐름, 그리고 한 줄만 블로킹해도 전부 무너지는 규율.

왜 기본값에서 내려왔나

가상 스레드가 같은 이득을 대가 없이 준다. 벤치마크상 DB·HTTP 바운드 워크로드에서 둘의 처리량은 통계적으로 거의 같고(5~8% 이내), 가상 스레드가 코드 단순함·디버깅에서 이긴다. 게다가 Java 24의 JEP 491이 "가상 스레드로 DB를 때리면 pinning으로 막힌다"는 마지막 반론까지 지웠다.

그래도 WebFlux가 맞는 경우

상황이유
스트리밍·SSE·WebSocket"요청 하나 → 응답 하나"가 아니라 흐름이 본질
백프레셔가 실제로 필요생산자가 소비자보다 빠를 때 흐름을 역방향으로 제어해야 함
외부 API 대량 합성여러 호출을 병렬로 엮고 타임아웃·재시도를 조합하는 게 게이트웨이의 본업
이미 리액티브인 시스템스택 전체가 논블로킹이고 R2DBC 등을 이미 씀

가상 스레드는 백프레셔를 대체하지 못한다. 동시성 비용 문제를 푼 것이지 흐름 제어 문제를 푼 게 아니다.

채택 전에 반드시 계산할 것

리액티브를 택하면 DB 접근이 R2DBC가 된다. 그리고 R2DBC에는 JPA가 없다 — 영속성 컨텍스트·변경 감지·지연 로딩이 전부 사라진다. 이게 실제로 팀들을 되돌린 이유다.

면접 함정

  • "WebFlux + JPA" → 성립하지 않는 조합이다. 이렇게 답하면 안 써 봤다는 게 바로 드러난다.
  • "고동시성 = 리액티브" → 옛 공식이다.
  • "리액티브가 CPU를 더 잘 쓴다" → 아니다. 스레드 자원 효율의 문제였다.

코드가 어떻게 달라지나

// WebFlux — 값이 아니라 파이프라인을 조립한다
@GetMapping("/orders/{id}")
public Mono<OrderView> get(@PathVariable Long id) {
    return orderRepo.findById(id)
        .flatMap(o -> userClient.fetch(o.userId())      // 중첩되기 시작한다
            .map(u -> new OrderView(o, u)))
        .switchIfEmpty(Mono.error(new NotFoundException()));
}

// 가상 스레드 — 평범한 블로킹 코드 그대로
@GetMapping("/orders/{id}")
public OrderView get(@PathVariable Long id) {
    Order o = orderRepo.findById(id).orElseThrow(NotFoundException::new);
    User u = userClient.fetch(o.userId());
    return new OrderView(o, u);
}

두 번째 코드는 디버거로 한 줄씩 따라갈 수 있고 스택 트레이스에 호출 경로가 그대로 남는다. 처리량이 비슷하다면 이 차이가 곧 유지보수 비용이다.

켜는 법도 비대칭이다

spring:
  threads:
    virtual:
      enabled: true      # 가상 스레드는 설정 한 줄

WebFlux는 의존성·반환 타입·데이터 접근 계층까지 바꿔야 한다. 되돌리기 비용도 그만큼 크다는 걸 채택 시점에 계산해야 한다.

단 하나의 금기 — 블로킹 호출

@GetMapping("/orders/{id}")
public Mono<Order> get(@PathVariable Long id) {
    Order o = jdbcTemplate.queryForObject(...);   // ❌ 이벤트 루프가 여기서 멈춘다
    return Mono.just(o);
}

이벤트 루프 스레드는 코어 수 정도로 소수다. 하나가 멈추면 그 위의 모든 요청이 함께 멈추고, 몇 개가 겹치면 처리량이 폭락한다. 부하 테스트 전에는 잘 안 드러나는 것도 특징이다.

// 꼭 블로킹해야 한다면 전용 스케줄러로 격리한다
Mono.fromCallable(() -> jdbcTemplate.queryForObject(...))
    .subscribeOn(Schedulers.boundedElastic());

다만 이 시점에 이미 스레드를 쓰고 있는 것이라, 리액티브를 택한 이유가 상당 부분 사라진다. 격리는 예외 처리이지 설계가 아니다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 모던 자바·스프링 — 가상 스레드·JEP 491·리액티브·jOOQ·Boot 4