스레드 하나가 요청 하나를 붙잡고 I/O를 기다리는 모델은 언젠가 벽에 부딪혀요. 리액티브는 논블로킹·이벤트 루프로 이 벽을 넘습니다. 다만 은탄환은 아니에요 — 파이프라인 전체가 논블로킹이어야 이득이고, 가상 스레드라는 강력한 대안도 등장했어요. 언제 쓰고 언제 피하는지, 그 판단선을 잡습니다.
전통적인 서블릿 모델은 요청 하나 = 스레드 하나예요. 그 스레드가 DB·외부 API 응답을 블로킹 I/O로 기다리는 동안, 스레드는 아무 일도 못 하고 점유만 합니다. 동시 요청이 수천이면 스레드도 수천 개 필요한데, 스레드는 각각 스택 메모리(수백 KB~1MB)를 먹고 컨텍스트 스위칭 비용도 커요. 그래서 "CPU는 놀고 있는데 스레드가 부족해 처리량이 안 오르는" 상황이 옵니다.
리액티브·논블로킹 모델은 소수의 이벤트 루프 스레드(대개 CPU 코어 수)가 준비된 I/O 이벤트만 골라 처리해요. 스레드가 응답을 기다리며 멈추지 않고, 데이터가 도착하면 콜백/시그널로 이어받습니다. 그래서 적은 스레드로도 수만 동시 연결을 감당할 수 있어요.
리액티브 스트림(Reactive Streams)은 논블로킹 백프레셔가 있는 비동기 스트림 처리의 표준 규약이에요. Java 9의 java.util.concurrent.Flow로도 표준화됐고, Reactor·RxJava·Akka 등이 이를 구현합니다. 핵심 인터페이스는 네 가지예요.
| 인터페이스 | 역할 |
|---|---|
| Publisher<T> | 데이터를 생산·발행. subscribe()로 구독자를 받음 |
| Subscriber<T> | 데이터를 소비. onNext·onError·onComplete·onSubscribe 콜백 |
| Subscription | 구독의 연결선. request(n)으로 필요한 만큼 요청, cancel()로 취소 |
| Processor<T,R> | Publisher이면서 Subscriber — 중간 변환 단계(입력을 받아 가공해 다시 발행) |
Subscription.request(n)으로 "지금 n개까지 받을 수 있다"고 알리면, Publisher는 그 이상 밀어 넣지 않아요.
이게 없으면 빠른 생산자가 느린 소비자를 압도해 메모리 폭증(OOM)·큐 무한 증가가 생깁니다. 백프레셔는 push(밀기) 모델에 pull(당기기)의 안전장치를 더한 것이라고 보면 돼요 — 소비자가 준비된 만큼만 흐르게.
onBackpressureBuffer/Drop/Latest 등으로 지정합니다. "무한 버퍼"는 사실상 백프레셔 포기이니 주의.Project Reactor는 리액티브 스트림을 구현한 라이브러리로, 두 가지 핵심 타입을 제공해요.
Mono는 0~1개, Flux는 0~N개의 값을 비동기로 방출해요. map·flatMap·filter 같은 연산자로 선언적으로 파이프라인을 조립합니다. map은 값을 1:1 동기 변환, flatMap은 각 값을 또 다른 Publisher로 비동기 변환해 펼쳐요(예: id → 비동기 DB 조회).
Flux<Integer> nums = Flux.just(1, 2, 3, 4)
.filter(n -> n % 2 == 0) // 2, 4
.map(n -> n * 10); // 20, 40
// flatMap: 각 원소를 비동기 호출로 확장
Flux<User> users = Flux.just(1L, 2L, 3L)
.flatMap(id -> userRepository.findById(id)); // 각 id → Mono<User>
// ⚠️ 여기까지는 아무것도 실행되지 않는다! (조립만 됨)
users.subscribe(u -> System.out.println(u)); // 구독해야 비로소 흐른다
subscribe())하기 전엔 데이터가 흐르지 않습니다. 초심자가 "왜 내 코드가 안 도냐"며 가장 많이 걸리는 함정이에요. WebFlux에서는 프레임워크가 반환된 Publisher를 대신 구독해주므로, 컨트롤러에서 직접 subscribe()를 호출할 필요가 없습니다(오히려 하면 안 돼요).리액티브 파이프라인도 결국 어떤 스레드 위에서 실행돼요. Reactor는 Scheduler로 실행 컨텍스트를 지정합니다.
| 연산자 / 스케줄러 | 의미 |
|---|---|
subscribeOn(scheduler) | 구독이 일어나는(=소스가 시작되는) 위치를 지정. 파이프라인 앞쪽 전체에 영향. 위치와 무관하게 하나만 유효 |
publishOn(scheduler) | 그 지점 이후의 연산을 다른 스케줄러로 전환. 여러 번 걸어 단계별로 바꿀 수 있음 |
Schedulers.parallel() | CPU 바운드 작업용 고정 크기 워커 풀 |
Schedulers.boundedElastic() | 블로킹 I/O 격리용. 필요 시 늘어나되 상한이 있는 스레드 풀 |
| Spring MVC | Spring WebFlux | |
|---|---|---|
| 모델 | 동기·블로킹, 스레드-per-request | 비동기·논블로킹, 이벤트 루프 |
| 기본 서버 | Tomcat(서블릿) | Netty(서블릿 3.1+/Undertow도 가능) |
| 반환 타입 | 객체·ResponseEntity | Mono·Flux |
| DB 접근 | JDBC(블로킹) | R2DBC·리액티브 드라이버(논블로킹) |
| 강점 | 단순·성숙한 생태계·디버깅 쉬움 | 고동시성 I/O·스트리밍·적은 스레드 |
RestTemplate·Thread.sleep·동기 파일 I/O)을 하면, 루프 스레드가 통째로 멈춰 다른 수천 요청까지 굶깁니다. 이 경우 WebFlux는 MVC보다 오히려 나빠져요. "중간에 블로킹 하나만 섞여도 전체가 무너진다"가 리액티브의 냉정한 진실이에요. R2DBC·WebClient처럼 끝까지 논블로킹인 스택을 갖췄을 때 비로소 값어치를 합니다.리액티브는 강력하지만 대가가 있어요. 선언적·비동기 코드는 흐름이 눈에 안 보여 디버깅이 어렵고, 스택 트레이스가 짧게 끊겨 원인 추적이 힘들어요(그래서 checkpoint()·onOperatorDebug()·Reactor 디버그 에이전트를 씁니다). 사고방식 전환이 필요해 학습 곡선도 가파릅니다.
// 어쩔 수 없이 블로킹 라이브러리를 써야 한다면 → 전용 스케줄러로 격리
Mono<Report> report = Mono
.fromCallable(() -> legacyBlockingClient.fetch(id)) // 블로킹 호출
.subscribeOn(Schedulers.boundedElastic()); // 이벤트 루프와 분리!
// 이벤트 루프 스레드가 아니라 boundedElastic 워커가 대기를 떠안는다
subscribeOn(Schedulers.boundedElastic())으로 전용 풀에 밀어내 이벤트 루프를 보호하세요. 이름 그대로 상한(bounded)이 있어 무한 팽창을 막습니다. 또한 BlockHound 같은 도구로 이벤트 루프 위의 우발적 블로킹을 탐지할 수 있어요.request(n)으로 감당할 만큼만 요청.map/flatMap/filter. 구독 전엔 실행 안 됨(lazy). 스레드는 subscribeOn/publishOn.