본문 바로가기

Spring

Spring Webflux 동작 원리 및 Spring MVC와의 비교

Spring WebFlux는 비동기(Async) 및 논블로킹(Non-Blocking) 기반의 리액티브 프로그래밍을 지원하는 Spring의 웹 프레임워크이다. 다만, 하지만, 요청마다 Dispatcher Servlet에서 스레드를 하나씩 할당하여 처리하는 비교적 직관적인 Spring MVC와 비교했을 때 Webflux는 조금 어렵다! 적어도 나에게는 그랬다. 

그래서 이번 글에서는 Spring Webflux가 어떻게 동작하는지, 그리고 왜 높은 처리량과 확장성에서 강점을 가지는지 알아보고자 한다. 

Spring MVC vs Webflux

특징 Spring MVC Spring Webflux
프로그래밍 모델 Sync / Blocking Async / Non-Blocking
기반 기술 Servlet API Reactor, Reactive Stream
실행 모델 Thread-per-request Event Loop
스레드 관리 요청당 하나의 스레드 할당 소수의 스레드로 많은 요청 처리
내장 Server (Default Thread 갯수) 톰캣 (200개) Netty (CPU 코어 수의 두배)
I/O 처리 Blocking I/O Non Blocking I/O

 

Spring Webflux에 대하여 검색하게 되면, 대부분 위와 같은 비교글을 보게 된다. 그렇다면 여기서 Event Loop란 무엇일까? 어떻게 소수의 스레드로 많은 요청을 처리할 수 있는 것일까?

 


이벤트루프란

이벤트 루프는 비동기 I/O 작업을 관리하고, 처리해야 할 이벤트를 지속적으로 확인하며 실행하는 역할을 한다. 이벤트 루프는 Event Queue를 관리한다. 이 이벤트 큐는 네트워크 I/O, 타이머, 사용자 입력 등 이벤트 루프가 처리할 작업을 저장하고 있으며, 이벤트 루프는 지속적으로 이벤트 큐를 확인하며다 다음으로 처리할 작업을 꺼내어 실행하는 역할을 한다. 즉, 이벤트루프는 간단히 말해 계속해서 이벤트큐를 모니터링하며 새로운 작업이 있는지 확인하고, 새로운 작업이 존재할 때 스레드를 할당하여 작업을 실행하도록 하는 무한루프이다! 

이벤트루프 동작 방식

아래는 이벤트루프가 어떻게 동작하는지에 대한 가장 간략한 설명이다. 

 

  1. 이벤트루프에서 요청을 수신하면 이벤트루프는 해당 이벤트를 수신하고 처리한다. 
  2. I/O와 같은 비동기 작업이 필요한 경우, 요청을 처리하는 스레드가 I/O 작업을 시작한다. ⭐️이 때, 스레드는 I/O 작업이 완료되기를 기다리지 않고, 다른 작업을 처리할 수 있다.
  3. I/O 작업이 완료되면 해당 작업을 완료했다는 signal이 발생한다. Netty와 같은 라이브러리는 운영체제의 비동기 I/O API (ex. Linux의 epoll, Windows의 IOCP)를 사용하여 작업 완료 신호를 받는다.
  4. 작업이 완료되면, 해당 작업 이후에 처리될 콜백이 이벤트루프에 의해 처리된다. 

 

그렇다면 위의 기본적인 동작 방식을 기반으로, Spring Webflux로 들어온 HTTP 요청이 어떤 식으로 처리가 되는지 조금 더 자세히 알아보자. 

 

Webflux (Netty)에서의 HTTP 요청 처리 방식

 

Webflux에 대해 공부할 때에 필수적으로 동반되야 하는 건 디폴트 내장 서버인 Netty에 대한 공부라고 생각한다. Netty에는 요청을 수락(accept)하는 것만 담당하는 Boss EventLoop가 있고, 실제로 요청을 처리하는 스레드들의 집합인 Worker EventLoopGroup이 있다.

각 이벤트루프는 1개의 스레드 + 1개의 Selector + 1개의 TaskQueue로 이루어진다. 연결 요청의 결과로 생성된 채널은 특정 이벤트루프에 등록되며, 이후 해당 이벤트루프가 그 채널에 대한 모든 이벤트(Read, Write 등)를 처리한다. Selector는 등록된 채널 중에서 이벤트가 발생하여 처리 가능한 상태인 채널을 감지하고 반환하며, 이벤트루프는 해당 채널의 ChannelPipeline에 등록된 Handler들을 순차적으로 실행하여 이벤트를 처리한다. 이벤트 처리 중 추가적인 비동기 작업(ex. 콜백)이 필요할 경우, TaskQueue에 작업이 등록되며, 이벤트루프는 I/O 이벤트를 처리한 후 TaskQueue의 작업을 꺼내와 실행한다.

이를 조금 더 자세히 톺아보기 위해, 실제로 Webflux에서 클라이언트가 서버로 HTTP요청을 보내게 되면 어떻게 요청이 처리가 되는지 살펴보자. 

 

  1. 클라이언트가 서버로 HTTP 요청을 보내면 Netty의 Boss EventLoop은 TCP 연결 요청을 수락(accept)하고, 새로운 Channel을 생성한다. 생성된 Channel은 Worker EventLoopGroup의 이벤트 루프 스레드 중 하나에 할당되며, 이후 해당 Channel에 대한 모든 작업은 할당된 Worker EventLoop 스레드에서 처리된다. 
  2. 할당된 이벤트루프 스레드는 Channel을 NIO Selector에 등록한다. 이벤트루프 스레드는 ChannelPipeline에 등록된 핸들러를 실행하며, Selector를 통해 Channel에 발생한 I/O 이벤트(read, write)를 감지한다. 
  3. ChannelPipeline의 첫 단계에서 HTTP 디코더에 의해 요청이 디코딩 되고, 요청을 Webflux의 DispatcherHandler로 라우팅한다.
  4. DispatcherHandler는 HandlerMapping을 통해 요청을 적절한 컨트롤러로 라우팅하여 요청을 처리한다. 
  5. 요청을 처리하는 중에 비동기 작업(예: I/O)이 필요할 경우, 해당 작업이 논블락킹 방식으로 수행된다. 즉, ⭐️요청 시 워커스레드가 블락되지 않는다. 이 때, 해당 작업이 완료된 이후 실행되어야 하는 코드를 담은 콜백(리액티브 체인)이 비동기 작업의 컨텍스트 내에서 관리된다.
  6. 이벤트 루프는 이벤트 큐를 확인하며 대기 중인 다른 이벤트나 요청을 처리한다.
  7. 비동기 작업이 완료되어 Selector가 완료 이벤트를 감지하면, 해당 작업의 콜백이 이벤트 큐에 추가되고, 이벤트루프가 이벤트 큐에서 해당 콜백을 꺼내와 실행한다. 
  8. 콜백이 실행되고 필요한 응답이 준비되면 준비되면, HTTP 응답이 생성되어 클라이언트로 반환된다. 

단순한 API 요청 같지만, Webflux 내에서는 위와 같은 매커니즘을 통해 요청이 처리된다! 

 

EventLoop의 I/O Multiplexing

위의 설명과 같이 서버에 요청이 들어왔을때 생성된 Channel은 EventLoopGroup의 이벤트루프 중 하나에 할당이 된다. 이 때, 하나의 이벤트루프는 여러 개의 Channel의 처리를 담당하게 되고, 이 덕분에 event loop는 특정 채널에서 I/O가 발생해도 다른 채널의 작업들을 처리할 수 있다. 이를 효율적으로 하는데에 사용하는 기술이 I/O Multiplexing이다. 쉽게 요약을 하면, I/O Multiplexing은 하나의 스레드가 리눅스의 epoll과 같은 메커니즘을 통해 여러 파일 디스크립터(fd)의 이벤트 상태를 확인하고, 이벤트가 발생한 fd만 효율적으로 처리하는 기술이다. 

I/O Multiplexing에 대한 글은 별도로 집필해볼 예정이다. 

 

여기서 한 가지 꼭 짚고 넘어가야 하는 부분은, 소수의 EventLoopGroup이 여러 채널에 대한 이벤트를 처리하게 되기 때문에, 이벤트루프 스레드가 절대로 블락되면 안된다. Spring MVC와 같은 Thread-per-Request 모델에서는 하나의 스레드가 블락되어도 다른 요청들에 영향을 미치지 않지만, 이벤트루프 모델에서는 이벤트루프 스레드를 블락하게 되면, 해당 이벤트루프가 처리하는 여러개의 채널에 대한 처리가 모두 영향을 받게 된다. 위의 작업의 5번에서 논블락킹 I/O를 수행한다는 것은 Webflux 안에서 어떠한 마법이 일어나서 그렇게 수행하는게 아니라, 어플리케이션 코드에서 블락킹 코드를 호출하지 않도록 잘 관리를 해주어야한다! Blocking I/O 요청을 사용하는 JDBC Driver를 Spring Webflux와 함께 사용하면 안되고, R2DBC와 같은 드라이버가 별도로 존재하는 이유이다. 

 


Spring MVC와의 비교

그렇다면 이 방식이 Spring MVC와 어떤 차이를 가지며, 어떤 강점을 갖는가?

Spring MVC와 같은 Sync / Blocking Framework의 경우, I/O 작업이 시작되면 요청을 한 스레드는 해당 I/O 작업이 완료될 때까지 Block이 된다. 즉, 짧지 않은 시간동안 해당 스레드는 사용할 수 없는 자원이 되어버린다. Tomcat의 경우, default thread 갯수가 200개이고, acceptCount가 100개이다. 이 말은 즉, 동시에 300개 이상의 요청이 들어왔을 때에 추가적인 요청은 아예 처리할 수 없게 된다는 의미이며, 이는 곧 최대 처리량이 스레드 갯수와 맞물리는 물리적인 제한이 존재한다는 의미이다.

그렇다면 단순히 스레드 갯수를 늘리면 되지 않는가? 결국 200개의 스레드도 완전히 병렬적으로 실행되는 것이 아니라 Concurrent하게 실행되기 때문에, 스레드 갯수가 늘어나게 되면 그만큼 Context Switching 오버헤드가 늘어난다. 우리의 리소스는 무한하지 않다. 

Spring Webflux의 경우, 자원이 무한하지 않다는 것은 당연히 동일하나, 적어도 블로킹이 없이 요청을 처리하기 때문에 최대 처리 가능한 요청량이 스레드 수에 직접적으로 제한되지 않는다. 그리고 적은 수의 스레드로 동작하기 때문에, Context Switching Overhead면에서 Spring MVC에 비해 이점을 갖고 서버 리소스를 효율적으로 활용할 수 있게 해준다. 

 

Worker Thread 갯수를 올려보면 어떨까?

Netty의 기본 스레드 갯수(Worker 이벤트루프 갯수)는 availableProcessor갯수 * 2이다. 그런데 이런 생각을 해볼 수도 있지 않을까? "톰캣도 디폴트로 200개의 스레드를 갖고있는데, 너무 적은거 아닌가? 혹시 Worker 갯수를 좀 더 늘리면 성능을 향상시킬 수 있지 않을까?" Netty에서는 대부분의 작업이 비동기/논블락킹으로 처리가 되기 때문에, 스레드 갯수의 향상이 성능의 향상으로 이어지긴 어렵다. 오히려 역효과가 날 수 있는데, 아래와 같은 이유들 때문이다. 

 

  1. 컨텍스트스위칭 오버헤드가 증가한다. 
  2. 스레드 갯수가 늘어나면 각 스레드가 CPU를 할당받는 시간이 줄어들게 되고, 결과적으로 스레드 당 작업 처리를 할 수 있는 시간이 짧아진다. 

이처럼 이벤트루프기반 모델은 Thread-per-Request 모델처럼 스레드 갯수가 많아지는 것이 더 많은 요청을 처리할 수 있는 구조가 아니다. 요청마다 스레드를 할당하지 않고, 적은 수의 스레드로 많은 요청을 효율적으로 처리하도록 설계되어 있다. 그리하여 Netty의 Worker EventLoop 수를 무작정 늘리는 것은 성능 향상보다는 성능 저하를 초래할 가능성이 높다.
애플리케이션의 특성과 내부 구현 로직을 면밀히 분석한 후, 적절한 최적화 전략을 적용하는 것이 더 중요하다.

 

무조건 더 좋은가?

그래서, Webflux가 무조건 더 좋아요? 쓰면 되나요? 라고 묻는다면, "케바케"라고 답할 수밖에 없을 것 같다. 

대용량트래픽 환경에서는 Webflux가 압도적인 성능을 보여주지만, 적은 요청 수에서는 Spring MVC와 비교하여 성능상 큰 이점을 보여주지 못한다. 그에 비하여, Webflux는 Reactive Stream API, Netty와 같은 비동기 기술을 이해해야 하며, 이로 인해 러닝 커브가 꽤 가파른 편이다. 또한, Spring MVC에 비해 코드도 더 복잡해지고 디버깅도 쉽지 않은 경향이 있다. 

더 나아가, Webflux를 사용한다고 해서 개별 요청의 latency를 크게 줄일 수 있는 것은 아니다. Webflux의 진정한 이점은 서비스 전체의 latency를 줄이는 것이 아니라, 대규모 동시 요청을 더 효과적으로 처리할 수 있다는 점에 있다. 즉, WebFlux는 대용량 트래픽 환경에서 자원을 효율적으로 사용하고 확장성을 높이는 데 초점을 맞춘 기술이다. 

 

이로써 Webflux 관련 글을 마친다! 

Netty와 같은 비동기 기술은 파고파고 들어갈 수록 잘못 알고 있었던 부분, 놓치고 있던 OS 관련 지식들 등 재밌고 흥미로운 내용이 참 많다! 이 글 작성 이후로도 계속해서 deep dive를 해보고 싶은 주제이다 :) 

 


References

 

I/O Multiplexing (입출력 다중화)

I/O 작업은 user space에서 직접 수행할 수 없고,user process가 kernel에 I/O 작업을 요청하고 응답을 받는 구조이다.kernel로부터 응답을 어떤 순서로 받는지, (동기/비동기) 혹은기다렸다가 받는지 (blocking

velog.io

 

 

톰켓과 네티 서버의 차이점

Tomcat 서버와 Netty 서버의 차이점을 알아보겠습니다.

velog.io

 

 

네티 이해하기 (Netty Deep Dive)

Netty가 어떻게 동작하는지 직접 코드를 살펴보면서 Deep Dive하여 정리한 글입니다.

mark-kim.blog