값이 아니라 "값이 올 파이프라인" 을 표현하는 타입. Mono는 0~1개, Flux는 0~N개다.
조립과 실행이 분리된다
Mono<Order> mono = repo.findById(id) // 아직 아무것도 실행되지 않는다
.map(this::toDto)
.doOnNext(log::info);
// 여기까지는 '설계도' 만 만들었다
mono.subscribe(); // 이 순간 흐르기 시작한다 (WebFlux 는 프레임워크가 대신 구독한다)
구독하지 않으면 아무 일도 안 일어난다 — 리액티브 입문자가 가장 먼저 겪는 함정이다.
flatMap이냐 concatMap이냐
flux.flatMap(this::call) // 동시 실행. 완료 순서대로 나온다 → 순서 보장 X
flux.concatMap(this::call) // 하나씩 순차 실행 → 순서 보장, 느리다
flux.flatMapSequential(...) // 동시 실행하되 결과는 원래 순서로
순서가 중요한데 flatMap을 쓴 버그는 부하가 낮을 땐 우연히 순서가 맞아 재현이 어렵다.
이벤트 루프와 스케줄러
-
이벤트 루프 스레드는 코어 수 정도로 소수다
- 여기서 절대 블로킹하면 안 된다
-
Schedulers.parallel() — CPU 바운드
-
Schedulers.boundedElastic() — 어쩔 수 없는 블로킹 작업 격리용
// 단 하나의 금기
@GetMapping("/orders/{id}")
Mono<Order> get(@PathVariable Long id) {
Order o = jdbcTemplate.queryForObject(...); // ❌ 이벤트 루프가 멈춘다
return Mono.just(o);
}
소수의 루프 스레드 중 하나가 멈추면 그 위의 모든 요청이 함께 멈춘다. 몇 개 겹치면 처리량이 폭락한다.
백프레셔 — 리액티브가 대체 불가능한 지점
생산자가 소비자보다 빠르면 버퍼가 쌓이다 OOM 이 난다
- 소비자가 request(n) 으로 "N개만 더" 라고 역방향 요구한다
생산자가 속도를 못 늦추면 전략을 고른다
- onBackpressureBuffer / Drop / Latest
가상 스레드는 백프레셔를 대체하지 못한다 — 동시성 비용 문제를 푼 것이지 흐름 제어 문제를 푼 게 아니다.
가상 스레드가 계산을 바꿨다
벤치마크상 DB·HTTP 바운드에서 처리량이 통계적으로 거의 같고(5~8% 이내) 가상 스레드가 코드 단순함·디버깅에서 이긴다 Java 24 의 JEP 491 이 pinning 이라는 마지막 반론까지 지웠다
- 순수 I/O 동시성이면 이제 가상 스레드가 더 단순한 답이다
- 스트리밍 · 백프레셔 · 복잡한 비동기 합성이면 여전히 리액티브
채택 전 반드시 계산할 것 — R2DBC
리액티브를 택하면 DB 는 R2DBC 가 된다. 그리고 R2DBC 에는 JPA 가 없다
- 영속성 컨텍스트 · 변경 감지 · 지연 로딩이 전부 사라진다
- 이게 WebFlux 채택률이 꺾인 진짜 이유다
면접 함정
- ❌ "WebFlux + JPA" → 성립하지 않는 조합이다.
- ❌ "리액티브가 CPU를 더 잘 쓴다" → 스레드 자원 효율의 문제였다.
컨텍스트 전파가 다르다
// ThreadLocal 이 안 통한다 — 한 요청이 여러 스레드를 옮겨 다니기 때문이다
MDC.put("traceId", id); // ❌ 이후 연산자에서 사라진다
// Reactor Context 를 쓴다
Mono.deferContextual(ctx -> {
String traceId = ctx.get("traceId");
return service.call();
}).contextWrite(Context.of("traceId", id));
로깅·보안 컨텍스트·트랜잭션이 전부 이 영향을 받는다. "ThreadLocal 기반 라이브러리는 그대로 쓸 수 없다" 가 리액티브 전환의 숨은 비용이다.
디버깅이 어려운 이유와 대책
Hooks.onOperatorDebug(); // 스택 트레이스에 조립 지점을 남긴다 (개발용·느리다)
// 또는 reactor-tools 의 ReactorDebugAgent (운영에서도 쓸 만하다)
mono.log() // 각 신호(onSubscribe·onNext·onComplete)를 로그로
.checkpoint("주문 조회") // 이 지점 이름을 스택에 남긴다
기본 스택 트레이스에는 어느 연산자에서 났는지가 안 나온다 — 이 도구들 없이는 원인 추적이 매우 어렵다.