Spring WebFlux는 왜 만들어졌을까?
그 이유 중 하나는 소수의 스레드로 동시성을 처리하고 하드웨어 자원을 적게 사용하면서 확장할 수 있는 논블로킹 웹 스택이 필요했기 때문입니다.
Servlet 논블로킹 I/O는 나머지 Servlet API에서 벗어나게 됩니다.
나머지 Servlet API는 동기적(Filter
, Servlet
)이거나 블로킹(getParameter
, getPart
) 방식이기 때문입니다.
이것이 모든 논블로킹 런타임에서 기반이 될 수 있는 새로운 공통 API를 만들게 된 동기입니다.
이는 Netty와 같이 비동기 및 논블로킹 환경에서 잘 정립된 서버들 때문에 중요합니다.
다른 이유는 함수형 프로그래밍입니다.
Java 5에서 어노테이션이 추가되면서 주석 기반 REST 컨트롤러나 단위 테스트와 같은 기회가 생긴 것처럼, Java 8에서 람다 표현식이 추가되면서 Java에서 함수형 API를 사용할 기회가 생겼습니다.
이는 논블로킹 애플리케이션과 CompletableFuture
나 ReactiveX에서 인기 있는 연속형(continuation-style) API에서 비동기 로직을 선언적으로 구성할 수 있게 해줍니다.
프로그래밍 모델 수준에서, Java 8은 Spring WebFlux가 어노테이션 기반 컨트롤러와 함께 함수형 웹 엔드포인트를 제공할 수 있게 해주었습니다.
“논블로킹(non-blocking)”과 “함수형(functional)”을 언급했지만, reactive가 정확히 무엇을 의미할까?
“Reactive”라는 용어는 변화에 반응하는 프로그래밍 모델을 의미합니다. 예를 들어, 네트워크 컴포넌트가 I/O 이벤트에 반응하거나, UI 컨트롤러가 마우스 이벤트에 반응하는 식입니다. 이런 의미에서 논블로킹은 reactive입니다. 블로킹되는 대신, 연산이 완료되거나 데이터가 사용 가능해질 때 알림에 반응하는 방식이기 때문입니다.
Spring 팀에서 reactive와 연관된 또 다른 중요한 메커니즘은 논블로킹 백프레셔(non-blocking back pressure)입니다. 동기적 명령형 코드에서는 블로킹 호출이 자연스럽게 백프레셔 역할을 하여 호출자가 기다리게 합니다. 하지만 논블로킹 코드에서는 빠른 생산자가 대상(destination)을 압도하지 않도록 이벤트 속도를 제어하는 것이 중요합니다.
Reactive Streams는 백프레셔가 있는 비동기 컴포넌트 간의 상호작용을 정의하는 작은 사양으로, Java 9에서도 채택되었습니다. 예를 들어 데이터 저장소(Publisher 역할)가 데이터를 생성하면 HTTP 서버(Subscriber 역할)가 이를 응답으로 작성할 수 있습니다. Reactive Streams의 주요 목적은 구독자가 퍼블리셔가 데이터를 얼마나 빠르게 혹은 느리게 생성할지 제어할 수 있도록 하는 것입니다.
자주 묻는 질문: 퍼블리셔가 속도를 늦출 수 없다면? Reactive Streams의 목적은 단지 메커니즘과 경계를 설정하는 것입니다. 퍼블리셔가 속도를 늦출 수 없다면, 데이터를 버퍼링할지, 버릴지, 실패할지 스스로 결정해야 합니다.
Reactive Streams는 상호 운용성(interoperability)에서 중요한 역할을 합니다.
라이브러리와 인프라 컴포넌트에는 유용하지만, 애플리케이션 API로서는 너무 저수준이어서 덜 유용합니다.
애플리케이션에서는 비동기 로직을 구성하기 위해 Java 8 Stream
API와 유사하지만 컬렉션뿐만 아니라 다양한 데이터에 적용 가능한, 더 높은 수준의 풍부한 함수형 API가 필요합니다.
이것이 리액티브 라이브러리가 수행하는 역할입니다.
Reactor는 Spring WebFlux에서 선택된 리액티브 라이브러리입니다.
Reactor는 Mono
와 Flux
API 타입을 제공하여 0..1(Mono) 및 0..N(Flux)의 데이터 시퀀스를 다룰 수 있으며, ReactiveX 연산자와 일치하는 풍부한 연산자 세트를 제공합니다.
Reactor는 Reactive Streams 라이브러리이므로, 모든 연산자가 논블로킹 백프레셔를 지원합니다.
Reactor는 서버 사이드 Java에 초점을 맞추고 있으며, Spring과 긴밀하게 협력하여 개발됩니다.
WebFlux는 핵심 의존성으로 Reactor를 필요로 하지만, Reactive Streams를 통해 다른 리액티브 라이브러리와 상호 운용 가능합니다.
일반적으로 WebFlux API는 입력으로 일반 Publisher
를 받아 내부적으로 Reactor 타입으로 변환하여 사용하고, 출력으로 Flux
또는 Mono
를 반환합니다.
따라서 입력으로 어떤 Publisher
든 전달할 수 있으며, 출력에 연산을 적용할 수 있지만, 다른 리액티브 라이브러리와 함께 사용하려면 출력을 적절히 변환해야 합니다.
가능할 경우(예: 어노테이션 기반 컨트롤러) WebFlux는 RxJava나 다른 리액티브 라이브러리 사용을 투명하게 지원합니다. 자세한 내용은 Reactive Libraries를 참조하세요.
Reactive API 외에도, WebFlux는 Kotlin의 Coroutines API와 함께 사용할 수 있으며, 이는 보다 명령형(imperative) 스타일의 프로그래밍을 제공합니다. 다음 Kotlin 코드 샘플은 Coroutines API와 함께 제공됩니다.
spring-web
모듈에는 HTTP 추상화, 지원되는 서버용 Reactive Streams 어댑터, 코덱, 그리고 Servlet API와 유사하지만 논블로킹 계약을 가진 핵심 WebHandler API를 포함한 Spring WebFlux의 리액티브 기반이 들어 있습니다.
이 기반 위에서 Spring WebFlux는 두 가지 프로그래밍 모델을 제공합니다:
어노테이션 기반 컨트롤러(Annotated Controllers): Spring MVC와 일관되며 spring-web 모듈의 동일한 어노테이션을 기반으로 합니다. Spring MVC와 WebFlux 컨트롤러 모두 리액티브(Reactor 및 RxJava) 반환 타입을 지원하므로, 둘을 구분하기 쉽지 않습니다. 한 가지 주목할 만한 차이점은 WebFlux가 리액티브 @RequestBody 인수도 지원한다는 점입니다.
함수형 엔드포인트(Functional Endpoints): 람다 기반의 경량 함수형 프로그래밍 모델입니다. 애플리케이션이 요청을 라우팅하고 처리하는 데 사용할 수 있는 작은 라이브러리나 유틸리티 집합으로 생각할 수 있습니다. 어노테이션 기반 컨트롤러와의 큰 차이점은, 함수형 모델에서는 애플리케이션이 요청 처리를 처음부터 끝까지 직접 관리하는 반면, 어노테이션 기반 모델에서는 의도를 선언하고 호출되는 방식이라는 점입니다.
Spring MVC 아니면 WebFlux?
자연스럽게 떠올릴 수 있는 질문이지만, 잘못된 이분법을 설정하게 됩니다. 사실 두 프레임워크는 함께 사용되어 사용 가능한 옵션의 범위를 확장합니다. 두 프레임워크는 상호 연속성과 일관성을 위해 설계되었으며, 나란히 사용할 수 있고, 각 측의 피드백은 서로에게 도움이 됩니다. 다음 다이어그램은 두 프레임워크가 어떻게 관련되어 있는지, 공통점과 각자가 고유하게 지원하는 내용을 보여줍니다.
다음 구체적인 점들을 고려해보는 것이 좋습니다:
이미 정상적으로 작동하는 Spring MVC 애플리케이션이 있다면 굳이 변경할 필요가 없습니다. 명령형(imperative) 프로그래밍이 코드를 작성하고 이해하며 디버깅하기 가장 쉽습니다. 대부분의 라이브러리가 블로킹 방식이었기 때문에 선택할 수 있는 라이브러리도 최대화됩니다.
이미 논블로킹 웹 스택을 고려하고 있다면, Spring WebFlux는 이 분야의 다른 옵션과 동일한 실행 모델 이점을 제공하며, 서버 선택(Netty, Tomcat, Jetty, Undertow, Servlet 컨테이너), 프로그래밍 모델 선택(어노테이션 기반 컨트롤러와 함수형 웹 엔드포인트), 리액티브 라이브러리 선택(Reactor, RxJava 등)도 제공합니다.
Java 8 람다나 Kotlin과 함께 사용할 수 있는 경량 함수형 웹 프레임워크에 관심이 있다면 Spring WebFlux의 함수형 웹 엔드포인트를 사용할 수 있습니다. 이는 복잡하지 않은 소규모 애플리케이션이나 마이크로서비스에도 적합하며, 더 큰 투명성과 제어를 제공할 수 있습니다.
마이크로서비스 아키텍처에서는 Spring MVC 또는 Spring WebFlux 컨트롤러, 혹은 Spring WebFlux 함수형 엔드포인트가 혼합된 애플리케이션을 가질 수 있습니다. 두 프레임워크 모두 동일한 어노테이션 기반 프로그래밍 모델을 지원하므로 지식을 재사용하면서 적합한 도구를 선택하기가 용이합니다.
애플리케이션을 평가하는 간단한 방법은 의존성을 확인하는 것입니다. 블로킹 방식의 영속성 API(JPA, JDBC)나 네트워킹 API를 사용해야 한다면, 최소한 일반적인 아키텍처에서는 Spring MVC가 가장 적합합니다. Reactor나 RxJava 모두 별도 스레드에서 블로킹 호출을 수행할 수는 있지만, 논블로킹 웹 스택의 장점을 충분히 활용하는 것은 아닙니다.
원격 서비스 호출이 있는 Spring MVC 애플리케이션이라면, 리액티브 WebClient를 사용해보세요. Spring MVC 컨트롤러 메서드에서 리액티브 타입(Reactor, RxJava 등)을 직접 반환할 수 있습니다. 호출당 지연(latency)이나 호출 간 상호 의존성이 클수록 그 이점은 더 극적입니다. Spring MVC 컨트롤러도 다른 리액티브 컴포넌트를 호출할 수 있습니다.
큰 팀의 경우, 논블로킹, 함수형, 선언형 프로그래밍으로 전환할 때 학습 곡선이 가파르다는 점을 염두에 두어야 합니다. 전체 전환 없이 시작하는 현실적인 방법은 리액티브 WebClient를 사용하는 것입니다. 그 이상으로는 작은 규모로 시작하고 이점을 측정하세요. 광범위한 애플리케이션에서는 전환이 불필요할 수 있습니다. 어떤 이점을 기대해야 할지 확실하지 않다면, 먼저 논블로킹 I/O가 어떻게 작동하는지(예: 단일 스레드 Node.js에서의 동시성)와 그 효과를 학습하는 것부터 시작하세요.
Spring WebFlux는 Tomcat, Jetty, Servlet 컨테이너뿐만 아니라 Netty, Undertow와 같은 비-Servlet 런타임에서도 지원됩니다. 모든 서버는 저수준의 공통 API에 맞춰 적응되어, 상위 수준의 프로그래밍 모델이 서버 전반에서 지원될 수 있도록 합니다.
Spring WebFlux 자체에는 서버를 시작하거나 중지하는 기능이 내장되어 있지 않습니다. 그러나 Spring 설정과 WebFlux 인프라를 조합하여 애플리케이션을 구성하고, 몇 줄의 코드로 실행하는 것은 쉽습니다.
Spring Boot에는 이러한 단계를 자동화하는 WebFlux 스타터가 있습니다. 기본적으로 스타터는 Netty를 사용하지만, Maven이나 Gradle 의존성을 변경하여 Tomcat, Jetty, Undertow로 쉽게 전환할 수 있습니다. Spring Boot가 기본으로 Netty를 사용하는 이유는 비동기 논블로킹 환경에서 더 널리 사용되며, 클라이언트와 서버가 자원을 공유할 수 있기 때문입니다.
Tomcat과 Jetty는 Spring MVC와 WebFlux 모두에서 사용할 수 있습니다. 다만, 사용 방식이 매우 다르다는 점을 유념해야 합니다. Spring MVC는 Servlet 블로킹 I/O에 의존하며, 필요할 경우 애플리케이션이 Servlet API를 직접 사용할 수 있게 합니다. 반면 Spring WebFlux는 Servlet 논블로킹 I/O에 의존하며, 저수준 어댑터를 통해 Servlet API를 사용하지만 직접 노출하지는 않습니다.
WebFlux 애플리케이션에서는 Servlet 필터를 매핑하거나 Servlet API를 직접 조작하는 것은 강력히 권장되지 않습니다. 앞서 언급한 이유 때문에, 동일한 컨텍스트에서 블로킹 I/O와 논블로킹 I/O를 혼합하면 런타임 문제가 발생할 수 있습니다.
Undertow의 경우, Spring WebFlux는 Servlet API 없이 Undertow API를 직접 사용합니다.
성능에는 여러 가지 특성과 의미가 있습니다. 일반적으로 리액티브 및 논블로킹 방식이 애플리케이션을 더 빠르게 실행시키는 것은 아닙니다. 특정 경우에는 속도를 높일 수 있습니다. 예를 들어 WebClient를 사용해 원격 호출을 병렬로 실행하는 경우가 그렇습니다. 그러나 논블로킹 방식으로 처리하려면 더 많은 작업이 필요하며, 이로 인해 처리 시간이 약간 늘어날 수 있습니다.
리액티브와 논블로킹의 핵심 기대 이점은 소수의 고정 스레드와 적은 메모리로 확장할 수 있다는 점입니다. 이로 인해 애플리케이션이 부하 상황에서도 보다 예측 가능한 방식으로 확장되어 내결함성이 높아집니다. 다만 이러한 이점을 관찰하려면 일정한 지연(latency)이 필요하며, 여기에는 느리거나 예측 불가능한 네트워크 I/O가 혼합된 경우가 포함됩니다. 이러한 상황에서 리액티브 스택의 강점이 나타나며, 차이는 매우 뚜렷할 수 있습니다.
Spring MVC와 Spring WebFlux는 모두 어노테이션 기반 컨트롤러를 지원하지만, 동시성 모델과 블로킹 및 스레드에 대한 기본 가정에는 중요한 차이가 있습니다.
Spring MVC(및 일반적인 서블릿 애플리케이션)에서는 애플리케이션이 현재 스레드를 블로킹할 수 있다고 가정합니다(예: 원격 호출). 이러한 이유로 서블릿 컨테이너는 요청 처리 중 발생할 수 있는 블로킹을 흡수하기 위해 큰 스레드 풀을 사용합니다.
Spring WebFlux(및 일반적인 논블로킹 서버)에서는 애플리케이션이 블로킹하지 않는다고 가정합니다. 따라서 논블로킹 서버는 요청을 처리하기 위해 소수의 고정 크기 스레드 풀(이벤트 루프 워커)을 사용합니다.
“확장(scale)”과 “소수의 스레드(small number of threads)”는 모순처럼 들릴 수 있지만, 현재 스레드를 절대 블로킹하지 않고 대신 콜백에 의존한다는 것은 추가 스레드가 필요 없음을 의미합니다. 블로킹 호출이 없기 때문입니다.
블로킹 라이브러리를 반드시 사용해야 한다면 어떻게 해야 할까요? Reactor와 RxJava 모두 publishOn 연산자를 제공하여 다른 스레드에서 처리를 계속할 수 있습니다. 즉, 쉽게 빠져나올 수 있는 방법이 있다는 뜻입니다. 다만 블로킹 API는 이 동시성 모델에는 적합하지 않다는 점을 염두에 두어야 합니다.
Reactor와 RxJava에서는 연산자를 통해 로직을 선언합니다. 런타임에서는 데이터가 구분된 단계로 순차적으로 처리되는 리액티브 파이프라인이 형성됩니다. 이로 인한 주요 이점은 파이프라인 내의 애플리케이션 코드가 동시에 호출되지 않기 때문에 애플리케이션이 변경 가능한 상태를 보호할 필요가 없다는 것입니다.
Spring WebFlux로 실행되는 서버에서 어떤 스레드를 볼 수 있을까요?
“순수” Spring WebFlux 서버(예: 데이터 접근이나 기타 선택적 의존성 없음)에서는 서버용 스레드 1개와 요청 처리를 위한 몇 개의 다른 스레드(일반적으로 CPU 코어 수와 동일)를 예상할 수 있습니다. 그러나 서블릿 컨테이너는 서블릿(블로킹) I/O와 서블릿 3.1(논블로킹) I/O를 모두 지원하기 위해 더 많은 스레드(예: Tomcat의 경우 10개)로 시작할 수 있습니다.
리액티브 WebClient는 이벤트 루프 방식으로 동작합니다. 따라서 관련된 소수의 고정 스레드를 볼 수 있습니다(예: Reactor Netty 커넥터 사용 시 reactor-http-nio-). 그러나 Reactor Netty가 클라이언트와 서버 모두에 사용되는 경우, 두 측은 기본적으로 이벤트 루프 자원을 공유합니다.
Reactor와 RxJava는 publishOn 연산자를 사용하여 처리를 다른 스레드 풀로 전환할 수 있는 스레드 풀 추상화인 스케줄러(scheduler)를 제공합니다. 스케줄러 이름은 특정 동시성 전략을 나타냅니다. 예를 들어, “parallel”(제한된 수의 스레드로 CPU 바운드 작업 수행) 또는 “elastic”(많은 스레드로 I/O 바운드 작업 수행)입니다. 이러한 스레드를 확인하면 일부 코드가 특정 스레드 풀 스케줄러 전략을 사용하고 있음을 의미합니다.
데이터 접근 라이브러리나 기타 서드파티 의존성도 자체적으로 스레드를 생성하고 사용할 수 있습니다.
Spring Framework 자체에는 서버 시작과 중지를 지원하는 기능이 없습니다. 서버의 스레딩 모델을 구성하려면 서버별 설정 API를 사용하거나, Spring Boot를 사용하는 경우 각 서버에 대한 Spring Boot 설정 옵션을 확인해야 합니다. WebClient는 직접 구성할 수 있습니다. 그 외 모든 라이브러리는 해당 문서를 참조하세요.