Spring WebFlux & Reactor Netty
지금까지 Netty의 EventLoop, Channel, Pipeline 같은 개념을 하나씩 공부해 왔는데, 그러면 실제로 Spring WebFlux 애플리케이션을 띄울 때 이 개념들이 어떻게 조립되어 돌아가는 걸까?
Spring WebFlux의 서버 엔진 — 왜 Tomcat이 아니라 Netty인가
Spring MVC를 쓸 때는 대부분 Tomcat이 기본 서버입니다. 그런데 WebFlux를 쓰면 기본 서버가 Netty 로 바뀝니다. 왜 그럴까요?
핵심은 I/O 모델의 차이 입니다.
- Tomcat (전통적 모델): 요청 하나당 스레드 하나를 할당한다. 스레드가 I/O를 기다리는 동안 블로킹된다.
- Netty: 소수의 EventLoop 스레드가 수많은 연결을 논블로킹으로 처리한다. I/O 대기 시간에 다른 연결의 이벤트를 처리할 수 있다.
WebFlux는 ** 리액티브 스트림** 기반입니다. Mono와 Flux로 데이터를 비동기적으로 흘려보내는 구조인데, 이걸 제대로 살리려면 서버 엔진 자체가 논블로킹이어야 합니다. Tomcat 위에서 WebFlux를 돌릴 수도 있지만, Netty가 이 모델에 가장 자연스럽게 맞아떨어지기 때문에 ** 기본 엔진 **으로 채택된 겁니다.
[Tomcat 모델]
요청 1 → 스레드 1 (블로킹 대기...)
요청 2 → 스레드 2 (블로킹 대기...)
요청 3 → 스레드 3 (블로킹 대기...)
// 스레드 200개 넘으면 → 스레드 풀 고갈
[Netty 모델]
요청 1 ─┐
요청 2 ─┤→ EventLoop 1 (논블로킹, 이벤트 기반 순회)
요청 3 ─┘
요청 4 ─┐
요청 5 ─┤→ EventLoop 2
요청 6 ─┘
// CPU 코어 수만큼의 스레드로 수만 연결 처리
면접에서 "WebFlux는 왜 Netty를 쓰나요?"라고 물으면, ** 리액티브 스트림의 비동기 논블로킹 특성과 Netty의 EventLoop 모델이 일치하기 때문 **이라고 답하면 됩니다.
Reactor Netty란 — Project Reactor + Netty
Reactor Netty는 이름 그대로 Project Reactor 와 Netty 를 결합한 라이브러리입니다. Netty의 저수준 API를 Reactor의 Mono/Flux 기반 리액티브 API로 감싸서, 개발자가 Netty를 직접 다루지 않고도 리액티브 네트워크 프로그래밍을 할 수 있게 해줍니다.
계층 구조
┌─────────────────────────────────┐
│ Spring WebFlux │ ← 컨트롤러, 라우터 함수
├─────────────────────────────────┤
│ Reactor Netty │ ← HttpServer, HttpClient (리액티브 API)
├─────────────────────────────────┤
│ Project Reactor │ ← Mono, Flux, Scheduler
├─────────────────────────────────┤
│ Netty │ ← EventLoop, Channel, Pipeline
└─────────────────────────────────┘
- Netty: 네트워크 I/O, 이벤트 루프, 버퍼 관리 등 저수준 담당
- Project Reactor: 리액티브 스트림 구현체 (
Mono,Flux, 배압 지원) - Reactor Netty: 이 둘을 연결해서
HttpServer,HttpClient,TcpServer같은 리액티브 네트워크 API 제공 - Spring WebFlux: Reactor Netty 위에서
@Controller,RouterFunction등 웹 프레임워크 기능 제공
결국 WebFlux 애플리케이션을 실행하면, 밑바닥에서는 Netty가 돌아가고 있습니다.
Netty를 어떻게 감싸는가 — HttpServer.create()의 내부
Reactor Netty로 HTTP 서버를 띄우는 코드는 놀라울 정도로 간단합니다.
// Reactor Netty로 직접 HTTP 서버 띄우기
HttpServer.create()
.port(8080)
.route(routes -> routes
.get("/hello", (req, res) ->
res.sendString(Mono.just("Hello, Reactor Netty!")))
)
.bindNow(); // 서버 시작
이 코드가 내부적으로 하는 일을 풀어보면 이렇습니다.
HttpServer.create()
│
▼
ServerBootstrap 생성
│
├─ bossGroup: NioEventLoopGroup (acceptor, 보통 1개 스레드)
├─ workerGroup: NioEventLoopGroup (worker, CPU 코어 수만큼)
├─ channel: NioServerSocketChannel
└─ childHandler: Pipeline 구성
├─ HttpRequestDecoder // HTTP 요청 파싱
├─ HttpResponseEncoder // HTTP 응답 인코딩
├─ HttpObjectAggregator // 청크 합치기
└─ ReactorNettyHandler // Reactor 스트림과 연결
Spring WebFlux를 쓸 때는 이 과정이 ReactorHttpHandlerAdapter를 통해 자동으로 일어납니다. 개발자가 ServerBootstrap을 직접 만질 일은 없지만, 내부에서 일어나는 일을 알고 있으면 트러블슈팅할 때 큰 도움이 됩니다.
커스터마이징이 필요할 때
application.yml로 간단한 설정은 가능하지만, Netty 수준의 세밀한 튜닝이 필요하면 WebServerFactoryCustomizer를 씁니다.
@Bean
public WebServerFactoryCustomizer<NettyReactiveWebServerFactory> customizer() {
return factory -> factory.addServerCustomizers(httpServer ->
httpServer
.wiretap(true) // 패킷 로깅
.idleTimeout(Duration.ofSeconds(30)) // 유휴 타임아웃
.option(ChannelOption.SO_BACKLOG, 256) // TCP 백로그
);
}
EventLoop 공유 — WebFlux의 요청 처리 흐름
WebFlux 애플리케이션에서 요청 하나가 처리되는 과정을 EventLoop 관점에서 보겠습니다.
클라이언트 요청
│
▼
[Boss EventLoop] ─── accept() ──→ 새 Channel 생성
│
▼
[Worker EventLoop #3에 등록] ─── Channel을 EventLoop에 바인딩
│
▼
Worker EventLoop #3에서 모든 후속 처리 실행:
├─ HTTP 디코딩 (Pipeline)
├─ ReactorNettyHandler 호출
├─ WebFlux DispatcherHandler 실행
├─ @Controller 메서드 호출
├─ Mono/Flux 시그널 전파
└─ HTTP 응답 인코딩 & flush
핵심 포인트가 두 가지 있습니다.
1) 하나의 요청은 하나의 EventLoop에서 처리된다
MVC처럼 요청마다 새 스레드를 할당하는 게 아닙니다. Channel이 특정 EventLoop에 등록되면, 그 Channel의 모든 I/O 이벤트는 해당 EventLoop 스레드에서 처리됩니다. 컨텍스트 스위칭이 없으므로 효율적입니다.
2) EventLoop 스레드 수는 기본적으로 CPU 코어 수
reactor.netty.ioWorkerCount 시스템 프로퍼티로 조절할 수 있지만, 기본값은 Runtime.getRuntime().availableProcessors() (최소 4)입니다. 코어가 8개면 worker EventLoop도 8개입니다.
// EventLoop 스레드 수 확인 (디버깅용)
// 로그에서 reactor-http-nio-1, reactor-http-nio-2, ... 형태로 보임
@GetMapping("/thread")
public Mono<String> checkThread() {
return Mono.just("현재 스레드: " + Thread.currentThread().getName());
// 결과: "현재 스레드: reactor-http-nio-3"
}
블로킹 코드의 위험 — EventLoop를 멈추면 서버가 멈춘다
이 부분이 WebFlux에서 가장 중요하고, 실수도 가장 많이 발생하는 지점입니다.
왜 위험한가
EventLoop 하나가 수백~수천 개의 Channel을 담당합니다. 만약 EventLoop 스레드에서 JDBC 호출, Thread.sleep(), 동기 파일 I/O 같은 ** 블로킹 코드 **를 실행하면, 그 EventLoop에 바인딩된 ** 모든 연결 **의 이벤트 처리가 멈춥니다.
EventLoop #3이 담당하는 연결들:
Channel A ← 정상 요청 대기 중
Channel B ← 정상 요청 대기 중
Channel C ← JDBC 호출 중... 500ms 블로킹!
→ Channel A, B도 500ms 동안 아무 이벤트도 처리 못함
→ 8개 EventLoop 중 하나가 먹통 → 전체 처리량 12.5% 감소
→ 여러 EventLoop에서 동시에 블로킹 → 서버 전체 응답 불능
블로킹 코드를 격리하는 방법
불가피하게 블로킹 호출이 필요하면 Schedulers.boundedElastic()으로 ** 별도 스레드 풀에 격리 **합니다.
@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable Long id) {
// 잘못된 방법: EventLoop에서 직접 블로킹
// User user = userRepository.findById(id); // JDBC 블로킹!
// return Mono.just(user);
// 올바른 방법: 블로킹 작업을 boundedElastic으로 격리
return Mono.fromCallable(() -> userRepository.findById(id))
.subscribeOn(Schedulers.boundedElastic()); // 별도 스레드 풀에서 실행
}
Schedulers 비교
| 스케줄러 | 스레드 풀 특성 | 용도 |
|---|---|---|
Schedulers.parallel() | CPU 코어 수만큼 고정 | CPU 집약적 연산 |
Schedulers.boundedElastic() | 최대 10 * 코어 수, 유휴 스레드 60초 후 제거 | 블로킹 I/O 격리 |
Schedulers.single() | 단일 스레드 | 순서 보장 필요한 작업 |
Schedulers.immediate() | 현재 스레드 | 스케줄링 없이 즉시 실행 |
실무에서 WebFlux 도입 후 성능이 오히려 나빠졌다는 사례는 대부분 EventLoop에서 블로킹 코드를 실행 한 것이 원인입니다.
BlockHound같은 도구를 써서 블로킹 호출을 탐지하는 것을 권장합니다.
Netty 개념과의 매핑
지금까지 공부한 Netty 개념이 WebFlux에서 어떻게 대응되는지 정리해 봅시다.
| Netty 개념 | WebFlux/Reactor Netty 대응 | 설명 |
|---|---|---|
Channel | HTTP 연결 (Connection) | 클라이언트와의 하나의 TCP 연결 |
ChannelPipeline | HttpHandler 체인 | WebFilter → DispatcherHandler → Controller 순서로 이벤트 통과 |
ChannelHandler | WebFilter, HandlerFunction | 파이프라인의 각 처리 단계 |
EventLoopGroup | reactor-http-nio-* 스레드 그룹 | Worker EventLoop 풀 |
EventLoop | 개별 reactor-http-nio-N 스레드 | 여러 연결을 논블로킹으로 처리하는 단일 스레드 |
ServerBootstrap | HttpServer.create() | 서버 설정과 시작을 담당 |
ByteBuf | DataBuffer | Spring의 버퍼 추상화, 내부적으로 Netty ByteBuf 사용 |
ChannelFuture | Mono<Void> | 비동기 작업의 완료를 나타내는 리액티브 시그널 |
Pipeline → WebFlux 핸들러 체인 매핑
[Netty Pipeline]
HttpRequestDecoder → HttpObjectAggregator → ReactorNettyHandler
↓ 연결
[WebFlux Handler Chain]
WebFilter A → WebFilter B → DispatcherHandler → @Controller
Netty의 Pipeline은 바이트 수준의 인코딩/디코딩을 담당하고, 그 위에서 WebFlux의 핸들러 체인이 HTTP 수준의 요청/응답 처리를 담당합니다. 두 계층이 겹치는 지점이 ReactorNettyHandler입니다.
언제 WebFlux를 쓰고 언제 MVC를 쓰는가
이 질문은 면접에서도 자주 나오고, 실무에서도 판단이 필요한 부분입니다.
판단 기준 표
| 기준 | Spring MVC 적합 | Spring WebFlux 적합 |
|---|---|---|
| I/O 패턴 | DB 쿼리 위주 (JDBC, JPA) | API 게이트웨이, 외부 API 호출 다수 |
| ** 동시 연결 수** | 수백~수천 | 수만 이상 |
| ** 팀 경험** | 동기 프로그래밍에 익숙 | 리액티브 패러다임 이해 |
| DB 드라이버 | JDBC (블로킹) | R2DBC, MongoDB Reactive |
| ** 기존 코드** | 블로킹 라이브러리 많음 | 논블로킹 라이브러리 사용 가능 |
| ** 디버깅** | 스택 트레이스가 직관적 | 스택 트레이스가 복잡 |
| ** 스트리밍** | 필요 없음 | SSE, WebSocket 활용 많음 |
| ** 학습 곡선** | 낮음 | 높음 |
MVC를 선택해야 하는 경우
✅ RDBMS + JPA/MyBatis 중심 서비스
✅ 팀원 대부분이 동기 프로그래밍에 익숙
✅ 블로킹 라이브러리 의존성이 많음
✅ 동시 연결 수가 극단적으로 높지 않음
WebFlux를 선택해야 하는 경우
✅ MSA 게이트웨이 — 여러 서비스 호출을 동시에 처리
✅ 실시간 데이터 스트리밍 (SSE, WebSocket)
✅ 높은 동시 연결 + 낮은 지연 요구
✅ R2DBC 등 논블로킹 DB 드라이버 사용 가능
✅ 팀이 리액티브 프로그래밍에 익숙
가장 흔한 실수는 "WebFlux가 더 빠르니까"라는 이유만으로 도입하는 겁니다. 블로킹 코드가 섞이면 MVC보다 오히려 느려지고, 디버깅 난이도만 올라갑니다. ** 전체 호출 체인이 논블로킹으로 구성될 수 있는지 **가 판단의 핵심입니다.
정리
지금까지 배운 Netty 개념들이 Spring WebFlux에서 어떻게 활용되는지 정리해 봤습니다.
- **WebFlux의 기본 서버가 Netty인 이유 **: 리액티브 스트림의 비동기 논블로킹 모델과 Netty의 EventLoop 모델이 자연스럽게 맞아떨어지기 때문
- Reactor Netty: Netty의 저수준 API를 Reactor의
Mono/Flux로 감싸서 리액티브 네트워크 프로그래밍을 가능하게 하는 브릿지 - **EventLoop 공유 **: WebFlux의 요청 처리는 Netty EventLoop 스레드 위에서 실행되며, 블로킹하면 안 됨
- ** 블로킹 격리 **: 불가피한 블로킹은
Schedulers.boundedElastic()으로 별도 스레드 풀에 격리 - **MVC vs WebFlux 선택 **: 성능이 아니라 ** 전체 호출 체인의 논블로킹 가능 여부 **가 판단 기준