왜 리액티브 스트림 스펙 Reactor 스케줄러 WebFlux vs MVC 주의점 📝 문제풀이
◀ 심화 CH 12 📋 목차 심화 CH 14 ▶
🌊 심화 CHAPTER 13 · 5년차 트랙

적은 스레드로 더 많은 동시성을 — 리액티브와 WebFlux

스레드 하나가 요청 하나를 붙잡고 I/O를 기다리는 모델은 언젠가 벽에 부딪혀요. 리액티브는 논블로킹·이벤트 루프로 이 벽을 넘습니다. 다만 은탄환은 아니에요 — 파이프라인 전체가 논블로킹이어야 이득이고, 가상 스레드라는 강력한 대안도 등장했어요. 언제 쓰고 언제 피하는지, 그 판단선을 잡습니다.

🎯 이 장을 끝내면
🌊
왜 리액티브
스레드가 "기다리는" 비용
스레드-per-request 모델이 벽에 부딪히는 지점

전통적인 서블릿 모델은 요청 하나 = 스레드 하나예요. 그 스레드가 DB·외부 API 응답을 블로킹 I/O로 기다리는 동안, 스레드는 아무 일도 못 하고 점유만 합니다. 동시 요청이 수천이면 스레드도 수천 개 필요한데, 스레드는 각각 스택 메모리(수백 KB~1MB)를 먹고 컨텍스트 스위칭 비용도 커요. 그래서 "CPU는 놀고 있는데 스레드가 부족해 처리량이 안 오르는" 상황이 옵니다.

🧵
스레드-per-request
요청마다 스레드 점유·I/O 대기 중 낭비
🔁
이벤트 루프
적은 스레드가 준비된 이벤트만 처리
📈
높은 동시성
스레드 수와 무관하게 많은 연결 수용

리액티브·논블로킹 모델은 소수의 이벤트 루프 스레드(대개 CPU 코어 수)가 준비된 I/O 이벤트만 골라 처리해요. 스레드가 응답을 기다리며 멈추지 않고, 데이터가 도착하면 콜백/시그널로 이어받습니다. 그래서 적은 스레드로도 수만 동시 연결을 감당할 수 있어요.

⚖️ 가상 스레드(Java 21)와의 비교 — 은탄환은 없다 Java 21의 가상 스레드(Project Loom)는 블로킹 코드를 그대로 쓰면서도 OS 스레드를 붙잡지 않게 해줘요. 블로킹 호출 시 캐리어 스레드를 양보(park)하므로, "친숙한 명령형 코드 + 높은 동시성"을 동시에 얻습니다. 그래서 I/O 대기가 문제의 핵심이라면 가상 스레드가 리액티브보다 훨씬 단순한 대안일 때가 많아요. 리액티브의 강점은 여기에 더해 백프레셔·스트리밍·풍부한 연산자(조합·시간 기반 처리)가 필요할 때 빛납니다. 반대로 단순 요청/응답이라면 가상 스레드로 충분할 수 있어요.
💡
핵심은 "스레드를 놀리지 않기". 리액티브든 가상 스레드든 목표는 같아요 — I/O를 기다리는 동안 스레드가 다른 일을 하게 만드는 것. 접근이 다를 뿐입니다(리액티브=명시적 논블로킹 파이프라인, 가상 스레드=투명한 스케줄링). 팀 역량·기존 라이브러리 생태계까지 보고 고르세요.
🖼️ 그림으로 보기 — 블로킹 vs 논블로킹
블로킹 — 요청마다 스레드가 대기 🧵 T1 ⏳ I/O 응답 대기(낭비) 🧵 T2 ⏳ I/O 응답 대기(낭비) 🧵 T3 ⏳ I/O 응답 대기(낭비) 🧵 T4 ⏳ I/O 응답 대기(낭비) 스레드 4개가 전부 멈춰 대기 → 동시성 한계 논블로킹 — 적은 스레드가 이벤트 처리 🔁 이벤트 루프 소수 스레드 수천 연결 🔗 conn 🔗 conn 🔗 conn 🔗 conn 준비된 이벤트만 스레드가 멈추지 않음 → 적은 스레드로 높은 동시성
📜
스트림 스펙
Publisher · Subscriber · Subscription, 그리고 백프레셔
이 4요소가 리액티브 라이브러리들의 공통 규약이에요.

리액티브 스트림(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 — 중간 변환 단계(입력을 받아 가공해 다시 발행)
🎚️ 백프레셔(Back-pressure) — 소비자가 속도를 정한다 백프레셔는 소비자가 감당할 수 있는 만큼만 생산자에게 요청하는 흐름 제어예요. Subscription.request(n)으로 "지금 n개까지 받을 수 있다"고 알리면, Publisher는 그 이상 밀어 넣지 않아요. 이게 없으면 빠른 생산자가 느린 소비자를 압도해 메모리 폭증(OOM)·큐 무한 증가가 생깁니다. 백프레셔는 push(밀기) 모델에 pull(당기기)의 안전장치를 더한 것이라고 보면 돼요 — 소비자가 준비된 만큼만 흐르게.
🧭
전략도 여러 가지예요. 소비자가 못 따라올 때 무엇을 할지 — 버퍼(buffer)·최신값만(latest)·드롭(drop)·에러(error) 등 오버플로 전략을 골라요. Reactor에서는 onBackpressureBuffer/Drop/Latest 등으로 지정합니다. "무한 버퍼"는 사실상 백프레셔 포기이니 주의.
🖼️ 그림으로 보기 — 백프레셔 — 소비자가 속도를 정한다
📤 Publisher 생산자(빠름) 📥 Subscriber 소비자(느림) onNext — 데이터 방출 request(n) — n개만 주세요 소비자가 감당할 만큼만 당겨옴 → 버퍼 폭증·OOM 방지
⚛️
Reactor
Mono와 Flux, 그리고 "구독해야 실행된다"
Spring WebFlux의 기반이 되는 리액티브 라이브러리예요.

Project Reactor는 리액티브 스트림을 구현한 라이브러리로, 두 가지 핵심 타입을 제공해요.

1️⃣
Mono<T>
0 또는 1개 방출
(단건 조회·저장 결과)
 
🔢
Flux<T>
0~N개 방출
(목록·스트림·이벤트)

Mono0~1개, Flux0~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)); // 구독해야 비로소 흐른다
⚠️
"Nothing happens until you subscribe." Mono/Flux는 지연(lazy)이에요. 연산자를 아무리 붙여도 그건 실행 계획을 조립하는 것일 뿐, 구독(subscribe())하기 전엔 데이터가 흐르지 않습니다. 초심자가 "왜 내 코드가 안 도냐"며 가장 많이 걸리는 함정이에요. WebFlux에서는 프레임워크가 반환된 Publisher를 대신 구독해주므로, 컨트롤러에서 직접 subscribe()를 호출할 필요가 없습니다(오히려 하면 안 돼요).
🧵
스케줄러
어느 스레드에서 돌릴까 — publishOn · subscribeOn
스레드 전환을 통제하는 두 연산자

리액티브 파이프라인도 결국 어떤 스레드 위에서 실행돼요. Reactor는 Scheduler로 실행 컨텍스트를 지정합니다.

연산자 / 스케줄러의미
subscribeOn(scheduler)구독이 일어나는(=소스가 시작되는) 위치를 지정. 파이프라인 앞쪽 전체에 영향. 위치와 무관하게 하나만 유효
publishOn(scheduler)그 지점 이후의 연산을 다른 스케줄러로 전환. 여러 번 걸어 단계별로 바꿀 수 있음
Schedulers.parallel()CPU 바운드 작업용 고정 크기 워커 풀
Schedulers.boundedElastic()블로킹 I/O 격리용. 필요 시 늘어나되 상한이 있는 스레드 풀
🔀 subscribeOn vs publishOn 한 문장 요약 subscribeOn = 파이프라인이 시작(구독)되는 스레드를 정한다(위치 상관없이 소스부터 적용).
publishOn = 그 아래(downstream) 연산자들이 돌 스레드를 거기서부터 바꾼다. "소스를 어디서 켜느냐"는 subscribeOn, "중간부터 스레드를 어디로 옮기느냐"는 publishOn — 이 감만 잡아도 대부분의 스레딩 이슈가 풀려요.
🌱
WebFlux vs MVC
논블로킹의 이득은 "끝까지 논블로킹"일 때만
언제 WebFlux를 쓰고, 언제 MVC로 충분한가
Spring MVCSpring WebFlux
모델동기·블로킹, 스레드-per-request비동기·논블로킹, 이벤트 루프
기본 서버Tomcat(서블릿)Netty(서블릿 3.1+/Undertow도 가능)
반환 타입객체·ResponseEntityMono·Flux
DB 접근JDBC(블로킹)R2DBC·리액티브 드라이버(논블로킹)
강점단순·성숙한 생태계·디버깅 쉬움고동시성 I/O·스트리밍·적은 스레드
⚠️
파이프라인 전체가 논블로킹이어야 이득이 나요. WebFlux의 이벤트 루프 스레드는 소수예요. 그 스레드에서 블로킹 호출(JDBC·RestTemplate·Thread.sleep·동기 파일 I/O)을 하면, 루프 스레드가 통째로 멈춰 다른 수천 요청까지 굶깁니다. 이 경우 WebFlux는 MVC보다 오히려 나빠져요. "중간에 블로킹 하나만 섞여도 전체가 무너진다"가 리액티브의 냉정한 진실이에요. R2DBC·WebClient처럼 끝까지 논블로킹인 스택을 갖췄을 때 비로소 값어치를 합니다.
🧭 언제 WebFlux, 언제 MVC? WebFlux가 유리 — 외부 I/O가 지배적인 고동시성(수많은 API 호출을 팬아웃), 스트리밍/SSE·WebSocket, 백프레셔가 필요한 데이터 파이프라인, 게이트웨이·프록시.
MVC로 충분 — 전형적 CRUD, 블로킹 라이브러리(JDBC/JPA) 중심, 동시성이 극단적이지 않은 서비스, 팀이 명령형에 익숙할 때. 그리고 Java 21+라면 "높은 동시성이 필요한데 코드는 단순하게"의 상당수를 가상 스레드 + MVC가 대신 해결해줘요. WebFlux는 백프레셔·스트리밍·조합 연산자가 진짜 필요할 때 선택하세요.
🚧
주의점
학습 곡선과 블로킹 격리
실전에서 발을 헛디디는 지점들

리액티브는 강력하지만 대가가 있어요. 선언적·비동기 코드는 흐름이 눈에 안 보여 디버깅이 어렵고, 스택 트레이스가 짧게 끊겨 원인 추적이 힘들어요(그래서 checkpoint()·onOperatorDebug()·Reactor 디버그 에이전트를 씁니다). 사고방식 전환이 필요해 학습 곡선도 가파릅니다.

// 어쩔 수 없이 블로킹 라이브러리를 써야 한다면 → 전용 스케줄러로 격리
Mono<Report> report = Mono
    .fromCallable(() -> legacyBlockingClient.fetch(id)) // 블로킹 호출
    .subscribeOn(Schedulers.boundedElastic());          // 이벤트 루프와 분리!
// 이벤트 루프 스레드가 아니라 boundedElastic 워커가 대기를 떠안는다
💡
블로킹은 boundedElastic으로 격리. 도저히 논블로킹 대안이 없는 레거시 블로킹 코드는 subscribeOn(Schedulers.boundedElastic())으로 전용 풀에 밀어내 이벤트 루프를 보호하세요. 이름 그대로 상한(bounded)이 있어 무한 팽창을 막습니다. 또한 BlockHound 같은 도구로 이벤트 루프 위의 우발적 블로킹을 탐지할 수 있어요.
🧭
리액티브가 항상 더 빠른 건 아니에요. 리액티브는 지연시간(latency)을 줄이는 마법이 아니라 자원 효율·동시성 확장(scalability)을 위한 도구예요. 부하가 낮거나 CPU 바운드 작업이라면 오히려 오버헤드와 복잡성만 늘 수 있어요. "빨라지려고" 도입하지 말고, 많은 동시 I/O를 적은 스레드로 견디려고 도입하세요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·설계 리뷰에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 14 📦 컨테이너와 쿠버네티스 배포 — 이미지·리소스 제한·헬스체크·롤아웃 (제작 예정)