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());
다만 이 시점에 이미 스레드를 쓰고 있는 것이라, 리액티브를 택한 이유가 상당 부분 사라진다. 격리는 예외 처리이지 설계가 아니다.