Reactive(반응형) 프로그래밍과 Spring WebFlux 기본 개념

2023. 5. 24. 21:53[ 백엔드 개발 ]/[ Spring ]

이번 포스팅에서는 반응형(Reactive) 모델이 최근 떠오르고 있는 이유와 이에 대한 장단점, 반응형 프로그래밍을 표준화하기 위한 과정들, Spring에서 기존 서블릿을 대체하는 반응형 네트워크 엔진 등에 대해 알아본다.

 

Blocking I/O와 Non-Blocking I/O의 동작 원리

반응형(Reactive) 프로그래밍을 이해하기 위해선 Non-Blocking I/O의 동작 원리와 Blocking I/O와 비교했을 때 어떤 장단점이 있는지를 명확하게 이해해야 한다.

https://jh-labs.tistory.com/334

위 그림과 같이 Non-Blokcing I/O 모델은 I/O가 발생하더라도 block되지 않기 때문에 다른 요청을 처리할 수 있게 된다. 따라서 더 적은 양의 쓰레드와 CPU로 다수의 요청을 동시에(Parallel의 의미가 아니라 Concurrent의 의미) 처리할 수 있다는 것이 Non-Blokcing I/O의 핵심 중에 핵심이다.

 

그럼 Non-Blokcing I/O가 무조건 좋은 거 아닌가?

결론부터 말하자면 Non-Blokcing I/O 무조건적으로 좋다고 할 순 없다. I/O가 적은 상황에서는 Blocking I/O 모델이 더 유리할 수 있다. 컴퓨터 시스템에서 유저 쓰레드의 모든 I/O 작업은 OS의 관여(시스템 콜)를 필요로 하게 되는데, Blocking I/O에서 읽기 작업을 하면, 요청한 데이터가 준비될 때까지 프로세스가 대기 상태로 전환된다(실행되지 않고 Block된다). 데이터가 준비되면, 시스템 콜이 완료되고 프로세스는 다시 실행 상태로 전환된다. 이 과정에서 시스템 콜이 1회 발생하기 때문에 오버헤드가 비교적 작다. 반면, Non-Blocking I/O에서 읽기 작업을 수행하면, 데이터가 즉시 준비되지 않더라도 시스템 콜이 완료되고 프로세스는 여전히 실행 상태를 유지한다. 프로세스는 데이터가 준비되었는지 여부를 지속적으로 확인(polling)하기 위해 추가 시스템 콜을 발생시켜야 한다. 즉, 시스템 콜 수가 늘어나게 되며, 이로 인한 오버헤드도 증가하게 된다. 결과적으로 I/O 작업이 적거나 오래걸리지 않는 경우에는 Blocking I/O 모델이 더 유리하다고 할 수 있다.

 

 


 

Reactive(반응형) 프로그래밍이란?

Spring 공식문서에서는 Reactive를 "특정 이벤트에 대해 반응하는 프로그래밍 모델"이라고 정의하고 있다. 예를 들어, 네트워크 컴포넌트들이 특정 network I/O 이벤트에 반응하던가, 마우스 이벤트에 의해 UI 컴포넌트가 반응하는 것을 설명한다. 좀 더 일반적인 설명으로 반응형 프로그래밍이란 Non-Blocking I/O 기반으로 생성된  데이터 스트림(Stream)을 비동기 프로그래밍을 기반으로 처리하는 프로그래밍 방식을 의미한다.

 

반응형 모델의 표준화: Reactive Streams

Reactive Streams은 반응형 프로그래밍 모델을 구현하기 위한 명세라고 생각하면 된다. Reactive Streams이라는 용어 그대로 반응형 스트림을 의미하며 스트림으로 흐르는 데이터들을 비동기 메소드들이 체인 형태로 구성되어 순차적으로 처리하는 방식을 명세한다.

 

Reactive Streams 명세에서는 다음과 같은 고민들로 시작되었다.

 

1) I/O 데이터를 한 번에 받는게 아니라 수신자가 받을 수 있을 만큼만 나눠서 받을 순 없을까?
-> 데이터가 모두 완료되었을 때에만 처리하는 게 아니라 어느 정도 완료가 되었다면 바로 스트림에서 처리하기 위한 것. (Iterator 패턴 적용)

2) 데이터를 강제적으로 받지 않고 수신자가 받고 싶을 때에만 받을 순 없을까?
-> Non-Blocking I/O의 결과를 버퍼링 없이 받기 위한 것. (Back Pressure 개념 도입)

 

이러한 고민들은 Non-Blocking I/O 형태로 생성된 데이터를 '스트림'으로 흘려보내고 이런 데이터들의 흐름을 비동기적으로 콜백 메소드들이 체인을 이루어 처리될 수 있도록 하기 위해 필요하다. 즉, 데이터를 생성하는 송신자가 생성하고 전송하는 데이터의 속도를 수신자가 제어할 수 있도록 해서 반응형 프로그래밍을 구현하자는 것이 Reactive Streams의 핵심이자 전부이다.

 

위 고민들을 해결하기 위해 Reactive Streams은 Observer 패턴을 적용하고 Observer 패턴에서 발생하던 다음과 같은 문제점들을 Iterator 패턴과 back pressure 개념을 도입한다. 기존 Observer 패턴은 Publisher가 Subscriber에게 Event를 Push(Notify)하는 방식으로 동작하는데, 대표적으로 아래와 같은 문제점이 존재한다.

 

1) Observer가 준비가 되지 않았는데, 이벤트(데이터)가 전달될 수 있다.
-> Reactive Streams에서는 Subscriber가 이를 제어하도록 한다.

2) Publisher가 보내는 연속된 데이터의 끝을 알려주지 못한다.
-> Reactive Streams에서는 Iterator 패턴을 도입한다. (Iterator에서는 next()를 통해 다음 데이터를 받고, hasNext()를 통해 데이터의 끝을 알 수 있다.)

3) Observer가 이벤트를 처리하는 속도보다, Publisher가 이벤트를 Notify하는 속도가 빠를 수 있다.
- 이러한 문제는 일반적으로 큐잉을 통해 해결할 수 있으리라 생각하지만 무한 큐잉은 OOM을 발생시킬 수 있으며 제한된 큐 사이즈는 데이터 유실을 발생시킬 수 있다.
-> Reactive Streams에서는 큐잉 필요성 자체를 제거하고 non-blocking back pressure 개념을 도입하여 문제를 해결한다.

 

1. Observer 패턴 + Iterator 패턴 도입

위 문제를 해결하기 위해 Reactive Streams은 Observer 패턴에 Iterator 패턴을 도입한다. 즉, I/O가 완료된 데이터를 한 번에 받는게 아니라 Iterator의 next, hasNext API와 결합하여 순차적으로 받고 데이터의 끝을 알 수 있게 된다.

 

참고로 Java9부터 Reactive Streams 명세를 구현한 인터페이스(Flow)가 제공되며 그 중 Subscriber API는 아래와 같다.

 

https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/concurrent/Flow.Subscriber.html

- onComplete(): Iterator 패턴의 hasNext()와 같음

- onNext(T item): Observer 패턴의 observe와 Iterator 패턴의 next() 결합

- onError(Throwable t): next에서 Exception이 발생할 경우 전파를 위해 사용

 

 

2. back pressure 개념 도입

- 일반적으로 API를 호출하면 caller가 자기 자신의 실행 흐름에 대한 주도권을 가지고 있는 게 정상인데, Blocking API의 경우에는 반대로 이들이 caller에게 '기다리라는 억압(pressure)'을 하는 방식으로 동작한다.(Blocking API가 비정상이라는게 아니라 I/O라는 특수 상황이기 때문인 것) 즉, Blocking API는 본인 일이 언제 끝날지도 모르는데 caller는 이를 무한정 기다려야 하는 강제성이 부여된 것이다. 뿐만 아니라 작업이 완료되었을 때 모든 데이터를 속도 제한 없이 한 번에 받아야 한다.

- 이와 반대로 Non-Blocking I/O에서는 back pressure 개념을 도입할 수 있다. back pressure는 Non-Blocking I/O 자체적으로 제공되는 기능은 아니며 Reactive Streams에서 정의한 것으로 OS 레벨의 I/O 시스템 콜 레벨과 유저 쓰레드 사이에 특별한 레이어를 둠으로써 publisher(간접적으로는 Non-Blocking API)가 생성하는 데이터의 속도를 subscriber(caller)가 제어할 수 있도록 하는 것을 의미한다. 

- Non-Blocking back pressure를 활용하면 중간에서 데이터를 큐잉할 필요성도 없어진다. subscriber가 원하는 만큼의 데이터를 원하는 속도에 맞춰 받을 수 있기 때문이다.

- Reactive Streams는 이것을 Subscription으로 제어한다. Subscription의 request API는 요청량을 조절할 수 있게 한다.

- 즉, 쉽게 말해 Non-Blocking I/O의 결과 데이터를 모두 한번에 특정 시점에 받는 게 아니라, subscriber가 받을 수 있는 상황이 될 때, 원하는 만큼만 받도록 하자는 개념이며, 이로써 버퍼링 없이도 I/O 데이터를 처리하여 과부하를 방지할 수 있게 되었다. (Blocking I/O API에서는 publisher가 주도권을 갖고 있지만, 반대로  Non-Blocking I/O에서의 주도권을 subscriber에게 부여하자는 매커니즘이라고 이해하면 된다.)

 

 

* 개념정리

- Reactive Streams에는 Subscriber가 호출하는 모든 신호는 Non-Blocking I/O를 기반으로 해야 한다는 규약이 포함된다. 즉, 정리하자면 Reactive Streams은 기존 Observer 패턴의 단점을 Iterator 패턴과 Non-Blocking back pressure 개념 도입을 통해 해결함으로써 데이터 송신자(publisher)가 생성하는 데이터의 속도를 데이터 수신자(subscriber)가 제어할 수 있도록 하는 것이 핵심이다. 이를 통해, 함수형 프로그래밍을 기반으로 하는 반응형 모델에서 데이터 스트림을 유량제어하여 필요한 만큼만 바로 받아 처리할 수 있게 한다.

- Java의 Collection Stream과 Reactive Streams 프로그래밍 방식은 약간 유사하지만 완전히 다른 개념이다. Collection Stream은 모든 데이터에 대해(즉, Non-Blocking I/O 개념 없음) Iterator를 돌면서 스트림 데이터를 처리하는 것이고(물론 병렬 스트림 처리 등의 목적이 있음) Reactive Streams는 모든 데이터가 아니라 Subscriber가 필요한 데이터 만큼만을 바로 데이터 스트림에 흘려보내고 비동기적으로 이들이 처리되는 방식을 의미한다.

 


Reactive library: Project Reactor

Reactive Streamspublisher 및 subscriber 간 제어를 위해 중요한 역할을 하지만 너무 low-level이기 때문에 애플리케이션 API로는 유용성이 떨어진다. 애플리케이션은 비동기 로직을 구성하기 위해 high-level 수준의 더 풍부한 기능의 API가 필요하다. Project Reactor는 Reactive Streams 명세를 구현한 구현체이면서 라이브러리이며 Project Reactor에는 대표적으로 Reactor Core, Reactor Netty, Reactor Kafka 등 다수의 라이브러리들이 제공된다. 특히, Reactor Netty는 Spring WebFlux에서 서블릿을 대체하는 네트워크 엔진으로 사용된다.

 

 

그리고 지금까지 설명한 각 구성요소를 도식화하면 아래와 같다.

 

 

1) Java Reactive Streams API: Reactive Streams 명세를 Java에서 구현한 표준 API(Flow 인터페이스)

2) Reactor: Java Reactive Streams API 명세를 구현하고 확장한 라이브러리

3) Spring WebFlux: 반응형 웹 애플리케이션 개발을 제공하는 '애플리케이션 프레임워크'

 

정리하자면, Java Reactive Streams API는 Reactive Streams 명세를 Java 인터페이스(Flow)로 명세한 Java 표준 API이며 비동기 통신을 위한 약간의 명세사항을 포함한다. Java Reactive Streams API(인터페이스)를 구현한 것 중 대표적인 것이 Project Reactor라는 라이브러리이다. Project Reactor 중 대표적으로 Reactor Core는 비동기 통신을 위한 Mono, Flux와 같은 형태의 API를 제공한다. Spring WebFlux는 Reactor Netty를 기반으로 하는 '애플리케이션 프레임워크'이다. 즉, 특정 부분의 프레임워크 역할만 담당하는 게 아니라, 클라이언트 요청을 받는 Socket I/O 부분, 뒷단의 DB I/O 부분, 다른 서버를 호출하는 WebClient 등 애플리케이션 개발에 적용되는 전반적인 부분을 반응형으로 구현할 수 있도록 제공되는 '애플리케이션 프레임워크'이다.

 

 

cf. Project Reactor 외, Reactive Streams 명세를 구현한 다른 오픈소스는?

- 대표적으로 RxJava와 Akka Streams가 있다.

 

 

cf. Reactor Netty

- Reactor Core에서 제공하는 Mono, Flux와 Netty가 결합된 라이브러리이다.

- Reactor Core에서는 전반적인 Non-Blocking I/O와 비동기 기능을 제공하지만 Netty는 이와 더불어 네트워크에 초첨이 맞춰진 프레임워크이다.

- Reactor Netty를 통해 비동기, 반응형 네트워크 개발이 가능하다.(HTTP, TCP, UDP 등)

- Spring WebFlux가 서블릿(WAS)을 대신하는 네트워크 엔진으로 사용한다.

- Reactor Netty는 WAS가 아니다. WAS는 Socket I/O 부분을 명세한 서블릿과 DB접근과 같은 다양한 미들웨어를 연동하는 통합 플랫폼이며, Reactor Netty는 미들웨어 연동이 아닌 Non-Blocking I/O, 비동기 기반의 애플리케이션을 구현할 수 있도록 하는 라이브러리이다.

 

 

Reactor의 Mono, Flux API 타입

Flux와 Mono는 모두 Project Reactor(그 중에서도 Reactor Core)에서 정의된 타입으로 모두 Publisher 인터페이스의 구현체이다. 기본적으로 Flux와 Mono는 비동기 작업의 결과를 단일 값(Mono) 또는 여러 값(Flux)으로 정의하는 타입이다. 즉, I/O의 데이터를 가져올 때 해당 데이터가 최대 1개인 것으로 예상된다면 Mono를 사용하고, 그렇지 않다면 Flux 타입의 Publisher를 사용하면 된다. 이 두 타입을 사용하면 비동기 작업을 수행하고 결과를 더 쉽게 다룰 수 있는 메커니즘이 제공된다.

 

cf. Mono는 Optional과, Flux는 List와 유사함

 

1. Mono

https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html

Mono는 최대 1개의 데이터 항목을 처리하는 스트림이자 Publisher의 타입이다. 이 타입은 Subscriber의 onNext()에서 최대 1개 값만 방출되므로 단일 값만 처리되는 파이프라인을 구성하는 데 사용된다. 예를 들어, 데이터베이스 조회 결과 중 하나만 반환해야 하는 경우에 Mono를 사용할 수 있다.

 

[실행 구조]

- onNext() 1회 -> onComplete() (성공시 시그널) or onError() (에러 발생시 시그널)

 

2. Flux

https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Flux.html

 

Flux는 0개 이상의 데이터 항목을 처리하는 스트림이다. 이 타입은 복수 개의 데이터를 비동기적인 순서로 방출하는 파이프라인을 구성하는 데 사용된다. onNext() 요청 여러개를 통해 가능한 무한한 데이터를 처리할 수 있는 타입이다. 예를 들어, 비동기 웹 서비스를 호출한 결과에 대해 여러 작업을 수행하는 파이프라인을 구성하려면 Flux를 사용할 수 있다. 

 

[실행 구조]

- onNext() N회 -> onComplete() (성공시 시그널) or onError() (에러 발생시 시그널)

 


 

Spring WebFlux란?

- 기존 스프링 프레임워크나 스프링 MVC는 서블릿 API 및 서블릿 컨테이너용으로 특별히 제작되었다.

- 반응형 기반인 Spring WebFlux는 스프링 프레임워크 버전 5부터(부트는 2부터) 추가되었으며 완전히 non-blocking I/O를 지원한다.

- Spring WebFlux는 Project Reactor에서 제공하는 Reactor Netty(React Core + Netty)를 기반으로 하며 서블릿 API를 사용하지 않는다. 대신, Reactor Netty에서 제공되는 독자적인 네트워크 엔진을 기반으로 한다.

- Spring WebFlux를 Spring MVC와 함께 프로젝트에서 사용하면 서블릿(Tomcat)을 사용할 수도 있다.

- source modules (spring-webmvc and spring-webflux)은 서로 하나의 프로젝트에 함께 사용될 수 있다. 예를 들어 컨트롤러는 Spring MVC를 사용하고 뒷단에서는 WebFlux의 WebClient를 사용할 수도 있다.

 

 

Spring WebFlux가 생겨난 배경 (공식문서 출처)

Spring 공식문서에서는 WebFlux가 생겨난 배경을 총 2가지 이유로 설명하고 있다.

 

[탄생 배경1: Non-Blocking I/O 지원을 통한 동시성 및 HW리소스 효율화]

적은 수의 쓰레드를 기반으로 동시성(concurrency)을 처리하고 하드웨어 리소스를 더욱 소량으로 사용하기 위한 기반이 Non-Blcoking I/O이기 때문이라고 말한다. 서블릿 3.1에서는 Non-Blocking I/O 기반의 API 제공했지만 이는 기존에 남아 있던 다른 (Blocking I/O 기반의) 서블릿 API들과 어울리지 못했다고 한다. 이러한 이유로 인해 완전히 새로운 Non-Blocking I/O 기반의 엔진이 필요하게 되었고 이것이 Reactor Netty(Reactor Core + Netty)가 Spring에서 선택된 이유이다. 즉, Spring WebFlux가 반응형 웹 애플리케이션 프레임워크를 제공하기 위해 서블릿을 선택하지 않은 이유는 서블리에서 기존에 남아 있던 다른 (Blocking I/O 기반의) 서블릿 API들과 어울리지 못했기 때문이다.

 

[탄생 배경2: 함수형 프로그래밍]

Java 8부터 등장한 람다식은 Java에서 함수형 API를 위한 기회를 만들었다. 람다식은 Functional Interface를 기반으로 개발자가 콜백 함수 정의를 간단하게 할 수 있게 했고 비동기 프레임워크 단에서는 람다식은 함수를 값으로 취급하여 다른 함수에 인수로 전달하거나 결과로 반환할 수 있게 했다. 이러한 특성은 함수형 프로그래밍 패러다임을 이용하여 비동기 작업을 체인으로 연결하거나 병렬화하고, 스트림을 처리하는데 효율적이다. 즉, 반응형 프로그래밍에 적합하다.

 

 

cf. Spring에서 Non-Blocking I/O, 비동기 프로그래밍 모델을 중요하게 생각하는 이유

인터넷 환경이 발전함에 따라 트래픽이 수가 크게 증가되었고 이는 기존 Spring MVC 모델에서 request-per-thread(정확히는 connection-per-thread) 모델의 한계가 존재한다고 설명한다. 이는 Latency의 증가를 야기했고 이에 대한 대안으로 반응형 모델이 부각되었다.

 

1) 기존 Spring MVC 모델

김영한님 강의 중

위 그림은 Spring MVC에서 request-per-thread 모델을 대략적으로 도식화한 것이다. 위 그림과 같이 하나의 클라이언트와의 커넥션(일반적으로 TCP 커넥션)은 하나의 쓰레드가 관장하게 되고 해당 커넥션이 끊기기 전까지 해당 커넥션에 발생하는 HTTP 요청을 하나의 쓰레드가 처리하게 된다. 즉, 쓰레드 풀 크기가 200이라면(톰캣 기본값) 최대 동시 TCP 커넥션을 받아들일 수 있는 클라이언트 수도 200인 것이다. Spring MVC가 request-per-thread인 동기적인 모델이 될 수밖에 없던 이유는 대부분의 blocking I/O를 기반으로 하기 때문이다. 예를들어, 백엔드 애플리케이션에서 발생하는 대부분의 I/O인 Socket I/O부분(서블릿(정확히는 서블릿 3.1 이전))과 DB I/O(JDBC)가 모두 Blocking I/O를 기반으로 하기 때문이다. 즉, 하나의 쓰레드는 클라이언트의 새로운 요청을 받기 위한 Socket I/O, DB I/O를 모두 blocking하여 대기해야 한다.

 

 

2) Spring WebFlux 모델

https://www.baeldung.com/spring-webflux-concurrency

위 그림은 Spring WebFlux에서 사용되는 쓰레드 모델이다. request-per-thread 모델과 다르게 반응형 모델에서는 요청이 아닌 '데이터 흐름'에 더욱 초점을 맞춘다. 즉, 특정 쓰레드가 특정 요청을 처음부터 끝까지 실행하는데 관장하지 않고 하나의 쓰레드는 여러 요청을 처리하면서 데이터 흐름을 관장하게 된다. 따라서 I/O가 발생한 상황에서도 Block되지 않기 때문에 MVC보다 더 적은 양의 쓰레드를 쉴 틈 없이 Concurrent 하게 돌려서 동시 다발로 발생하는 여러 요청들을 처리할 수 있게 된다.

 

 


 

포스팅 요약

- 반응형(Reactive) 프로그래밍이란 Non-Blocking I/O, 비동기를 기반으로 데이터 스트림을 처리하는 패러다임이다.

- 반응형 프로그래밍을 표준화하기 위해 Reactive Streams 명세가 등장했으며 이는 Observer 패턴의 단점을 Iterator 패턴으로 보완하고 Back Pressure 개념을 도입하여 I/O가 발생한 상황에서 subscriber에게 주도권을 부여한다.

- Java에서는 9버전부터 Reactive Streams를 명세한 Flow 인터페이스를 도입했다.

- Flow 인터페이스의 구현체 중 대표적으로 Project Reactor(정확히는 Reactor Core)가 있다.

- Project Reactor는 Reactive Streams 스펙을 준수하는 오픈소스이다.

- Project Reactor에는 다수의 프로젝트가 있으며 그 중에서 Mono, Flux 타입을 정의한 Reactor Core가 대표적이다.

- Project Reactor 중에서 Reactor Netty는 Project Core와 Netty가 합쳐져 만들어진 네트워크 엔진이다.

- Spring은 기존 Blocking I/O 기반인 서블릿 및 WAS를 기반으로 하던 MVC모델에서 Reactor Netty를 기반으로하는 WebFlux 프로젝트를 제공한다.

- 서블릿 3.1에 Non-Blocking I/O 기반의 스펙이 추가되었으나, 기존의 Blokcing I/O 방식의 다른 API들과의 연관성 문제로 인해 서블릿을 사용하지 않고 새로운 Reactor Netty 엔진을 채택하게 되었다.

- Spring MVC는 blocking I/O를 기반으로 하기 때문에 request-per-thread 형태로 동작한다.

- Spring WebFlux는 Non-Blocking I/O 기반의 반응형 모델로, 더 적은 양의 쓰레드를 쉴 틈 없이 돌려 다수의 요청을 Concurrent 하게 처리할 수 있다.

 

 

 

** 잘못된 내용은 알려주시면 감사하겠습니다. **

 

 

 

Reference

- Java 11 docs Flow interface, https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/concurrent/Flow.html

- Baeldung Flux and Mono, https://www.baeldung.com/java-reactor-flux-vs-mono

- Spring docs WebFlux, https://docs.spring.io/spring-framework/reference/web/webflux.html#webflux

- Java Reactive Streams, https://www.reactive-streams.org/

- Spring docs WebFlux Overview, https://docs.spring.io/spring-framework/reference/web/webflux/new-framework.html

- Project Reactor, https://projectreactor.io/

- Mono docs, https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html

- Flux docs, https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Flux.html

- Reactor Netty, https://projectreactor.io/docs/netty/release/reference/index.html#getting-started-introducing-reactor-netty

- Baeldung Spring WebFlux Concurrency, https://www.baeldung.com/spring-webflux-concurrency