지금까지 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는 ** 리액티브 스트림** 기반입니다. MonoFlux로 데이터를 비동기적으로 흘려보내는 구조인데, 이걸 제대로 살리려면 서버 엔진 자체가 논블로킹이어야 합니다. Tomcat 위에서 WebFlux를 돌릴 수도 있지만, Netty가 이 모델에 가장 자연스럽게 맞아떨어지기 때문에 ** 기본 엔진 **으로 채택된 겁니다.

PLAINTEXT
[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를 직접 다루지 않고도 리액티브 네트워크 프로그래밍을 할 수 있게 해줍니다.

계층 구조

PLAINTEXT
┌─────────────────────────────────┐
│        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 서버를 띄우는 코드는 놀라울 정도로 간단합니다.

JAVA
// Reactor Netty로 직접 HTTP 서버 띄우기
HttpServer.create()
    .port(8080)
    .route(routes -> routes
        .get("/hello", (req, res) ->
            res.sendString(Mono.just("Hello, Reactor Netty!")))
    )
    .bindNow();  // 서버 시작

이 코드가 내부적으로 하는 일을 풀어보면 이렇습니다.

PLAINTEXT
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를 씁니다.

JAVA
@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 관점에서 보겠습니다.

PLAINTEXT
클라이언트 요청


[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개입니다.

JAVA
// 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에 바인딩된 ** 모든 연결 **의 이벤트 처리가 멈춥니다.

PLAINTEXT
EventLoop #3이 담당하는 연결들:
  Channel A ← 정상 요청 대기 중
  Channel B ← 정상 요청 대기 중
  Channel C ← JDBC 호출 중... 500ms 블로킹!

→ Channel A, B도 500ms 동안 아무 이벤트도 처리 못함
→ 8개 EventLoop 중 하나가 먹통 → 전체 처리량 12.5% 감소
→ 여러 EventLoop에서 동시에 블로킹 → 서버 전체 응답 불능

블로킹 코드를 격리하는 방법

불가피하게 블로킹 호출이 필요하면 Schedulers.boundedElastic()으로 ** 별도 스레드 풀에 격리 **합니다.

JAVA
@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 대응설명
ChannelHTTP 연결 (Connection)클라이언트와의 하나의 TCP 연결
ChannelPipelineHttpHandler 체인WebFilter → DispatcherHandler → Controller 순서로 이벤트 통과
ChannelHandlerWebFilter, HandlerFunction파이프라인의 각 처리 단계
EventLoopGroupreactor-http-nio-* 스레드 그룹Worker EventLoop 풀
EventLoop개별 reactor-http-nio-N 스레드여러 연결을 논블로킹으로 처리하는 단일 스레드
ServerBootstrapHttpServer.create()서버 설정과 시작을 담당
ByteBufDataBufferSpring의 버퍼 추상화, 내부적으로 Netty ByteBuf 사용
ChannelFutureMono<Void>비동기 작업의 완료를 나타내는 리액티브 시그널

Pipeline → WebFlux 핸들러 체인 매핑

PLAINTEXT
[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를 선택해야 하는 경우

PLAINTEXT
✅ RDBMS + JPA/MyBatis 중심 서비스
✅ 팀원 대부분이 동기 프로그래밍에 익숙
✅ 블로킹 라이브러리 의존성이 많음
✅ 동시 연결 수가 극단적으로 높지 않음

WebFlux를 선택해야 하는 경우

PLAINTEXT
✅ 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 선택 **: 성능이 아니라 ** 전체 호출 체인의 논블로킹 가능 여부 **가 판단 기준
댓글 로딩 중...