Web on Reactive Stack


이 문서는 Netty(이하 네티), Undertow(이하 언더토), 서블릿 3.1+ 컨테이너와 같은 논 블로킹 서버 위에 구동되는, Reactive Streams(이하 리액티브 스트림) API 기반의 리액티브 스택 웹 어플리케이션 지원에 대해 다룬다. 각 챕터는 Spring WebFlux(이하 스프링 웹플럭스) 프레임워크, 리액티브 WebClient(이하 웹클라이언트), 테스팅 그리고 리액티브 라이브러리를 주제로 한다. 서블릿-스택 웹 어플리케이션에 대해서는 Web on Servlet Stack을 보라.

1. 스프링 웹플럭스(Spring WebFlux)

스프링 프레임워크의 오리지널 웹 프레임워크인 스프링 웹 MVC는 서블릿 API와 서블릿 컨테이너를 위한 것이었다. 리액티브 스택 웹 프레임워크인 스프링 웹플럭스는 스프링 버전 5.0 이후로 추가되었다. 스프링 웹플럭스는 완전한 논 블로킹으로, 리액티브 스트림 back pressure(이하 백프레셔)를 지원하며, 네티, 언더토, 서블릿 3.1+ 컨테이너 등등의 서버에서 구동된다.

스프링 웹 MVC와 스프링 웹플럭스 각각은 스프링 프레임워크 안에서 대칭적으로 존재한다(spring-webmvc, spring-webflux 모듈). 각 모듈은 선택적이며, 어플리케이션은 하나, 또는 다른 모듈, 또는 경우에 따라 둘 모두를 동시에 사용할 수 있다 - 예를 들어 스프링 MVC 컨트롤러와 리액티브 웹클라이언트를 함께 사용할 수 있다.

1.1. 개요

스프링 웹플럭스는 왜 탄생했는가?

이 물음에 대한 답 일부는, 논 블로킹 웹 스택이 적은 수의 쓰레드와 보다 적은 하드웨어 자원으로 동시성을 처리하기 위함이다. 서블릿 3.1 에서도 이미 논 블로킹 I/O를 다루기 위한 API를 제공하지만, 이 API를 사용하면 다른 나머지 서블릿 API와는 멀어지게 된다(필터, 서블릿과 같은 동기 방식 처리나 getParameter, getPart 등 블로킹 API). 이런 점이 어떠한 논 블로킹 런타임에서든 기반 역할로 지원하는 새로운 공통 API의 탄생 동기가 되었다. 네티와 같이 비동기, 논 블로킹 영역이 잘 구현된 서버로 인해 이 점은 중요하다.

스프링 웹플럭스의 또다른 탄생 배경은 함수형 프로그래밍이다. 자바 8 에서 추가된, 함수형 API를 위한 람다 표현식은 자바 5 의 어노테이션(어노테이티드 REST 컨트롤러, 단위 테스트)만큼이나 자바 세계에 새로운 기회를 제공한다. 람다 표현식은 비동기 로직을 서술적 구성으로 작성 가능하도록 하는, 논 블로킹 어플리케이션과 continuation-style APIs(CompletableFuture 및 ReactiveX로 보급된)를 위한 유용한 도구이다. 프로그래밍 모델 레벨에서, 자바 8 은 스프링 웹플럭스에서 함수형 웹 엔드포인트를 어노테이티드 컨트롤러와 함께 제공하는 일을 가능하게 한다.

1.1.1. "리액티브" 의 정의(Define "Reactive")

위에서 "논 블로킹" 과 "함수형" 을 언급했다. 그런데 리액티브는 무슨 의미일까?

용어 "리액티브" 는 변경에 대한 반응에 중점을 두어 만들어진 프로그래밍 모델을 가리킨다. 네트워크 컴포넌트는 I/O 이벤트에 반응하며, UI 컨트롤러는 마우스 등과 같은 이벤트에 반응한다. 이 맥락에서, 논 블로킹은 리액티브이다. 왜냐하면 동작을 중단(blocking)하는 대신 명령의 완료 또는 데이터의 제공 등의 알림에 반응하는 방식을 취하기 때문이다.

스프링과 "리액티브" 의 연결과, 논 블로킹 백프레셔 기술에는 또다른 중요한 메커니즘이 있다. 동기 방식, 명령형 코드, 블로킹 호출은 요청자를 대기 상태로 두어 자연스럽게 백프레셔의 형태를 취한다. 논 블로킹 코드에서는 빠른 producer(이하 프로듀서)가 목적지(소비자)를 압도하지 않도록 하기 위해 이벤트의 속도를 제어하는 것이 중요하다.

리액티브 스트림은 자바 9 에서 채택된 작은 스펙이다. 리액티브 스트림은 백프레셔를 통해 비동기 컴포넌트들 사이의 비동기적 상호 작용을 정의한다. 예를 들어, 데이터 저장소(Publisher(이하 발행자) 역할)는 HTTP 서버(Subscriber(이하 구독자))가 응답에 쓰기 위한 위한 데이터를 생성한다. 리액티브 스트림의 주안점은 구독자로 하여금 발행자가 데이터를 얼마나 빠르게, 혹은 얼마나 천천히 생성할지 제어할 수 있게 한다는 데에 있다.

# 공통 질문: 발행자를 늦출 수 없으면 어떻게 되는가? 리액티브 스트림의 목적은 오직 그 메커니즘과 경계선을 확립하는 것이다. 발행자를 늦출 수 없다면 버퍼의 사용, 드랍 또는 실패 등을 결정해야 한다.

1.1.2. 리액티브 API(Reactive API)

리액티브 스트림은 시스템의 상호 정보 교환에 있어 중요한 역할을 한다. 이는 라이브러리와 인프라스트럭처 컴포넌트에는 흥미로운 점이지만, 어플리케이션 API에는 상대적으로 유용하지 못하다. 왜냐하면 이는 너무 로우 레벨이기 때문이다. 어플리케이션은 비동기 로직을 작성하기 위해서 보다 고수준의, 풍부한 함수형 API를 필요로 한다. 자바 8 의 스트림 API와 유사하지만 컬렉션에 국한되지 않는다. 이것이 리액티브 라이브러리의 역할이다.

Reactor(이하 리액터)는 스프링 웹플럭스가 채택한 리액티브 라이브러리이다. 리액터는 ReactiveX와 함께하는 풍부한 연산자를 통해 0..1(Mono) 와 0..N(Flux) 방식 API를 제공한다. 리액터는 리액티브 스트림 라이브러리다. 따라서 리액터의 연산자는 논 블로킹 백프레셔를 지원하며, 특히 서버 사이드 자바에 집중한다. 그리고 스프링과 긴밀하게 협업하여 개발되었다.

웹플럭스는 리액터에 핵심적인 의존성을 가지지만, 리액티브 스트림을 통해 다른 리액티브 라이브러리들과도 상호 운용이 가능하다. 일반적으로 웹플럭스 API는 플레인 Publisher를 인풋으로 받고, 내부적으로 이를 리액터 타입으로 맞추어 적용하고, 사용하고, Flux 또는 Mono를 아웃풋으로 반환한다. 때문에 어떠한 Publisher든 인풋으로 전달하여 아웃풋에 대한 연산을 적용할 수 있지만, 또다른 리액티브 라이브러리 사용을 위해 아웃풋을 형식에 맞추어야 한다. 웹플럭스는 언제든지 필요에 따라서(어노테이티드 컨트롤러 등) RxJava 또는 다른 리액티브 라이브러리에 쉽게 적용될 수 있다. 이에 관한 더 자세한 내용은 리액티브 라이브러리를 보라.

# 리액티브 API에 더하여, 웹플럭스는 코틀린의 Coroutines(이하 코루틴) API 와도 함께 사용될 수 있다. 코루틴은 보다 명령형의 프로그래밍을 제공한다. 앞으로의 코틀린 코드 샘플은 코루틴 API와 함께할 것이다.

1.1.3. 프로그래밍 모델(Programming Models)

spring-web 모듈은 스프링 웹플럭스의 근간이 되는 리액티브의 기반을 포함하며, HTTP 추상화, 지원되는 서버를 위한 리액티브 스트림 어댑터, 코덱, 그리고 서블릿 API와 유사하면서 논 블로킹 계약을 포함하는 핵심 WebHandler API를 포함한다.

이를 토대로 스프링 웹플럭스는 프로그래밍 모델에 있어 두 가지 선택지를 제공한다.

  • 어노테이티드 컨트롤러: 스프링 MVC와 일치하며, spring-web 모듈과 동일한 어노테이션을 기반으로 구성되었다. 스프링 MVC와 웹플럭스 컨트롤러는 리액티브(리액터, RxJava) 반환 타입을 지원하며, 결과적으로 이 둘을 구분하기가 쉽지 않게 되었다. 주목할만한 차이점은 웹플럭스는 리액티브 @RequestBody 아규먼트를 지원한다는 것이다.
  • 함수형 엔드포인트: 람다 기반의 경량 함수형 프로그래밍 모델. 소형 라이브러리, 혹은 요청을 라우팅하고 핸들링하기 위해 어플리케이션이 사용할 수 있는 유틸리티의 모음이라고 할 수 있다. 어노테이티드 컨트롤러와의 큰 차이점은, 어플리케이션이 요청 핸들링의 시작부터 끝까지 책임지느냐 vs 어노테이션을 통해 의사를 표시하고 콜백을 받느냐이다.


1.1.4. 적용 영역(Applicability)

스프링 MVC냐, 웹플럭스냐?

이 질문은 자연스럽지만 적절하지 못한 이분법이라 할 수 있다. 실제로 이 둘은 사용 가능한 옵션의 범위를 확장하기 위해 함께 사용될 수 있다. 이 둘은 서로간의 지속성과 일관성을 지향하도록 설계되었다. 나란히 함께 사용될 수 있으며, 서로가 서로에게 응답과 이점을 주고 받을 수 있다. 아래 다이어그램은 이 둘이 어떻게 연관되어 있는지 보여준다. 둘이 공통적으로 지닌 것, 한 쪽만 지니고 있는 것을 보여준다.

spring mvc and webflux venn

다음 사항에 주목하기 바란다:
  • 잘 작동 중인 기존 스프링 MVC 어플리케이션이 있다면, 변경할 필요가 없다. 명령형 프로그래밍은 작성하고 이해하고 디버깅 하기에 가장 쉬운 방법이며, 라이브러리 선택에 있어 최대한의 선택지를 가지게 된다. 대부분 블로킹 방식이기 때문에.
  • 이미 논 블로킹 웹 스택을 찾고 있다면, 스프링 웹플럭스는 다른 웹 스택과 동일한 실행 모델이라는 이점과 함께, 서버에 있어서의 선택지(네티, 톰캣, 제티, 언더토, 서블릿 3.1+ 컨테이너), 프로그래밍 모델에 있어서의 선택지(어노테이티드 컨트롤러, 함수형 웹 엔드포인트), 리액티브 라이브러리에 있어서의 선택지(리액터, RxJava 또는 그 외)를 제공한다.
  • 자바 8 람다 또는 코틀린과 함께 사용할 경량 함수형 웹 프레임워크를 원한다면, 스프링 웹플럭스 함수형 웹 엔드포인트를 사용할 수 있다. 또한 더 작은 어플리케이션이나, 더 훌륭한 명료성과 제어(control)라는 이점을 보다 적은 복잡도로 제공하는 마이크로서비스에도 스프링 웹플럭스는 좋은 선택지가 된다.
  • 마이크로서비스 아케텍처에서 스프링 MVC 또는 스프링 웹플럭스 컨트롤러 또는 스프링 웹플럭스 함수형 엔드포인트 각각으로 만들어진 서로 다른 어플리케이션을 혼합하여 사용할 수 있다. 이 두 프레임워크에가 동일한 어노테이션 기반 프로그래밍 모델을 지원한다는 점은 올바른 도구를 적재적소에 사용하는 동시에 기존 지식을 재사용하기 쉽게 만들어준다.
  • 어플리케이션을 평가하는 간단한 방법은 어플리케이션의 의존성을 확인하는 것이다. 블로킹 영속성 API(JPA, JDBC) 또는 네트워킹 API를 사용하고 있다면, 스프링 MVC는 적어도 공통 아키텍처에 있어서는 최고의 선택이 된다. 스프링 MVC는 기술적으로 개별 쓰레드에 대해 리액터와 RxJava를 사용하여 블로킹 호출을 수행하는 것이 가능하지만, 논 블로킹 웹 스택을 최대한 활용하지는 못한다.
  • 기존 스프링 MVC어플리케이션이 원격 서비스 호출(remoting)을 수행한다면, 리액티브 WebClient(이하 웹클라이언트)를 사용해보라. 스프링 MVC 컨트롤러 메서드로부터 리액티브 타입(Reactor, RxJava, 기타 등)을 반환값으로 직접 얻을 수 있다. 호출 당, 혹은 호출 간 상호 작용의 지연이 클수록, 더욱 드라마틱한 이점을 얻을 수 있다. 스프링 MVC 컨트롤러는 다른 리액티브 컴포넌트를 똑같이 호출할 수 있다.
  • 팀의 규모가 크다면, 논 블로킹, 함수형, 선언적 프로그래밍으로의 전환에 따르는 학습 곡선이 가파르다는 점을 유념해야 한다. 전체를 바꾸지 않고 시작하는 실용적인 방법은 리액티브 웹클라이언트를 사용하는 것이다. 이걸 넘어서면 작은 것부터 시작해보고, 이로부터 얻은 장점을 측정해보라. 대부분의 어플리케이션의 경우 이 전환이 필수적이지는 않다고 본다. 얻고자 하는 장점이 무엇인지 불확실하다면, 논 블로킹 I/O가 어떻게 작동하는지, 그리고 이것의 효과가 무엇인지 배우는 것에서 시작하도록 한다(예로, 싱글 쓰레드 Node.js의 동시성 처리가 있다).


1.1.5. 서버(Servers)

스프링 웹플럭스는 톰캣, 제티, 서블릿 3.1+ 컨테이너뿐만 아니라 네티, 언더토와 같은 논 서블릿 런타임에서도 지원된다. 다양한 서버에 고수준 프로그래밍 모델을 지원하기 위해, 모든 서버에는 로우 레벨 공통 API가 적용되어 있다.

스프링 웹플럭스에는 서버를 시작하고 정지하기 위한 내장형 기능은 없다. 하지만 스프링 설정과 웹플럭스 인프라스트럭처로 어플리케이션을 조립하기란 어렵지 않은 일이다. 그리고 단 몇 줄의 코드만으로 이 어플리케이션을 구동할 수 있다.

스프링 부트는 웹플럭스 스타터를 내장하고 있다. 웹플럭스 스타터는 이 과정을 자동화한다. 스타터는 기본 설정으로 네티를 사용하지만, 메이븐이나 그레들을 이용한 의존성 변경을 통해서 톰캣, 제티, 언더토 등 다른 서버를 사용하도록 쉽게 변경할 수 있다. 스프링 부트가 기본 설정으로 네티를 사용하는 이유는, 네티는 비동기, 논 블로킹 영역에서 폭넓게 사용되며 클라이언트과 서버가 자원을 공유하도록 하기 때문이다.

톰캣과 제티는 스프링 MVC와 웹플럭스 모두와 함께 사용할 수 있다. 그러나 이 둘은 그 작동 방식이 매우 다르다는 점을 유의해야 한다. 스프링 MVC는 서블릿 블로킹 I/O에 기반을 두며, 필요에 따라 어플리케이션이 서블릿 API를 직접 사용하도록 한다. 스프링 웹플럭스는 서블릿 3.1 논 블로킹 I/O에 기반을 두며, 로우 레벨 어댑터 뒷단에서 서블릿 API를 사용하며 이를 직접적으로 노출하지 않는다.

언더토의 경우 스프링 웹플럭스는 서블릿 API가 아닌 언더토 API를 직접 사용한다.

1.1.6. 퍼포먼스(Performance)

퍼포먼스는 많은 의미를 내포하고 있다. 리액티브와 논 블로킹은 어플리케이션을 더 빠르게 만들어주지 않는다. 몇몇 경우에 한하여 더 빨라질 수는 있다(예로, 병렬로 웹클라이언트를 사용하여 원격 호출을 실행할 때). 대체로 논 블로킹 방식은 더 많은 작업량을 필요로 하며, 이는 요청 처리 시간을 약간 늘어나게 할 수 있다.

리액티브와 논 블로킹을 사용할 때의 중요한 이점은 적고 고정된 수의 쓰레드와 보다 적은 메모리를 사용하도록 조정할 수 있는 능력에 있다. 이는 어플리케이션이 부하에 대해 더 탄력적으로 동작할 수 있도록 한다. 왜냐하면 보다 예측할 수 있는 방법으로 조정되기 때문이다. 하지만 이 스케일링을 관측하기 위해서는 약간의 지연을 필요로 한다(느리고 예측 불가능한 네트워크 I/O의 혼재로 인해). 여기서 리액티브 스택의 장점을 볼 수 있으며, 그 차이가 드라마틱하게 드러나는 지점이다.

1.1.7. 동시성 모델(Concurrency Model)

스프링 MVC와 스프링 웹플럭스는 모두 어노테이티드 컨트롤러를 지원하지만, 동시성 모델 및 블로킹과 쓰레드에 대한 기본적인 상정에 중요한 차이가 있다.

스프링 MVC(그리고 일반적인 서블릿 어플리케이션)에서는 어플리케이션은 현재 쓰레드가 블로킹될 것을 상정한다(예로, 원격 호출에 대하여). 그리고 이로 인하여 서블릿 컨테이너는 요청을 핸들링하는 동안 발생할 수 있는 잠재적인 블로킹에 대비하기 위해 큰 수의 쓰레드 풀을 사용하게 된다.

스프링 웹플럭스(그리고 일반적인 논 블로킹 서버에서)에서는 어플리케이션은 쓰레드를 블로킹 하지 않을 것을 상정한다. 따라서 논 블로킹 서버는 적고 고정된 크기의 쓰레드 풀을 사용하여 요청을 처리한다(이벤트 루프 워커).

# "스케일링" 과 "적은 수의 쓰레드" 는 모순으로 보일 수 있지만, 현재 쓰레드가 절대 블로킹되지 않는다는 것은 블로킹 호출을 받아들일 추가 쓰레드가 필요하지 않다는 의미가 된다.

블로킹 API 실행하기

블로킹 라이브러리를 사용해야 한다면? 리액터와 RxJava는 publishOn 연산자를 제공하여 다른 쓰레드가 처리하도록 한다. 이는 쉬운 대안이 될 수 있지만 블로킹 API는 이 동시성 모델에 잘 어울리지 않음을 유념해야 한다.

가변 상태(Mutable State)

리액터와 RxJava에서는 연산자를 통해서 로직을 선언한다. 그리고 런타임에 데이터가 순차적으로, 뚜렷한 단계로 처리되는 곳에서 리액티브 파이프라인이 형성된다. 여기서의 중요한 이점은 어플리케이션이 가변 상태를 보호할 필요가 없다는 점이다. 왜냐하면 이 파이프라인 안의 어플리케이션 코드는 절대로 동시에 실행되지 않기 때문이다.

쓰레딩 모델

스프링 웹플럭스로 구동되는 서버에서는 어떤 쓰레드를 볼 수 있는가?
  • 순수 스프링 웹플럭스 서버(예로, 데이터 접근이나 다른 선택적인 의존성이 존재하지 않는)에서는, 서버를 위한 쓰레드 하나, 요청을 처리하기 위한 쓰레드 여럿을 예상할 수 있다(보통 CPU 코어의 수가 쓰레드의 수가 된다). 그러나 서블릿 컨테이너의 경우, 서블릿 블로킹 I/O와 서블릿 3.1 논 블로킹 I/O를 모두 지원하기 위해 더 많은 수의 쓰레드가 사용될 수 있다(톰캣에서는 10개).
  • 이벤트 루프 방식의 리액티브 웹클라이언트 연산자. 적고 고정된 수의 요청 처리 쓰레드가 사용된다(예로, 리액터 네티 커넥터와 사용되는 reactor-http-nio-). 리액터 네티가 클라이언트와 서버 모두에서 쓰인다면, 이 둘은 기본적으로 이벤트 루프 자원을 공유한다.
  • 리액터와 RxJava는 Schedulers(이하 스케쥴러)라는 쓰레드 풀 추상화를 제공하여 publishOn 연산자와 함께 사용하며 다른 쓰레드 풀으로 처리를 전환한다. 스케쥴러는 특정한 동시성 전략을 제안한다. -예로, "parallel"(CPU 바운드 동작에는 제한된 수의 쓰레드 사용), 또는 "elastic"(I/O 바운드 동작에는 큰 수의 쓰레드 사용). 이러한 쓰레드를 본다는 것은 프로그램 코드가 특정 스케쥴러 전략을 사용하고 있음을 의미한다.
  • 데이터 접근 라이브러리와 써드파티 의존성은 각자 스스로의 쓰레드를 생성하고 사용할 수 있다.


설정하기

스프링 프레임워크는 서버를 시작하고 정지하는 기능을 제공하지 않는다. 서버의 쓰레딩 모델을 설정하기 위해서는 서버에 특화된 설정 API를 사용해야 한다. 스프링 부트를 사용한다면 스프링 부트의 서버 옵션을 확인하고, 웹클라이언트는 직접 설정 가능하다. 다른 라이브러리에 대해서는 각각의 레퍼런스 문서를 참조하도록 한다.

1.2. 리액티브 코어(Reactive Core)

spring-web 모듈은 리액티브 웹 어플리케이션 지원을 위한 다음의 제반 사항을 포함한다:
  • 서버의 요청 처리에 대해서는 두 가지 레벨의 지원이 있다.
    • HttpHandler: HTTP 요청 핸들링을 위한 논 블로킹 I/O와 리액티브 스트림 기반의 기본 핸들러. 리액터 네티, 언더토, 톰캣, 제티, 서블릿 3.1+ 컨테이너를 지원하는 어댑터와 함께 작동한다.
    • WebHandler API: 요청 처리를 위한 좀 더 고수준의, 다용도 웹 API. 어노테이티드 컨트롤러와 함수형 엔드포인트와 같은 구체적인 프로그래밍 모델 위에 존재한다.
  • 클라이언트 사이드에는 논 블로킹 I/O, 리액티브 스트림 백프레셔로 HTTP 요청을 수행하기 위한 기본 ClientHttpConnector 계약이 있다. 리액터 네티 및 리액티브 제티 HttpClient를 위한 어댑터를 사용해 작동한다. 여기에는 더 고수준의 웹클라이언트가 사용된다.
  • 클라이언트와 서버는 HTTP 요청과 응답 컨텐츠를 시리얼라이징/디시리얼라이징하기 위해 코덱을 사용한다.


1.2.1. HttpHandler

HttpHandler는 요청과 응답을 처리하는 싱글 메서드를 가진 단순한 계약이다. 의도적으로 작게 설계되었으며, 이것의 목적은 오로지 각기 다른 HTTP 서버 API에 대응하는 최소형의 추상화이다.

다음 테이블은 지원되는 서버 API에 대해 기술한다:

서버 사용되는 서버 API 리액티브 스트림 지원
네티 네티 API 리액터 네티
언더토 언더토 API spring-web: 언더토 to 리액티브 스트림 브릿지
톰캣 서블릿3.1 논 블로킹 I/O; byte[]에 대응하여 ByteBuffer를 읽고 쓰는 톰캣 API spring-web: 서블릿3.1 논 블로킹 I/O to 리액티브 스트림 브릿지
제티 서블릿3.1 논 블로킹 I/O; byte[]에 대응하여 ByteBuffer를 읽고 쓰는 제티 API spring-web: 서블릿3.1 논 블로킹 I/O to 리액티브 스트림 브릿지
서블릿3.1 컨테이너 서블릿3.1 논 블로킹 I/O spring-web: 서블릿3.1 논 블로킹 I/O to 리액티브 스트림 브릿지


다음 테이블은 서버 의존성에 대해 기술한다(지원되는 버전):

서버 그룹 아티팩트
리액터 네티 io.projectreactor.netty reactor-netty
언더토 io.undertow undertow-core
톰캣 org.apache.tomcat.embed tomcat-embed-core
제티 org.eclipse.jetty jetty-server, jetty-servlet


아래 코드 스니펫은 각 서버 API로 HttpHandler 어댑터를 사용하는 예제이다:

리액터 네티

Java
HttpHandler handler = ...
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(handler);
HttpServer.create().host(host).port(port).handle(adapter).bind().block();
Kotlin
val handler: HttpHandler = ...
val adapter = ReactorHttpHandlerAdapter(handler)
HttpServer.create().host(host).port(port).handle(adapter).bind().block()


언더토

Java
HttpHandler handler = ...
UndertowHttpHandlerAdapter adapter = new UndertowHttpHandlerAdapter(handler);
Undertow server = Undertow.builder().addHttpListener(port, host).setHandler(adapter).build();
server.start();
Kotlin
val handler: HttpHandler = ...
val adapter = UndertowHttpHandlerAdapter(handler)
val server = Undertow.builder().addHttpListener(port, host).setHandler(adapter).build()
server.start()


톰캣

Java
HttpHandler handler = ...
Servlet servlet = new TomcatHttpHandlerAdapter(handler);

Tomcat server = new Tomcat();
File base = new File(System.getProperty("java.io.tmpdir"));
Context rootContext = server.addContext("", base.getAbsolutePath());
Tomcat.addServlet(rootContext, "main", servlet);
rootContext.addServletMappingDecoded("/", "main");
server.setHost(host);
server.setPort(port);
server.start();
Kotlin
val handler: HttpHandler = ...
val servlet = TomcatHttpHandlerAdapter(handler)

val server = Tomcat()
val base = File(System.getProperty("java.io.tmpdir"))
val rootContext = server.addContext("", base.absolutePath)
Tomcat.addServlet(rootContext, "main", servlet)
rootContext.addServletMappingDecoded("/", "main")
server.host = host
server.setPort(port)
server.start()


제티

Java
HttpHandler handler = ...
Servlet servlet = new JettyHttpHandlerAdapter(handler);

Server server = new Server();
ServletContextHandler contextHandler = new ServletContextHandler(server, "");
contextHandler.addServlet(new ServletHolder(servlet), "/");
contextHandler.start();

ServerConnector connector = new ServerConnector(server);
connector.setHost(host);
connector.setPort(port);
server.addConnector(connector);
server.start();
Kotlin
val handler: HttpHandler = ...
val servlet = JettyHttpHandlerAdapter(handler)

val server = Server()
val contextHandler = ServletContextHandler(server, "")
contextHandler.addServlet(ServletHolder(servlet), "/")
contextHandler.start();

val connector = ServerConnector(server)
connector.host = host
connector.port = port
server.addConnector(connector)
server.start()


서블릿 3.1+ 컨테이너

어플리케이션을 WAR로 서블릿 3.1+ 컨테이너에 배포하려면 AbstractReactiveWebInitializer를 확장하여 WAR 안에 포함해야 한다. 이 클래스는 HttpHandler를 ServletHttpHandlerAdapter로 래핑하고 이를 서블릿으로 등록한다.

1.2.2. WebHandler API

다수의 WebExceptionHandlerWebFilter, 단일 WebHandler 컴포넌트를 통해 요청을 처리하는 다목적 웹 API 제공을 위해, org.springframework.web.server 패키지는 HttpHandler를 기반으로 한다. 이 컴포넌트들은 스프링 ApplicationContext로 참조되어 WebHttpHandlerBuilder와 함께 자동으로 감지되거나 등록되어 사용된다.

HttpHandler의 목적은 서로 다른 HTTP 서버에서의 사용을 위한 추상화에 있기에, WebHandler API는 웹 어플리케이션에서 일반적으로 사용되는 기능들보다 더 많은 기능을 제공한다. 웹 어플리케이션에서 일반적으로 사용되는 기능들은 다음과 같다:
  • 사용자 세션(세션 어트리뷰트)
  • 리퀘스트 어트리뷰트
  • 요청에 대한 리졸빙된 Locale or Principal
  • 파싱되고 캐싱된 폼 데이터 접근
  • 멀티파트 데이터 추상화
  • 기타 등등..


스페셜 빈 타입



아래 테이블은 스프링 ApplicationContext에서 자동으로 감지되는, 혹은 직접 등록되는, WebHttpHandlerBuilder가 참조하는 컴포넌트 목록이다:

빈 이름 빈 타입 카운트 설명
<any> WebExceptionHandler 0..N WebFilter 인스턴스들과 타겟 WebHandler에서 발생한 익셉션에 대한 핸들링을 제공한다. 자세한 내용은 Exceptions를 참조한다.
<any> WebFilter 0..N 인터셉터 스타일 로직을 적용하여 타겟 WebHandler의 전/후 동작을 담당한다. 자세한 내용은 Filters를 참조한다.
webHandler WebHandler 1 요청을 처리한다.
webSessionManager WebSessionManager 0..N ServerWebExchange의 메서드를 통해 노출된 WebSession 인스턴스를 관리한다. 디폴트는 DefaultWebSessionManager.
serverCodecConfigurer ServerCodecConfigurer 0..1 HttpMessageReader로 접근하여 ServerWebExchange의 메서드를 통해 노출된 폼 데이터와 멀티파트 데이터를 파싱하는 컴포넌트. 디폴트는 ServerCodecConfiguter.create()
localeContextResolver LocaleContextResolver 0..1 ServerWebExchange의 메서드를 통해 노출된 LocaleContext에 대한 리졸버. 디폴트는 AcceptHeaderLocaleContextResolver.
forwardedHeaderTransformer ForwardedHeaderTransformer 0..1 포워드 타입 헤더를 처리하기 위한 컴포넌트. 각 헤더는 추출과 제거 or 제거 only. 디폴트는 사용하지 않음.


폼 데이터



ServerWebExchange는 폼 데이터로의 접근을 위해 다음 메서드를 노출한다.

Java
Mono<MultiValueMap<String, String>> getFormData();
Kotlin
suspend fun getFormData(): MultiValueMap<String, String>


DefaultServerWebExchange는 설정된 HttpMessageReader를 사용하여 폼 데이터(application/x-www-form-urlencoded)를 MultiValueMap으로 파싱한다. 디폴트로 FormHttpMessageReader가 설정되어 ServerCodecConfigurer 빈이 이를 사용한다(Web Handler API를 보라).

멀티파트 데이터



ServerWebExchange는 멀티파트 데이터로의 접근을 위해 다음 메서드를 노출한다.

Java
Mono<MultiValueMap<String, Part>> getMultipartData();
Kotlin
suspend fun getMultipartData(): MultiValueMap<String, Part>


DefaultServerWebExchange는 설정된 HttpMessageReader<MultiValueMap<String, Part>>를 사용하여 multipart/form-data 내용을 MultiValueMap으로 파싱한다. 현재는 동기식 NIO 멀티파트가 유일하게 지원되는 써드파티 라이브러리이며, 멀티파트 요청을 논 블로킹으로 파싱한다. ServerCodeConfigurer 빈을 통해 활성화된다(Web Handler API 참조).

멀티파트 데이터를 스트리밍 방식으로 파싱하기 위해서는 HttpMessageReader가 반환하는 Flux<Part>를 사용한다. 예를 들어, 어노테이티드 컨트롤러에서 @RequestPart를 사용함은 Map 방식의, name을 사용한 개별 파트로의 접근을 암시한다. 따라서 이는 멀티파트 데이터 전체를 파싱해야 한다. 반면 @RequestBody를 사용하면 데이터를 MultiValueMap으로 모으지 않고 Flux<Part>로 디코딩할 수 있다.

포워디드 헤더(Forwareded Headers)



사용자 요청이 프록시를 통해 전송되면(예: 로드밸런서), 호스트, 포트, 스킴이 변경될 수 있고, 이는 클라이언트의 관점에서 정확한 호스트, 포트, 스킴으로의 링크를 생성하는 일을 방해한다.

RFC 7239는 Forwarded(이하 포워디드) HTTP 헤더를 정의한다. 이 헤더는 프록시가 원 요청 정보를 제공하기 위해 사용할 수 있다. 그리고 같은 목적으로 사용 가능한 X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Proto, X-Forwarded-Ssl, X-Forwarded-Prefix와 같은 비표준 헤더들이도 있다.

ForwardedHeaderTransformer는 포워디드 헤더를 기반으로 요청의 호스트, 포트, 스킴을 변경하고 제거하는 컴포넌트이다. forwardedHeaderTransformer 빈으로 등록하면 감지되어 사용된다.

어플리케이션은 헤더가 프록시 혹은 악의적인 클라이언트에 의해 의도적으로 추가된 것인지 알 수 없기 때문에, 여기에 대한 보안 고려사항이 있다. 이것이 프록시가 신뢰의 경계에서 외부로부터 전송된 신뢰할 수 없는 트래픽을 제거하도록 설정되어야 하는 이유이다. ForwardedHeaderTransformer로 removeOnly=true 설정을 함으로써 헤더를 사용하지 않고 제거할 수 있다.

#ForwardedHeaderFilter는 버전 5.1 에서 deprecated 되면서 ForwardedHeaderTransformer로 대체되었다. 때문에 포워디드 헤더는 exchange의 생성 전에 먼저 처리될 수 있다. 필터가 설정되어 있다면 필터는 필터 목록에서 빠지고 대신 ForwardedHeaderTransformer가 사용된다.

1.2.3. 필터

WebHandler API에서는 WebFilter를 사용하여 WebHandler의 전/후처리 로직을 필터 체이닝 방식으로 적용할 수 있다. WebFlux Config를 사용하면 WebFilter를 등록하는 일은 스프링 빈을 등록하는 일만큼 간단하다. 그리고 빈 선언부에 @Order를 사용하거나 Ordered인터페이스를 구현하여 우선순위를 설정할 수 있다.

CORS



스프링 웹플럭스는 컨트롤러의 어노테이션을 통해 CORS설정을 잘 지원하기 위한 도구를 제공한다. 그러나 스프링 시큐리티와 함께 사용할 때는 내장된 CorsFilter를 사용할 것을 권한다. 이 필터는 반드시 스프링 시큐리티의 필터 체인보다 앞단에 적용되어야 한다.

더 자세한 정보는 CORSCORS WebFilter를 보라.

1.2.4. 익셉션(Exceptions)

WebHandler API에서는 WebExceptionHandler를 사용하여 WebFilter와 WebHandler에서 발생한 익셉션을 처리할 수 있다. WebFlux Config를 사용하면 WebExceptionHandler를 등록하는 일은 스프링 빈을 등록하는 일만큼 간단하다. 그리고 빈 선언부에 @Order를 사용하거나 Ordered인터페이스를 구현하여 우선순위를 설정할 수 있다.

다음 테이블은 사용할 수 있는 WebExceptionHandler구현체에 대해 기술한다:

익셉션 핸들러 설명
ResponseStatusExceptionHandler ResponseStatusException 타입 익셉션을 처리한다. 익셉션에 있는 HTTP 상태 코드를 응답에 세팅한다.
WebFluxResponseStatusExceptionHandler ResponseStatusExceptionHandler의 확장판. 익셉션 타입에 상관없이 @ResponseStatus의 HTTP 상태 코드를 결정한다. 이 핸들러는 WebFlux Config에서 선언한다.


1.2.5. 코덱

spring-web과 spring-core 모듈은 리액티브 스트림 백프레셔와 논 블로킹 I/O를 통해서 바이트 컨텐츠와 고수준 객체 사이의 시리얼라이징/디시리얼라이징을 지원한다.

  • EncoderDecoder는 HTTP와 독립적으로 콘텐츠를 인코딩/디코딩하기 위한 클래스이다.
  • HttpMessageReaderHttpMessageWriter는 HTTP 메시지 콘텐츠를 인코딩/디코딩하기 위한 클래스이다.
  • EncoderHttpMessageWriter는 Encoder를 래핑하여 웹 어플리케이션에 사용하고, 같은 이유로 DecoderHttpMessageReader는 Decoder를 래핑한다.
  • DataBuffer는 서로 다른 바이트버퍼들을 추상화한다.(네티의 ByteBuf, java.nio.ByteBuffer, 기타 등등) 그리고 모든 코덱은 여기서 작동한다. 이에 대해서는 "스프링 코어" 섹션의 Data Buffers and Codecs에서 더 알아볼 수 있다.


spring-core 모듈은 byte[], ByteBuffer, DataBuffer, Resource, String 인코더와 디코더 구현체를 제공한다. spring-web 모듈은 Jackson JSON, Jackson Smile, JAXB2, Protocol Buffers 그리고 기타 다른 인코더와 디코더를 제공한다. 이 인코더와 디코더는 폼 데이터, 멀티파트 컨텐츠, 서버 전송 이벤트 및 기타 웹 요청 처리를 위한 웹 전용 HTTP 메시지 reader와 writer 구현체와 함께한다.

ClientCodecConfigurer와 ServerCodecConfigurer는 어플리케이션에서의 코덱 사용 설정 및 커스터마이징을 위해 일반적으로 사용되는 클래스이다. 이 설정에 대해서는 HTTP message codecs에서 다룬다.

Jackson JSON



Jackson(이하 잭슨)라이브러리가 존재할 경우 JSON 과 바이너리 JSON(Smile) 이 지원된다.

Jackson2Decoder는 다음과 같이 동작한다:
  • 잭슨의 비동기, 논 블로킹 파서는 바이트 청크 스트림을 각각 JSON 객체를 나타내는 TokenBuffer로 종합하기 위해 사용된다.
  • 단일 값 퍼블리셔(Mono)를 디코딩하는 경우, 하나의 TokenBuffer가 존재한다.
  • 다수 값 퍼블리셔(Flux)를 디코딩하는 경우, 각 TokenBuffer는 완전하게 포맷팅된 객체가 되기 충분한 바이트를 받은 시점에 ObjectMapper에게 전달된다. 인풋 컨텐츠는 JSON 배열이 될 수 있고, 컨텐츠 타입이 "application/stream+json"인 경우 line-delimited JSON이 될 수 있다.
  • SSE의 경우 Jackson2Encoder는 이벤트마다 실행되며 그 아웃풋은 지연 없이 플러싱된다.


Jackson2Encoder는 다음과 같이 동작한다:
  • 단일 값 퍼블리셔(Mono)의 경우, 이를 간단히 ObjectMapper로 시리얼라이징한다.
  • 다수 값 "application/json" 퍼블리셔의 경우, 기본적으로 값들을 Flux#collectToList() 로 모은 뒤 그 결과를 시리얼라이징한다.
  • 다수 값 스트리밍 미디어 타입(예로, application/stream+json 또는 appliction/stream+x-jackson-smile) 퍼블리셔의 경우, line-delimited JSON 포맷을 사용하여 각 값을 독립적으로 인코딩하고, 쓰고, 플러싱한다.
  • SSE의 경우 Jackson2Encoder는 이벤트마다 실행되며 그 아웃풋은 지연 없이 플러싱된다.


#Jackson2Encoder와 Jackson2Decoder는 기본적으로 String 타입 요소를 지원하지 않는다. 대신 시리얼라이징된 JSON 컨텐츠는 한 문자 혹은 일련의 문자로 구성되어 있을 것을 상정하여 CharSequenceEncoder로 렌더링한다. Flux<String>으로 JSON 배열을 렌더링해야 한다면, Flux#collectToList()를 사용하고 Mono<List<String>>를 인코딩하라.

폼 데이터



"application/x-www-form-urlencoded" 컨텐츠의 인코딩 및 디코딩은 FormHttpMessageReader와 FormHttpMessageWriter가 처리한다.

다양한 곳으로부터의 폼 컨텐츠 접근이 자주 이루어지는 서버에서는 ServerWebExchange가 제공하는 getFormData() 메서드로 컨텐츠를 파싱한다. 이 파싱 작업은 FormHttpMessageReader를 통해 이루어지며, 반복되는 접근을 위해 파싱 결과를 캐싱한다. WebHandler API 섹션의 Form Data를 보라.

getFormData() 가 한 번 호출되면, 요청 본문에서 원본 컨텐츠는 더이상 읽을 수 없다. 이런 이유로 어플리케이션은 요청 본문에서 원본 컨텐츠를 읽는 대신 ServerWebExchange를 통해 캐싱된 폼 데이터로 접근하도록 한다.

멀티파트



"multipart/form-data" 컨텐츠의 인코딩 및 디코딩은 MultipartHttpMessageReader와 MultipartHttpMessageWriter가 처리한다. 결국 MultipartHttpMessageReader는 Flux<Part> 로의 실제 파싱 작업은 HttpMessageReader에게 위임하고, 간단히 그 결과를 MultiValueMap으로 모으기만 한다. 현재는 동기식 NIO 멀티파트가 실제 파싱에 사용된다.

다양한 곳으로부터의 멀티파트 컨텐츠 접근이 자주 이루어지는 서버에서는 ServerWebExchange가 제공하는 getMultipartData() 메서드로 컨텐츠를 파싱한다. 이 파싱 작업은 MultipartHttpMessageReader를 통해 이루어지며, 반복되는 접근을 위해 파싱 결과를 캐싱한다. WebHandler API 섹션의 Multipart Data를 보라.

getMultipartData() 가 한 번 호출되면, 요청 본문에서 원본 컨텐츠는 더이상 읽을 수 없다. 이런 이유로 어플리케이션은 반복적인 파트에의 맵 방식 접근에 대해서 getMultipartData()를 지속적으로 호출해야 하고, Flux<Part> 로의 한 번의 접근에 대해서는 SynchronossPartHttpMessageReader를 사용한다.

제한



Decoder와 HttpMessageReader 구현체는 인풋스트림을 버퍼링한다. 설정을 통해 메모리의 버퍼 바이트 사이즈의 최대치를 지정할 수 있다. 인풋 버퍼링이 발생할 수 있는 몇 가지 경우가 있다. 예를 들어 @RequestBody byte[] 나 x-www-form-urlencoded 데이터 및 기타 등등의 방식으로 데이터를 다루는 컨트롤러 메서드와 같이 인풋이 합쳐지고 하나의 객체로 나타나는 경우 버퍼링이 발생한다. 또한 스트리밍에서 인풋스트림을 분리할 때 버퍼링은 발생할 수 있다. 예를 들어 제한되지 않은 텍스트, JSON 객체 스트림 및 기타 등등의 경우가 있다. 이런 스트리밍의 경우 버퍼 바이트 사이즈 제한은 스트림 안의 하나의 객체에 연관되어 적용된다.

버퍼 사이즈를 설정하기 위해서는, 주어진 Decoder 또는 HttpMessageReader의 maxInMemorySize 프로퍼티 설정이 가능한지 확인하고, 가능하다면 관련 자바독에 기본값에 대한 상세 정보가 있을 것이다. 웹플럭스에는 ServerCodecConfigurer가 기본 코덱의 maxInMemorySize 프로퍼티를 통해 모든 코덱을 설정할 수 있는 단일한 위치를 제공한다. 클라이언트 단에서는 WebClientBuilder로 이 사이즈 제한을 변경할 수 있다.

maxInMemorySize 프로퍼티는 멀티파트 파싱에 적용되는 non-file 파트의 사이즈를 제한한다. 파일 파트에서는 디스크에의 파일 쓰기(writing) 작업의 임계치가 된다. 이 쓰기 작업에는 추가적으로 maxDiskUsagePerPart 프로퍼티가 있다. 이 프로퍼티는 파트 당 디스크의 크기에 대한 제한을 설정한다. 또, maxParts 프로퍼티는 하나의 멀티파트 요청의 전체 사이즈에 대한 제한을 설정한다. 웹플럭스에서 이 3 가지 프로퍼티를 모두 설정하려면 미리 설정된 MultipartHttpMessageReader 인스턴스를 ServerCodecConfigurer에게 설정해야 한다.

스트리밍



text/event-stream, application/stream+json 과 같은, HTTP 응답 스트리밍 시에는 연결이 끊어진 클라이언트를 가능한 빠르게 감지하기 위해 데이터를 주기적으로 전송하는 것이 중요하다. 이 한 번의 전송에는 코멘드만 담길 수도 있고, 비어 있는 SSE 이벤트가 될 수도 있다. 아니면 다른 어떤한 "무동작 명령(no-op)" 데이터든 가능하다. 이 데이터는 서버의 신호(heartbeat) 역할을 한다.

DataBuffer



DataBuffer는 웹플럭스의 바이트버퍼이다. 스프링 코어의 데이터 버퍼와 코덱에서 이에 관한 더 많은 정보를 얻을 수 있다. 여기서 핵심이 되는 부분은, 네티와 같은 서버에서는 바이트버퍼는 풀(pool)으로 관리되며, 참조는 카운팅된다. 그리고 소비(consume) 후에는 반드시 릴리즈하여 메모리 릭을 방지해야 한다.

데이터 버퍼를 직접 소비하거나 생성하지 않는 이상, 혹은 커스텀 코덱을 만들어 사용하지 않는 한, 아니면 반대로 더 고수준 객체들로의/로부터의 컨버팅 작업에 코덱을 사용하는 한은, 일반적으로 웹플럭스 어플리케이션은 이런 이슈로부터 자유롭다. 이런 경우에 대해서는 데이터 버퍼와 코덱를 다시 한 번 보길 바란다. 특히 DataBuffer 사용하기 섹션을 권한다.

1.2.6. 로깅

스프링 웹플럭스에서 DEBUG 레벨 로깅은 가볍게, 최소한으로, 사람에게 친화적으로 작성된다. 특정 문제를 디버깅할때만 유용한 다른 정보에 비해서 지속적으로 유용한 가치있는 정보에 중점을 둔다.

TRACE 레벨 로깅은 일반적으로 DEBUG와 같은 원칙을 따르지만, 어떠한 디버깅에도 쓰일 수 있다. 그리고 어떤 로그 메시지들은 TRACE와 DEBUG 레벨에 대해 각기 다른 수준의 디테일을 보인다.

좋은 로깅이란 로그 사용 경험으로부터 나온다. 로깅에 있어 위에 공언된 목표에 부합하지 않는 것이 보이면 알려주기 바란다.

Log Id



웹플럭스에서는 하나의 요청을 처리하는 데에 다수의 쓰레드가 작동할 수 있기에, 특정 요청에 대한 로그 메시지들간의 연관성을 찾는 데에 있어 쓰레드ID는 유용하지 못하다. 이런 이유로 웹플럭스 로그 메시지는 기본적으로 요청 전용ID를 접두어로 둔다.

서버 측에서는 로그ID는 ServerWebExchange의 어트리뷰트(LOG_ID_ATTRIBUTE) 로 저장된다. ServerWebExchange#getLogPrefix()를 통해 완전히 포맷팅된 ID를 얻을 수 있다. WebClient 측에서는 로그ID는 ClientRequest의 어트리뷰트(LOG_ID_ATTRIBUTE)로 저장된다. ClientRequest#logPrefix()를 통해 완전히 포맷팅된 ID를 얻을 수 있다.

민감한 데이터



DEBUG와 TRACE 로깅은 민감한 정보를 남길 수 있다. 때문에 폼 파라미터와 헤더는 기본적으로 마스킹되어야 하며, 전체 로깅 활성화는 명시적으로 이루어져야 한다.

다음 예제는 서버측 요청에 대한 상세 로그 설정 코드이다:

Java
@Configuration
@EnableWebFlux
class MyConfig implements WebFluxConfigurer {

    @Override
    public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
        configurer.defaultCodecs().enableLoggingRequestDetails(true);
    }
}
Kotlin
@Configuration
@EnableWebFlux
class MyConfig : WebFluxConfigurer {

    override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) {
        configurer.defaultCodecs().enableLoggingRequestDetails(true)
    }
}


다음 예제는 클라이언트측 요청에 대한 상세 로그 설정 코드이다:

Java
Consumer<ClientCodecConfigurer> consumer = configurer ->
        configurer.defaultCodecs().enableLoggingRequestDetails(true);

WebClient webClient = WebClient.builder()
        .exchangeStrategies(strategies -> strategies.codecs(consumer))
        .build();
Kotlin
val consumer: (ClientCodecConfigurer) -> Unit  = { configurer -> configurer.defaultCodecs().enableLoggingRequestDetails(true) }

val webClient = WebClient.builder()
        .exchangeStrategies({ strategies -> strategies.codecs(consumer) })
        .build()


커스텀 코덱



지원되는 미디어타입을 추가하거나 기본 코덱에서 지원되지 않는 동작을 지원하기 위해 어플리케이션에 커스텀 코덱을 등록할 수 있다.

개발자가 조정 가능한 몇가지 설정 옵션은 기본 코덱에 적용된다. 커스텀 코덱은 버퍼링 사이즈 제한 혹은 민감한 데이터 로깅처럼 이러한 기호(preferences)를 필요로 할 수 있다.

다음 예제는 클라이언트측 요청에 대한 커스텀 코덱 설정이다:

Java
WebClient webClient = WebClient.builder()
        .codecs(configurer -> {
                CustomDecoder decoder = new CustomDecoder();
                configurer.customCodecs().registerWithDefaultConfig(decoder);
        })
        .build();
Kotlin
val webClient = WebClient.builder()
        .codecs({ configurer ->
                val decoder = CustomDecoder()
                configurer.customCodecs().registerWithDefaultConfig(decoder)
         })
        .build()


1.3. DispatcherHandler

스프링 웹플럭스는 스프링 MVC와 유사한 프론트 컨트롤러 패턴으로 설계되었다. 중앙의 WebHandler, DispatcherHandler는 요청 처리 알고리즘이 동일하다. 실제 작업은 설정 가능한 위임 컴포넌트에 의해 이루어진다. 이 모델은 유연하며, 다양한 형태의 작업 흐름을 지원한다.

DispatcherHandler는 스프링 설정으로부터 필요한 위임 컴포넌트를 찾는다. 스프링 빈으로 만들어졌으며, ApplicationContextAware를 구현하여 자신이 속한 컨텍스트에 접근한다. DispatcherHandler의 빈 이름이 webHandler으로 선언되면 결국 WebHttpHandlerBuilder에 의해 발견되어 사용된다. WebHttpHandlerBuilder는 WebHandler API에 따라 요청 처리 체인을 구성한다.

웹플럭스 어플리케이션의 스프링 설정은 보통 다음의 사항을 포함한다:

이 설정은 WebHttpHandlerBuilder에게 주어져 요청 처리 체인이 만들어진다. 다음은 그 예제이다:

Java
ApplicationContext context = ...
HttpHandler handler = WebHttpHandlerBuilder.applicationContext(context).build();
Kotlin
val context: ApplicationContext = ...
val handler = WebHttpHandlerBuilder.applicationContext(context).build()


위 코드 결과의 HttpHandler는 서버 어댑터와 함께 사용된다.

1.3.1. 스페셜 빈 타입

DispatcherHandler는 요청을 처리하고 적절한 응답을 주기 위해 스페셜 빈으로 작업을 위임한다. "스페셜 빈" 이란 웹플럭스 프레임워크의 요소를 구현한 스프링으로 관리되는 객체 인스턴스를 의미한다. 기본 내장 형태(built-in)가 보통이지만, 그 속성값을 변경하거나 빈을 확장(extend)하거나 다른 빈으로 대체(replace)하는 일도 가능하다.

다음 테이블은 DispatcherHandler에 의해 감지되는 스페셜 빈 목록을 보여준다. 로우 레벨에서는 이 밖에 다른 빈들도 존재한다.(웹 핸들러 API의 Special bean types를 보라).

빈 타입 설명
HandlerMapping 요청을 핸들러에 매핑한다. 매핑은 몇 가지 조건에 기반하여 이루어진다. 이 조건은 HandlerMapping 구현체에 따라 달라진다. -어노테이티드 컨트롤러, 단순 URL 패턴 매핑, 기타 등 핸들러.

@RequestMapping 적용 메서드에 대한 주요 HandlerMapping 구현체는 RequestMappingHandlerMapping, 함수형 엔드포인트 라우팅에 대해서는 RouterFunctionMapping, 명시적 URI 경로 패턴 및 WebHandler 인스턴스 등록에 대해서는 SimpleUrlHandlerMapping이 된다.
HandlerAdapter DispatcherHandler가 요청에 매핑된 핸들러를 실행하는 작업을 돕는다. 실제로 핸들러가 어떤 방식으로 실행되는지는 상관없다. 예를 들어, 어노테이티드 컨트롤러는 어노테이션 리졸빙을 필요로한다. HandlerAdapter의 주 목적은 DispatcherHandler를 그러한 구체적인 부분으로부터 분리하는 것이다.
HandlerResultHandler 핸들러의 실행 결과를 처리하여 응답을 마친다. Result Handling에서 다룬다.


1.3.2. 웹플럭스 설정

어플리케이션의 요청 처리의 기반이 되는 빈을 선언할 수 있다.(Web Handler APIDispatcherHandler에 나열된 빈) 그러나 대부분의 경우에 있어 WebFlux Config는 최고의 시작점이 된다. 필요한 빈을 선언하고, 커스터마이징을 위한 더 고수준의 콜백 API 설정을 제공한다.

#스프링 부트는 웹플럭스 설정을 통해 스프링 웹플럭스를 설정하며, 또한 많은 편리한 옵션을 추가로 제공한다.

1.3.3. 처리

DispatcherHandler는 다음에 따라 요청을 처리한다:
  • 각 HandlerMapping에게 매칭 핸들러를 요청한다. 처음 매칭된 핸들러가 사용된다.
  • 핸들러를 찾으면, 적절한 HandlerAdapter를 통해 이 핸들러를 실행한다. 핸들러 실행의 반환값은 HandlerResult이다.
  • HandlerAdapter 로부터 반환된 HandlerResult는 적절한 HandlerResultHandler에게 주어지며, 직접 응답을 주거나, 혹은 뷰를 사용하여 요청 처리를 완료한다.


1.3.4. 결과 핸들링

HandlerAdapter로 핸들러를 실행하여 나온 반환값은 몇몇 추가적인 컨텍스트와 함께 HandlerResult로 래핑된다. 그리고 이 반환값은 HandlerResult 핸들링을 지원하는 첫 HandlerResultHandler에게 전달된다. 다음 테이블은 사용 가능한 HandlerResultHandler 구현체를 보여준다. 여기에 나온 모든 구현체는 WebFlux Config에서 정의한다:

타입 반환값 디폴트 적용 순서
ResponseEntityResultHandler ResponseEntity, 보통 @Controller 인스턴스로부터 반환된다. 0
ServerResponseResultHander ServerResponse, 보통 함수형 엔드포인트로부터 반환된다. 0
ResponseBodyResultHandler @ResponseBdoy 메서드나 @RestController 클래스로부터의 반환값을 핸들링한다. 100
ViewResolutionResultHandler CharSequence, View, Model, Map, Rendering, 이외 모델 어트리뷰트로 취급되는 Object.

View Resolution에서 다룬다.
Integer.MAX_VALUE


1.3.5. 익셉션

HandlerAdapter 로부터 반환된 HandlerResult는 몇가지 핸들러 특징적인 메커니즘에 기반한 에러 핸들링 함수를 제공한다. 이 에러 핸들링 함수가 호출되는 조건은 다음과 같다:
  • 핸들러 실행 실패
  • HandlerResultHandler의 핸들러 결과값 핸들링 실패


핸들러가 반환한 리액티브 타입이 데이터를 생성하기 전에 에러 시그널이 발생하게 되면 이 에러 핸들링 함수는 응답을 변경할 수 있다.(예로, 에러 상태 코드)

이것이 @Controller 클래스의 @ExceptionHandler 메서드가 작동하는 방식이다. 반면 스프링 MVC는 HandlerExceptionResolver를 기반으로 한다. 이 차이점은 보통 큰 상관이 없지만, 웹플럭스에서는 핸들러가 선택되기 전에 발생한 익셉션은 @ControllerAdvice으로 핸들링할 수 없음은 유념해야 한다.

더한 내용은 "어노테이티드 컨트롤러" 섹션의 Managing Exceptions 또는 웹 핸들러 API 섹션의 Exceptions에서 다룬다.

1.3.6. 뷰 리솔루션

뷰 리솔루션은 특정한 뷰 기술에 구애받지 않으면서 HTML 템플릿과 모델을 이용하여 브라우저에 응답을 렌더링하도록 한다. 스프링 웹플럭스의 뷰 리솔루션은 HandlerResultHandler을 통해 이루어진다. HandlerResultHandler는 VeiwResolver 인스턴스를 사용하여 논리적 뷰 이름을 나타내는 String을 View 인스턴스로 매핑한다. 이 View는 응답 렌더링에 사용된다.

핸들링



ViewResolutionResultHandler로 전달된 HandlerResult는 핸들러의 반환값과 모델이 가진, 요청 처리 과정에서 추가된 어트리뷰트를 포함한다. 이 반환값은 다음과 같이 처리된다:
  • String, CharSequence: 논리적 뷰 이름으로, 설정된 ViewResolver 구현체 목록에서 View를 가져온다.
  • void: 요청 경로에 기반하여 기본 디폴트 뷰 이름을 선택한다. 경로 처음과 끝 슬래시를 뺀 값을 View로 한다. 반환된 뷰 이름이 없을 경우나(예를 들어, 모델 어트리뷰터가 반환되지 않을 때), 값이 비동기 반환값일 때도 똑같이 동작한다(예를 들어, Mono가 빈 값으로 완료될 때).
  • Rendering: 뷰 리솔루션 시나리오 API. 통합개발환경(IDE)의 코드 컴플리션의 옵션을 탐색한다.
  • Model, Map: 추가적인 모델 어트리뷰트로, 요청의 모델에 추가된다.
  • 그 외: BeanUtils#isSimpleProperty 에 의해 결정되는 단순 타입을 제외하고, 이외의 반환값은 요청의 모델에 추가되는 모델 어트리뷰트로 취급된다. @ModelAttribute 핸들러 메서드가 아니라면, 어트리뷰트 이름은 conventions를 사용하여 클래스 이름으로부터 얻어진다.


모델은 비동기, 리액티브 타입을 포함할 수 있다(예로, Reactor 또는 RxJava 로부터). AbstractView는 이러한 모델 어트리뷰트를 구체적인 값으로 리졸빙하고 그 모델을 업데이트한다. 이 작업은 뷰 렌더링 작업 전에 이루어진다. 단일 값 리액티브 타입은 단일 값 혹은 빈 값(비어있다면)으로 리졸빙되며, Flux<T> 와 같은 다수 값 리액티브 타입은 모아져(collected) List<T> 로 리졸빙된다.

뷰 리솔루션 설정은 스프링 설정에 ViewResolutionResultHandler 빈을 추가하는 것 만큼 쉽다. WebFlux Config는 뷰 리솔루션 전용 설정을 제공한다.

스프링 웹플럭스와 통합된 뷰 기술에 대해서는 View Technologies에서 더 많은 정보를 찾아볼 수 있다.

리다이렉팅



뷰 네임의 접두어로 쓰이는 redirect: 는 리다이렉팅을 수행한다. UrlBasedViewResolver(그리고 그 서브클래스들) 은 이 접두어를 리다이렉팅 요청으로 받아들인다. 접두어를 제외한 나머지 부분이 리다이렉팅 경로 URL이 된다.

redirect: 의 리다이렉팅에 있어서의 효과는 컨트롤러가 RedirectView 또는 Rendering.redirectTo("abc").build()를 반환한 것과 동일하지만, 이렇게 할 경우 컨트롤러는 자체적으로 뷰 이름에 관한 연산이 가능해진다. redirect:/some/resource/ 와 같은 뷰 이름은 현재 어플리케이션으로 연결되지만, redirect:https://example.com/arbitrary/path는 절대 경로 URL으로 리다이렉팅한다.

컨텐츠 협상



ViewResolutionResultHandler는 컨텐츠 협상을 지원한다. 요청 미디어 타입을 선택된 View가 지원하는 미디어 타입과 비교한다. 그리고 요청된 미디어 타입을 지원하는, 첫 번째로 발견된 View가 사용된다.

JSON, XML 과 같은 미디어 타입을 지원하기 위해, 스프링 웹플럭스는 HttpMessageWriterView를 제공한다. 이 특별한 View는 HttpMessageWriter를 통해 렌더링 작업을 수행한다. 보통 WebFlux Configuration을 통해 이 View를 디폴트 뷰로 설정하게 된다. 요청 미디어 타입에 매칭될 경우, 언제나 디폴트 뷰가 선택되어 사용된다.

1.4. 어노테이티드 컨트롤러

스프링 웹플럭스는 어노테이션 기반 프로그래밍 모델을 제공한다. @Controller와 @RestController 컴포넌트는 어노테이션을 사용하여 요청 매핑, 요청 인풋, 익셉션 핸들링 그리고 기타 필요한 작업을 지정한다. 어노테이티드 컨트롤러는 유연한 메서드 시그니처를 가지며, 기반 클래스를 확장(extend)하거나 특정 인터페이스를 구현할 필요가 없다.

다음은 어노테이티드 컨트롤러의 기본적인 예제이다:

Java
@RestController
public class HelloController {

    @GetMapping("/hello")
    public String handle() {
        return "Hello WebFlux";
    }
}
Kotlin
@RestController
class HelloController {

    @GetMapping("/hello")
    fun handle() = "Hello WebFlux"
}


위 예제의 메서드는 String를 반환하고, 이 반환값은 요청 본문에 쓰인다.

1.4.1 @Controller

표준 스프링 빈 정의에 따라 컨트롤러 빈을 정의할 수 있다. @Controller 스테레오타입은 클래스패스의 @Component 클래스 자동 감지 및 빈 등록을 허용한다. 또한 웹 컴포넌트임을 나타내는 어노테이션 적용 클래스의 스테레오 타입 역할을 한다.

아래와 같이, 자바 설정에 컴포넌트 스캐닝을 추가하여 @Controller와 같은 빈의 자동 감지를 활성화한다:

Java
@Configuration
@ComponentScan("org.example.web") (1)
public class WebConfig {

    // ...
}
Kotlin
@Configuration
@ComponentScan("org.example.web") (1)
class WebConfig {

    // ...
}


(1) org.example.web 패키지를 스캐닝한다.

@RestController는 스스로 @Contoller와 @ResponseBody를 적용하는 composed annotation이다. 타입 레벨으로 모든 메서드에 @ResponseBody가 적용되는 컨트롤러임을 나타내며, 고로 뷰 리솔루션와 HTML 템플릿 렌더링을 수행하지 않고 응답 본문을 직업 작성한다.

1.4.2. 요청 매핑

@RequestMapping 어노테이션은 컨트롤러에의 메서드와 요청을 매핑하기 위해 사용된다. 이 어노테이션은 URL, HTTP 메서드, 요청 파라미터, 헤더, 미디어 타입 각각으로 요청을 매핑하기 위한 다양한 어트리뷰트를 가지고 있다. 이 어노테이션은 클래스 레벨에 적용하여 메서드들의 매핑 공유에 사용할 수도 있고, 메서드 레벨에 적용하여 특정 엔드포인트로의 매핑을 지정할 수도 있다.

다음 어노테이션들은 HTTP 메서드로 요청을 매핑하는 @RequestMapping의 변형이다:

  • @GetMapping
  • @PostMapping
  • @PutMapping
  • @DeleteMapping
  • @PatchMapping


위 어노테이션들은 함께 제공되는 Custom Annotations이다. 이런 어노테이션들이 제공되는 이유는, 대부분의 컨트롤러에 있어서 @RequestMapping을 기본 형태로 사용하여 HTTP 메서드를 고려하지 않는 URL 매핑을 적용하기 보다는 특정 HTTP 메서드로의 매핑을 적용하는 것이 바람직하기 때문이다. 동시에 @RequestMapping은 클래스 레벨로 적용하여 매핑 범위를 공유하는 데에 사용할 수 있다.

다음은 클래스 레벨과 메서드 레벨 매핑 예제이다:

Java
@RestController
@RequestMapping("/persons")
class PersonController {

    @GetMapping("/{id}")
    public Person getPerson(@PathVariable Long id) {
        // ...
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public void add(@RequestBody Person person) {
        // ...
    }
}
Kotlin
@RestController
@RequestMapping("/persons")
class PersonController {

    @GetMapping("/{id}")
    fun getPerson(@PathVariable id: Long): Person {
        // ...
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun add(@RequestBody person: Person) {
        // ...
    }
}


URI 패턴



글롭 패턴(glob pattern) 과 와일드카드를 사용하여 요청을 매핑할 수 있다:
  • ? 는 한 문자와 매칭된다.
  • * 는 한 경로 세그먼트 안에서 0 개 이상의 문자와 매칭된다.
  • ** 는 경로 세그먼트를 포함하여 0 개 이상의 문자와 매칭된다.


URI 변수를 선언하고 변수의 값을 @PathVariable으로 접근하는 일도 가능하다:

Java
@GetMapping("/owners/{ownerId}/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
    // ...
}
Kotlin
@GetMapping("/owners/{ownerId}/pets/{petId}")
fun findPet(@PathVariable ownerId: Long, @PathVariable petId: Long): Pet {
    // ...
}


클래스 레벨과 메서드 레벨에서 각각 URL 변수를 선언하는 일도 가능하다:

Java
@Controller
@RequestMapping("/owners/{ownerId}") (1)
public class OwnerController {

    @GetMapping("/pets/{petId}") (2)
    public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
        // ...
    }
}
Kotlin
@Controller
@RequestMapping("/owners/{ownerId}") (1)
class OwnerController {

    @GetMapping("/pets/{petId}") (2)
    fun findPet(@PathVariable ownerId: Long, @PathVariable petId: Long): Pet {
        // ...
    }
}


(1) 클래스 레벨 URI 매핑 (2) 메서드 레벨 URI 매핑

URI 변수는 적절한 타입으로 자동 컨버팅되거나 TypeMismatchException 이 발생한다. int, long, Date 및 기타 등등과 같은 단순 타입은 기본으로 지원되고, 이 외 다른 데이터 타입을 등록하는 일도 가능하다. 이 부분은 Type ConversionDataBinder에서 다룬다.

@PathVariable("customId") 처럼, URI 변수명은 명시적으로 지정될 수 있지만, 사용되는 이름이 동일하고, 디버깅 정보로 컴파일이 이루어지거나 Java8 의 -parameter 컴파일러 플래그를 사용한다면 이런 상세한 부분은 생략할 수 있다.

{*varName} 문법은 0 개 이상의 남은 경로 세그먼트와 매칭되는 URI 변수를 선언한다. 예를 들어 /resources/{*path} 는 /resources/ 의 모든 파일과 매칭되고, "path" 변수는 완전한 상대경로를 캡쳐한다.

{varName:regex} 문법은 URI 변수를 {varName:regex} 문법을 가진 정규식으로 선언한다. 예를 들어 URL 경로 /spring-web-3.0.5.jar가 주어지면 다음 메서드는 이름, 변수, 파일 확장자를 추출한다:

Java
@GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}")
public void handle(@PathVariable String version, @PathVariable String ext) {
    // ...
}
Kotlin
@GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}")
fun handle(@PathVariable version: String, @PathVariable ext: String) {
    // ...
}


URI 경로 패턴은 내장된 ${...} 플레이스홀더를 가지고 있다. 이 플레이스홀더는 로컬, 시스템, 환경, 그리고 다른 프로퍼티 자원에 대한 PropertyPlaceHolderConfigurer을 통해 시작 시점에 리졸빙된다. 이 기능은 외부 설정에 기반한 기본 URL 파라미터 처리에 사용될 수 있다.

# 스프링 웹플럭스는 PathPattern 과 PathPatternParser를 사용하여 URI 경로 매칭을 지원한다. 이 두 클래스는 spring-web 에 포함되어 있으며, 런타임에 많은 수의 URI 경로 패턴 매칭이 발생하는 웹 어플리케이션에서 HTTP URL 경로와 함께 사용되도록 명시적으로 만들어져 있다.

스프링 웹플럭스는 접미어 패턴 매칭을 지원하지 않는다. 스프링 MVC 에서는 /person.* 이 /person으로 매칭되지만, 스프링 웹플럭스는 이를 지원하지 않는다. URL 기반 컨텐츠 협상에 대해서는 필요하다면 쿼리 파라미터를 사용하길 권한다. 쿼리 파라미터는 더 단순하고, 더 명시적이며, URL 경로를 악용하는 취약성 측면에서 더 낫다.

패턴 비교



한 URL 에 다수의 패턴이 매칭될 때는 이들 중 가장 적합한 매칭을 찾기 위해 패턴 비교 작업이 필요하다. 이 작업은 PathPattern.SPECIFICITY_COMPARATOR으로 수행된다. 보다 구체적으로 매칭되는 패턴을 찾는다.

모든 패턴에는 URI 변수와 와일드카드의 숫자에 근거한 점수가 산정된다. URI 변수는 와일드카드보다 낮은 점수를 가진다. 점수의 총합이 낮은 패턴이 선택되며, 두 패턴이 같은 점수를 가질 경우 더 긴 패턴이 선택된다.

캐치올(catch-all) 패턴(**, {*varName}) 은 이 점수 산정에서 제외되고, 언제나 가장 낮은 우선순위를 갖는다. 두 패턴이 모두 캐치올 패턴이라면, 더 긴 패턴이 선택된다.

소비형(consumable) 미디어 타입



다음과 같이, 요청의 Content-Type으로 요청 매핑을 좁힐 수 있다:

Java
@PostMapping(path = "/pets", consumes = "application/json")
public void addPet(@RequestBody Pet pet) {
    // ...
}
Kotlin
@PostMapping("/pets", consumes = ["application/json"])
fun addPet(@RequestBody pet: Pet) {
    // ...
}


consumes 어트리뷰트는 협상 표현식을 지원한다. 예로, !text/plain은 text/plain을 제외한 컨텐츠 타입을 의미한다.

클래스 레벨에 consumes 어트리뷰트를 선언하여 메서드 매핑에 공유할 수 있다. 다른 대부분의 요청 매핑 어트리뷰트와는 달리, consume 어트리뷰트가 클래스 레벨과 메서드 레벨에서 함께 사용될 경우 메서드 레벨의 어트리뷰트가 적용되고, 클래스 레벨의 어트리뷰트는 무시된다.

# MediaType은 공통적으로 사용되는 미디어 타입 상수를 제공한다 - 예로, APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE.

생산형(producible) 미디어 타입



다음과 같이, 요청 헤더의 Accept와 컨트롤러 메서드가 생산하는 컨텐츠 타입 목록으로 요청 매핑을 좁힐 수 있다:

Java
@GetMapping(path = "/pets/{petId}", produces = "application/json")
@ResponseBody
public Pet getPet(@PathVariable String petId) {
    // ...
}
Kotlin
@GetMapping("/pets/{petId}", produces = ["application/json"])
@ResponseBody
fun getPet(@PathVariable String petId): Pet {
    // ...
}


이 미디어 타입은 문자 집합을 특정할 수 있다. 부정 표현식이 지원된다 - 예로, !text/plain은 text/plain을 제외한 컨텐츠 타입을 의미한다.

클래스 레벨에 produces 어트리뷰트를 선언하여 메서드 매핑에 공유할 수 있다. 다른 대부분의 요청 매핑 어트리뷰트와는 달리, produces 어트리뷰트가 클래스 레벨과 메서드 레벨에서 함께 사용될 경우 메서드 레벨의 어트리뷰트가 적용되고, 클래스 레벨의 어트리뷰트는 무시된다.

# MediaType은 공통적으로 사용되는 미디어 타입 상수를 제공한다 - 예로, APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE.

파라미터와 헤더



쿼리 파라미터 조건으로 요청 매핑을 좁힐 수 있다. 특정 쿼리 파라미터의 존재 여부로 체크할 수 있고(myParam, !myParam) , 특정 값 여부로 체크할 수도 있다(myParam=myValue). 다음은 그 예제이다:

Java
@GetMapping(path = "/pets/{petId}", params = "myParam=myValue") (1)
public void findPet(@PathVariable String petId) {
    // ...
}
Kotlin
@GetMapping("/pets/{petId}", params = ["myParam=myValue"]) (1)
fun findPet(@PathVariable petId: String) {
    // ...
}


(1) myParam 파라미터의 값이 myValue 인지 체크한다.

또한 헤더의 조건으로 체크할 수도 있다:

Java
@GetMapping(path = "/pets", headers = "myHeader=myValue") (1)
public void findPet(@PathVariable String petId) {
    // ...
}
Kotlin
@GetMapping("/pets", headers = ["myHeader=myValue"]) (1)
fun findPet(@PathVariable petId: String) {
    // ...
}


(1) myHeader의 값이 myValue 인지 체크한다.

HTTP HEAD, OPTIONS



@GetMappping 과 @RqeustMapping(method=HttpMethod.GET) 은 요청 매핑 목적의 HTTP HEAD를 투명하게 지원한다. 컨트롤러 메서드는 변경될 필요가 없다. HttpHandler 서버 어댑터의 응답 래퍼는 실제 응답을 주지 않으면서 Content-Length 헤더에 바이트 수를 설정한다.

기본적으로 HTTP OPTIONS 핸들링은 매칭 URL 패턴을 가진 모든 @RequsetMapping 메서드의 HTTP 메서드 목록에 Allow 응답 헤더를 설정함으로써 이루어진다.

HTTP 메서드 선언이 없는 @RequestMapping의 경우, Allow 헤더는 GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS로 설정된다. 컨트롤러 메서드는 언제나 지원되는 HTTP 메서드로 선언되어야 한다(예로, @GetMapping, @PostMapping 및 기타 등과 같은 HTTP 메서드 특정 매핑을 사용한다).

@RequestMapping 메서드를 HTTP HEAD와 HTTP OPTIONS으로 명시적으로 매핑할 수 있지만, 보통의 경우 불필요한 일이다.

커스텀 어노테이션



스프링 웹플럭스는 요청 매핑에 있어 composed annoations(이하 컴포즈드 어노테이션) 사용을 지원한다. 이런 어노테이션은 스스로 @RequestMapping을 적용하고 구성하여 필요한 @RequestMapping 어트리뷰트를 설정하여 보다 특정적이고 구체적인 목적으로 사용할 수 있다.

@GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping은 컴포즈드 어노테이션의 예이다. 이런 어노테이션들이 제공되는 이유는, 대부분의 컨트롤러에 있어서 @RequestMapping을 기본 형태로 사용하여 HTTP 메서드를 고려하지 않는 URL 매핑을 적용하기 보다는 특정 HTTP 메서드로의 매핑을 적용하는 것이 바람직하기 때문이다. 컴포즈드 어노테이션의 예제가 필요하다면 이 어노테이션들이 어떻게 선언되어 있는지 보면 된다.

스프링 웹플럭스는 또한 커스텀 요청 매칭 로직을 가진 커스텀 요청 매핑 어트리뷰트를 지원한다. 이 방법은 RequestMappingHandlerMapping을 확장하여 getCustomMethodCondition 메서드를 오버라이딩하는, 보다 고차원의 옵션이다. getCustomMethodCondition 메서드는 커스텀 어트리뷰트를 체크하고 당신만의 RequestCondition를 반환할 수 있다.

명시적 등록



핸들러 메서드를 프로그래밍 방식으로 등록할 수 있다. 이 방식은 핸들러 메서드를 동적으로 등록하거나, 같은 핸들러의 서로 다른 인스턴스들로 서로 다른 URL을 처리하는 경우와 같이 보다 고차원적인 목적으로 사용될 수 있다. 다음은 그 예제이다:

Java
@Configuration
public class MyConfig {

    @Autowired
    public void setHandlerMapping(RequestMappingHandlerMapping mapping, UserHandler handler) (1)
            throws NoSuchMethodException {

        RequestMappingInfo info = RequestMappingInfo
                .paths("/user/{id}").methods(RequestMethod.GET).build(); (2)

        Method method = UserHandler.class.getMethod("getUser", Long.class); (3)

        mapping.registerMapping(info, handler, method); (4)
    }

}
Kotlin
@Configuration
class MyConfig {

    @Autowired
    fun setHandlerMapping(mapping: RequestMappingHandlerMapping, handler: UserHandler) { (1)

        val info = RequestMappingInfo.paths("/user/{id}").methods(RequestMethod.GET).build() (2)

        val method = UserHandler::class.java.getMethod("getUser", Long::class.java) (3)

        mapping.registerMapping(info, handler, method) (4)
    }
}


(1) 타겟 핸들러와 핸들러 매핑을 컨트롤러에 주입한다. (2) 요청 매핑 메타데이터를 준비한다. (3) 핸들러 메서드를 얻는다. (4) 등록한다.

1.4.3. 핸들러 메서드

@RequestMapping 핸들러 메서드는 유연한 시그니처를 가지며, 지원되는 컨트롤러 메서드 아규먼트와 반환값을 골라 사용할 수 있다.

메서드 아규먼트



다음 테이블은 지원되는 컨트롤러 메서드 아규먼트를 보여준다.

리액티브 타입(Reactor, RxJava, 기타 등) 블로킹 I/O를 요구하는 아규먼트를 지원한다(예로, 요청 본문 읽기). 이는 설명 컬럼에 표시되어 있다. 리액티브 타입은 블로킹을 요구하지 않는 아규먼트에는 존재하지 않는다.

JDK 1.8 의 java.util.Optional은 requried 어트리뷰트를 가진 어노테이션과 함께 메서드 아규먼트로 지원되며(@RequestParam, @RequestHeader, 기타 등), required=false와 동등하다.

컨트롤러 메서드 아규먼트 설명
ServerWebExchange ServerWebExchange 전체에 접근한다 - HTTP 요청과 응답, 요청과 세션 어트리뷰트, checkNotModified 메서드, 그리고 기타 등등을 얻을 수 있다.
ServerHttpRequest ServerHttpResponse / HTTP 요청이나 응답에 접근한다.
WebSession 세션에 접근한다. 따로 추가된 어트리뷰트가 없다면, 새 세션의 시작을 강제하지는 않는다. 리액티브 타입을 지원한다.
java.security.Principal 현재 인가된 유저 - 알려진 특정 Principal 구현체가 있다면 그것이 될 것이다. 리액티브 타입을 지원한다.
org.springframework.http.HttpMethod 요청의 HTTP 메서드.
java.util.Locale 현재 요청의 로케일. 사용 가능한 가장 구체적인 LocaleResolver 에 의해 결정된다. 사실상, 설정된 LocaleResolver / LocaleContextResolver가 된다.
java.util.TimeZone + java.time.ZoneId 현재 요청과 관련된 타임 존. LocaleContextResolver 에 의해 결정된다.
@PathVariable URI 템플릿 변수로 접근하기 위해 사용된다. URI Patterns를 보라.
@MatrixVariable URI 경로 세그먼트의 이름-값 쌍으로 접근하기 위해 사용된다. Matrix Variables를 보라.
@RequestParam 서블릿 요청 파라미터에 접근하기 위해 사용된다. 파라미터 값은 선언된 메서드 아규먼트 타입으로 컨버팅된다. @RequestParam을 보라.

@RequestParam 사용은 선택적이다 - 예로, 이 어노테이션의 어트리뷰트를 설정하기 위해 사용할 수 있다. 이 테이블의 "이 외의 아규먼트"를 보라.
@RequestHeader 요청 헤더에 접근하기 위해 사용된다. 헤더 값은 선언된 메서드 아규먼트 타입으로 컨버팅된다. @RequestHeader를 보라.
@CookieValue 쿠키에 접근하기 위해 사용된다. 쿠키 값은 선언된 메서드 아규먼트 타입으로 컨버팅된다. @CookieValue를 보라.
@RequestBody 요청 본문에 접근하기 위해 사용된다. 본문 내용은 선언된 메서드 아규먼트 타입으로 컨버팅된다. 이 컨버팅에는 HttpMessageReader 인스턴스가 사용된다. @RequestBody를 보라.
HttpEntity 요청 헤더와 본문에 접근하기 위해 사용된다. 본문은 HttpMessageReader 인스턴스를 사용하여 컨버팅된다. 리액티브 타입을 지원한다. HttpEntity를 보라.
@RequestPart multipart/form-data 요청의 파트에 접근하기 위해 사용된다. 리액티브 타입을 지원한다. Multipart ContentMultipart Data를 보라.
HttpEntity 요청 헤더와 본문에 접근하기 위해 사용된다. 본문은 HttpMessageReader 인스턴스를 사용하여 컨버팅된다. 리액티브 타입을 지원한다. HttpEntity를 보라.
java.util.Map, org.springframework.ui.Model, org.springframework.ui.ModelMap HTML 컨트롤러에 사용되고 뷰 렌더링의 일부로서 템플릿이 되는 모델에 접근하기 위해 사용된다.
@ModelAttribute 데이터 바인딩과 검증이 적용되는 모델(없다면 초기화한다)에 존재하는 어트리뷰트에 접근하기 위해 사용된다. @ModelAttibute 그리고 ModelDataBinder를 보라.

@ModelAttibute는 선택적이다 - 예로, 이 어노테이션의 어트리뷰트를 설정하기 위해 사용할 수 있다. 이 테이블의 "이 외의 아규먼트"를 보라.
Errors, BindingResult 벨리데이션과 커맨드 객체(@ModelAttribute 아규먼트) 로의 데이터 바인딩, 혹은 @RequestBody, @RequestPart 아규먼트의 벨리데이션에서 발생한 에러로의 접근을 위해 사용한다. Erros 또는 BindingResult 아규먼트는 반드시 벨리데이션 대상 메서드 아규먼트의 바로 다음에 선언되어야 한다.
SessionStatus + class-level @SessionAttributes 요청 처리 완료를 표시하기 위해 사용된다. 요청 처리 완료 시 클래스 레벨 @SessionAttribute 어노테이션을 통해 선언된 세션 어트리뷰트를 비운다. 더 자세한 정보는 @SessionAttributes에서 찾을 수 있다.
UriComponentsBuilder 현재 요청의 호스트, 포트, 스킴, 경로에 연관된 URL을 준비하기 위해 사용된다. URI Links를 보라.
@SessionAttribute 세션 어트리뷰트에 접근하기 위해 사용된다 - 클래스 레벨 @SessionAttribute의 결과로 세션에 저장된 모델 어트리뷰트와 대조된다. 더 자세한 정보는 @SessionAttributes에서 찾을 수 있다.
@RequestAttribute 요청 어트리뷰트에 접근하기 위해 사용된다. 더 자세한 정보는 @RequestAttributes에서 찾을 수 있다.
UriComponentsBuilder 현재 요청의 호스트, 포트, 스킴, 경로에 연관된 URL을 준비하기 위해 사용된다. URI Links를 보라.
이 외의 아규먼트 메서드 아규먼트가 위에서 다룬 아규먼트들와 매칭되지 않는다면, 단순 타입의 경우 기본적으로 @RequestParam를 통해 리졸빙된다. 단순 타입 여부는 BeanUtils#isSimpleProperty를 통해 결정된다. @RequestParam 이 아니면 @ModelAttribute가 된다.


반환값



다음 테이블은 지원되는 컨트롤러 메서드 반환값을 보여준다. Reactor, RxJava, 혹은 이 외의 리액티브 라이브러리의 리액티브 타입은 일반적으로 모든 반환값을 지원한다.

컨트롤러 메서드 반환값 설명
@ResponseBody 반환값은 HttpMessageWriter 인스턴스를 통해 인코딩되어 응답으로 작성된다. @ResponseBody를 보라.
@HttpEntity, ResponseEntity 반환값이 응답 전체를 지정한다. HTTP 헤더와 본문 모두 HttpMessageWriter 인스턴스를 통해 인코딩되어 응답으로 작성된다. @ResponseEntity를 보라.
@HttpHeader 응답 헤더를 반환하기 위해 사용된다. 본문은 취급하지 않는다.
String / ViewResolver 인스턴스가 리졸빙에 사용할 뷰 이름이 된다. 그리고 내포된 모델과 함께 사용된다 - 모델은 커맨드 객체와 @ModelAttribute 메서드를 통해 결정된다. 핸들러 메서드는 Model 아규먼트 선언을 통해 프로그래밍 방식으로 모델을 더욱 풍부하게 만들 수 있다(앞서 설명되었다).
String 내포된 모델과 함께 렌더링될 View 인스턴스 - 커맨드 객체와 @ModelAttribute 메서드를 통해 결정된다. 핸들러 메서드는 Model 아규먼트 선언을 통해 프로그래밍 방식으로 모델을 더욱 푸부하게 만들 수 있다(앞서 설명되었다).
java.util.Map, org.springframework.ui.model 내포된 모델에 어트리뷰트를 추가하기 위해 사용된다. 요청 경로를 바탕으로 암시적으로 선택된 뷰 이름과 함께 작동한다.
@ModelAttribute 모델에 한 어트리뷰트를 추가하기 위해 사용된다. 요청 경로를 바탕으로 암시적으로 선택된 뷰 이름과 함께 작동한다.

@ModelArribute를 선택적이다. 이 테이블의 "이 외의 반환값" 을 보라.
Rendering 모델과 뷰 렌더링 시나리오를 위한 API.
void 반환 타입이 void, 비동기(Mono), 혹은 null 반환값 인 메서드는 ServerHttpResponse, ServerWebExchange 아규먼트를 갖거나, 혹은 @ResponseStatus 어노테이션이 적용된 경우, 응답 요소 전체를 핸들링한다. 컨트롤러가 낙관적 ETag 또는 lastModified 타임스탬프 체크를 생성한 경우에도 마찬가지이다. Controller에서 더 자세한 정보를 찾을 수 있다. 위 사항에 모두 해당되지 않는 경우, void 반환 타입은 REST 컨트롤러에선 "응답 본문이 없음" 을 의미하고, HTML 컨트롤러에선 디폴트 뷰 이름이 선택된다.
Flux, Observable, 혹은 이 외 리액티브 타입 서버 전송 이벤트를 발생시킨다. ServerSentEvent 래퍼는 오직 데이터가 작성될 경우에만 생략할 수 있다(그러나 text/event-stream은 반드시 요청되거나 혹은 produces 어트리뷰트 선언을 통해 매핑되어야 한다).
이 외의 반환값 반환값이 위의 어느 것에도 매칭되지 않을 경우, 기본적으로 String 또는 void(디폴트 뷰 이름 적용) 라면 뷰 이름으로 취급되며, 여기에 해당하지 않으면서 BeanUtils#isSimpleProperty가 단순 타입으로 판단하지 않는다면, 모델로 추기되는 모델 어트리뷰트로 취급된다(리졸빙되지 않은 상태로 남는 경우).


타입 컨버젼



스트링 기반 요청 인풋(예: @RequestParam, @RequestHeader, @PathVariable, @MatrixVariable, @CookieValue) 을 나타내는 몇몇 어노테이티드 컨트롤러 메서드 아규먼트들은, 아규먼트가 String 외의 타입으로 선언된 경우 타입 컨버젼을 필요로 한다.

이런 경우 타임 컨버젼은 설정된 타입 컨버터에 기반하여 자동으로 수행된다. 단순 타입(int, long, Date, 기타 등등) 은 기본으로 지원된다. 타입 컨버젼은 WebDataBinder([mvc-ann-initbinder]를 보라) 또는 Formatters를 FormattingConversionService(Spring Field Formatting을 보라) 와 등록함으로써 통해 커스터마이징 가능하다.

매트릭스 변수(Matrix Variables)



RFC 3986 은 경로 세그먼트 안의 이름-값 쌍을 주제로 한다. 스프링 웹플럭스에선 팀 버너스 리의 옛 포스팅에 근거하여, 이를 "매트릭스 변수" 라 칭한다. 또한 URI 경로 파라미터라 칭하기도 한다.

매트릭스 변수는 어떠한 경로 세그먼트에서도 존재할 수 있다. 각 변수는 세미콜론으로 구분되며, 콤마로 나뉘어진 다수의 값으로 이루어져 있다 - 예: "/cars'color=red,green;year=2012". 다수 값은 반복적인 이름과 함께 지정될 수도 있다 - 예: "color=red;color=green;color=blue".

스프링 MVC 와는 달리, 웹플럭스에서는 URL 에서 매트릭스 변수의 존재 여부는 요청 매핑에 어떠한 영향도 주지 않는다. 다시 말해서, 변수 컨텐츠를 마스킹하기 위해 URI 변수를 사용할 필요는 없다. 그렇지만, 컨트롤러 메서드에서 매트릭스 변수에 접근해야 한다면 URI 변수를 매트릭스 변수가 위치하는 경로 세그먼트에 추가할 필요가 있다. 다음은 그 예제이다:

Java
// GET /pets/42;q=11;r=22

@GetMapping("/pets/{petId}")
public void findPet(@PathVariable String petId, @MatrixVariable int q) {

    // petId == 42
    // q == 11
}
Kotlin
// GET /pets/42;q=11;r=22

@GetMapping("/pets/{petId}")
fun findPet(@PathVariable petId: String, @MatrixVariable q: Int) {

    // petId == 42
    // q == 11
}


주어진 모든 경로 세그먼트는 매트릭스 변수를 포함할 수 있다. 때로는 매트릭스 변수가 어떤 경로 변수의 것인지 구분할 필요가 있을 수 있다. 다음은 그에 대한 예제이다:

Java
// GET /owners/42;q=11/pets/21;q=22

@GetMapping("/owners/{ownerId}/pets/{petId}")
public void findPet(
        @MatrixVariable(name="q", pathVar="ownerId") int q1,
        @MatrixVariable(name="q", pathVar="petId") int q2) {

    // q1 == 11
    // q2 == 22
}
Kotlin
@GetMapping("/owners/{ownerId}/pets/{petId}")
fun findPet(
        @MatrixVariable(name = "q", pathVar = "ownerId") q1: Int,
        @MatrixVariable(name = "q", pathVar = "petId") q2: Int) {

    // q1 == 11
    // q2 == 22
}


선택적인 매트릭스 변수를 기본값과 함께 선언할 수도 있다:

Java
// GET /pets/42

@GetMapping("/pets/{petId}")
public void findPet(@MatrixVariable(required=false, defaultValue="1") int q) {

    // q == 1
}
Kotlin
// GET /pets/42

@GetMapping("/pets/{petId}")
fun findPet(@MatrixVariable(required = false, defaultValue = "1") q: Int) {

    // q == 1
}


MultiValueMap을 사용해서 모든 매트릭스 변수를 가져올 수도 있다:

Java
// GET /owners/42;q=11;r=12/pets/21;q=22;s=23

@GetMapping("/owners/{ownerId}/pets/{petId}")
public void findPet(
        @MatrixVariable MultiValueMap<String, String> matrixVars,
        @MatrixVariable(pathVar="petId") MultiValueMap<String, String> petMatrixVars) {

    // matrixVars: ["q" : [11,22], "r" : 12, "s" : 23]
    // petMatrixVars: ["q" : 22, "s" : 23]
}
Kotlin
// GET /owners/42;q=11;r=12/pets/21;q=22;s=23

@GetMapping("/owners/{ownerId}/pets/{petId}")
fun findPet(
        @MatrixVariable matrixVars: MultiValueMap<String, String>,
        @MatrixVariable(pathVar="petId") petMatrixVars: MultiValueMap<String, String>) {

    // matrixVars: ["q" : [11,22], "r" : 12, "s" : 23]
    // petMatrixVars: ["q" : 22, "s" : 23]
}


@RequestParam



@RequestParam를 사용하여 컨트롤러에서 쿼리 파라미터를 메서드 아규먼트로 바인딩할 수 있다.

Java
@Controller
@RequestMapping("/pets")
public class EditPetForm {

    // ...

    @GetMapping
    public String setupForm(@RequestParam("petId") int petId, Model model) { (1)
        Pet pet = this.clinic.loadPet(petId);
        model.addAttribute("pet", pet);
        return "petForm";
    }

    // ...
}
Kotlin
import org.springframework.ui.set

@Controller
@RequestMapping("/pets")
class EditPetForm {

    // ...

    @GetMapping
    fun setupForm(@RequestParam("petId") petId: Int, model: Model): String { (1)
        val pet = clinic.loadPet(petId)
        model["pet"] = pet
        return "petForm"
    }

    // ...
}


(1) @RequestParam 사용.

# 서블릿 API "요청 파라미터" 컨셉은 쿼리 파라미터, 폼 데이터, 멀티파트를 하나로 묶는다. 그러나 웹플럭스는 이 각각에 대해 ServerWebExchange를 통해 독립적으로 접근한다. @RequestParam 이 쿼리 파라미터만을 대상으로 하는 것과는 달리, 데이터 바인딩을 사용하여 쿼리 파라미터, 폼 데이터, 멀티파트를 커맨드 객체에 적용할 수 있다.

@RequestParam 어노테이션을 사용하는 메서드 파라미터는 기본적으로 필수값이 된다. @RequestParam의 required 플래그를 false로 설정하거나, 아규먼트를 java.util.Optional 래퍼로 선언해서 선택적 파라미터로 지정할 수 있다.

타겟 메서드 파라미터 타입이 String 이 아닐 경우, 타입 컨버젼은 자동으로 적용된다. [mvc-ann-typeconversion]을 보라.

@RequestParam 어노테이션이 Map<String, Stirng>이나 MultiValueMap<String, String> 아규먼트에 선언되면, 이 맵에 모든 쿼리 파라미터를 담는다.

@RequestParam 사용은 선택적이다 - 예로, 이 어노테이션에 어트리뷰트를 설정하기 위해 사용할 수 있다. 기본적으로 BeanUtils#isSimpleProperty로 단순 타입 값으로 판단된 아규먼트이면서, 어떠한 아규먼트 리졸버에 의해서도 리졸빙되지 않은 아규먼트는 @RequestParam 이 적용된 것과 같이 작동한다.

@RequestHeader



@RequestHeader를 사용하여 컨트롤러에서 요청 헤더를 메서드 아규먼트로 바인딩할 수 있다.

다음은 요청 헤더의 예이다:

요청헤더

다음 예제는 Accept-Encoding 과 Keep-Alive 헤더의 값을 가져온다:

Java
@GetMapping("/demo")
public void handle(
        @RequestHeader("Accept-Encoding") String encoding, (1)
        @RequestHeader("Keep-Alive") long keepAlive) { (2)
    //...
}
Kotlin
@GetMapping("/demo")
fun handle(
        @RequestHeader("Accept-Encoding") encoding: String, (1)
        @RequestHeader("Keep-Alive") keepAlive: Long) { (2)
    //...
}


(1) Accept-Endocing 헤더의 값을 가져온다. (2) Keep-Alive 헤더의 값을 가져온다.

타겟 메서드 파라미터 타입이 String 이 아닐 경우, 타입 컨버젼은 자동으로 적용된다. [mvc-ann-typeconversion]을 보라.

@RequestParam 어노테이션이 Map<String, Stirng> 또는 MultiValueMap<String, String> 아규먼트에 선언되면, 이 맵에 모든 헤더값을 담는다.

# 기본 내장형 컨버터는 컴마로 구분된 문자열을 스트링 배열이나 컬렉션, 혹은 이 외 다른 알려진 타입으로의 컨버젼을 지원한다. 예를 들어, @RequestHeader("Accept") 어노테이션이 적용된 메서드 파라미터는 String 또는 String[], List<String> 로 컨버팅 될 수 있다.

@CookieValue



@CookieValue를 사용하여 HTTP 쿠키를 메서드 아규먼트로 바인딩할 수 있다.

다음은 쿠키 예시이다:

쿠키

다음은 쿠키값을 가져오는 샘플이다:

Java
@GetMapping("/demo")
public void handle(@CookieValue("JSESSIONID") String cookie) { (1)
    //...
}
Kotlin
@GetMapping("/demo")
fun handle(@CookieValue("JSESSIONID") cookie: String) { (1)
    //...
}


(1) 쿠키값을 가져온다.

타겟 메서드 파라미터 타입이 String 이 아닐 경우, 타입 컨버젼은 자동으로 적용된다. [mvc-ann-typeconversion]을 보라.

@ModelAttribute



@ModelAttribute를 메서드 아규먼트에 적용하여 모델의 어트리뷰트에 접근하거나, 기존 모델이 없는 경우 초기화를 수행하도록 할 수 있다. 이 모델 어트리뷰트는 쿼리 파라미터와 필드 이름과 매칭된 폼 필드들을 덮어쓴다. 이는 데이터 바인딩이라 할 수 있고, 이 동작은 각각의 쿼리 파라미터와 폼 필드를 파싱하고 컨버팅하는 작업으로부터 자유롭게 해준다. 다음은 Pet 인스턴스 바인딩 예제이다:

Java
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@ModelAttribute Pet pet) { } (1)
Kotlin
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
fun processSubmit(@ModelAttribute pet: Pet): String { } (1)


(1) Pet 인스턴스에 바인딩한다.

위 예제의 Pet 인스턴스는 다음과 같이 리졸빙된다.
  • 이미 기존 Model에 존재한다면 모델로부터 얻는다.
  • @SessionAttribute을 통해 HTTP 세션으로부터 얻는다.
  • 기본 생성자를 실행하여 얻는다.
  • 쿼리 파라미터나 폼 필드와 매칭되는 아규먼트를 가진 "주요 생성자"를 실행하여 얻는다. 아규먼트의 이름은 자바빈 @ConstructorProperties 또는 바이트코드의 런타임 파라미터 이름을 통해 확정된다.


모델 어트리뷰트 인스턴스를 얻은 뒤, 데이터 바인딩이 적용된다. WebExchangeDataBinder 클래스는 쿼리 파라미터와 폼 필드의 이름을 타겟 Object의 필드 이름과 매칭한다. 매칭 필드 필요에 따라 타임 컨버젼을 적용하여 값을 세팅한다. 데이터 바인딩와 밸리데이션에 관한 더 자세한 정보는 Validation에서 찾을 수 있다. 데이터 바인딩에 관하여는 DataBinder를 보라.

데이터 바인딩에서 에러가 발생할 수 있다. 기본적으로 WebExchangeBindException 이 발생하지만, 컨트롤러 메서드에서 이러한 에러를 체크하기 위해서는 BindingResult 아규먼트를 @ModelAttribute 바로 다음에 선언해야 한다. 다음은 그 예제이다:

Java
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@ModelAttribute("pet") Pet pet, BindingResult result) { (1)
    if (result.hasErrors()) {
        return "petForm";
    }
    // ...
}
Kotlin
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
fun processSubmit(@ModelAttribute("pet") pet: Pet, result: BindingResult): String { (1)
    if (result.hasErrors()) {
        return "petForm"
    }
    // ...
}


(1) BindingResult를 추가한다.

javax.validation.Valid 어노테이션이나 스프링의 @Validated 어노테이션을 추가하여 데이터 바인딩 뒤 자동 벨리데이션을 적용할 수 있다(Bean ValidationSpring validation을 보라). 다음은 @Valid 어노테이션의 사용 예이다:

Java
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@Valid @ModelAttribute("pet") Pet pet, BindingResult result) { (1)
    if (result.hasErrors()) {
        return "petForm";
    }
    // ...
}
Kotlin
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
fun processSubmit(@Valid @ModelAttribute("pet") pet: Pet, result: BindingResult): String { (1)
    if (result.hasErrors()) {
        return "petForm"
    }
    // ...
}


(1) @Valid를 모델 어트리뷰트 아규먼트에 적용한다.

스프링 MVC 와는 달리, 스프링 웹플럭스는 모델에서 리액티브 타입을 지원한다 - 예: Mono<Account>, io.reactivex.Single<Account>. @ModelAttribute 아규먼트를 리액티브 타입 래퍼와 함께 사용하거나 사용하지 않거나 하면 필요에 따라 그에 맞게 실제 값으로 리졸빙된다. 하지만 위의 예제에서처럼 BindingResult 아규먼트는 반드시 위치 상 @ModelAttribute 아규먼트 직후에, 리액티브 타입 래퍼 없이 사용해야 한다. 그게 아니라면 아래와 같이 리액티브 타입을 통해 에러 핸들링을 수행할 수 있다:

Java
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public Mono<String> processSubmit(@Valid @ModelAttribute("pet") Mono<Pet> petMono) {
    return petMono
        .flatMap(pet -> {
            // ...
        })
        .onErrorResume(ex -> {
            // ...
        });
}
Kotlin
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
fun processSubmit(@Valid @ModelAttribute("pet") petMono: Mono<Pet>): Mono<String> {
    return petMono
            .flatMap { pet ->
                // ...
            }
            .onErrorResume{ ex ->
                // ...
            }
}


@ModelAttribute 사용은 선택적이다 - 예로, 이 어노테이션에 어트리뷰트를 설정하기 위해 사용할 수 있다. 기본적으로 BeanUtils#isSimpleProperty로 단순 타입 값이 아닌 것으로 판단된 아규먼트이면서, 어떠한 아규먼트 리졸버에 의해서도 리졸빙되지 않은 아규먼트는 @ModelAttribute가 적용된 것과 같이 작동한다.

@SessionAttributes



@SessionAttributes는 요청 간 모델 어트리뷰트를 WebSession 에 저장하기 위해 사용된다. 타입 레벨 어노테이션으로, 특정 컨트롤러에 의해 사용되며 세션 어트리뷰트를 선언한다. 보통 이어지는 요청에서의 접근을 위해, 세션에 그대로 저장되어야 하는 모델 어트리뷰트의 이름이나 타입을 나열한다.

다음 예제를 보자:

Java
@Controller
@SessionAttributes("pet") (1)
public class EditPetForm {
    // ...
}
Kotlin
@Controller
@SessionAttributes("pet") (1)
class EditPetForm {
    // ...
}


(1) @SessionAttributes 어노테이션을 적용한다.

최초 요청에서, 모델 어트리뷰트가 이름 pet으로 모델에 추가된다. 이 모델 어트리뷰트는 자동으로 WebSession 에 저장된다. 이 모델은 다른 컨트롤러 메서드가 SessionStatus 메서드 아규먼트로 세션 저장소를 클리어하기 전까지 남아 있는다. 다음은 그 예제이다:

Java
@Controller
@SessionAttributes("pet") (1)
public class EditPetForm {

    // ...

    @PostMapping("/pets/{id}")
    public String handle(Pet pet, BindingResult errors, SessionStatus status) { (2)
        if (errors.hasErrors()) {
            // ...
        }
            status.setComplete();
            // ...
        }
    }
}
Kotlin
@Controller
@SessionAttributes("pet") (1)
class EditPetForm {

    // ...

    @PostMapping("/pets/{id}")
    fun handle(pet: Pet, errors: BindingResult, status: SessionStatus): String { (2)
        if (errors.hasErrors()) {
            // ...
        }
        status.setComplete()
        // ...
    }
}


(1) @SessionAttributes 어노테이션을 적용한다. (2) SessionStatus 변수를 사용한다.

@SessionAttribute



전역적으로 관리되는(컨트롤러 바깥에서 - 예: 필터) 기존 세션 어트리뷰트로 접근해야 하고, 그 존재 여부가 확실치 않다면, @SessionAttribute 어느테이션을 메서드 파라미터에 적용할 수 있다:

Java
@GetMapping("/")
public String handle(@SessionAttribute User user) { (1)
    // ...
}
Kotlin
@GetMapping("/")
fun handle(@SessionAttribute user: User): String { (1)
    // ...
}


(1) @SessionAttribute 어노테이션을 적용한다.

세션 어트리뷰트를 추가하거나 제거하는 경우, WebSession을 컨트롤러에 주입할 것을 고려해볼 수 있다.

컨트롤러의 워크플로우의 일부로서 세션에 모델 어트리뷰트를 임시 저장하려면 SessionAttribute를 사용할 수 있다. @SessionAttribute에 설명되어 있다.

@RequestAttribute



@SessionAttribute와 유사하게, @RequestAttribute를 사용하여 이전에 생성된 기존 요청 어트리뷰트에 접근할 수 있다(예: WebFilter 에서 생성한). 다음은 그 예제이다:

Java
@GetMapping("/")
public String handle(@RequestAttribute Client client) { (1)
    // ...
}
Kotlin
@GetMapping("/")
fun handle(@RequestAttribute client: Client): String { (1)
    // ...
}


(1) @RequestAttribute를 적용한다.

멀트파트 컨텐츠



Multipart Data에서 설명된대로, ServerWebExchange는 멀티파트 컨텐츠에 접근할 수 있다. 컨트롤러에서 파일 업로드 폼을 핸들링하는 가장 최적의 방법은 커맨드 객체 로 데이터 바인딩하는 것이다. 다음은 그 예제이다:

Java
class MyForm {

    private String name;

    private MultipartFile file;

    // ...

}

@Controller
public class FileUploadController {

    @PostMapping("/form")
    public String handleFormUpload(MyForm form, BindingResult errors) {
        // ...
    }

}
Kotlin
class MyForm(
        val name: String,
        val file: MultipartFile)

@Controller
class FileUploadController {

    @PostMapping("/form")
    fun handleFormUpload(form: MyForm, errors: BindingResult): String {
        // ...
    }

}


RESTful 서비스 시나리오의 브라우저가 아닌 클라이언트로부터의 멀티파트 요청을 전송할 수 있다. 다음은 파일과 JSON을 요청에 함께 사용한 예제이다:

요청

@RequestPart로 각 파트에 접근할 수 있다:

Java
@PostMapping("/")
public String handle(@RequestPart("meta-data") Part metadata, (1)
        @RequestPart("file-data") FilePart file) { (2)
    // ...
}
Kotlin
@PostMapping("/")
fun handle(@RequestPart("meta-data") Part metadata, (1)
        @RequestPart("file-data") FilePart file): String { (2)
    // ...
}


(1) @RequestPart로 metadata를 얻는다. (2) @RequestPart로 file을 얻는다.

원본 파트 컨텐츠를 디시리얼라이징하기 위해(예: JSON으로 - @RequestBody와 유사), Part 대신 구체적인 타겟 Object를 선언할 수 있다:

Java
@PostMapping("/")
public String handle(@RequestPart("meta-data") MetaData metadata) { (1)
    // ...
}
Kotlin
@PostMapping("/")
fun handle(@RequestPart("meta-data") metadata: MetaData): String { (1)
    // ...
}


(1) @RequestPartmetadata를 얻는다.

@RequestPart와 javax.validation.Valid 또는 스프링의 @Validated를 함께 사용할 수 있다. javax.validation.Valid와 @Validated는 표준 빈 밸리데이션이 적용된다. 기본적으로 밸리데이션 에러는 WebExchangeBindException을 발생시킨다. 이 익셉션은 400(BAD_REQUEST) 응답이 된다. 그렇지 않으면, 컨트롤러 안에서 Errors 또는 BindingResult 아규먼트로 밸리데이션 에러를 핸들링할 수 있다:

Java
@PostMapping("/")
public String handle(@Valid @RequestPart("meta-data") Mono<MetaData> metadata) {
    // use one of the onError* operators...
}
Kotlin
@PostMapping("/")
fun handle(@Valid @RequestPart("meta-data") metadata: MetaData): String {
    // ...
}


@RequestBody를 사용하여 멀티파트 데이터 전체를 MultiValueMap으로 접근할 수 있다:

Java
@PostMapping("/")
public String handle(@RequestBody Mono<MultiValueMap<String, Part>> parts) { (1)
    // ...
}
Kotlin
@PostMapping("/")
fun handle(@RequestBody parts: MultiValueMap<String, Part>): String { (1)
    // ...
}


(1) @RequestBody를 적용한다.

@RequestBody와 Flux<Part>(코틀린에선 Flow<Part>)를 사용하여 멀티파트 데이터를 순차적으로, 스트리밍 방식으로 접근할 수 있다:

Java
@PostMapping("/")
public String handle(@RequestBody Flux<Part> parts) { (1)
    // ...
}
Kotlin
@PostMapping("/")
fun handle(@RequestBody parts: Flow<Part>): String { (1)
    // ...
}


(1) @RequestBody를 적용한다.

@RequestBody



@RequestBody를 사용하여 요청 본문을 읽고 Object로 디시리얼라이징할 수 있다. 내부적으로 HttpMessageReader를 사용한다:

Java
@PostMapping("/accounts")
public void handle(@RequestBody Account account) {
    // ...
}
Kotlin
@PostMapping("/accounts")
fun handle(@RequestBody account: Account) {
    // ...
}


스프링 MVC 와는 달리, 웹플럭스에서는 @RequestBody 메서드 아규먼트는 리액티브 타입과 완전한 논 블로킹 읽기,(클라이언트에서 서버로) 스트리밍을 지원한다.

Java
@PostMapping("/accounts")
public void handle(@RequestBody Mono<Account> account) {
    // ...
}
Kotlin
@PostMapping("/accounts")
fun handle(@RequestBody accounts: Flow<Account>) {
    // ...
}


WebFlux ConfigHTTP message codec 옵션을 사용하여 메시지 리더(readers)를 설정하거나 커스터마이징할 수 있다.

@RequestBody와 javax.validation.Valid 또는 스프링의 @Validated를 함께 사용할 수 있다. javax.validation.Valid와 @Validated는 표준 빈 밸리데이션이 적용된다. 기본적으로 밸리데이션 에러는 WebExchangeBindException을 발생시킨다. 이 익셉션은 400(BAD_REQUEST) 응답이 된다. 그렇지 않으면, 컨트롤러 안에서 Errors 또는 BindingResult 아규먼트로 밸리데이션 에러를 핸들링할 수 있다:

Java
@PostMapping("/accounts")
public void handle(@Valid @RequestBody Mono<Account> account) {
    // use one of the onError* operators...
}
Kotlin
@PostMapping("/accounts")
fun handle(@Valid @RequestBody account: Mono<Account>) {
    // ...
}


HttpEntity

HttpEntity는 @RequestBody를 사용하는 것과 거의 동일하지만, HttpEntity는 요청 헤더와 본문을 노출하는 컨테이너 객체에 기반한다. 다음은 HttpEntity 사용 예제이다:

Java
@PostMapping("/accounts")
public void handle(HttpEntity<Account> entity) {
    // ...
}
Kotlin
@PostMapping("/accounts")
fun handle(entity: HttpEntity<Account>) {
    // ...
}


@ResponseBody



@ResponseBody를 메서드에 적용하여 반환값을 시리얼라이징하고 응답 본문에 작성할 수 있다. 내부적으로 HttpMessageWriter를 사용한다:

Java
@GetMapping("/accounts/{id}")
@ResponseBody
public Account handle() {
    // ...
}
Kotlin
@GetMapping("/accounts/{id}")
@ResponseBody
fun handle(): Account {
    // ...
}


@ResponseBody를 클래스 레벨에 적용하여 컨트롤러 안의 모든 메서드에 공통 적용할 수도 있다. 이는 @RestController의 효과와 동일하다. @Controller와 @ResponseBody를 메타 어노테이션으로 적용하는 것과 같다.

@ResponseBody는 리액티브 타입을 지원한다. Reactor 또는 RxJava 타입을 반환할 수 있고, 리액티브 타입이 생성하는 값을 비동기로 응답에 작성할 수 있다. 더 자세한 내용은 StreamingJSON rendering에서 다룬다.

@ResponseBody 메서드를 JSON 시리얼라이징 뷰와 함께 사용할 수 있다. 자세한 내용은 Jackson JSON를 보라.

WebFlux ConfigHTTP message codecs 옵션을 사용하여 메시지 작성을 설정하고 커스터마이징할 수 있다.

ResponseEntity



ResponseEntity는 @ResponseBody와 비슷하지만, 상태(status)와 헤더를 가지고 있다:

Java
@GetMapping("/something")
public ResponseEntity<String> handle() {
    String body = ... ;
    String etag = ... ;
    return ResponseEntity.ok().eTag(etag).build(body);
}
Kotlin
@GetMapping("/something")
fun handle(): ResponseEntity<String> {
    val body: String = ...
    val etag: String = ...
    return ResponseEntity.ok().eTag(etag).build(body)
}


웹플럭스는 ResponseEntity를 비동기로 생성하기 위한 단일 값 리액티브 타입을 지원한다. 그리고/또는 단일과 다수 값 티액티브 타입을 본문에 사용할 수 있다.

Jackson JSON



스프링은 Jackson JSON 라이브러리를 지원한다.

JSON Views

스프링 웹플럭스는 내장형 Jackson's Serialization Views를 제공한다. Object의 필드 중 일부만을 렌더링할 수 있다. @ResponseBody 또는 ResponseEntity 컨트롤러 메서드와 함께 사용하기 위해서는 Jackson's @JsonView를 사용할 수 있다. 이 어노테이션으로 시리얼라이징 뷰 클래스를 활성화한다. 다음은 그 예제이다:

Java
@RestController
public class UserController {

    @GetMapping("/user")
    @JsonView(User.WithoutPasswordView.class)
    public User getUser() {
        return new User("eric", "7!jd#h23");
    }
}

public class User {

    public interface WithoutPasswordView {};
    public interface WithPasswordView extends WithoutPasswordView {};

    private String username;
    private String password;

    public User() {
    }

    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }

    @JsonView(WithoutPasswordView.class)
    public String getUsername() {
        return this.username;
    }

    @JsonView(WithPasswordView.class)
    public String getPassword() {
        return this.password;
    }
}
Kotlin
@RestController
class UserController {

    @GetMapping("/user")
    @JsonView(User.WithoutPasswordView::class)
    fun getUser(): User {
        return User("eric", "7!jd#h23")
    }
}

class User(
        @JsonView(WithoutPasswordView::class) val username: String,
        @JsonView(WithPasswordView::class) val password: String
) {
    interface WithoutPasswordView
    interface WithPasswordView : WithoutPasswordView
}


# @JsonView는 뷰 클래스 지정 시 배열을 허용하지만, 한 컨트롤러 메서드 당 하나의 뷰만 지정할 수 있다. 다수의 뷰를 활성화하려면 컴포짓 인터페이스를 사용하라.

1.4.4. Model

@ModelAttribute를 다음과 같이 사용할 수 있다:
  • @RequestMapping 메서드의 메서드 아규먼트에 적용하여 모델로부터의 Object를 생성하거나 접근하고, WebDataBinder를 통해 이를 요청에 바인딩한다.
  • @Controller 또는 @ControllerAdvice 클래스의 메서드 레벨 어노테이션으로 적용하여 @RequestMapping 메서드 실행 전 모델 초기화 동작을 수행하도록 한다.
  • @RequestMapping 메서드에 적용하여 이 메서드의 반환값이 모델 어트리뷰트임을 표시한다.


이 섹션은 @ModelAttribute 메서드나, 위 목록의 두 번째 - 메서드 레벨 어노테이션을 주제로 한다. 컨트롤러는 @ModelAttribute 메서드를 몇 개든 가질 수 있다. 이 메서드들은 같은 컨트롤러 안의 @RequestMapping 메서드가 실행되기 전에 먼저 실행된다. @ModelAttribute 메서드는 @ControllerAdvice를 통해 컨트롤러 간 공유되어 사용될 수 있다. 더 자세한 내용은 Controller Advice를 보라.

@ModelAttribute 메서드는 유연한 시그니처를 갖는다. @RequestMapping 메서드와 동일한 아규먼트를 다수 지원한다(@ModelAttribute 자체는 는 요청 본문과 무관하다는 점을 제외하고).

다음은 @ModelAttribute 메서드 예제이다:

Java
@ModelAttribute
public void populateModel(@RequestParam String number, Model model) {
    model.addAttribute(accountRepository.findAccount(number));
    // add more ...
}
Kotlin
@ModelAttribute
fun populateModel(@RequestParam number: String, model: Model) {
    model.addAttribute(accountRepository.findAccount(number))
    // add more ...
}


다음 예제는 한 어트리뷰트만 추가한다:

Java
@ModelAttribute
public Account addAccount(@RequestParam String number) {
    return accountRepository.findAccount(number);
}
Kotlin
@ModelAttribute
fun addAccount(@RequestParam number: String): Account {
    return accountRepository.findAccount(number);
}


# @ModelAttribute의 이름이 명시적으로 지정되지 않은 경우, Convention 자바독에 기술된 내용에 근거한 디폴트 이름이 선택된다. 오버로딩된 addAttribute 메서드나 @ModelAttribute의 name 어트리뷰트를 통해 언제나 명시적 이름을 지정할 수 있다(반환값에 대한).

스프링 MVC와 달리, 스프링 웹플럭스는 모델에서의 리액티브 타입을 지원한다(예: Mono<Account>, io.reactivex.Single<Account>). 이런 비동기 모델 어트리뷰트는 @RequestMapping 메서드의 실행 시점에 실제 값으로 투명하게 리졸빙된다(그리고 모델이 갱신된다). @RequestMapping 메서드의 @ModelAttribute 아규먼트는 래퍼 없이 실제 타입으로 선언된다. 다음은 그 예제이다:

Java
@ModelAttribute
public void addAccount(@RequestParam String number) {
    Mono<Account> accountMono = accountRepository.findAccount(number);
    model.addAttribute("account", accountMono);
}

@PostMapping("/accounts")
public String handle(@ModelAttribute Account account, BindingResult errors) {
    // ...
}
Kotlin
import org.springframework.ui.set

@ModelAttribute
fun addAccount(@RequestParam number: String) {
    val accountMono: Mono<Account> = accountRepository.findAccount(number)
    model["account"] = accountMono
}

@PostMapping("/accounts")
fun handle(@ModelAttribute account: Account, errors: BindingResult): String {
    // ...
}


추가로, 리액티브 타입 래퍼를 가진 모델 어트리뷰트는 뷰 렌더링 바로 전에 실제 값으로 리졸빙된다(그리고 모델이 갱신된다).

@RequestMapping 메서드의 반환값을 모델 어트리뷰트로 해석하는 경우, @ModelAttribute를 @RequestMapping 메서드의 메서드 레벨 어노테이션으로 적용할 수도 있다. HTML 컨트롤러에서는 이것이 반환값이 뷰 이름이 되는 String 이 아닐 때의 기본 동작이기 때문에 일반적으로는 필요치 않다. @ModelAttribute를 사용하여 모델 어트리뷰트의 이름을 커스터마이징할 수도 있다. 다음은 그 예제이다:

Java
@GetMapping("/accounts/{id}")
@ModelAttribute("myAccount")
public Account handle() {
    // ...
    return account;
}
Kotlin
@GetMapping("/accounts/{id}")
@ModelAttribute("myAccount")
fun handle(): Account {
    // ...
    return account
}


1.4.5. DataBinder

@Controller, @ControllerAdvice 클래스는 @InitInder 메서드로 WebDataBinder 인스턴스를 초기화할 수 있다. 이 인스턴스는 아래와 같이 사용된다:
  • 요청 파라미터(폼 데이터나 쿼리)를 모델 객체에 바인딩한다.
  • String 기반 요청 값(요청 파라미터, 경로 변수, 헤더, 쿠키, 기타 등등) 을 타겟 컨트롤러 메서드 아규먼트 타입으로 컨버팅한다.
  • HTML 폼 렌더링 시 모델 객체값들을 String 값으로 포맷팅한다.


@InitBinder 메서드는 컨트롤러 특징적인 java.bean.PropertyEditor 혹은 스프링 Converter와 Formatter 컴포넌트롤 등록할 수 있다. 추가로 WebFlux Java configuration를 사용하여 Converter와 Formatter 타입을 전역적으로 사용되는 FormattingConversionService 에 등록할 수 있다.

@InitBinder 메서드는 @RequestMapping 메서드와 동일한 아규먼트를 다수 지원한다(@ModelAttribute(커맨드 객체)를 제외하고). 일반적으로 아규먼트는 컴포넌트 등록을 위해 WebDataBinder 아규먼트와 함께 void 반환값으로 선언된다. 다음은 @InitBinder 사용 예제이다:

Java
@Controller
public class FormController {

    @InitBinder (1)
    public void initBinder(WebDataBinder binder) {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        dateFormat.setLenient(false);
        binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));
    }

    // ...
}
Kotlin
@Controller
class FormController {

    @InitBinder (1)
    fun initBinder(binder: WebDataBinder) {
        val dateFormat = SimpleDateFormat("yyyy-MM-dd")
        dateFormat.isLenient = false
        binder.registerCustomEditor(Date::class.java, CustomDateEditor(dateFormat, false))
    }

    // ...
}


(1) @InitBinder를 적용한다.

다른 방법으로는, 전역 FormattingConversionService를 통한 Formatter 기반 설정 사용 시, 같은 방식을 재사용하며 컨트롤러 특징적 Formatter 인스턴스를 등록할 수 있다. 다음은 그 예제이다:

Java
@Controller
public class FormController {

    @InitBinder
    protected void initBinder(WebDataBinder binder) {
        binder.addCustomFormatter(new DateFormatter("yyyy-MM-dd")); (1)
    }

    // ...
}
Kotlin
@Controller
class FormController {

    @InitBinder
    fun initBinder(binder: WebDataBinder) {
        binder.addCustomFormatter(DateFormatter("yyyy-MM-dd")) (1)
    }

    // ...
}


(1) 커스텀 포맷터를 추가한다(여기서는 DataFormatter를 사용했다).

1.4.6. 익셉션 관리

@Controller, @ControllerAdvice 클래스는 @ExceptionHandler 메서드로 컨트롤러 메서드에서 발생한 익셉션을 핸들링할 수 있다. 다음은 그 예제이다:

Java
@Controller
public class SimpleController {

    // ...

    @ExceptionHandler (1)
    public ResponseEntity<String> handle(IOException ex) {
        // ...
    }
}
Kotlin
@Controller
class SimpleController {

    // ...

    @ExceptionHandler (1)
    fun handle(ex: IOException): ResponseEntity<String> {
        // ...
    }
}


(1) @ExceptionHandler를 적용한다.

익셉션 아규먼트는 전파된 익셉션의 최상위 레벨 익셉션과 매칭할 수 있다(예제의 IOException). 혹은 최상위 레벨 래퍼 익셉션의 원인이 되는 익셉션과도 매칭할 수 있다(예: IOException으로 래핑된 IllegalStateException).

위 예제에서와 같이, 매칭 익셉션 타입으로는 가급적 타겟 익셉션을 메서드 아규먼트로 선언하는 것이 좋다. 아니면 어노테이션을 선언하여 매칭 익셉션 범위를 좁힐 수도 있다. 최대한 구체적인 익섹션을 아규먼트 시그니처에 사용하여 주요한 루트 익셉션 매핑을 상응하는 순서에 맞는 @ControllerAdvice 에 선언할 것을 권한다. 더 자세한 정보는 MVC 섹션에서 다룬다.

# 웹플럭스의 @ExceptionHandler 메서드는 요청 본문의 익셉션과 @ModelAttribute 관련 메서드 아규먼트로 @RequestMapping 메서드와 같은 메서드 아규먼트와 반환값을 지원한다.

REST API 익셉션



REST 서비스의 공통 요건은 응답 본문에 상세한 에러 내용을 포함하는 것이다. 스프링 프레임워크는 이 작업을 자동으로 해주지 않기 때문에, 응답 본문의 상세한 에러 내용 표시는 어플리케이션에 특징적이다. 하지만 @RestController는 @ExceptionHandler 메서드를 ResponseEntity 반환값과 함께 사용하여 상태 코드와 응답 본문을 작성할 수 있다. 이런 메서드는 @ControllerAdvice 클래스에도 선언되어 전역적인 적용이 가능하다.

# 스프링 웹플럭스는 스프링 MVC의 ResponseEntityExceptionHandler와 같은 컴포넌트를 제공하지 않는다. 왜냐하면 웹플럭스에서 발생하는 익셉션은 모두 ResponseStatusException(혹은 이것의 서브클래스) 이고, 이 익셉션들은 HTTP 상태 코드로 해석될 필요가 없기 때문이다.

1.4.7. 컨트롤러 어드바이스(Controller Advice)

보통 @ExceptionHandler, @InitBinder, @ModelAttribute 메서드는 자신이 선언된 @Controller 클래스(혹은 클래스 계층) 안에서 적용된다. 클래스에 @ControllerAdvice 또는 @RestControllerAdvice를 적용하면 이런 메서드들을 더 넓은 범위로(컨트롤러 간) 적용할 수 있다.

@ControllerAdvice는 @Component와 함께 적용된다. 이는 컨트롤러 어드바이스 클래스는 컴포넌트 스캐닝을 통해 스프링 빈으로 등록될 수 있음을 의미한다. @RestControllerAdvice는 컴포즈드 어노테이션으로, @ControllerAdvice와 @ResponseBody를 함께 적용한다. 이것은 곧 @ExceptionHandler 메서드가 메시지 컨버팅을 통해 응답 본문으로 작성된다는 뜻이다(뷰 리솔루션이나 템플릿 렌더링에 대응한다).

어플리케이션 시작 시에 @RequestMapping 과 @ExceptionHandler 메서드의 기반 클래스들이 @ControllerAdvice가 적용된 스프링 빈을 감지하고, 이 메서드들을 런타임에 적용한다. 전역 @ExceptionHandler 메서드(@ControllerAdvice 에서 선언된)는 지역 @ExceptionHandler 메서드(@Controller 에서 선언된)가 적용된 다음에 적용된다. 이와 반대로 전역 @ModelAttribute, @InitBinder 메서드들은 지역 메서드들이 적용되기 전에 먼저 적용된다.

기본적으로, @ControllerAdvice 메서드는 모든 요청에 적용되지만(모든 컨트롤러에 적용), 어노테이션에 어트리뷰트를 사용하여 컨트롤러 적용 범위를 좁힐 수 있다. 다음은 그 예제이다:

Java
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
Kotlin
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = [RestController::class])
public class ExampleAdvice1 {}

// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = [ControllerInterface::class, AbstractController::class])
public class ExampleAdvice3 {}


위 예제의 컨트롤러 셀렉터는 런타임에 평가되기 때문에, 광범위하게 사용될 경우 성능에 부정적인 영향을 줄 수 있다. ControllerAdvice 자바독에서 더 자세한 정보를 찾을 수 있다.

1.5. 함수형 엔드포인트(Functional Endpoints)

스프링 웹플럭스는 WebFlux.fn를 포함한다. WebFlux.fn은 경량 함수형 프로그래밍 모델으로, 함수는 요청을 라우팅하고 핸들링하며, 각 요소는 불변형(immutable)이다. 어노테이션 기반 프로그래밍 모델의 대체제인 동시에 동일한 Reactive Core 기반 위에 작동한다.

1.5.1. 개요

WebFlux.fn 에서 HTTP 요청은 HandlerFunction으로 핸들링한다: ServerRequest 아규먼트를 가지며, 지연된 ServerResponse를 반환한다(Mono). 요청과 응답 객체 모두 불변형이며 HTTP 요청과 응답에의 접근에 있어 자바 8 에 친화적인 방식을 제공한다. HandlerFunction은 어노테이션 기반 프로그래밍 모델의 @RequestMapping 메서드의 본문와 동등하다.

RouterFunction은 들어오는 요청을 핸들러 함수로 라우팅한다: ServerRequest 아규먼트를 가지며, 지연된 HandlerFunction을 반환한다(Mono). 라우터 함수가 매칭되면 핸들러 함수를 반환한다. 매칭되지 않으면 빈 Mono를 반환한다. RouterFunction은 @RequestMapping 어노테이션과 동등하지만, 라우터 함수가 제공하는 큰 차이점은 데이터가 아닌 그 동작에 있다.

RouterFunctions.route() 는 라우터를 생성하는 라우터 빌더를 제공한다. 다음은 그 예제이다:

Java
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;

PersonRepository repository = ...
PersonHandler handler = new PersonHandler(repository);

RouterFunction<ServerResponse> route = route()
    .GET("/person/{id}", accept(APPLICATION_JSON), handler::getPerson)
    .GET("/person", accept(APPLICATION_JSON), handler::listPeople)
    .POST("/person", handler::createPerson)
    .build();


public class PersonHandler {

    // ...

    public Mono<ServerResponse> listPeople(ServerRequest request) {
        // ...
    }

    public Mono<ServerResponse> createPerson(ServerRequest request) {
        // ...
    }

    public Mono<ServerResponse> getPerson(ServerRequest request) {
        // ...
    }
}
Kotlin
val repository: PersonRepository = ...
val handler = PersonHandler(repository)

val route = coRouter { (1)
    accept(APPLICATION_JSON).nest {
        GET("/person/{id}", handler::getPerson)
        GET("/person", handler::listPeople)
    }
    POST("/person", handler::createPerson)
}


class PersonHandler(private val repository: PersonRepository) {

    // ...

    suspend fun listPeople(request: ServerRequest): ServerResponse {
        // ...
    }

    suspend fun createPerson(request: ServerRequest): ServerResponse {
        // ...
    }

    suspend fun getPerson(request: ServerRequest): ServerResponse {
        // ...
    }
}


RouterFunction을 가동하는 방법 하나는 이를 HttpHandler로 변경하고 내장형 서버 어댑터를 통해 설치하는 것이다.
  • RouterFunctions.toHttpHandler(RouterFunction)
  • RouterFunctions.toHttpHandler(RouterFunction, HandlerStrategies)


대부분의 어플리케이션은 웹플럭스 자바 설정을 통해 실행할 수 있다. Running a Server를 보라.

1.5.2. HandlerFunction

ServerRequest, ServerResponse는 불변형 인터페이스로, JDK 8 에 친숙한 HTTP 요청과 응답 접근법을 제공한다. 요청과 응답은 본문 스트림에 대한 Reactive Streams 백프레셔를 제공한다. 요청 본문은 리액터 Flux 또는 Mono로 표현된다. 응답 본문은 Flux와 Mono를 포함하는 리액티브 스트림 Publisher로 표현된다. 더 자세한 내용은 리액티브 라이브러리를 보라.

ServerRequest



ServerRequest는 HTTP 메서드, URI, 헤더, 쿼리 파라미터에의 접근을 제공하며, 본문에의 접근은 body 메서드를 통해 이루어진다.

다음 예제는 요청 본문을 Mono<String>으로 추출한다:

Java
Mono<String> string = request.bodyToMono(String.class);
Kotlin
val string = request.awaitBody<String>()


다음 예제는 본문을 Flux<Person>(코틀린은 Flow) 로 추출한다. Person 객체는 JSON 또는 XML 처럼 시리얼라이징된 폼으로부터 디코딩된다.

Java
Flux<Person> people = request.bodyToFlux(Person.class);
Kotlin
val people = request.bodyToFlow<Person>()


위의 예제는 더 일반적인 ServerRequest.body(BodyExtractor) 사용 예이다. ServerRequest.body(BodyExtractor) 는 BodyExtractor 함수형 전략(strategy) 인터페이스를 받는다. 유틸리티 클래스 BodyExtractors는 다량의 인스턴스에의 접근을 제공한다. 예로, 위 예제는 아래와 같이 작성될 수 있다:

Java
Mono<String> string = request.body(BodyExtractors.toMono(String.class));
Flux<Person> people = request.body(BodyExtractors.toFlux(Person.class));
Kotlin
    val string = request.body(BodyExtractors.toMono(String::class.java)).awaitFirst()
    val people = request.body(BodyExtractors.toFlux(Person::class.java)).asFlow()


다음 예제는 폼 데이터에 접근한다:

Java
Mono<MultiValueMap<String, String> map = request.formData();
Kotlin
val map = request.awaitFormData()


다음 예제는 맵 방식으로 멀티파트 데이터에 접근한다:

Java
Mono<MultiValueMap<String, Part> map = request.multipartData();
Kotlin
val map = request.awaitMultipartData()


다음 예제는 멀트파트에 한 번에 하나씩, 스트리밍 방식으로 접근한다:

Java
Flux<Part> parts = request.body(BodyExtractors.toParts());
Kotlin
val parts = request.body(BodyExtractors.toParts()).asFlow()


ServerResponse



ServerResponse는 HTTP 응답에의 접근을 제공한다. 불변형이며, build 메서드로 생성한다. 빌더를 사용하여 응답 상태를 설정하고 응답 헤더를 추가하거나 본문을 작성한다. 다음 예제는 200(OK) 응답을 JSON 컨텐츠로 생성한다:

Java
Mono<Person> person = ...
ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person, Person.class);
Kotlin
val person: Person = ...
ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(person)


다음 예제는 201(CREATED) 응답을 Location 헤더와 빈 본문으로 생성한다:

Java
URI location = ...
ServerResponse.created(location).build();
Kotlin
val location: URI = ...
ServerResponse.created(location).build()


사용된 코덱에 따라서 힌트 파라미터를 전달하여 응답 본문이 시리얼라이징 혹은 디시리얼라이징되는 방식을 커스터마이징할 수 있다. 예로, Jackson JSON view를 지정한다:

Java
ServerResponse.ok().hint(Jackson2CodecSupport.JSON_VIEW_HINT, MyJacksonView.class).body(...);
Kotlin
ServerResponse.ok().hint(Jackson2CodecSupport.JSON_VIEW_HINT, MyJacksonView::class.java).body(...)


핸들러 클래스(Handler Classes)



핸들러 함수를 람다로 작성할 수 있다:

Java
HandlerFunction<ServerResponse> helloWorld =
  request -> ServerResponse.ok().bodyValue("Hello World");
Kotlin
val helloWorld = HandlerFunction<ServerResponse> { ServerResponse.ok().bodyValue("Hello World") }


이 방식은 편리하지만 다수의 함수가 필요한 어플리케이션에선 다수의 인라인 람다는 지저분할 수 있다. 때문에 관련된 함수 그룹을 만들어 하나의 핸들러 클래스에 모으는 것이 좋다. 어노테이션 기반 어플리케이션에선 @Controller 클래스가 비슷한 역할을 한다. 예를 들어, 다음 클래스는 리액티브 Person 리파지토리를 노출한다:

Java
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.web.reactive.function.server.ServerResponse.ok;

public class PersonHandler {

    private final PersonRepository repository;

    public PersonHandler(PersonRepository repository) {
        this.repository = repository;
    }

    public Mono<ServerResponse> listPeople(ServerRequest request) { (1)
        Flux<Person> people = repository.allPeople();
        return ok().contentType(APPLICATION_JSON).body(people, Person.class);
    }

    public Mono<ServerResponse> createPerson(ServerRequest request) { (2)
        Mono<Person> person = request.bodyToMono(Person.class);
        return ok().build(repository.savePerson(person));
    }

    public Mono<ServerResponse> getPerson(ServerRequest request) { (3)
        int personId = Integer.valueOf(request.pathVariable("id"));
        return repository.getPerson(personId)
            .flatMap(person -> ok().contentType(APPLICATION_JSON).bodyValue(person))
            .switchIfEmpty(ServerResponse.notFound().build());
    }
}
Kotlin
class PersonHandler(private val repository: PersonRepository) {

    suspend fun listPeople(request: ServerRequest): ServerResponse { (1)
        val people: Flow<Person> = repository.allPeople()
        return ok().contentType(APPLICATION_JSON).bodyAndAwait(people);
    }

    suspend fun createPerson(request: ServerRequest): ServerResponse { (2)
        val person = request.awaitBody<Person>()
        repository.savePerson(person)
        return ok().buildAndAwait()
    }

    suspend fun getPerson(request: ServerRequest): ServerResponse { (3)
        val personId = request.pathVariable("id").toInt()
        return repository.getPerson(personId)?.let { ok().contentType(APPLICATION_JSON).bodyValueAndAwait(it) }
                ?: ServerResponse.notFound().buildAndAwait()

    }
}


(1) listPeople은 리파지토리에서 검색한 모든 Person 객체를 JSON으로 반환하는 핸들러 함수이다. (2) createPerson은 요청 본문에 있는 새 Person을 저장하는 핸들러 함수이다. PersonRepository.savePerson(Person)Mono를 반환한다: 요청에서 Person을 읽어 저장하면 빈 Mono는 완료 시그널을 발생시킨다. 이 완료 시그널을 받으면(Person 저장이 완료되면) build(Publisher) 메서드를 사용하여 응답을 보낼 수 있다. (3) getPersonPerson을 반환하는 핸들러 함수이다. Person은 경로 변수인 id를 통해 검색된다. 리파지토리에서 Person을 검색하고 JSON 응답을 생성한다. 검색된 Person 이 없으면 switchIfEmpty(Mono<T>)를 사용해 404 Not Found 응답을 반환한다.

밸리데이션



함수형 엔드포인트는 스프링의 밸리데이션 기능을 요청 본문에 적용한다. 예로, Person 에 대한 커스텀 스프링 Validator 구현체는 다음과 같이 사용한다:

Java
public class PersonHandler {

    private final Validator validator = new PersonValidator(); (1)

    // ...

    public Mono<ServerResponse> createPerson(ServerRequest request) {
        Mono<Person> person = request.bodyToMono(Person.class).doOnNext(this::validate); (2)
        return ok().build(repository.savePerson(person));
    }

    private void validate(Person person) {
        Errors errors = new BeanPropertyBindingResult(person, "person");
        validator.validate(person, errors);
        if (errors.hasErrors()) {
            throw new ServerWebInputException(errors.toString()); (3)
        }
    }
}
Kotlin
class PersonHandler(private val repository: PersonRepository) {

    private val validator = PersonValidator() (1)

    // ...

    suspend fun createPerson(request: ServerRequest): ServerResponse {
        val person = request.awaitBody<Person>()
        validate(person) (2)
        repository.savePerson(person)
        return ok().buildAndAwait()
    }

    private fun validate(person: Person) {
        val errors: Errors = BeanPropertyBindingResult(person, "person");
        validator.validate(person, errors);
        if (errors.hasErrors()) {
            throw ServerWebInputException(errors.toString()) (3)
        }
    }
}


(1) Validator 인스턴스를 생성한다. (2) 밸리데이션을 적용한다. (3) 400 응답을 위한 익셉션을 발생시킨다.

핸들러는 LocalValidatorFactoryBean 에 기반한 전역 Validator 인스턴스를 생성하고 주입하여 표준 빈 밸리데이션 API(JSR-303)를 사용할 수 있다. Spring Validation을 보라.

1.5.3. RouterFunction

라우터 함수를 사용하여 요청을 그에 맞는 HandlerFunction 에 라우팅할 수 있다. 보통 라우터 함수를 직접 작성하지는 않고, RouterFunction 유틸리티 클래스의 메서드로 생성하여 사용한다. RouterFunctions.route()(파라미터 없음) 은 라우터 함수를 생성하기 위한 훌륭한 빌더를 제공하고, RouterFunctions.route(RequestPredicate, HandlerFunction) 는 직접 라우터를 생성하도록 한다.

일반적으로는 route() 빌더 사용을 권장한다. 이 빌더는 전형적인 매핑 시나리오를, 찾기 어려운 정적 임포트 없이 사용할 수 있는 편리하고 간결한 방식으로 제공한다. 예를 들어, 라우터 함수 빌더는 메서드 GET(String, HandlerFunction)으로 GET 요청 매핑을 생성한다. POST 요청 매핑에는 POST(String, HandlerFunction) 가 있다.

HTTP 메서드 기반 매핑 외에도, 이 빌더는 요청에 매핑할 때 추가적인 술어(predicates)를 도입하는 방법을 제공한다. RequestPredicate를 파라미터로 취하는, 각 HTTP 메서드에 대한 과적화된 변종이 존재하지만, 추가 제약조건을 표현할 수 있다.

Predicates



자신만의 RequestPredicate를 작성할 수 있지만, RequestPredicates 유틸리티 클래스는 요청 경로, HTTP 메서드, 컨텐츠 타입, 그리고 그 외의 것들에 근거하여 공통적으로 사용되는 구현체들을 제공한다. 다음은 요청 술어를 사용하여 Accept 헤더에 기반한 제약조건을 생성한다:

Java
RouterFunction<ServerResponse> route = RouterFunctions.route()
    .GET("/hello-world", accept(MediaType.TEXT_PLAIN),
        request -> ServerResponse.ok().bodyValue("Hello World")).build();
Kotlin
val route = coRouter {
    GET("/hello-world", accept(TEXT_PLAIN)) {
        ServerResponse.ok().bodyValueAndAwait("Hello World")
    }
}


다음을 사용하여 다수의 요청 술어를 함께 구성할 수 있다.
  • RequestPredicate.and(RequestPredicate) - 둘 모두 반드시 매칭.
  • RequestPredicate.or(RequestPredicate) - 하나만 매칭 가능.


RequestPredicates 에는 많은 술어가 구성되어 있다. 예를 들어, RequestPredicates.GET(String) 은 RequestPredicates.method(HttpMethod) 와 RequestPredicates.path(String)으로부터 구성되었다. 위 예제는 또한 두 요청 술어를 사용한다. 빌더는 RequestPredicates.GET을 사용하고, accept 술어를 함께 구성한다.

Routes



라우터 함수는 순서에 따라 평가된다: 첫 번째 라우터에 매칭되지 않으면 두 번째 라우터가 평가되고, 같은 과정을 밟는다. 따라서, 더 구체적인 라우터가 일반적인 라우터에 앞서도록 선언하는 것이 좋다. 유의할 점은, 이 동작은 어노테이션 기반 프로그래밍 모델과는 다르다. 어노테이션 기반 프로그래밍 모델에서는 더 구체적인 컨트롤러 메서드가 자동으로 선택된다.

라우터 함수 빌더를 사용하면, 정의된 모든 라우팅은 하나의 RouterFunction 안에 구성되고, build() 로부터 반환된다. 또한 다수의 라우터 함수를 함께 구성하는 다른 방법들이 있다:
  • RouterFunctions.route() 빌더의 add(RouterFunction)
  • RouterFunction.and(RouterFunction)
  • RouterFunction.andRoute(RequestPredicate, HandlerFunction) - RouterFunction.and() 와 중첩된 RouterFunctions.route() 의 간결한 형태


다음은 네 가지 라우팅의 구성을 보여준다:

Java
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.web.reactive.function.server.RequestPredicates.*;

PersonRepository repository = ...
PersonHandler handler = new PersonHandler(repository);

RouterFunction<ServerResponse> otherRoute = ...

RouterFunction<ServerResponse> route = route()
    .GET("/person/{id}", accept(APPLICATION_JSON), handler::getPerson) (1)
    .GET("/person", accept(APPLICATION_JSON), handler::listPeople) (2)
    .POST("/person", handler::createPerson) (3)
    .add(otherRoute) (4)
    .build();
Kotlin
import org.springframework.http.MediaType.APPLICATION_JSON

val repository: PersonRepository = ...
val handler = PersonHandler(repository);

val otherRoute: RouterFunction<ServerResponse> = coRouter {  }

val route = coRouter {
    GET("/person/{id}", accept(APPLICATION_JSON), handler::getPerson) (1)
    GET("/person", accept(APPLICATION_JSON), handler::listPeople) (2)
    POST("/person", handler::createPerson) (3)
}.and(otherRoute) (4)


1) GET /person/{id}와 함께 Accept 헤더가 JSON에 매칭되면 PersonHandler.getPerson으로 라우팅한다 2) GET /person와 함께 Accept 헤더가 JSON에 매칭되면 PersonHandler.listPeople으로 라우팅한다. 3) POST /person이 매칭되면 PersonHandler.createPerson으로 라우팅한다, 그리고 4) otherRoute는 다른 곳에서 생성된 라우터 함수이다. 라우팅에 추가된다.

중첩 라우팅(Nested Routes)



라우터 함수 그룹은 술어를 공유하는 것이 일반적이다. 예를 들어 경로를 공유할 수 있다. 위의 예제에서, 공유된 술어는 /person 에 매칭되는 경로 술어가 된다. 이 술어는 세 가지 라우팅에 사용되었다. 어노테이션을 사용할 때는 타입 레벨 @RequestMapping 어노테이션을 /person 과 매핑하여 이런 중복을 제거했다. WebFlux.fn 에서는 경로 술어는 라우터 함수 빌더의 path 메서드를 통해 공유될 수 있다. 예를 들어, 위 예제의 마지막 몇 줄은 중첩 라우팅을 사용하면 다음과 같이 개선된다:

Java
RouterFunction<ServerResponse> route = route()
    .path("/person", builder -> builder (1)
        .GET("/{id}", accept(APPLICATION_JSON), handler::getPerson)
        .GET("", accept(APPLICATION_JSON), handler::listPeople)
        .POST("/person", handler::createPerson))
    .build();
Kotlin
val route = coRouter {
    "/person".nest {
        GET("/{id}", accept(APPLICATION_JSON), handler::getPerson)
        GET("", accept(APPLICATION_JSON), handler::listPeople)
        POST("/person", handler::createPerson)
    }
}


(1) path의 두 번째 파라미터는 라우터 빌더를 소비한다.

경로 기반의 중첩이 가장 일반적이기는 하나, 빌더의 nest 메서드를 사용하여 어떠한 종류의 술어든 중첩할 수 있다. 위 예제는 Accept 헤더 술어를 공유하는 형태에서 여전히 중복이 있다. nest 메서드와 accept를 함께 사용하여 더욱 개선할 수 있다:

Java
RouterFunction<ServerResponse> route = route()
    .path("/person", b1 -> b1
        .nest(accept(APPLICATION_JSON), b2 -> b2
            .GET("/{id}", handler::getPerson)
            .GET("", handler::listPeople))
        .POST("/person", handler::createPerson))
    .build();
Kotlin
val route = coRouter {
    "/person".nest {
        accept(APPLICATION_JSON).nest {
            GET("/{id}", handler::getPerson)
            GET("", handler::listPeople)
            POST("/person", handler::createPerson)
        }
    }
}


1.5.4. 서버 가동하기

어떻게 HTTP 서버에서 라우터 함수를 가동할까? 다음 중 하나를 사용하여 라우터 함수를 HttpHandler로 컨버팅하는 간단한 옵션이 있다:
  • RouterFunctions.toHttpHandler(RoutherFunction)
  • RouterFunctions.toHttpHandler(RouterFunction, HandlerStrategies)


반환된 HttpHandler와 서버 어댑터를 서버 특징적 명령을 위한 HttpHandler에 따라 사용할 수 있다.

더 일반적인, 또한 스프링 부트에서 사용하는 옵션은 WebFlux Config를 통한 DispatcherHandler 기반 설정을 사용하는 것이다. 이 방법은 스프링 설정을 사용하여 요청 처리에 필요한 컴포넌트를 선언한다. 웹플럭스 자바 설정은 다음의 기반 컴포넌트를 선언하여 함수형 엔드포인트를 지원한다:
  • RouterFunctionMapping: 스프링 설정에서 하나 이상의 RouterFunction 빈을 감지하고, RouterFunction.andOther를 통해 이 빈들을 결합한다. 그리고 이 결과로 구성된 RouterFunction 에 요청을 라우팅한다.
  • HandlerFunctionAdapter: DispatcherHandler가 요청에 매핑된 HandlerFunction을 실행하도록 하는 단순한 어댑터이다.
  • ServerResponseResultHandler: ServerResponse의 writeTo 메서드를 통해 HandlerFunction을 실행한 결과를 핸들링한다.


위의 컴포넌트들은 함수형 엔드포인트가 DispatcherHandler 요청 처리 라이프싸이클 안에서 적절하게 작동하도록 한다. 그리고(잠재적으로) 어노테이티드 컨트롤러가 선언되어 있다면 여기에서도 함께 동작하도록 한다. 이것이 스프링 부트 웹플럭스 스타터가 함수형 엔드포인트를 적용하는 방식이다.

다음 예제는 웹플럭스 자바 설정을 보여준다(동작 방식에 관하여는 DispatcherHandler를 보라):

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Bean
    public RouterFunction<?> routerFunctionA() {
        // ...
    }

    @Bean
    public RouterFunction<?> routerFunctionB() {
        // ...
    }

    // ...

    @Override
    public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
        // configure message conversion...
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // configure CORS...
    }

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        // configure view resolution for HTML rendering...
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    @Bean
    fun routerFunctionA(): RouterFunction<*> {
        // ...
    }

    @Bean
    fun routerFunctionB(): RouterFunction<*> {
        // ...
    }

    // ...

    override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) {
        // configure message conversion...
    }

    override fun addCorsMappings(registry: CorsRegistry) {
        // configure CORS...
    }

    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        // configure view resolution for HTML rendering...
    }
}


1.5.5. 핸들러 함수 필터링(Filtering Handler Functions)

라우팅 함수 빌더의 before, after, filter 메서드를 사용하여 핸들러 함수를 필터링할 수 있다. 어노테이션을 사용할 때는 @ControllerAdvice 또는 @ServerFilter, 혹은 둘 모두를 통해 비슷한 기능을 구현할 수 있다. 필터는 해당 빌더가 생성한 모든 라우팅에 적용된다. 이는 중첩 라우팅 안에 선언된 필터는 최상위 레벨 라우팅에 적용되지 않음을 의미한다. 예를 들어, 다음 예제를 보자:

Java
RouterFunction<ServerResponse> route = route()
    .path("/person", b1 -> b1
        .nest(accept(APPLICATION_JSON), b2 -> b2
            .GET("/{id}", handler::getPerson)
            .GET("", handler::listPeople)
            .before(request -> ServerRequest.from(request) (1)
                .header("X-RequestHeader", "Value")
                .build()))
        .POST("/person", handler::createPerson))
    .after((request, response) -> logResponse(response)) (2)
    .build();
Kotlin
val route = router {
    "/person".nest {
        GET("/{id}", handler::getPerson)
        GET("", handler::listPeople)
        before { (1)
            ServerRequest.from(it)
                    .header("X-RequestHeader", "Value").build()
        }
        POST("/person", handler::createPerson)
        after { _, response -> (2)
            logResponse(response)
        }
    }
}


(1) 커스텀 요청 헤더를 추가하는 before 필터는 두 GET 라우팅에만 적용된다. (2) 응답 로깅을 수행하는 after 필터는 중첩 라우팅을 포함한 모든 라우팅에 적용된다.

라우터 빌더의 filter 메서드는 HandlerFilterFunction을 아규먼트로 갖는다: HandlerFilterFunction은 ServerRequest와 HandlerFunction을 가지고 ServerResponse를 반환한다. 핸들러 함수 파라미터는 체인의 다음 요소를 나타낸다. 일반적으로 다음 요소는 라우팅 대상 핸들러가 되지만, 다수의 필터를 적용할 때는 다른 필터가 될 수도 있다.

다음 예제는 간단한 보안 필터를 라우팅에 추가한다. SecurityManager는 특정 경로에의 접근 허용 여부를 결정한다:

Java
SecurityManager securityManager = ...

RouterFunction<ServerResponse> route = route()
    .path("/person", b1 -> b1
        .nest(accept(APPLICATION_JSON), b2 -> b2
            .GET("/{id}", handler::getPerson)
            .GET("", handler::listPeople))
        .POST("/person", handler::createPerson))
    .filter((request, next) -> {
        if (securityManager.allowAccessTo(request.path())) {
            return next.handle(request);
        }
        else {
            return ServerResponse.status(UNAUTHORIZED).build();
        }
    })
    .build();
Kotlin
val securityManager: SecurityManager = ...

val route = router {
        ("/person" and accept(APPLICATION_JSON)).nest {
            GET("/{id}", handler::getPerson)
            GET("", handler::listPeople)
            POST("/person", handler::createPerson)
            filter { request, next ->
                if (securityManager.allowAccessTo(request.path())) {
                    next(request)
                }
                else {
                    status(UNAUTHORIZED).build();
                }
            }
        }
    }


위 예제는 next.handle(ServerRequest) 실행이 선택적임을 보여준다. 핸들러 함수는 접근이 허용된 경우에만 실행된다.

라우터 함수 빌더의 filter 함수 사용 외에도, RouterFunction.filter(HandlerFunction) 을 통해 기존 라우터 함수에 필터링을 적용할 수 있다.

# 함수형 엔드포인트에의 CORS 지원은 여기에 특화된 CorsWebFilter를 통해 제공한다.

1.6.1. URI 링크

이 섹션은 스프링 프레임워크의 URI 에 대한 다양한 옵션에 대해 다룬다.

1.6.1. UriComponents

UriComponentsBuilder는 변수를 사용한 URI 템플릿으로부터의 URI 빌드를 제공한다. 다음은 그 예제이다:

Java
UriComponents uriComponents = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}")  (1)
        .queryParam("q", "{q}")  (2)
        .encode() (3)
        .build(); (4)

URI uri = uriComponents.expand("Westin", "123").toUri();  (5)
Kotlin
val uriComponents = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}")  (1)
        .queryParam("q", "{q}")  (2)
        .encode() (3)
        .build() (4)

val uri = uriComponents.expand("Westin", "123").toUri()  (5)


(1) URI 템플릿을 지정하는 스태틱 팩토리 메서드이다. (2) URI 컴포넌트를 추가하거나 교체한다. (3) URI 템플릿과 URI 변수를 인코딩한다. (4) UriComponents를 빌드한다. (5) 변수를 추가하고 URI를 얻는다.

위 예제는 buildAndExpand로 하나의 체인으로 통합하여 더 간결하게 만들 수 있다:

Java
URI uri = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}")
        .queryParam("q", "{q}")
        .encode()
        .buildAndExpand("Westin", "123")
        .toUri();
Kotlin
val uri = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}")
        .queryParam("q", "{q}")
        .encode()
        .buildAndExpand("Westin", "123")
        .toUri()


(인코딩이 적용된) URI로 직접 이동하여 더 단축할 수 있다:

Java
URI uri = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}")
        .queryParam("q", "{q}")
        .build("Westin", "123");
Kotlin
val uri = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}")
        .queryParam("q", "{q}")
        .build("Westin", "123")


완전한 URI 템플릿으로 여기서 더 단축할 수 있다:

Java
URI uri = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}?q={q}")
        .build("Westin", "123");
Kotlin
val uri = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}?q={q}")
        .build("Westin", "123")


1.6.2. UriBuilder

UriComponentsBuilder는 UriBuilder를 구현한다. UriBuilderFactory를 사용하여 UriBuilder를 생성할 수 있다. 그리고 UriBuilderFactory와 UriBuilder는 URI 템플릿으로부터 베이스 URL, 인코딩, 기타 세부적인 사항과 같은 공유되는 설정을 바탕으로 URI를 빌드하는 장착형(pluggable) 메커니즘을 제공한다.

UriBuilderFactory를 사용하여 RestTemplate, WebClient를 설정하고 URI 준비 과정을 커스터마이징할 수 있다. DefaultUriBuilderFactory는 내부적으로 UriComponentsBuilder를 사용하고 공유되는 설정 옵션을 노출하는 UriBuilderFactory의 기본 구현체이다.

다음은 RestTemplate 설정 방법이다:

Java
// import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode;

String baseUrl = "https://example.org";
DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(baseUrl);
factory.setEncodingMode(EncodingMode.TEMPLATE_AND_VALUES);

RestTemplate restTemplate = new RestTemplate();
restTemplate.setUriTemplateHandler(factory);
Kotlin
// import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode

val baseUrl = "https://example.org"
val factory = DefaultUriBuilderFactory(baseUrl)
factory.encodingMode = EncodingMode.TEMPLATE_AND_VALUES

val restTemplate = RestTemplate()
restTemplate.uriTemplateHandler = factory


다음 예제는 WebClient 설정 방법이다:

Java
// import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode;

String baseUrl = "https://example.org";
DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(baseUrl);
factory.setEncodingMode(EncodingMode.TEMPLATE_AND_VALUES);

WebClient client = WebClient.builder().uriBuilderFactory(factory).build();
Kotlin
// import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode

val baseUrl = "https://example.org"
val factory = DefaultUriBuilderFactory(baseUrl)
factory.encodingMode = EncodingMode.TEMPLATE_AND_VALUES

val client = WebClient.builder().uriBuilderFactory(factory).build()


추가로, DefaultBuilderFactory를 직접 사용할 수 있다: UriComponentsBuilder를 사용하는 것과 비슷하지만, 이는 스태틱 팩토리 메서드 대신, 설정을 담은 실제 인스턴스이다. 다음은 그 예제이다:

Java
String baseUrl = "https://example.com";
DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory(baseUrl);

URI uri = uriBuilderFactory.uriString("/hotels/{hotel}")
        .queryParam("q", "{q}")
        .build("Westin", "123");
Kotlin
val baseUrl = "https://example.com"
val uriBuilderFactory = DefaultUriBuilderFactory(baseUrl)

val uri = uriBuilderFactory.uriString("/hotels/{hotel}")
        .queryParam("q", "{q}")
        .build("Westin", "123")


1.6.3. URI 인코딩(URI Encoding)

UriComponentsBuilder는 두 가지 레벨의 인코딩 옵션을 노출한다:

이 두 옵션은 아스키가 아닌 잘못된 문자를 이스케이핑한 8진수로 대체한다. 그런데 첫 번째 옵션은 문자를 URI 변수가 의미하는 예약 문자로 대체한다.

# ":"를 생각해보자. 경로 문자열에서는 유효한 문자이지만, 동시에 예약 문자이기도 하다. 첫 번째 옵션은 URI 변수의 ":"를 "%3B" 로 대체하지만, URI 템플릿에선 대체하지 않는다. 이와 반대로 두 번째 옵션은 ":"를 절대 대체하지 않는다. 왜냐하면 이 문자는 경로 문자열에서 유효한 문자이기 때문이다.

대부분의 경우, 기대하는 결과를 가져오는 쪽은 첫 번째 옵션이다. 왜냐하면 이 옵션은 URI 변수를 불분명한 데이터 완전한 인코딩 대상으로 취급하기 때문이다. 두 번째 옵션이 유용한 경우는 URI 변수에 의도적으로 예약 문자가 포함된 경우 뿐이다.

다음은 첫 번째 옵션 예제이다:

Java
URI uri = UriComponentsBuilder.fromPath("/hotel list/{city}")
        .queryParam("q", "{q}")
        .encode()
        .buildAndExpand("New York", "foo+bar")
        .toUri();

// Result is "/hotel%20list/New%20York?q=foo%2Bbar"
Kotlin
val uri = UriComponentsBuilder.fromPath("/hotel list/{city}")
        .queryParam("q", "{q}")
        .encode()
        .buildAndExpand("New York", "foo+bar")
        .toUri()

// Result is "/hotel%20list/New%20York?q=foo%2Bbar"


(인코딩이 적용된) URI로 직접 이동하여 더 단축할 수 있다:

You can shorten the preceding example by going directly to the URI (which implies encoding), as the following example shows:

Java
URI uri = UriComponentsBuilder.fromPath("/hotel list/{city}")
        .queryParam("q", "{q}")
        .build("New York", "foo+bar")
Kotlin
val uri = UriComponentsBuilder.fromPath("/hotel list/{city}")
        .queryParam("q", "{q}")
        .build("New York", "foo+bar")


완전한 URI 템플릿으로 여기서 더 단축할 수 있다:

Java
URI uri = UriComponentsBuilder.fromPath("/hotel list/{city}?q={q}")
        .build("New York", "foo+bar")
Kotlin
val uri = UriComponentsBuilder.fromPath("/hotel list/{city}?q={q}")
        .build("New York", "foo+bar")


WebClient와 RestTemplate는 UriBuilderFactory 전략을 통해 URI 템플릿을 내부적으로 적용하고 인코딩한다. 이 둘 모두 커스터마이징 전략으로 설정할 수 있다. 다음은 그 예제이다:

Java
String baseUrl = "https://example.com";
DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(baseUrl)
factory.setEncodingMode(EncodingMode.TEMPLATE_AND_VALUES);

// Customize the RestTemplate..
RestTemplate restTemplate = new RestTemplate();
restTemplate.setUriTemplateHandler(factory);

// Customize the WebClient..
WebClient client = WebClient.builder().uriBuilderFactory(factory).build();
Kotlin
val baseUrl = "https://example.com"
val factory = DefaultUriBuilderFactory(baseUrl).apply {
    encodingMode = EncodingMode.TEMPLATE_AND_VALUES
}

// Customize the RestTemplate..
val restTemplate = RestTemplate().apply {
    uriTemplateHandler = factory
}

// Customize the WebClient..
val client = WebClient.builder().uriBuilderFactory(factory).build()


DefaultBuilderFactory 구현체는 내부적으로 UriComponentsBuilder를 사용하여 URI 템플릿을 적용하고 인코딩한다. 이 구현체는 팩토리로서 아래 인코딩 모드 중 하나를 선택하여 인코딩 방식을 설정할 수 있다:
  • TEMPLATE_AND_VALUES: 첫 번째 옵션, UriComponentsBuilder#encode()를 사용하여 URI 템플릿을 먼저 인코딩하고 URI 변수를 적용 시에 인코딩한다.
  • VALUES_ONLY: URI 템플릿은 인코딩하지 않고, UriUtils#encodeUriUriVariables를 사용하여 URI 변수를 템플릿에 적용하기 전에 인코딩한다.
  • URI_COMPONENTS: 두 번째 옵션, UriComponents#encode()를 사용하여 URI 변수를 적용한 뒤에 URI 컴포넌트를 인코딩한다.
  • NONE: 아무 인코딩도 적용하지 않는다.


RestTemplate은 히스토릭한 사유로, 그리고 하위 호환성을 위해 EncodingMode.URI_COMPONENTS를 설정한다. WebClient는 기본값으로 DefaultUriBuilderFactory를 갖는다. 인코딩 방식은 버전 5.0.x 에선 Encoding.URI_COMPONENTS 였고, 5.1 에서 EncodingMode.TEMPLATE_AND_VALUES로 변경되었다.

1.7. CORS

스프링 웹플럭스는 CORS(Cross-Origin Resource Sharing) 을 핸들링한다. 이 섹션은 그 방법을 주제로 한다.

1.7.1. 소개

보안상의 이유로, 브라우저는 현재 origin 에서 벗어난 자원에의 AJAX 호출을 금지한다. 예를 들어, 한 탭에 은행 계좌를 열어두고 다른 탭에 evil.com로 접속했다. evil.com의 스크립트는 당신의 인증서로 은행 API 에 AJAX 요청을 보낼 수 없어야 한다 - 잘못된 예로 당신의 계좌에서 돈을 인출할 수 있다!

Cross-Origin Resource Sharing(CORS) 는 대부분의 브라우저가 구현하는 W3C 명세로, IFRAME 또는 JSONP을 사용한 방법이 아닌, 어떤 종류의 크로스도메인 요청을 허용할 것인지 지정하는 더 안전하고 강력한 방법을 사용한다.

1.7.2. 처리(Processing)

CORS 명세는 예비, 단순, 실제 요청을 구분한다. CORS의 작동 방식을 배우려면 이 글이나 명세에서 더 자세한 내용을 볼 수 있다.

스프링 웹플럭스 HandlerMapping 구현체는 CORS를 내장형으로 지원한다. 요청을 핸들러에 성공적으로 매핑한 뒤, HandlerMapping은 주어진 요청과 핸들러에 대한 CORS 설정을 체크하고 다음 동작을 수행한다. 예비 요청은 직접 핸들링하고, 단순 그리고 실제 CORS 요청은 인터셉팅하고, 검증하고, 필요한 CORS 응답 헤더를 설정한다.

크로스오리진 요청(Origin 헤더 존재하며 요청의 host 헤더와 다른 요청) 을 활성화하기 위해서는, 명시적 CORS 설정을 선언해야 한다. 매칭되는 CORS 설정이 존재하지 않으면 예비 요청은 거부된다. CORS 헤더가 추가되지 않은 단순, 실제 CORS 요청은 브라우저에 의해 거부된다.

URL 패턴 기반 CorsConfiguration 매핑을 통해 각 HandlerMapping을 개별적으로 설정할 수 있다. 대부분의 경우 어플리케이션은 웹플럭스 자바 설정을 사용하여 이런 매핑을 선언한다. 설정 결과는 단일의, 전역 맵으로 모든 HandlerMapping 구현체에 전달된다.

HandlerMapping 레벨의 전역 CORS 설정과 보다 잘 정돈된 핸들러 레벨 CORS 설정을 결합할 수 있다. 예를 들어, 어노테이티드 컨트롤러는 클래스 또는 메서드 레벨 @CrossOrigin을 사용할 수 있다(다른 핸들러는 CorsConfigurationSource를 구현한다).

전역과 지역 설정을 결합하는 규칙은 보통 추가적이다 - 예: 모든 전역 과 지역 설정. allowCredentials와 maxAge 처럼 단일 값만을 받아들이는 어트리뷰트들은 지역 설정이 전역 설정보다 우선한다. 더 자세한 내용은 CorsConfiguration#combine(CorsConfiguration)을 보라.

# 소스 혹은 고급 커스터마이징은 아래를 찾아보자:
  • CorsConfiguraion
  • CorsProcessor, DefaultCorsProcessor
  • AbstractHandlerMapping


1.7.3. @CrossOrigin

@CrossOrigin은 어노테이티드 컨트롤러 요청에 크로스오리진을 활성화한다. 다음은 그 예제이다:

Java
@RestController
@RequestMapping("/account")
public class AccountController {

    @CrossOrigin
    @GetMapping("/{id}")
    public Mono<Account> retrieve(@PathVariable Long id) {
        // ...
    }

    @DeleteMapping("/{id}")
    public Mono<Void> remove(@PathVariable Long id) {
        // ...
    }
}
Kotlin
@RestController
@RequestMapping("/account")
class AccountController {

    @CrossOrigin
    @GetMapping("/{id}")
    suspend fun retrieve(@PathVariable id: Long): Account {
        // ...
    }

    @DeleteMapping("/{id}")
    suspend fun remove(@PathVariable id: Long) {
        // ...
    }
}


기본적으로 @CrossOrigin은 다음을 허용한다:
  • 모든 오리진
  • 모든 헤더
  • 컨트롤러 메서드에 매핑되는 모든 HTTP 메서드


allowedCredentials는 비활성화가 기본값이다. 왜냐하면 민감한 사용자 특징적 정보(쿠키와 CSRF 토큰과 같은)를 노출하는 신뢰 수준을 결정하기 때문이다. 반드시 적재적소에만 사용되어야 한다.

maxAge를 30분으로 설정한다.

@CrossOrigin을 클래스 레벨에 적용하면 컨트롤러의 모든 메서드에 적용된다. 다음 예제는 특정 도메인을 지정하고 maxAge를 한시간으로 설정한다:

Java
@CrossOrigin(origins = "https://domain2.com", maxAge = 3600)
@RestController
@RequestMapping("/account")
public class AccountController {

    @GetMapping("/{id}")
    public Mono<Account> retrieve(@PathVariable Long id) {
        // ...
    }

    @DeleteMapping("/{id}")
    public Mono<Void> remove(@PathVariable Long id) {
        // ...
    }
}
Kotlin
@CrossOrigin("https://domain2.com", maxAge = 3600)
@RestController
@RequestMapping("/account")
class AccountController {

    @GetMapping("/{id}")
    suspend fun retrieve(@PathVariable id: Long): Account {
        // ...
    }

    @DeleteMapping("/{id}")
    suspend fun remove(@PathVariable id: Long) {
        // ...
    }
}


@CrossOrigin을 클래스와 메서드 레벨 모두에 적용할 수 있다:

Java
@CrossOrigin(maxAge = 3600) (1)
@RestController
@RequestMapping("/account")
public class AccountController {

    @CrossOrigin("https://domain2.com") (2)
    @GetMapping("/{id}")
    public Mono<Account> retrieve(@PathVariable Long id) {
        // ...
    }

    @DeleteMapping("/{id}")
    public Mono<Void> remove(@PathVariable Long id) {
        // ...
    }
}
Kotlin
@CrossOrigin(maxAge = 3600) (1)
@RestController
@RequestMapping("/account")
class AccountController {

    @CrossOrigin("https://domain2.com") (2)
    @GetMapping("/{id}")
    suspend fun retrieve(@PathVariable id: Long): Account {
        // ...
    }

    @DeleteMapping("/{id}")
    suspend fun remove(@PathVariable id: Long) {
        // ...
    }
}


(1) @CrossOrigin을 클래스 레벨에 사용한다. (2) @CrossOrigin을 메서드 레벨에 사용한다.

1.7.4. 전역 설정

잘 정돈된 컨트롤러 메서드 레벨 설정에 더하여, 전역 CORS 설정이 필요할 수 있다. URL 기반 CorsConfiguration 매핑을 어떠한 HandlerMapping 에나 독립적으로 설정할 수 있다. 그러나 대부분의 어플리케이션에선 웹플럭스 자바 설정을 사용하여 이를 처리한다.

전역 설정은 다음을 활성화한다:
  • 모든 오리진
  • 모든 헤더
  • GET, HEAD, POST 메서드


allowedCredentials는 비활성화가 기본값이다. 왜냐하면 민감한 사용자 특징적 정보(쿠키와 CSRF 토큰과 같은)를 노출하는 신뢰 수준을 결정하기 때문이다. 반드시 적재적소에만 사용되어야 한다.

maxAge를 30분으로 설정한다.

CorsRegistry 콜백을 사용하여 웹플럭스 자바 설정에서 CORS를 활성화할 수 있다. 다음은 그 예제이다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {

        registry.addMapping("/api/**")
            .allowedOrigins("https://domain2.com")
            .allowedMethods("PUT", "DELETE")
            .allowedHeaders("header1", "header2", "header3")
            .exposedHeaders("header1", "header2")
            .allowCredentials(true).maxAge(3600);

        // Add more mappings...
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun addCorsMappings(registry: CorsRegistry) {

        registry.addMapping("/api/**")
                .allowedOrigins("https://domain2.com")
                .allowedMethods("PUT", "DELETE")
                .allowedHeaders("header1", "header2", "header3")
                .exposedHeaders("header1", "header2")
                .allowCredentials(true).maxAge(3600)

        // Add more mappings...
    }
}


1.7.5. CORS WebFilter

내장된 CorsWebFilter 사용하여 CORS 지원을 활성화할 수 있다. 이 방식은 함수형 엔드포인트와 함께 사용하기 적합하다.

# CorsFilter를 스프링 시큐리티와 사용한다면 스프링 시큐리티의 내장형 CORS 지원을 유념하라.

CorsWebFilter 빈을 선언하고 CorsConfigurationSource를 빈의 생성자로 전달하여 필터를 설정할 수 있다. 다음은 그 예제이다:

Java
@Bean
CorsWebFilter corsFilter() {

    CorsConfiguration config = new CorsConfiguration();

    // Possibly...
    // config.applyPermitDefaultValues()

    config.setAllowCredentials(true);
    config.addAllowedOrigin("https://domain1.com");
    config.addAllowedHeader("*");
    config.addAllowedMethod("*");

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);

    return new CorsWebFilter(source);
}
Kotlin
@Bean
fun corsFilter(): CorsWebFilter {

    val config = CorsConfiguration()

    // Possibly...
    // config.applyPermitDefaultValues()

    config.allowCredentials = true
    config.addAllowedOrigin("https://domain1.com")
    config.addAllowedHeader("*")
    config.addAllowedMethod("*")

    val source = UrlBasedCorsConfigurationSource().apply {
        registerCorsConfiguration("/**", config)
    }
    return CorsWebFilter(source)
}


1.8. 웹 보안(Web Security)

스프링 시큐리티 프로젝트는 웹 어플리케이션을 악의적인 행위로부터 보호하기 위한 방어책을 지원한다. 스프링 시큐리티 레퍼런스 문서를 보라:

1.9. 뷰 기술(View Technologies)

스프링 웹플럭스의 뷰 기술은 장착형(pluggable)으로 사용할 수 있다. 뷰 기술로 타임리프(Thymeleaf), 프리마커(FreeMarker) 또는 이 외 다른 어떤 것을 사용하느냐는 주로 설정 변경의 문제가 된다. 이 챕터는 이러한 뷰 기술들과 스프링 웹플럭스의 통합에 대해 다루며, 독자는 뷰 리솔루션에 대해 숙지하고 있음을 전제로 한다.

1.9.1. 타임리프(Thymeleaf)

타임리프는 모던 서버 사이드 자바 템플릿 엔진으로, 더블 체킹을 통해 브라우저에서 미리 보여지는 플레인 HTML 템플릿을 강조한다. 이는 서버 구동 없이 작동할 수 있어, 독립적인 UI 템플릿 작업에 매우 유용하다. 타임리프는 광범위한 기능을 제공하고, 활발하게 개발되고 유지보수된다. 타임리프 프로젝트 홈페이지에서 보다 완전한 소개를 볼 수 있다.

타임리프와 스프링 웹플럭스의 통합은 타임리프 프로젝트가 관리한다. 설정은 몇 가지 빈 선언으로 이루어진다. SpringResourceTemplateResolver, SpringWebFluxTemplateEngine, ThymeleafReactiveViewResolver가 그 예이다. 더 자세한 정보는 타임리프+스프링웹플럭스 통합 선언문을 통해 찾아볼 수 있다.

1.9.2. 프리마커(FreeMarker)

아파치 프리마커는 HTML 부터 이메일이나 기타 다른 어떤 종류의 텍스트 아웃풋이든 생성 가능한 템플릿 엔진이다. 스프링 프레임워크는 스프링 웹플럭스와 프리마커 템플릿을 함께 사용하기 위한 내장형 통합을 제공한다.

뷰 설정



다음 예제는 프리마커를 뷰 기술로 설정하는 방법을 보여준다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.freeMarker();
    }

    // Configure FreeMarker...

    @Bean
    public FreeMarkerConfigurer freeMarkerConfigurer() {
        FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
        configurer.setTemplateLoaderPath("classpath:/templates/freemarker");
        return configurer;
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        registry.freeMarker()
    }

    // Configure FreeMarker...

    @Bean
    fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply {
        setTemplateLoaderPath("classpath:/templates/freemarker")
    }
}


위 예제에서와 같이, 템플릿이 저장된 경로를 FreeMarkerConfigurer 에 지정해야 한다. 이런 설정을 바탕으로, 컨트롤러가 뷰 이름 welcome을 반환하면 리졸버는 classpath:/templates/freemarker/welcome.ftl 템플릿을 찾는다.

프리마커 설정



FreeMarkerConfigurer 빈에 적절한 프로퍼티를 설정하여 프리마커 'Settings' 와 'SharedVariables' 디렉토리를 프리마커 Configuration 객체(스프링이 관리한다) 에 전달할 수 있다. freemarkerSettings 프로퍼티에는 java.util.Properties 객체를 사용한다. 그리고 freemarkerVariables 프로퍼티에는 java.util.Map을 사용한다. 다음은 FreeMarkerConfigurer 사용 예제이다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    // ...

    @Bean
    public FreeMarkerConfigurer freeMarkerConfigurer() {
        Map<String, Object> variables = new HashMap<>();
        variables.put("xml_escape", new XmlEscape());

        FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
        configurer.setTemplateLoaderPath("classpath:/templates");
        configurer.setFreemarkerVariables(variables);
        return configurer;
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    // ...

    @Bean
    fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply {
        setTemplateLoaderPath("classpath:/templates")
        setFreemarkerVariables(mapOf("xml_escape" to XmlEscape()))
    }
}


변수 설정과 Configuration 객체로의 적용에 대한 더 자세한 내용은 프리마커 문서를 보라.

폼 핸들링



스프링은 JSP 에서의 사용을 위한 태그 라이브러리를 제공한다. 태그 라이브러리에는 엘리먼트가 있다. 이 엘리먼트는 폼 지원 객체(form-backing objects)로부터 폼에 값을 보여주도록 하고, 웹 혹은 비즈니즈 계층의 Validator의 밸리데이션 실패 결과를 보여준다. 스프링은 프리마커에 대해서도 이와 동일한 기능과 함께, 폼 인풋 엘리먼트를 스스로 생성하는 편리한 매크로를 제공한다.

매크로 바인딩(The Bind Macros)



프리마커 매크로의 표준 집합은 spring-webflux.jar 파일에서 관리한다. 때문에 적절하게 설정된 어플리케이션에 언제든 유효하게 사용할 수 있다.

스프링 템플릿 라이브러리(Spring templating libraries)에 정의된 몇몇 매크로는 내부적으로(private) 취급되지만, 매크로 정의에는 이러한 범위 설정(scoping)은 존재하지 않는다. 모든 매크로는 호출 모드와 사용자 템플릿에서 접근할 수 있다(visible). 다음 섹션은 사용자 템플릿 안에서의 직접 매크로 호출에 관하여만 집중적으로 다룬다. 매크로 코드를 직접 보려면 org.springframework.web.reactive.result.view.freemarker 패키지의 spring.ftl 파일을 보자.

바인딩 지원에 관한 추가적인 정보는 스프링 MVC의 Simple Binding에서 찾아볼 수 있다.

폼 매크로



스프링의 프리마커 템플릿 폼 매크로 지원에 대한 자세한 내용은 스프링 MVC 문서의 다음 섹션들에서 다룬다.

1.9.3. 스크립트 뷰

스프링 프레임워크는 스프링 웹플럭스와 JSR-223 자바 스트립팅 엔진 위에서 작동하는 템플릿 라이브러리를 함께 사용하기 위한 내장형 통합을 제공한다. 다음 테이블은 여러 스트립트 엔진 테스트를 거친 템플릿 라이브러리를 보여준다:

스크립팅 라이브러리 스크립팅 엔진
Handlebars Nashorn
Mustache Nashorn
React Nashorn
EJS Nashorn
ERB JRuby
String template Jython
Kotlin Scripting templating Kotlin


# 다른 스트립트 엔진과 통합하는 기본 규칙은, 반드시 ScriptEngine와 Invocable 인터페이스를 구현하는 것이다.

요건



스크립트 엔진이 클래스패스에 위치해야 한다. 다음은 스크립트 엔진 종류에 따라 달라지는 상세 내용이다:
  • Nashorn 자바스크립트 엔진은 자바 8+ 을 요구한다. 가장 최근의 업데이트 릴리즈를 사용할 것을 강력히 권고한다.
  • 루비 지원을 위해 JRuby 의존성을 추가해야 한다.
  • 파이썬 지원을 위해 Jython 의존성을 추가해야 한다.
  • 코틀린 지원을 위해 org.jetbrains.kotlin:kotlin-script-util의존성과 org.jetbrains.kotlin.script.jsr223.KotlinJsr223JvmLocalScriptEngineFactory 라인을 포함하는 META-INF/services/javax.script.ScriptEngineFactory 파일을 추가해야 한다. 이 예제에서 자세히 알아볼 수 있다.
스크립트 템플릿 라이브러리가 필요하다. 자바스크립트를 위한 한 가지 방법으로 WebJars가 있다.

스크립트 템플릿



ScriptTemplateConfigurer를 선언하여 사용할 스트립트 엔진, 불러올 스크립트 파일, 어떤 함수를 호출하여 템플릿을 렌더링할지, 그리고 그 외의 것들을 지정할 수 있다. 다음 예제는 Mustache 템플릿 엔진과 Nashorn 자바스크립트 엔진들 사용한다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.scriptTemplate();
    }

    @Bean
    public ScriptTemplateConfigurer configurer() {
        ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
        configurer.setEngineName("nashorn");
        configurer.setScripts("mustache.js");
        configurer.setRenderObject("Mustache");
        configurer.setRenderFunction("render");
        return configurer;
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        registry.scriptTemplate()
    }

    @Bean
    fun configurer() = ScriptTemplateConfigurer().apply {
        engineName = "nashorn"
        setScripts("mustache.js")
        renderObject = "Mustache"
        renderFunction = "render"
    }
}


render 함수는 다음의 파라미터로 호출한다:
  • String template: 템플릿 컨텐츠
  • Map model: 뷰 모델
  • RenderingContext renderingContext: RenderingContext는 어플리케이션 컨텍스트, 로케일, 템플릿 로더, URL(버전 5.0 부터) 에의 접근을 제공한다.


Mustache.render() 는 이 시그니처와 선천적인 호환성을 지니기 때문에, 직접 호출할 수도 있다.

사용할 뷰 기술에 커스터마이징이 필요할 경우, 커스텀 render 함수를 구현하는 스크립트를 제공할 수 있다. 예를 들어, Handlerbars는 사용하기에 앞서 템플릿을 컴파일해야 하고, 서버 사이드 스크립트 엔진에서 사용할 수 없는 브라우저 기능을 에뮬레이팅하기 위해 polyfill을 필요로 한다. 다음 예제는 커스텀 render 함수를 설정한다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.scriptTemplate();
    }

    @Bean
    public ScriptTemplateConfigurer configurer() {
        ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
        configurer.setEngineName("nashorn");
        configurer.setScripts("polyfill.js", "handlebars.js", "render.js");
        configurer.setRenderFunction("render");
        configurer.setSharedEngine(false);
        return configurer;
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        registry.scriptTemplate()
    }

    @Bean
    fun configurer() = ScriptTemplateConfigurer().apply {
        engineName = "nashorn"
        setScripts("polyfill.js", "handlebars.js", "render.js")
        renderFunction = "render"
        isSharedEngine = false
    }
}


# 쓰레드세이프하지 않은 스크립트 엔진과 동시성을 고려하여 제작되지 않은 템플릿 라이브러리를 함께 사용할 때는 sharedEngine 프로퍼티를 false로 설정해야 한다. Nashorn 위에서 구동되는 Handlebars, React가 그렇다. 이런 경우에 대해서는 Java SE 8 update 60 이 필요하다. 이 버그가 이유인데, 사실 일반적으로 어떤 경우이든 최신 Java SE 패치 릴리즈를 사용하는 것을 권장한다.

polyfill.js은 Handlebars가 제대로 작동하기 위해 필요한 window 객체만을 정의한다. 다음은 그 스니펫이다:

var window = {};


이 기본 render.js 구현체는 템플릿 엔진을 컴파일하여 사용한다. 준비된 구현체 생성물은 캐싱된 템플릿이나 사전에 컴파일된 템플릿으로 저장하고 재사용하여야 한다. 이 작업은 스크립트사이드에서 이루어지며, 또한 필요에 따라 어떠한 커스터마이징이든 가능하다(템플릿 엔진 설정 관리를 예로 들 수 있다). 다음 예제는 템플릿을 컴파일한다:

function render(template, model) {
    var compiledTemplate = Handlebars.compile(template);
    return compiledTemplate(model);
}


더 많은 설정 예제는 스프링 프레임워크 유닛 테스트, Java 그리고 resources를 체크아웃하여 얻을 수 있다.

1.9.4. JSON 과 XML

컨텐츠 협상을 고려할 때, 클라이언트 요청 컨텐츠 타입에 따라 모델 렌더링을 HTML 템플릿이나 다른 포맷(JSON 또는 XML 과 같은) 사이에서 다르게 처리할 수 있도록 하는 것이 좋다. 이런 기능을 지원하기 위해서 스프링 웹플럭스는 HttpMessageWriterView를 제공한다. 이 컴포넌트는 Jackson2JsonEncoder, Jackson2SmileEncoder, 또는 Jaxb2XmlEncoder와 같은, spring-web 에서 유효한 어떠한 코덱에든 추가하여 사용할 수 있다.

다른 뷰 기술과는 다르게, HttpMessageWriterView는 ViewResolver를 필요로 하지 않고, 대신 기본 뷰로 설정한다. 여러 종류의 HttpMessageWriter 또는 Encoder 인스턴스를 래핑하여 하나 이상의 기본 뷰를 설정할 수 있다. 런타임에 요청 컨텐츠 타입에 매칭되는 뷰가 사용된다.

대부분의 경우 모델은 다수의 어트리뷰트를 갖는다. HttpMessageWriterView를 렌더링에 사용할 모델 어트리뷰트 이름과 함께 설정하여 시리얼라이징 대상 어트리뷰트를 결정할 수 있다. 모델이 가진 어트리뷰트가 하나라면, 그 하나를 사용한다.

1.10. HTTP 캐싱(HTTP Caching)

HTTP 캐싱은 웹 어플리케이션의 성능을 대폭 향상시킬 수 있다. HTTP 캐싱은 Cache-Control 응답 헤더와 Last-Modified, ETag 과 같은, 이어지는 조건 요청 헤더를 중심으로 작동한다. Cache-Control은 프라이빗(예: 브라우저), 퍼블릭(예: 프록시) 캐시를 어떻게 캐싱하고 응답에 재사용할지 권고한다. ETag 헤더를 사용하여 변경되지 않은 컨텐츠에 대해 304(NOT_MODIFIED) 본문 없는 응답을 내보내는 조건을 만든다. ETag는 Last-Modified 헤더의 보다 정교한 대체제가 된다.

이 섹션은 스프링 웹플럭스에서 사용 가능한 HTTP 캐싱과 관련된 옵션에 대해 다룬다.

1.10.1. CacheControl

CacheControl은 Cache-Control 헤더와 관련된 설정을 제공하고, 다양한 곳에서 아규먼트로 사용할 수 있다:

RFC 7234는 Cache-Control 응답 헤더를 위한 모든 가능한 디렉티브를 기술한다. CacheControl 타입은 다음과 같이 공통된 시나리오에 맞춘, 유스케이스 지향적인(use case-oriented) 방법을 취한다:

Java
// Cache for an hour - "Cache-Control: max-age=3600"
CacheControl ccCacheOneHour = CacheControl.maxAge(1, TimeUnit.HOURS);

// Prevent caching - "Cache-Control: no-store"
CacheControl ccNoStore = CacheControl.noStore();

// Cache for ten days in public and private caches,
// public caches should not transform the response
// "Cache-Control: max-age=864000, public, no-transform"
CacheControl ccCustom = CacheControl.maxAge(10, TimeUnit.DAYS).noTransform().cachePublic();
Kotlin
// Cache for an hour - "Cache-Control: max-age=3600"
val ccCacheOneHour = CacheControl.maxAge(1, TimeUnit.HOURS)

// Prevent caching - "Cache-Control: no-store"
val ccNoStore = CacheControl.noStore()

// Cache for ten days in public and private caches,
// public caches should not transform the response
// "Cache-Control: max-age=864000, public, no-transform"
val ccCustom = CacheControl.maxAge(10, TimeUnit.DAYS).noTransform().cachePublic()


1.10.2. 컨트롤러

컨트롤러는 HTTP 캐싱 지원 요소를 명시적으로 추가할 수 있다. 자원의 lastModified 혹은 ETag 값은 조건부 요청 해더와 비교하기 전에 계산되어야 하기 때문에, 이 방법을 사용할 것을 권한다. 컨트롤러는 ETag와 Cache-Control 설정을 ResponseEntit 에 추가할 수 있다:

Java
@GetMapping("/book/{id}")
public ResponseEntity<Book> showBook(@PathVariable Long id) {

    Book book = findBook(id);
    String version = book.getVersion();

    return ResponseEntity
            .ok()
            .cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS))
            .eTag(version) // lastModified is also available
            .body(book);
}
Kotlin
@GetMapping("/book/{id}")
fun showBook(@PathVariable id: Long): ResponseEntity<Book> {

    val book = findBook(id)
    val version = book.getVersion()

    return ResponseEntity
            .ok()
            .cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS))
            .eTag(version) // lastModified is also available
            .body(book)
}


위 예제는 조건부 요청 헤더와의 비교 결과 컨텐츠가 변경되지 않았음이 확인되면 304(NOT_MODIFIED) 응답과 빈 본문을 전송한다. 그렇지 않으면 ETag와 Cache-Control 헤더를 응답에 추가해 보낸다.

컨트롤러의 조건부 요청 헤더 체크를 다음과 같이 만들 수도 있다:

Java
@RequestMapping
public String myHandleMethod(ServerWebExchange exchange, Model model) {

    long eTag = ... (1)

    if (exchange.checkNotModified(eTag)) {
        return null; (2)
    }

    model.addAttribute(...); (3)
    return "myViewName";
}
Kotlin
@RequestMapping
fun myHandleMethod(exchange: ServerWebExchange, model: Model): String? {

    val eTag: Long = ... (1)

    if (exchange.checkNotModified(eTag)) {
        return null(2)
    }

    model.addAttribute(...) (3)
    return "myViewName"
}


(1) 어플리케이션 특징적 계산. (2) 304(NOT_MODIFIED) 응답 설정. 더 이상의 처리는 없다. (3) 요청 처리.

조건부 요청에 대한 eTag, lastModified 값 체크에는 세 가지 변형이 있다. 조건부 GET 과 HEAD 요청에는 304(NOT_MODIFIED)를 설정한다. POST, PUT, DELETE 에는 409(PRECONDITION_FAILED)를 설정하여 동시 변경을 방지한다.

1.10.3. 정적 자원

정적 자원을 Cache-Control 과 조건부 응답 헤더와 함께 제공하여 성능 최적화를 도모할 수 있다. 정적 자원 설정하기 센셕을 보라.

1.11 웹플럭스 설정(WebFlux Config)

웹플럭스 자바 설정은 어노테이티드 컨트롤러 혹은 함수형 엔드로인트로 요청을 처리하기 위해 필요한 컴포넌트를 선언하고, 설정을 커스터마이징하기 위한 API를 제공한다. 이는 자바 설정으로 생성되는 기반 빈을 이해할 필요가 없다는 의미이다. 하지만 기반 빈을 이해하고 싶다면 WebFluxConfigurationSupport을 보거나, 혹은 스페셜 빈 타입에서 필요한 내용을 읽어보자.

더 고급의 커스터마이징을 위해서는 설정 API가 아닌 고급 설정 모드를 통한 전체 설정 관리가 필요하다.

1.11.1. 웹플럭스 설정 활성화(Enabling WebFlux Config)

@EnableWebFlux를 자바 설정에 추가한다:

Java
@Configuration
@EnableWebFlux
public class WebConfig {
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig


위 예제는 스프링 웹플럭스의 다양한 인프라스트럭처 빈을 등록하고 클래스에서 유효한 의존성을 묶는다 - JSON, XML, 기타 등등.

1.11.2. 웹플럭스 설정 API(WebFlux config API)

자바 설정에 WebFluxConfigurer 인터페이스를 구현할 수 있다. 다음은 그 예제이다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    // Implement configuration methods...
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    // Implement configuration methods...
}


1.11.3. 컨버젼, 포맷팅(Conversion, formatting)

@NumberFormat, @DateTimeFormat을 통해 Number, Date 타입 포맷터는 기본적으로 지원한다. 클래스패스에 Joda-Time 이 존재할 경우 Joda-Time 포맷팅 라이브러리를 위한 완전한 지원도 제공한다.

다음 예제는 커스텀 포맷터와 컨버터 등록 방법을 보여준다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        // ...
    }

}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun addFormatters(registry: FormatterRegistry) {
        // ...
    }
}


# FormatterRegistrar 구현체를 언제 사용할지에 관한 더 자세한 내용은 FormatterRegistrar SPI와 FormattingConversionServiceFactoryBean을 보라.

1.11.4. 밸리데이션(Validation)

빈 벨리데이션이 클래스패스에 존재할 경우(예: 하이버네이트 밸리데이터), 기본적으로 LocalValidatorFactoryBean 이 전역 밸리데이터로 등록되어 @Valid, Validated를 @Controller 메서드의 아규먼트로 사용할 수 있다.

자바 설정에서 전역 Validator 인스턴스를 커스터마이징할 수 있다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public Validator getValidator(); {
        // ...
    }

}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun getValidator(): Validator {
        // ...
    }

}


Validator 구현체를 지역적으로 등록하는 것 또한 가능하다:

Java
@Controller
public class MyController {

    @InitBinder
    protected void initBinder(WebDataBinder binder) {
        binder.addValidators(new FooValidator());
    }

}
Kotlin
@Controller
class MyController {

    @InitBinder
    protected fun initBinder(binder: WebDataBinder) {
        binder.addValidators(FooValidator())
    }
}


# 어딘가에 LocalValidatorFactoryBean을 주입해야 한다면, 빈을 생성하고 @Primary를 사용하면 MVC 설정에서 선언된 빈과의 충돌을 회피할 수 있다.

1.11.5. 컨텐츠 타입 리졸버(Content Type Resolvers)

스프링 웹플럭스가 @Controller 인스턴스로 요청된 미디어 타입을 결정하는 방법을 설정할 수 있다. 기본적으로 Accept 헤더만을 체크하지만, 쿼리 파라미터 기반의 체크도 가능하다.

다음은 요청된 컨텐츠 타입 리솔루션 커스터마이징 예제이다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void configureContentTypeResolver(RequestedContentTypeResolverBuilder builder) {
        // ...
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun configureContentTypeResolver(builder: RequestedContentTypeResolverBuilder) {
        // ...
    }
}


1.11.6. HTTP 메시지 코덱(HTTP message codecs)

다음 예제는 요청과 응답 본문을 읽고 작성하는 방법을 커스터마이징하는 방법을 보여준다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
        // ...
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) {
        // ...
    }
}


ServerCodecConfigurer는 디폴트 readers와 writers 집합을 제공한다. ServerCodecConfigurer를 사용하여 reader와 writer를 추가하고 디폴트를 커스터마이징하거나 다른 것으로 교체할 수 있다.

Jackson JSON, XML 사용에 대해서는 Jackson2ObjectMapperBuilder 사용을 고려하라. Jackson의 디폴트 프로퍼티를 다음 중 하나로 커스터마이징할 수 있다:

또한, 다음의 잘 알려진 모듈들 중, 클래스패스에 존재하는 것을 자동으로 감지하여 등록한다:

1.11.7. 뷰 리졸버(View Resolvers)

다음 예제는 뷰 리졸버 설정을 보여준다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        // ...
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        // ...
    }
}


ViewResolverRegistry는 스프링 프레임워크와 통합된 뷰 기술을 위한 간단한 등록 방법을 제공한다. 다음 예제는 프리마커를 사용한다(기반 프리마커 뷰 기술 설정이 요구됨).

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {


    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.freeMarker();
    }

    // Configure Freemarker...

    @Bean
    public FreeMarkerConfigurer freeMarkerConfigurer() {
        FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
        configurer.setTemplateLoaderPath("classpath:/templates");
        return configurer;
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        registry.freeMarker()
    }

    // Configure Freemarker...

    @Bean
    fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply {
        setTemplateLoaderPath("classpath:/templates")
    }
}


또한 다음과 같이 어떠한 ViewResolver 구현체든 등록할 수 있다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {


    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        ViewResolver resolver = ... ;
        registry.viewResolver(resolver);
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        val resolver: ViewResolver = ...
        registry.viewResolver(resolver
    }
}


컨텐츠 협상과 뷰 리솔루션을 통한(HTML 이 아닌) 다른 포맷 렌더링 지원을 위해, HttpMessageWriterView 구현체에 기반한 하나 이상의 디폴트 뷰를 설정할 수 있다. HttpMessageWriterView 구현체는 spring-web으로부터 유효한 어느 코덱이든 받아들인다. 다음 예제는 그 설정을 보여준다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {


    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.freeMarker();

        Jackson2JsonEncoder encoder = new Jackson2JsonEncoder();
        registry.defaultViews(new HttpMessageWriterView(encoder));
    }

    // ...
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {


    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        registry.freeMarker()

        val encoder = Jackson2JsonEncoder()
        registry.defaultViews(HttpMessageWriterView(encoder))
    }

    // ...
}


뷰 기술에서 스프링 웹플럭스와 통합된 뷰 기술에 대한 더 자세한 내용을 찾아볼 수 있다.

1.11.8. 정적 자원(Static Resources)

이 옵션은 정적 자원을 Resource 기반 위치 목록으로부터 서비스하기 위한 편리한 방법을 제공한다.

다음 예제에서 상대경로 /resources로 시작하는 요청은 클래스패스의 /static 경로의 정적 자원을 찾아 서비스한다. 자원의 브라우저 캐싱 유지 기간을 1년으로 설정한다. Last-Modified 헤더가 있을 경우 그 값을 평가하고 304 상태 코드를 반환한다. 다음은 그 예제이다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/**")
            .addResourceLocations("/public", "classpath:/static/")
            .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS));
    }

}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
        registry.addResourceHandler("/resources/**")
                .addResourceLocations("/public", "classpath:/static/")
                .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS))
    }
}


자원 핸들러는 ResourceResolver, ResourceTransformer 구현체의 체인을 지원한다. 이 구현체들은 최적화된 자원으로 서비스하기 위한 툴체인을 생성하기 위해 사용된다.

컨텐츠, 어플리케이션 버전 또는 다른 정보로부터 계산된 MD5 해시에 기반하여 버저닝된(versioned) 자원 URL을 위해 VersionResourceResolver를 사용할 수 있다. ContentVersionStrategy(MD5 해시) 는(모듈 로더와 함께 사용되는 자바스크립트 자원과 같은) 몇몇 중요한 예외를 제외하고 좋은 선택이 된다.

다음 예제는 자바 설정에서 VersionResourceResolver를 사용하는 예제이다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/**")
                .addResourceLocations("/public/")
                .resourceChain(true)
                .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
    }

}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
        registry.addResourceHandler("/resources/**")
                .addResourceLocations("/public/")
                .resourceChain(true)
                .addResolver(VersionResourceResolver().addContentVersionStrategy("/**"))
    }

}


ResourceUrlProvider를 사용하여 URL 재작성(rewrite) 및 리졸버와 트랜스포머 의 완전 체이닝을 적용할 수 있다(예: 버전 입력을 위해). 웹플럭스 설정은 ResourceUrlProvider를 제공하여 이런 컴포넌트들을 다른 곳으로 주입할 수 있도록 한다.

스프링 MVC 와는 달리, 현재 웹플럭스에서는 정적 자원 URL을 투명하게 재작성하는 방법은 없다. 왜냐하면 논 블로킹 리졸버와 트랜스포머 체인을 사용하는 뷰 기술이 존재하지 않기 때문이다. 로컬 자원만을 서비스할 때는 ResourceUrlProvider를 직접 사용하고(예: 커스텀 엘리먼트를 통해) 블로킹 방식을 취하는 것이 대안이 된다.

EncodedResourceResolver(예: Gzip, Brotli) 와 VersionedResourceResolver를 사용할 때는 컨텐츠 기반 버전은 언제나 인코딩된 파일을 기반으로 하도록 반드시 해당 순서로 등록해야 한다.

WebJars는 WebJarsResourceResolver를 통해 지원된다. WebJarsResourceResolver는 org.webjars:webjars-locator-core 라이브러리가 클래스패스에 존재할 경우 자동으로 등록된다. 이 리졸버는 URL을 재작성하여 jar의 버전을 포함하도록 하고, 버전 없이 들어오는 요청 URL 과 매칭한다. 예를 들어, /jquery/jquery.min.js를 /jquery/1.2.0/jquery.min.js와 매칭한다.

1.11.9. 경로 매칭(path Matching)

경로 매칭 관련 옵션을 커스터마이징할 수 있다. 각각의 옵션에 관한 자세한 내용은 PathMatchConfigurer 자바독을 보라. 다음은 PathMatchConfigurer 예제이다:

Java
@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer
            .setUseCaseSensitiveMatch(true)
            .setUseTrailingSlashMatch(false)
            .addPathPrefix("/api",
                    HandlerTypePredicate.forAnnotation(RestController.class));
    }
}
Kotlin
@Configuration
@EnableWebFlux
class WebConfig : WebFluxConfigurer {

    @Override
    fun configurePathMatch(configurer: PathMatchConfigurer) {
        configurer
            .setUseCaseSensitiveMatch(true)
            .setUseTrailingSlashMatch(false)
            .addPathPrefix("/api",
                    HandlerTypePredicate.forAnnotation(RestController::class.java))
    }
}


# 스프링 웹플럭스는 세미콜론이 제거된 상태에서(경로 혹은 매트릭스 변수) 디코딩된 경로 세그먼트에의 접근을 위해 RequestPath 라 불리는 파싱된 요청 경로 표기에 의존한다. 이는 스프링 MVC 와는 다르게, 경로 매칭 목적으로 요청 경로의 디코딩 여부나 세미콜론의 제거 여부를 표시할 필요가 없음을 의미한다.

스프링 MVC 와는 달리, 스프링 웹플럭스는 접미어 패턴 매칭을 지원하지 않는다. 접미어 패턴에 의존하지 않는 다른 방법을 권한다.

1.11.10. 고급 설정 모드(Advanced Configutaion Mode)

@EnableWebFlux는 DelegatingWebFluxConfiguration을 임포트한다:
  • 웹플럭스 어플리케이션을 위한 디폴트 스프링 설정을 제공한다.
  • WebFluxConfigurer 구현체를 감지하고 위임을 통해 설정을 커스터마이징한다.


고급 모드 사용을 위해 @EnableWebFlux를 제거하고 DelegatingWebFluxConfiguration을 직접 확장할 수 있다. WebFluxConfigurer는 구현하지 않는다. 다음은 그 예제이다:

Java
@Configuration
public class WebConfig extends DelegatingWebFluxConfiguration {

    // ...
}
Kotlin
@Configuration
class WebConfig : DelegatingWebFluxConfiguration {

    // ...
}


기존 WebConfig의 메서드를 유지할 수 있지만, 기본 클래스로부터 빈 선언을 오버라이딩할 수도 있으며, 여전히 클래스패스에 WebMvcConfigurer 구현체를 몇 개든 가질 수 있다.

1.12. HTTP/2

서블릿 4 컨테이너는 HTTP/2를 지원해야 하고, 스프링 프레임워크 5 는 서블릿 API 4 와 호환된다. 프로그래밍 모델 관점에서는 어플리케이션에 더 해야할 일은 없다. 그러나 서버 설정에 관한 고려사항이 있다. HTTP/2 위키 페이지에서 더 자세한 내용을 찾아볼 수 있다.

스프링 웹플럭스는 현재 네티를 사용한 HTTP/2를 지원하지 않는다. 그리고 프로그래밍 방식 자원 푸싱(pushing)도 지원하지 않는다.

2. 웹클라이언트(WebClient)

스프링 웹플럭스는 HTTP 요청을 위한 리액티브한 논 블로킹 WebClient를 포함한다. 이 클라이언트는 선언적 구성을 위한 리액티브 타입을 사용하는 함수형의 능수능란한 API를 가지고 있다. 리액티브 라이브러리를 보라. 웹플럭스 클라이언트와 서버는 동일한 논 블로킹 코덱에 의존하여 요청와 응답 내용을 인코딩 및 디코딩한다.

WebClient는 내부적으로 HTTP 클라이언트 라이브러리에게 위임한다. 기본적으로 Reactor Netty(이하 리액터 네티)를 사용하고, 제티 리액티브 HttpClient를 위한 내장형 지원이 있다. 그리고 ClientHttpConnector를 통해 다른 라이브러리도 사용 가능하다.

2.1. 설정(Configuration)

WebClient를 생성하는 가장 단순한 방법은 스태틱 팩터리 메서드를 사용하는 것이다:
  • WebClient.create()
  • WebClient.create(String baseUrl)


위의 두 메서드는 리액터 네티를 디폴트 세팅으로 사용한다. 클래스패스에 io.projectreactor.netty:reactor-netty가 존재해야 한다.

WebClient.builder()를 몇 가지 옵션과 함께 사용할 수도 있다:
  • uriBuilderFactory: 기본 URL으로 사용하기 위한 커스터마이징된 UriBuilderFactory.
  • defaultHeader: 모든 요청에 대한 헤더.
  • defaultCookie: 모든 요청에 대한 쿠키.
  • defaultRequest: 모든 요청을 커스터마이징하기 위한 Consumer.
  • filter: 모든 요청에 대한 클라이언트 필터.
  • exchangeStrategies: HTTP 메시지 reader/writer 커스터마이징.
  • clientConnector: HTTP 클라이언트 라이브러리 세팅.


다음 예제는 HTTP 코덱을 설정한다:

Java
WebClient client = WebClient.builder()
        .exchangeStrategies(builder -> {
                return builder.codecs(codecConfigurer -> {
                    //...
                });
        })
        .build();
Kotlin
val webClient = WebClient.builder()
        .exchangeStrategies { strategies ->
            strategies.codecs {
                //...
            }
        }
        .build()


한 번 만들어진 WebClient 인스턴스는 불변형이다. 하지만 원본 인스턴스에 영향을 주지 않으면서 인스턴스를 복제하고 변경된 복제복을 만드는 일이 가능하다. 다음은 그 예제이다:

Java
WebClient client1 = WebClient.builder()
        .filter(filterA).filter(filterB).build();

WebClient client2 = client1.mutate()
        .filter(filterC).filter(filterD).build();

// client1 has filterA, filterB

// client2 has filterA, filterB, filterC, filterD
Kotlin
val client1 = WebClient.builder()
        .filter(filterA).filter(filterB).build()

val client2 = client1.mutate()
        .filter(filterC).filter(filterD).build()

// client1 has filterA, filterB

// client2 has filterA, filterB, filterC, filterD


2.1.1. MaxInMemorySize

스프링 웹플럭스는 어플리케이션의 메모리 이슈를 피하기 위해 코덱의 데이터 인 메모리 버퍼링 사이즈에 제한을 둘 수 있다. 이 제한의 디폴트 값은 256KB 이며, 이 값으로 충분하지 못할 때는 다음 메시지를 만나게 된다:

# org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer

모든 디폴트 코덱에 적용되는 제한값을 설정할 수 있다. 다음 그 예제이다:

Java
WebClient webClient = WebClient.builder()
        .exchangeStrategies(builder ->
            builder.codecs(codecs ->
                codecs.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)
            )
        )
        .build();
Kotlin
val webClient = WebClient.builder()
    .exchangeStrategies { builder ->
            builder.codecs {
                it.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)
            }
    }
    .build()


2.1.2. 리액터 네티(Reactor Netty)

리액터 네티 설정 커스터마이징을 위한 미리 설정된 HttpClient를 제공한다:

Java
HttpClient httpClient = HttpClient.create().secure(sslSpec -> ...);

WebClient webClient = WebClient.builder()
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .build();
Kotlin
val httpClient = HttpClient.create().secure { ... }

val webClient = WebClient.builder()
    .clientConnector(ReactorClientHttpConnector(httpClient))
    .build()


Resources



기본적으로, HttpClient는 이벤트 루프 쓰레드와 커넥션 풀을 포함하여 reactor.netty.http.HttpResources 에 있는 전역 리액터 네티에 속에 있다. 이벤트 루프 동시성에는 고정된 공유 자원들이 선호되므로, 이 모드가 권고된다. 이 모드에서는 전역적 자원은 프로세스가 종료될 때까지 유효하다.

서버가 프로세스에 맞추어져 있다면, 일반적으로 명시적 셧다운이 필요하지 않다. 하지만 만약 서버가 프로세스 안에서 시작되고 정지될 수 있다면(예를 들어, WAR로 배포된 스프링 MVC 어플리케이션), 여기에는 ReactorResourceFactory를 globalResources=true(디폴트) 로 설정한 스프링 관리 빈을 선언하여 리액터 네티 전역 자원이 스프링 ApplicationCotext의 클로징 시점에 셧다운되도록 할 수 있다. 다음은 그 예제이다:

Java
@Bean
public ReactorResourceFactory reactorResourceFactory() {
    return new ReactorResourceFactory();
}
Kotlin
@Bean
fun reactorResourceFactory() = ReactorResourceFactory()


또한 글로벌 리액터 네티 자원에 속하지 않도록 할 수도 있는데, 이 모드에서는 리액터 네티 클라이언트와 서버 인스턴스가 공유된 자원을 사용하도록 하는 것은 당신의 몫이 된다. 다음은 그 예제이다:

Java
@Bean
public ReactorResourceFactory resourceFactory() {
    ReactorResourceFactory factory = new ReactorResourceFactory();
    factory.setUseGlobalResources(false); (1)
    return factory;
}

@Bean
public WebClient webClient() {

    Function<HttpClient, HttpClient> mapper = client -> {
        // Further customizations...
    };

    ClientHttpConnector connector =
            new ReactorClientHttpConnector(resourceFactory(), mapper); (2)

    return WebClient.builder().clientConnector(connector).build(); (3)
}
Kotlin
@Bean
fun resourceFactory() = ReactorResourceFactory().apply {
    isUseGlobalResources = false (1)
}

@Bean
fun webClient(): WebClient {

    val mapper: (HttpClient) -> HttpClient = {
        // Further customizations...
    }

    val connector = ReactorClientHttpConnector(resourceFactory(), mapper) (2)

    return WebClient.builder().clientConnector(connector).build() (3)
}


(1) 전역 자원과 독립된 자원을 생성한다. (2) 자원 팩토리로 ReactorClientHttpConnector생성자를 사용한다. (3) 커넥터를 WebClient.Builder에 연결한다.

타임아웃(Timeouts)



커넥션 타임아웃은 다음과 같이 설정한다:

Java
import io.netty.channel.ChannelOption;

HttpClient httpClient = HttpClient.create()
        .tcpConfiguration(client ->
                client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000));
Kotlin
import io.netty.channel.ChannelOption

val httpClient = HttpClient.create()
        .tcpConfiguration { it.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)}


read/write 타임아웃은 다음과 같이 설정한다:

Java
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;

HttpClient httpClient = HttpClient.create()
        .tcpConfiguration(client ->
                client.doOnConnected(conn -> conn
                        .addHandlerLast(new ReadTimeoutHandler(10))
                        .addHandlerLast(new WriteTimeoutHandler(10))));
Kotlin
import io.netty.handler.timeout.ReadTimeoutHandler
import io.netty.handler.timeout.WriteTimeoutHandler

val httpClient = HttpClient.create().tcpConfiguration {
    it.doOnConnected { conn -> conn
            .addHandlerLast(ReadTimeoutHandler(10))
            .addHandlerLast(WriteTimeoutHandler(10))
    }
}


2.1.3. 제티(Jetty)

다음 예제는 제티 HttpClient 설정 커스터마이징을 보여준다:

Java
HttpClient httpClient = new HttpClient();
httpClient.setCookieStore(...);
ClientHttpConnector connector = new JettyClientHttpConnector(httpClient);

WebClient webClient = WebClient.builder().clientConnector(connector).build();
Kotlin
val httpClient = HttpClient()
httpClient.cookieStore = ...
val connector = JettyClientHttpConnector(httpClient)

val webClient = WebClient.builder().clientConnector(connector).build();


기본적으로 HttpClient는 자신의 리소스(Executor, ByteBufferPool, Scheduler)를 사용한다. 이 리소스들은 프로세스가 종료되거나 stop() 이 호출될 때까지 유효하다.

리소스를 다수의 제티 클라이언트(그리고 서버) 인스턴스 사이에서 공유할 수 있고, JettyResourceFactory를 스프링 관리 빈으로 선언함으로써 스프링 ApplicationContext가 클로징될 때 셧다운되도록 할 수 있다. 다음은 그 예제이다:

Java
@Bean
public JettyResourceFactory resourceFactory() {
    return new JettyResourceFactory();
}

@Bean
public WebClient webClient() {

    HttpClient httpClient = new HttpClient();
    // Further customizations...

    ClientHttpConnector connector =
            new JettyClientHttpConnector(httpClient, resourceFactory()); (1)

    return WebClient.builder().clientConnector(connector).build(); (2)
}
Kotlin
@Bean
fun resourceFactory() = JettyResourceFactory()

@Bean
fun webClient(): WebClient {

    val httpClient = HttpClient()
    // Further customizations...

    val connector = JettyClientHttpConnector(httpClient, resourceFactory()) (1)

    return WebClient.builder().clientConnector(connector).build() (2)
}


(1) JettyClientHttpConnector 생성자에 리소스 팩터리를 사용한다. (2) 커넥터를 WebClient.Builder에 연결한다.

2.2. retrieve()

retrieve() 메서드는 응답 본문을 얻고 디코딩하기 위한 가장 쉬운 방법이다. 다음은 그 예제이다:

Java
WebClient client = WebClient.create("https://example.org");

Mono<Person> result = client.get()
        .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
        .retrieve()
        .bodyToMono(Person.class);
Kotlin
val client = WebClient.create("https://example.org")

val result = client.get()
        .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
        .retrieve()
        .awaitBody<Person>()


응답으로부터 디코딩된 객체의 스트림을 얻을 수도 있다:

Java
Flux<Quote> result = client.get()
        .uri("/quotes").accept(MediaType.TEXT_EVENT_STREAM)
        .retrieve()
        .bodyToFlux(Quote.class);
Kotlin
val result = client.get()
        .uri("/quotes").accept(MediaType.TEXT_EVENT_STREAM)
        .retrieve()
        .bodyToFlow<Quote>()


기본적으로 상태 코드 4xx 혹은 5xx 응답의 결과는 WebClientResponseException 혹은 WebClientResponseException.BadRequest, WebClientResponseException.NotFound, 기타 등등과 같은 HTTP 상태 특징적 서브클래스가 된다. onStatus 메서드를 사용하여 결과 익셉션을 커스터마이징할 수 있다. 다음은 그 예제이다:

Java
Mono<Person> result = client.get()
        .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
        .retrieve()
        .onStatus(HttpStatus::is4xxClientError, response -> ...)
        .onStatus(HttpStatus::is5xxServerError, response -> ...)
        .bodyToMono(Person.class);
Kotlin
val result = client.get()
        .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
        .retrieve()
        .onStatus(HttpStatus::is4xxClientError) { ... }
        .onStatus(HttpStatus::is5xxServerError) { ... }
        .awaitBody<Person>()


onStatus가 사용될 때, 그 응답이 내용을 가지고 있다고 예상된다면 onStatus 콜백은 이를 소비해야 한다. 그렇지 않으면 응답 내용은 리소스를 릴리즈하기 위해 자동으로 비워진다.

2.3. exchange()

exchange() 메서드는 retrieve 메서드보다 더 많은 기능을 제공한다. 다음 예제는 retrieve() 와 동등하면서도 ClientResponse 에의 접근을 제공한다:

Java
Mono<Person> result = client.get()
        .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
        .exchange()
        .flatMap(response -> response.bodyToMono(Person.class));
Kotlin
val result = client.get()
        .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
        .awaitExchange()
        .awaitBody<Person>()


이 레벨에서는 완전한 ResponseEntity를 생성할 수도 있다:

Java
Mono<ResponseEntity<Person>> result = client.get()
        .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
        .exchange()
        .flatMap(response -> response.toEntity(Person.class));
Kotlin
val result = client.get()
        .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
        .awaitExchange()
        .toEntity<Person>()


retrieve() 와는 달리, exchange()를 사용하면 4xx, 5xx 응답에 대한 자동적인 에러 시그널이 존재하지 않는다. 직접 상태 코드를 체크하여 그 뒤의 동작을 결정해야 한다.

# exchange() 사용 시 응답 본문은 언제나 소비되거나 릴리즈되어야 한다. 익셉션이 발생하더라도 마찬가지이다(DataBuffer 사용하기를 보라). 보통, 본문을 원하는 타입의 객체로 컨버팅하기 위해 ClientResponse의 bodyTo* 혹은 toEntity*를 실행하는데, releaseBody()를 사용해서 본문 컨텐츠를 소비하지 않고 버리는 일도 가능하다. 아니면 toBodilessEntity()를 사용해서 상태와 헤더만을 얻을 수도 있다(본문은 버린다).

그리고, bodyToMono(Void.class) 가 있다. 이 방법은 오직 응답 컨텐츠가 없다고 예상되는 경우에 한하여 사용되어야 한다. 만일 응답이 컨텐츠를 가지고 있다면 그 커넥션은 닫히고, 커넥션 풀로 반환되지 않는다. 왜냐하면 이 커넥션은 재사용 가능한 상태로 남지 않기 때문이다.

2.4. 요청 본문(Request Body)

요청 본문은 Mono 혹은 코틀린 코루틴 Deferred와 같이, ReactiveAdapterRegistry가 핸들링하는 어떠한 비동기 타입으로부터든 인코딩 가능하다. 다음은 그 예제이다:

Java
Mono<Person> personMono = ... ;

Mono<Void> result = client.post()
        .uri("/persons/{id}", id)
        .contentType(MediaType.APPLICATION_JSON)
        .body(personMono, Person.class)
        .retrieve()
        .bodyToMono(Void.class);
Kotlin
val personDeferred: Deferred<Person> = ...

client.post()
        .uri("/persons/{id}", id)
        .contentType(MediaType.APPLICATION_JSON)
        .body<Person>(personDeferred)
        .retrieve()
        .awaitBody<Unit>()


또한 인코딩된 객체의 스트림을 가질 수도 있다:

Java
Flux<Person> personFlux = ... ;

Mono<Void> result = client.post()
        .uri("/persons/{id}", id)
        .contentType(MediaType.APPLICATION_STREAM_JSON)
        .body(personFlux, Person.class)
        .retrieve()
        .bodyToMono(Void.class);
Kotlin
val people: Flow<Person> = ...

client.post()
        .uri("/persons/{id}", id)
        .contentType(MediaType.APPLICATION_JSON)
        .body(people)
        .retrieve()
        .awaitBody<Unit>()


아니면, 실제 값을 가진 경우 bodyValue 메서드를 사용할 수도 있다:

Java
Person person = ... ;

Mono<Void> result = client.post()
        .uri("/persons/{id}", id)
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(person)
        .retrieve()
        .bodyToMono(Void.class);
Kotlin
val person: Person = ...

client.post()
        .uri("/persons/{id}", id)
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(person)
        .retrieve()
        .awaitBody<Unit>()


2.4.1. 폼 데이터(Form Data)

폼 데이터를 전송하기 위해 요청 본문으로 MultiValueMap<String, String> 을 사용할 수 있다. 본문 컨텐츠는 FormHttpMessageWriter 에 의해 자동으로 application/x-www-form-urlencoded로 설정된다. 다음 예제는 MultiValueMap<String, String> 을 사용한다:

Java
MultiValueMap<String, String> formData = ... ;

Mono<Void> result = client.post()
        .uri("/path", id)
        .bodyValue(formData)
        .retrieve()
        .bodyToMono(Void.class);
Kotlin
val formData: MultiValueMap<String, String> = ...

client.post()
        .uri("/path", id)
        .bodyValue(formData)
        .retrieve()
        .awaitBody<Unit>()


BodyInserters를 사용하여 폼 데이터 인라인으로 생성할 수도 있다:

Java
import static org.springframework.web.reactive.function.BodyInserters.*;

Mono<Void> result = client.post()
        .uri("/path", id)
        .body(fromFormData("k1", "v1").with("k2", "v2"))
        .retrieve()
        .bodyToMono(Void.class);
Kotlin
import org.springframework.web.reactive.function.BodyInserters.*

client.post()
        .uri("/path", id)
        .body(fromFormData("k1", "v1").with("k2", "v2"))
        .retrieve()
        .awaitBody<Unit>()


2.4.2. 멀티파트 데이터(Multipart Data)

멀티파트 데이터를 전송하기 위해 파트 컨텐츠 혹은 파트에 대한 컨텐츠나 헤더를 나타내는 HttpEntity 인스턴스를 나타내는 Object 인스턴스를 값으로 가진 MultiValueMap<String, ?> 을 사용할 수 있다. MultipartBodyBulder는 멀티파트 요청을 준비하기 위한 편리한 API를 제공한다. 다음 예제는 MultiValueMap<String, ?> 을 생성한다:

Java
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("fieldPart", "fieldValue");
builder.part("filePart1", new FileSystemResource("...logo.png"));
builder.part("jsonPart", new Person("Jason"));
builder.part("myPart", part); // Part from a server request

MultiValueMap<String, HttpEntity<?>> parts = builder.build();
Kotlin
val builder = MultipartBodyBuilder().apply {
    part("fieldPart", "fieldValue")
    part("filePart1", new FileSystemResource("...logo.png"))
    part("jsonPart", new Person("Jason"))
    part("myPart", part) // Part from a server request
}

val parts = builder.build()


대부분의 경우, 각 파트에 대한 Content-Type을 지정할 필요는 없다. 컨텐츠 타입은 시리얼라이징을 위해 선택된 HttpMessageWriter, 혹은 파일 확장자에 기반한 Resource를 바탕으로 자동적으로 결정된다. 필요에 따라서 오버로딩된 빌더 part 메소드를 통해 각 파트가 사용할 MediaType을 명시적으로 제공할 수 있다.

한 번 MultiValueMap 이 준비되면, 이를 WebClient에게 전달하는 가장 쉬운 방법은 body 메서드를 사용하는 것이다. 다음은 그 예제이다:

Java
MultipartBodyBuilder builder = ...;

Mono<Void> result = client.post()
        .uri("/path", id)
        .body(builder.build())
        .retrieve()
        .bodyToMono(Void.class);
Kotlin
val builder: MultipartBodyBuilder = ...

client.post()
        .uri("/path", id)
        .body(builder.build())
        .retrieve()
        .awaitBody<Unit>()


MultiValueMap 이 최소 하나의 non-String 값을 포함한다면 Content-Type을 multipart/form-data로 세팅할 필요가 없다. 이 때의 값은 일반 폼 데이터(application/x-www-form-urlencoded)를 나타낼 수도 있다. 이는 언제나 MultipartBodyBuilder를 사용하는 경우가 된다. 이 빌더는 HttpEntity 래퍼를 보장한다.

MultipartBodyBuilder의 대안으로, 내장형 BodyInserter를 통한 인라인 스타일의 멀티파트 컨텐츠를 제공할 수도 있다. 다음은 그 예제이다:

Java
import static org.springframework.web.reactive.function.BodyInserters.*;

Mono<Void> result = client.post()
        .uri("/path", id)
        .body(fromMultipartData("fieldPart", "value").with("filePart", resource))
        .retrieve()
        .bodyToMono(Void.class);
Kotlin
import org.springframework.web.reactive.function.BodyInserters.*

client.post()
        .uri("/path", id)
        .body(fromMultipartData("fieldPart", "value").with("filePart", resource))
        .retrieve()
        .awaitBody<Unit>()


2.5. 클라이언트 필터(Client Filters)

요청을 인터셉팅하고 변경하기 위해 WebClient.Builder를 사용해서 클라이언트 필터(ExchangeFilterFunction)를 등록할 수 있다. 다음은 그 예제이다:

Java
WebClient client = WebClient.builder()
        .filter((request, next) -> {

            ClientRequest filtered = ClientRequest.from(request)
                    .header("foo", "bar")
                    .build();

            return next.exchange(filtered);
        })
        .build();
Kotlin
val client = WebClient.builder()
        .filter { request, next ->

            val filtered = ClientRequest.from(request)
                    .header("foo", "bar")
                    .build()

            next.exchange(filtered)
        }
        .build()


필터는 인증과 같은 크로스커팅 관심사를 다루기 위해 사용될 수 있다. 다음 예제는 필터를 사용하여 스태틱 팩토리 메서드를 통해 기본 인증을 처리한다:

Java
import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication;

WebClient client = WebClient.builder()
        .filter(basicAuthentication("user", "password"))
        .build();
Kotlin
import org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication

val client = WebClient.builder()
        .filter(basicAuthentication("user", "password"))
        .build()


필터는 모든 요청에 글로벌하게 적용된다. 특정 요청에 대한 필터의 동작을 변경하려면, 체인 안의 모든 필터가 접근하는 ClientRequest 에 요청 어트리뷰트를 추가할 수 있다. 다음은 그 예제이다:

Java
WebClient client = WebClient.builder()
        .filter((request, next) -> {
            Optional<Object> usr = request.attribute("myAttribute");
            // ...
        })
        .build();

client.get().uri("https://example.org/")
        .attribute("myAttribute", "...")
        .retrieve()
        .bodyToMono(Void.class);

    }
Kotlin
val client = WebClient.builder()
            .filter { request, _ ->
        val usr = request.attributes()["myAttribute"];
        // ...
    }.build()

    client.get().uri("https://example.org/")
            .attribute("myAttribute", "...")
            .retrieve()
            .awaitBody<Unit>()


기존 WebClient를 복제하거나, 새 필터를 추가하거나, 이미 등록된 필터를 제거하는 것도 가능하다. 다음은 인덱스 0 에 기본 인증 필터를 추가한다:

Java
import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication;

WebClient client = webClient.mutate()
        .filters(filterList -> {
            filterList.add(0, basicAuthentication("user", "password"));
        })
        .build();
Kotlin
val client = webClient.mutate()
        .filters { it.add(0, basicAuthentication("user", "password")) }
        .build()


2.6. 동기식 사용(Synchronous Use)

WebClient를 결과를 위한 끝에 블로킹하는 방법으로 동기 방식으로 사용할 수 있다:

Java
Person person = client.get().uri("/person/{id}", i).retrieve()
    .bodyToMono(Person.class)
    .block();

List<Person> persons = client.get().uri("/persons").retrieve()
    .bodyToFlux(Person.class)
    .collectList()
    .block();
Kotlin
val person = runBlocking {
    client.get().uri("/person/{id}", i).retrieve()
            .awaitBody<Person>()
}

val persons = runBlocking {
    client.get().uri("/persons").retrieve()
            .bodyToFlow<Person>()
            .toList()
}


하지만 다수의 호출이 필요할 경우, 각 응답에 대한 개별적인 블로킹은 피하는 것이 더 효율적이다. 대신, 결합된 결과를 기다리도록 한다:

Java
Mono<Person> personMono = client.get().uri("/person/{id}", personId)
        .retrieve().bodyToMono(Person.class);

Mono<List<Hobby>> hobbiesMono = client.get().uri("/person/{id}/hobbies", personId)
        .retrieve().bodyToFlux(Hobby.class).collectList();

Map<String, Object> data = Mono.zip(personMono, hobbiesMono, (person, hobbies) -> {
            Map<String, String> map = new LinkedHashMap<>();
            map.put("person", person);
            map.put("hobbies", hobbies);
            return map;
        })
        .block();
Kotlin
val data = runBlocking {
        val personDeferred = async {
            client.get().uri("/person/{id}", personId)
                    .retrieve().awaitBody<Person>()
        }

        val hobbiesDeferred = async {
            client.get().uri("/person/{id}/hobbies", personId)
                    .retrieve().bodyToFlow<Hobby>().toList()
        }

        mapOf("person" to personDeferred.await(), "hobbies" to hobbiesDeferred.await())
    }


위 코드는 한밭 예시에 불과하다. 많은 수의 원격 호출, 잠재적으로 중첩된, 상호 의존적이면서 끝까지 블로킹하지 않는 리액티브 파이프라인을 만드는 수많은 패턴과 오퍼레이터가 존재한다.

# Flux 또는 Mono를 사용하여, 스프링 MVC 혹은 스프링 웹플럭스 컨트롤러에서 절대로 블로킹하지 않을 수 있다. 간단히 컨트롤러 메서드로부터 리액티브 타입을 반환하는 것이다. 같은 원칙은 코틀린 코루틴과 스프링 웹플럭스에 동일하게 적용된다. 서스펜딩 함수를 사용하거나 컨트롤러 메서드에서 Flow를 반환하라.

2.7. 테스팅(Testing)

WebClient를 사용하는 코드를 테스트하기 위해, OkHttp MockWebServer와 같은 목 웹서버를 사용할 수 있다. 이 서버의 사용 예제를 보려면 스프링 프레임워크 테스트 슈트의 WebClientIntergrationTests, 혹은 OkHttp 리파지토리의 static-server 샘플을 체크아웃하자.

3. 웹소켓(WebSockets)

이 부분은 리액티브 스택 웹소켓 메시징 지원에 대해 다룬다.

3.1. 웹소켓이란(Introduction to WebSocket)

웹소켓 프로토콜, RFC 6455는 단일 TCP 커넥션 위로 클라이언트와 서버 사이의 전 양방의, 양방향 통신 채널을 설정하는 표준적인 방법을 제공한다. 이는 HTTP 와는 다른 TCP 프로토콜이지만, HTTP를 근간으로 동작하도록 설계되었다. 80 과 443 포트를 사용하며, 기존 방화벽 규칙을 재사용한다.

웹소켓 상호작용은 업그레이드를, 혹은 웹소켓 프로토콜로의 스위칭을 위한 HTTP Upgrade 헤더를 사용하는 HTTP 요청과 함께 시작된다. 다음 예제는 이러한 상호작용을 보여준다:

GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket (1)
Connection: Upgrade (2)
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080


(1) Upgrade 헤더. (2) Upgrade 커넥션 사용.

보통의 200 상태 코드 대신, 웹소켓 서버는 다음과 비슷한 아웃풋을 반환한다.

HTTP/1.1 101 Switching Protocols (1)
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp


1) 프로토콜 스위치

핸드셰이크 성공 후, HTTP 업그레이드 요청의 기반 TCP 소켓은 메시지 송수신을 위해 클라이언트와 서버 양쪽으로 열려있게 된다.

웹소켓 동작에 대한 완전한 소개는 이 문서의 범위를 넘어선다. 더 자세한 내용은 RFC 6455, HTML5 의 웹소켓 챕터, 혹은 웹에 있는 수많은 인트로와 튜토리얼을 보도록 하자.

웹소켓 서버가 웹서버 뒤에서 동작할 때는(예: nginx), 웹소켓 업그레이드 요청을 웹소켓 서버로 보내도록 설정해야 한다. 마찬가지로, 어플리케이션이 클라우드 환경에서 구동된다면 웹소켓 지원에 관련된 클라우드 제공자의 지시를 확인해야 한다.

3.1.1. HTTP와 웹소켓(HTTP Versus WebSocket)

웹소켓이 HTTP와 호환되도록 만들어지고 또 HTTP 요청으로 시작된다고 할지라도, 이 두 프로토콜이 매우 다른 아키텍처와 어플리케이션 프로그래밍 모델을 가지고 있음을 이해할 필요가 있다.

HTTP와 REST 에서는 어플리케이션이 많은 URL으로 모델링된다. 어플리케이션과의 상호작용을 위해서 클라이언트는 이런 URL 에 요청-응답 방식으로 접근해야 한다. 서버는 요청을 HTTP URL, 메서드, 헤더를 바탕으로 적절한 핸들러에게 라우팅한다.

반대로, 웹소켓에서는 주로 초기 커넥션을 위한 오직 하나의 URL 만이 존재한다. 이어서, 모든 어플리케이션 메시지는 같은 TCP 커넥션 위로 흐른다. 이는 완전하게 다른 비동기적, 이벤트 드리븐 메시징 아키텍처를 시사한다.

웹소켓은 또한 로우레벨 전송 프로토콜이다. HTTP 와는 다르게, 메시지의 컨텐츠에 대한 어떠한 시맨틱스도 규정하지 않는다. 이는 클라이언트와 서버가 메시지 시맨틱스에 동의하지 않으면 메시지를 라우팅하고 처리하기 위한 방법이 없다는 의미이다.

웹소켓 클라이언트와 서버는 고수준 메시징 프로토콜(예: STOMP) 의 사용을 HTTP 핸드셰이크 요청의 Sec-WebSocket-Protocol 헤더를 통해 협상할 수 있다. 이 헤더가 없으면 클라이언트와 서버는 자신들의 협상 방식을 스스로 규정해야 한다.

3.1.2. 웹소켓을 언제 사용할 것인가(When to Use WebSocket)

웹소켓은 웹페이지를 동적이고 상호적으로 만든다. 하지만 많은 경우에 있어 Ajax와 HTTP 스트리밍, 혹은 롱 폴링(Ajax and HTTP streaming or long polling)의 결합은 간단하면서 효과적인 솔루션이 된다.

예를 들어, 뉴스, 메일, 소셜 피드는 동적으로 갱신될 필요가 있지만, 몇 분 마다 갱신하는 정도로도 완벽할지 모른다. 반면에 콜라보레이션, 게임, 금융 어플리케이션은 훨씬 더 실시간으로 동작해야 한다.

대기시간만으로는 결정적인 요인이 되지 못한다. 메시지의 볼륨이 상대적으로 작다면(예로, 네트워크 실패 모니터링), HTTP 스트리밍이나 폴링이 효과적인 솔루션이 될 수 있다. 웹소켓을 사용하기 위한 최적의 케이스는 적은 대기시간, 높은 빈도, 큰 볼륨이 함께 요구되는 곳이다.

또한 인터넷에서는 사용자의 통제 밖에 있는 프록시의 제한이 웹소켓의 상호동작을 방해할 수 있음을 유념해야 한다. 왜냐하면 프록시는 Upgrade 헤더를 전달하도록 설정되어 있지 않거나, 오랜 시간 열려 있는, 휴게 중으로 보이는 커넥션은 닫아버리기 때문이다. 이는 웹소켓 사용에 있어 방화벽이 작동하는 환경에서의 인터넷 어플리케이션에 대한 사용이, 자유롭게 접근 가능한 어플리케이션에 대한 사용보다 쉽다는 뜻이다.

3.2. 웹소켓 API(WebSocket API)

스프링 프레임워크는 웹소켓 메시지를 핸들링하는 클라이언트와 서버 사이드 어플리케이션을 작성하기 위한 웹소켓 API를 제공한다.

3.2.1. 서버(Server)

웹소켓 서버를 생성하기 위해, 먼저 WebSocketHandler를 생성할 수 있다. 다음은 그 예제이다:

Java
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.WebSocketSession;

public class MyWebSocketHandler implements WebSocketHandler {

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        // ...
    }
}
Kotlin
import org.springframework.web.reactive.socket.WebSocketHandler
import org.springframework.web.reactive.socket.WebSocketSession

class MyWebSocketHandler : WebSocketHandler {

    override fun handle(session: WebSocketSession): Mono<Void> {
        // ...
    }
}


다음은 이 핸들러를 URL 에 매핑하고 WebSocketHandlerAdapter를 추가한다:

Java
@Configuration
class WebConfig {

    @Bean
    public HandlerMapping handlerMapping() {
        Map<String, WebSocketHandler> map = new HashMap<>();
        map.put("/path", new MyWebSocketHandler());
        int order = -1; // before annotated controllers

        return new SimpleUrlHandlerMapping(map, order);
    }

    @Bean
    public WebSocketHandlerAdapter handlerAdapter() {
        return new WebSocketHandlerAdapter();
    }
}
Kotlin
@Configuration
class WebConfig {

    @Bean
    fun handlerMapping(): HandlerMapping {
        val map = mapOf("/path" to MyWebSocketHandler())
        val order = -1 // before annotated controllers

        return SimpleUrlHandlerMapping(map, order)
    }

    @Bean
    fun handlerAdapter() =  WebSocketHandlerAdapter()
}


3.2.2. webSocketHandler

WebSocketHandler의 handle 메서드는 WebSocketSession을 아규먼트로 가지며, Mono<Void>를 반환하여 어플리케이션의 세션 핸들링이 완료되었음을 알린다. 세션은 두 스크림을 통해 핸들링되는데, 하나는 인바운드, 다른 하나는 아웃바운드 메시지를 위한 것이다. 다음 테이블은 이 스트림을 핸들링하는 두 메서드에 대해 설명한다:

WebSocketSession 메서드 설명
Flux receive() 인바운드 메시지 스트림에 접근하고 커넥션이 닫혔을 때 완료된다.
Mono send(Publisher) 발신 메시지를 위한 소스를 취하고, 메시지를 작성하고, 소스가 완료되고 메시지 작성이 끝났을 때 완료되는 Mono<Void>를 반환한다.


WebSocketHandler는 인바운드와 아웃바운드 스트림을 반드시 통일환 하나의 흐름으로 구성하고, 이 흐름의 완료를 나타내는 Mono<Void>를 반환해야 한다. 어플리케이션 요건에 따라서, 통일된 흐름은 다음의 경우에 완료된다:
  • 인바운드나 아웃바운드 메시지 스트림 완료 시.
  • 인바운드 스트림 완료되고(커넥션 닫힘), 아웃바운드 스트림이 무한할 시.
  • 선택된 시점에, WebSocketSession의 close 메서드를 통해.


인바운드와 아웃바운드 메시지 스트림이 함께 구성되어 있을 때는 커넥션이 열려 있는지 확인할 필요가 없다. 리액티브 스트림 시그널이 활성화 상태를 종료하기 때문이다. 인바운드 스트림은 완료 혹은 에러 시그널을 수신하고, 아웃바운드 스트림은 취소 시그널을 수신한다.

핸들러의 가장 기본적인 구현체는 인바운드 스트림을 핸들링한다. 다음 예제는 이러한 구현체를 보여준다:

Java
class ExampleHandler implements WebSocketHandler {

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        return session.receive()            (1)
                .doOnNext(message -> {
                    // ...                  (2)
                })
                .concatMap(message -> {
                    // ...                  (3)
                })
                .then();                    (4)
    }
}
Kotlin
class ExampleHandler : WebSocketHandler {

    override fun handle(session: WebSocketSession): Mono<Void> {
        return session.receive()            (1)
                .doOnNext {
                    // ...                  (2)
                }
                .concatMap {
                    // ...                  (3)
                }
                .then()                     (4)
    }
}


(1) 인바운트 메시지 스트림에 접근한다. (2) 각 메시지에 대한 동작을 수행한다. (3) 메시지 컨텐츠를 사용하는 비동기 중첩 명령을 수행한다. (4) 수신 완료 시 완료되는 Mono를 반환한다.

# 중첩된 비동기 명령에 대해서는, 풀링된(pooled) 데이터 버퍼를 사용하는 서버(예: 네티)에서는 message.retain()를 호출할 필요가 있을 수 있다. 그렇지 않으면 데이터를 읽기 전에 버퍼가 비워질 수 있다. 더 자세한 배경 설명은 데이터 버퍼와 코덱을 보자.

다음 구현체는 인바운드와 아웃바운드 스트림을 결합한다.

Java
class ExampleHandler implements WebSocketHandler {

    @Override
    public Mono<Void> handle(WebSocketSession session) {

        Flux<WebSocketMessage> output = session.receive()               (1)
                .doOnNext(message -> {
                    // ...
                })
                .concatMap(message -> {
                    // ...
                })
                .map(value -> session.textMessage("Echo " + value));    (2)

        return session.send(output);                                    (3)
    }
}
Kotlin
class ExampleHandler : WebSocketHandler {

    override fun handle(session: WebSocketSession): Mono<Void> {

        val output = session.receive()                      (1)
                .doOnNext {
                    // ...
                }
                .concatMap {
                    // ...
                }
                .map { session.textMessage("Echo $it") }    (2)

        return session.send(output)                         (3)
    }
}


(1) 인바운드 메시지 스트림을 핸들링한다. (2) 아웃바운드 메시지를 생성하면서 결합된 흐름을 만든다. (3) 수신 중엔 완료되지 않는 Mono를 반환한다.

인바운드와 아운바운드 스트림은 독립적으로 존재할 수 있고, 완료 시에만 결합할 수 있다. 다음은 그 예제이다:

Java
class ExampleHandler implements WebSocketHandler {

    @Override
    public Mono<Void> handle(WebSocketSession session) {

        Mono<Void> input = session.receive()                                (1)
                .doOnNext(message -> {
                    // ...
                })
                .concatMap(message -> {
                    // ...
                })
                .then();

        Flux<String> source = ... ;
        Mono<Void> output = session.send(source.map(session::textMessage)); (2)

        return Mono.zip(input, output).then();                              (3)
    }
}
Kotlin
class ExampleHandler : WebSocketHandler {

    override fun handle(session: WebSocketSession): Mono<Void> {

        val input = session.receive()                                   (1)
                .doOnNext {
                    // ...
                }
                .concatMap {
                    // ...
                }
                .then()

        val source: Flux<String> = ...
        val output = session.send(source.map(session::textMessage))     (2)

        return Mono.zip(input, output).then()                           (3)
    }
}


(1) 인바운드 메시지 스트림을 핸들링한다. (2) 발신 메시지를 전송한다. (3) 스트림을 결합하고, 두 스트림 중 하나가 끝나면 완료되는 Mono를 반환한다.

3.2.3. DataBuffer

DataBuffer는 웹플럭스의 바이트 버퍼이다. 스프링 코어 레퍼런스에 이 버퍼에 대해 다루는 섹션 데이터 버퍼와 코덱이 있다. DataBuffer의 키 포인트가 되는 부분은, 네티와 같은 서버에서는 바이트 버퍼는 풀링되고(pooled) 레퍼런트가 카운팅된다는 점, 그리고 메모리 누수를 피하기 위해 소비한 뒤에는 반드시 릴리즈해야 한다는 점이다.

네티에서 구동되는 어플리케이션은 인풋 데이터가 릴리즈되지 않게 묶어두려면 반드시 DataBufferUtils.retain(dataBuffer)를 사용해야 한다. 이어서 버퍼를 소비하고 DataBufferUtil.release(dataBuffer)를 호출해야 한다.

3.2.4. 핸드셰이크(Handshake)

WebSocketHandlerAdapter는 WebSocketService에게 동작을 위임한다. 디폴트 인스턴스는 HandshakeWebSocketService이다. 이 구현체는 웹소켓 요청에 대한 기본적인 확인 작업을 수행한 다음, 구동 서버에 대해 RequestUpgradeStrategy를 사용한다. 현재는 리액터 네티, 톰캣, 제티, 언더토를 내장형으로 지원하고 있다.

HandshakeWebSocketService는 sessionAttributePredicate 프로퍼티를 제공한다. 이 프로퍼티는 Predicate<String> 을 세팅하여 WebSession으로부터 어트리뷰트를 추출해서 WebSocketSession의 어트리뷰트로 입력한다.

3.2.5. 서버 설정(Server Configuration)

RequestUpgradeStrategy는 각 서버에 대한, 기반 웹소켓 엔진에 대해 유효한 웹소켓 관련 설정 옵션을 제공한다. 다음 예제는 톰캣에서 구동되는 웹소켓 옵션을 설정한다.

Java
@Configuration
class WebConfig {

    @Bean
    public WebSocketHandlerAdapter handlerAdapter() {
        return new WebSocketHandlerAdapter(webSocketService());
    }

    @Bean
    public WebSocketService webSocketService() {
        TomcatRequestUpgradeStrategy strategy = new TomcatRequestUpgradeStrategy();
        strategy.setMaxSessionIdleTimeout(0L);
        return new HandshakeWebSocketService(strategy);
    }
}
Kotlin
@Configuration
class WebConfig {

    @Bean
    fun handlerAdapter() =
            WebSocketHandlerAdapter(webSocketService())

    @Bean
    fun webSocketService(): WebSocketService {
        val strategy = TomcatRequestUpgradeStrategy().apply {
            setMaxSessionIdleTimeout(0L)
        }
        return HandshakeWebSocketService(strategy)
    }
}


Upgrade strategy를 체크하여 자신이 구동하는 서버에 대해 어떤 옵션을 사용할 수 있는지 알아보자. 현재는 톰캣과 제티만이 이 옵션을 제공한다.

3.2.6. CORS

CORS를 설정하고 웹소켓 엔드포인트로의 접근을 제한하는 가장 쉬운 방법은 WebSocketHandler가 CorsConfigurationSource를 구현하여 CorsConfiguration을 반환하도록 하는 것이다. CorsConfiguration 에는 허용 오리진, 헤더, 그리고 다른 상세 요소를 담는다. 이 방법이 어렵다면 SImpleUrlHandler의 corsConfigurations 프로퍼티를 설정하는 방법도 있다. 이 방법은 CORS를 URL 패턴 기준으로 설정할 수 있다. 만약 이 두 방법을 모두 사용한다면, CorsConfiguration의 combine 메서드를 통해 두 설정이 결합되어 적용된다.

3.2.7. 클라이언트(Client)

스프링 웹플럭스는 WebSocketClient 추상화에 리액터 네티, 톰캣, 제티, 언더토, 그리고 표준 자바(JSR-356) 에 대한 구현체를 제공한다.

# 톰캣 클라이언트는 WebSocketSession의 몇몇 추가적인 기능을 함께 제공하는 표준 자바의 효과적인 확장판으로, 톰캣 특징적 API의 이점을 취하여 백프레셔를 위해 수신 메시지를 일시 중단한다.

웹소켓 세션을 시작하기 위해 클라이언트의 인스턴스를 생성하고 execute 메서드를 사용한다.

Java
WebSocketClient client = new ReactorNettyWebSocketClient();

URI url = new URI("ws://localhost:8080/path");
client.execute(url, session ->
        session.receive()
                .doOnNext(System.out::println)
                .then());
Kotlin
val client = ReactorNettyWebSocketClient()

        val url = URI("ws://localhost:8080/path")
        client.execute(url) { session ->
            session.receive()
                    .doOnNext(::println)
            .then()
        }


제티와 같은 몇몇 클라이언트는 Lifecycle을 구현하고, 사용 전에 중지 및 시작해야 한다. 모든 클라이언트는 기반 웹소켓 클라이언트의 설정 관련 옵션을 생성자로 제공한다.

4. 테스팅(Testing)

spring-test 모듈은 ServerHttpRequest, ServerHttpResponse, ServerWebExchange의 mock 구현체를 제공한다. 스프링 웹 리액티브에서 mock 객체에 대해 다룬다.

WebTestClient는 이러한 mock 요청과 응답 객체를 기반으로 한, HTTP 서버 없는 웹플럭스 어플리케이션 테스트를 지원한다. WebTestClient를 사용하여 end-to-end 통합 테스트를 할 수 있다.

5. RSocket

이 섹션은 스프림 프레임워크의 RSocket 프로토콜 지원에 대해 다룬다.

5.1. 개요

RSocket은 TCP, 웹소켓, 그리고 다른 바이트 스트림 전송을 바탕으로 다중화된 양방향 통신을 지원하는 어플리케이션 프로토콜이다. 다음 중 하나를 상호작용 모델로 따른다:
  • Request-Response: 메시지 하나를 송신하고 응답 하나를 수신한다.
  • Request-Stream: 메시지 하나를 송신하고 응답 하나를 스트림으로 수신한다.
  • Channel - 양방향으로 스트림 메시지를 송수신한다.
  • Fire-and-Forget: 단방향 메시지를 송신한다.


초기 커넥션이 맺어지면 "클라이언트" vs "서버" 구분은 무의미하다. 양쪽은 대칭적이며, 한쪽은 위 상호작용 모델 중 하나를 시작할 수 있다. 이것이 통신에 참여하는 양쪽을 "요청자" 와 "응답자" 로 칭하는 이유이다. 위의 상호작용 모델은 "요청 스트림" 혹은 간단히 "요청" 이라 칭한다.

다음은 RSocket 프로토콜의 핵심 기능과 이점이다:
  • 네트워크 경계를 아우르는 리액티브 스트림 시맨틱스: Request-Stream, Channel 과 같은 스트리밍 요청을 위해. 백프레셔는 요청자와 응답자 사이를 오가는 시그널을 보내서 요청자는 응답자의 속도를 근본적으로 늦추도록 한다. 그리하여 네트워크 계층의 혼잡 제어에의 의존과, 네트워크 레벨 혹은 어떤 레벨에서든 버퍼링의 필요성을 감소시킨다.
  • 요청 쓰로틀링: 통신의 한쪽은 LEASE 프레임을 송신하여 주어진 시간 동안 상대쪽에서의 요청의 총량에 제한을 걸 수 있다. 이 기능은 "Leasing" 이라 부른다.
  • 세션 재개: 이 기능은 요청 손실, 상태 보존 요건을 위해 만들어졌다. 상태 관리는 어플리케이션에 대해 명료하게 이루어지며, 가능 여부에 따라 프로듀서를 정지하여 필요한 상태 보존 분량을 감소시키는 백프레셔와 함께 훌륭하게 결합하여 작동한다.
  • 큰 메시지의 단편화(fragmentation) 및 재조립(re-assemply).
  • Keepalive(heartbeats).


RSocket 에는 다양항 언어로 작성된 구현체가 있다. 자바 라이브러리Project Reactor를 기반으로 하며, 데이터 수송을 위해 리액터 네티를 사용한다. 어플리케이션의 리액티브 스트림 퍼블리셔가 내보낸 시그널은 RSocket을 통해 네트워크를 가로질러 투명하게 전파된다.

5.1.1. 프로토콜(The Protocol)

RSocket의 이점 중 하나는 와이어에서의 동작이 잘 정의되어 있고 몇몇 프로토콜 확장에 따르는 읽기 쉬운 스펙이다. 따라서 구현체의 언어와 고수준 프레임워크 API 에 독립적으로 스펙을 읽어보는 것이 좋다. 이 섹션은 간결한 개요를 제공하여 몇몇 문맥을 확립하고자 한다.

연결하기(Connecting)

기본적으로 한 클라이언트는 TCP 혹은 웹소켓과 같은 몇몇 로우 레벨 스트리밍 송신을 통해 한 서버에 연결한다. 그리고 SETUP 프레임을 서버로 전송하여 커넥션을 위한 파라미터를 설정한다.

이 서버는 SETUP 프레임을 거부할 수도 있지만, 보통은 이 프레임이 전송되고 수신되면, SETUP 프레임이 요청 수 제한을 위한 leasing 시맨틱스 사용을 표시하지 않는 이상, 양쪽은 요청을 시작할 수 있다. SETUP 프레임이 leasing 시맨틱스 사용을 표시하면 양쪽은 요청 허가를 위해 반드시 서로의 LEASE 프레임을 기다려야 한다.

요청하기(Making Requests)

한 번 커넥션이 맺어지면, 양쪽은 다음 중 한 프레임을 통해 요청을 시작할 수 있다: REQUEST_RESPONSE, REQUEST_STREAM, REQUEST_CHANNEL, REQUEST_FNF. 이들 각 프레임은 요청자로부터 응답에게 한 메시지를 실어 나른다.

응답자는 응답 메시지와 함께 PAYLOAD 프레임을 반환할 수 있다. 그리고 REQUEST_CHANNEL 요청인 경우, 요청자는 PAYLOAD 프레임과 함께 요청 메시지를 더 보낼 수도 있다.

한 요청이 Request-Stream 과 Channel 과 같은 한 메시지 스트림에 속에 있을 때, 응답자는 반드시 각 시그널의 요구(demand)를 준수해야한다. 요구사항은 메시지의 수로 표현된다. 초기 요구는 REQUEST_STEAM 과 REQUEST_CHANNEL 프레임에 지정되어 있다. 이어지는 요구는 REQUEST_N 프레임을 통해 수행된다.

각 통신측은 메타데이터 알림을 송신할 수도 있다. 여기에는 METADATA_PUSH 프레임이 사용된다. 이 프레임은 어떠한 개별 요청에도 관련되지 않고, 커넥션 전체에 관련된다.

메시지 포맷(Message Format)

RSocket 메시지는 데이터와 메타데이터를 포함한다. 메타데이터는 라우팅, 보안 토큰 및 기타 등등을 전송할 때 사용된다. 데이터와 메타데이터는 각각 다르게 포맷팅된다. 각 데이터에 대한 마임타입은 SETUP 프레임에 선언되어 주어진 커넥션을 통하는 모든 요청에 적용된다.

모든 메시지는 메타데이터를 가질 수 있기 때문에, 보통 라우팅과 같은 메타데이터는 예비 요청이 되고, 따라서 요청의 최초 메시지 안에 포함된다. 예를 들어, 다음 프레임들 중 하나에 포함된다: REQUEST_RESPONSE, REQUEST_STREAM, REQUEST_CHANNEL, REQUEST_FNF.

프로토콜 확장은 어플리케이션에서의 사용을 위해 공통 메타데이터 포맷을 정의한다:

5.1.2. 자바 구현체(Java Implementation)

RSocket 자바 구현체프로젝트 리액터를 기반으로 한다. TCP와 웹소켓 송신은 리액터 네티를 기반으로 한다. 리액티브 스트림 라이브러리로서, 리액터는 프로토콜 구현 역할을 단순화한다. 어플리케이션은 Flux와 Mono를 선언적인 명령과 투명한 백프레셔 지원과 함께 사용하는 것이 자연스럽다.

RSocket 자바 API는 의도적으로 최소형의 기본적인 형태로 만들어졌다. 프로토콜 기능에 집중하며 어플리케이션 프로그래밍 모델(RPC codegen vs others)은 고수준의 독립된 관심사로 남겨두었다.

io.rsocket.RSocket 주요 계약은 네 가지 요청 상호동작 유형을 만든다. 단일 메시지를 위한 약속을 나타내는 Mono, Flux 메시지 스트림, 데이터와 메타데이터로의 바이트 버퍼 접근과 함께하는 실제 메시지 io.rsocket.Payload가 있다. RSocket 계약은 대칭적으로 사용된다. 요청에 대해서는 어플리케이션에 요청을 위한 RSocket 이 주어진다. 응답에 대해서는 어플리케이션은 RSocket을 구현하여 요청을 핸들링한다.

이 내용은 완전한 소개가 되지 못한다. 보통, 스프링 어플리케이션은 이 API를 직접 사용할 필요가 없다. 하지만 RSocket을 스프링과 독립적으로 바라보고 실험하는 것은 중요할 수 있다. RSocket 자바 리파지토리에서 RSocket API와 프로토콜 기능을 보여주는 많은 샘플 앱을 볼 수 있다.

5.1.3. 스프링 지원(Spring Support)

spring-messaging 모델은 다음을 포함한다:
  • RSocketRequester: io.rsocket.RSocket 과 데이터와 메타데이터 인코딩/디코딩을 통해 요청을 생성하는 숙련된 API
  • Annotated Responders: 응답을 위한 @MessageMapping 이 적용된 핸들러 메서드


spring-web 모듈은 Jackson CBOR/JSON 와, RSocket 어플리케이션이 필요로 할 Protobuf와 같은 Encoder와 Decoder 구현체를 포함한다. 그리고 효과적인 라우팅 매칭을 위해 연결하여 사용할 수 있는 PathPatternParser를 제공한다.

스프링 부트 2.2 는 TCP 또는 웹소켓을 통해 RSocket 서버를 지원한다. 웹플럭스 서버에서 웹소켓을 통한 RSocket을 노출하기 위한 옵션을 포함한다. 또한 RSocketRequester.Builder와 RSocketStrategies 에 대한 클라이언트 지원과 자동 설정이 있다. 스프링 부트 레퍼런스의 RSocket 섹션에서 더 자세한 정보를 찾을 수 있다.

스프링 시큐리티 5.2 에서 RSocket을 지원한다.

스프링 인티그레이션 5.2 는 RSocket 클라이언트와 서버의 상호작용을 위한 인바운드와 아웃바운드 게이트웨이를 제공한다. 스프링 인티그레이션 레퍼런스 메뉴얼에서 더 자세한 정보를 찾을 수 있다.

스프링 클라우드 게이트웨이는 RSocket 커넥션을 지원한다.

5.2. RSocketRequester

RSocketRequester는 능숙한 API를 제공하여 RSocket 요청을 수행하고, 데이터와 메타데이터를 로우 레벨 데이터 버퍼대신 객체로 받고 반환한다. 대칭적으로 사용하여 클라이언트나 서버의 요청을 생성할 수 있다.

5.2.1. Client Requester

클라이언트측의 RSocketRequester 를 얻기 위해서는 초기 RSocket SETUP 프레임을 준비하고 전송하는 방법을 통해 서버와 커넥션을 맺어야 한다. RSocketRequester 는 이를 위한 빌더를 제공한다. 내부적으로 RSocket 자바의 RSocketFactory 를 사용한다.

​ 아래는 디폴트 세팅으로 커넥션을 맺는 가장 기본적인 예제이다:

Java
Mono<RSocketRequester> requesterMono = RSocketRequester.builder()
    .connectTcp("localhost", 7000);

Mono<RSocketRequester> requesterMono = RSocketRequester.builder()
    .connectWebSocket(URI.create("https://example.org:8080/rsocket"));
Kotlin
import org.springframework.messaging.rsocket.connectTcpAndAwait
import org.springframework.messaging.rsocket.connectWebSocketAndAwait

val requester = RSocketRequester.builder()
        .connectTcpAndAwait("localhost", 7000)

val requester = RSocketRequester.builder()
        .connectWebSocketAndAwait(URI.create("https://example.org:8080/rsocket"))


​ 위 예제 코드는 커넥션을 유예한다. 실제로 커넥션을 맺고 requester 를 사용하려면 다음과 같이 한다:

Java
// Connect asynchronously
RSocketRequester.builder().connectTcp("localhost", 7000)
    .subscribe(requester -> {
        // ...
    });

// Or block
RSocketRequester requester = RSocketRequester.builder()
    .connectTcp("localhost", 7000)
    .block(Duration.ofSeconds(5));
Kotlin
// Connect asynchronously
import org.springframework.messaging.rsocket.connectTcpAndAwait

class MyService {

    private var requester: RSocketRequester? = null

    private suspend fun requester() = requester ?:
        RSocketRequester.builder().connectTcpAndAwait("localhost", 7000).also { requester = it }

    suspend fun doSomething() = requester().route(...)
}

// Or block
import org.springframework.messaging.rsocket.connectTcpAndAwait

class MyService {

    private val requester = runBlocking {
        RSocketRequester.builder().connectTcpAndAwait("localhost", 7000)
    }

    suspend fun doSomething() = requester.route(...)
}


커넥션 설정(Connection Setup)



RSocketRequester.Builder는 다음 요소를 통해 초기 SETUP 프레임을 커스터마이징한다:
  • dataMimeType(MimeType): 커넥션을 통하는 데이터의 마임 타입을 설정한다.
  • metadataMimeType(MimeType): 커넥션을 통하는 메타데이터의 마임 타입을 설정한다.
  • setupData(Object): SETPUP 프레임에 포함될 데이터를 설정한다.
  • setupRoute(String, Object...): SETUP 프레임에 포함될 메타데이터에 라우팅한다.
  • setupMetaData(Object, MimeType): SETPUP 프레임에 포함될 다른 메타데이터를 설정한다.


데이터에 대한 디폴트 마임 타입은 처음 설정된 Decoder 로부터 얻는다. 메타데이터에 대한 디폴트 마임 타입은 요청 당 다수의 메타데이터 값과 마임 타입 쌍을 허용하는 컴포짓 메타데이터가 된다. 보통 이 둘은 변경할 필요가 없다.

SETUP 프레임의 데이터와 메타데이터는 선택적이다. 서버측에서는 @ConnectMapping 메서드를 사용하여 커넥션의 시작과 SETUP 프레임의 컨텐츠를 핸들링한다. 메타데이터는 커넥션 레벨 보안을 위해 사용할 수도 있다.

전락(Strategies)



RSocketRequester.Builder는 RSocketStrategies를 받아들여 요청자를 설정한다. 이를 사용하여 데이터와 메타데이터 값의 시리얼라이징/디시리얼라이징을 위한 인코더와 디코더를 제공한다. spring-core 로부터 String, byte[], ByteBuffer 에 대한 코덱만이 기본으로 등록된다. spring-web 추가하여 다음과 같이 다른 코덱을 더 추가할 수 있다:

Java
RSocketStrategies strategies = RSocketStrategies.builder()
    .encoders(encoders -> encoders.add(new Jackson2CborEncoder()))
    .decoders(decoders -> decoders.add(new Jackson2CborDecoder()))
    .build();

Mono<RSocketRequester> requesterMono = RSocketRequester.builder()
    .rsocketStrategies(strategies)
    .connectTcp("localhost", 7000);
Kotlin
import org.springframework.messaging.rsocket.connectTcpAndAwait

val strategies = RSocketStrategies.builder()
        .encoders { it.add(Jackson2CborEncoder()) }
        .decoders { it.add(Jackson2CborDecoder()) }
        .build()

val requester = RSocketRequester.builder()
        .rsocketStrategies(strategies)
        .connectTcpAndAwait("localhost", 7000)


RSocketStrategies는 재사용을 고려하여 만들어졌다. 몇몇 시나리오에서는, 예를 들어 클라이언트와 서버가 같은 어플리케이션에서 동작할 경우, RSocketStrategies를 스프링 설정에 선언하는 쪽이 좋을 수 있다.

Client Responders



RSocketRequester.Builder를 사용하여 서버로부터의 요청에 대한 응답자의 동작을 설정할 수 있다.

서버에서 사용되는 것과 같은 인프라스트럭처에 기반하지만 클라이언트측의 응답을 위해 어노테이티드 핸들러를 사용할 수 있다. 다음과 같이 프로그래밍 방식으로 등록한다.

Java
RSocketStrategies strategies = RSocketStrategies.builder()
    .routeMatcher(new PathPatternRouteMatcher())  (1)
    .build();

ClientHandler handler = new ClientHandler(); (2)

Mono<RSocketRequester> requesterMono = RSocketRequester.builder()
    .rsocketFactory(RSocketMessageHandler.clientResponder(strategies, handler)) (3)
    .connectTcp("localhost", 7000);
Kotlin
import org.springframework.messaging.rsocket.connectTcpAndAwait

val strategies = RSocketStrategies.builder()
        .routeMatcher(PathPatternRouteMatcher())  (1)
        .build()

val handler = ClientHandler() (2)

val requester = RSocketRequester.builder()
        .rsocketFactory(RSocketMessageHandler.clientResponder(strategies, handler)) (3)
        .connectTcpAndAwait("localhost", 7000)

(1) spring-web 이 존재한다면 효과적인 라우팅 매칭을 위해 PathPatternRouteMatcher를 사용한다. (2) @MessageMapping 또는 @ConnectMapping 메서드를 포함하는 응답자를 생성한다. (3) RSocketMessageHandler의 스태틱 팩토리 메서드를 사용하여 하나 이상의 응답자를 등록한다.

위 코드는 프로그래밍 방식 클라이언트 응답자 등록을 보여주기 위해 간단하게 작성된 예제일 뿐이다. 다른 시나리오에 대해서는, 클라이언트 응답자가 스프링 설정에 존재할 경우 RSocketMessageHandler를 스프링 빈으로 선언하고 다음과 같이 적용할 수 있다:

Java
ApplicationContext context = ... ;
RSocketMessageHandler handler = context.getBean(RSocketMessageHandler.class);

Mono<RSocketRequester> requesterMono = RSocketRequester.builder()
    .rsocketFactory(factory -> factory.acceptor(handler.responder()))
    .connectTcp("localhost", 7000);
Kotlin
import org.springframework.beans.factory.getBean
import org.springframework.messaging.rsocket.connectTcpAndAwait

val context: ApplicationContext = ...
val handler = context.getBean<RSocketMessageHandler>()

val requester = RSocketRequester.builder()
        .rsocketFactory { it.acceptor(handler.responder()) }
        .connectTcpAndAwait("localhost", 7000)


위 예제에 대해서, 클라이언트 응답자를 감지하기 위한 다른 전략으로의 스위칭을 위해 RSocketMessageHandler의 setHandlerPredicate를 사용해야 할 수도 있다. 예를 들어, @RSocketCllientResponder와 같은 커스텀 어노테이션과 디폴트 @Controller를 비교할 수 있다. 클라이언트와 서버, 또는 다수의 클라이언트가 같은 어플리케이션에서 동작하는 시나리오에서는 이런 스위칭이 필요하다.

어노테이티드 응답자에서 프로그래밍 모델에 대한 정보를 더 찾아볼 수 있다.

고급(Advanced)



RSocketRequesterBulder는 기반 RSocket 자바의 ClientRSocketFactory를 노출하기 위한 콜백을 제공한다. ClientRSocketFactor는 keepalive intervals, 세션 재개, 인터셉터, 그리고 기타 등등의, 더 나아간 설정 옵션을 위해 사용된다. 이런 수준의 옵션을 설정하려면 다음과 같이 할 수 있다:

Java
Mono<RSocketRequester> requesterMono = RSocketRequester.builder()
    .rsocketFactory(factory -> {
        // ...
    })
    .connectTcp("localhost", 7000);
Kotlin
import org.springframework.messaging.rsocket.connectTcpAndAwait

val requester = RSocketRequester.builder()
        .rsocketFactory {
            //...
        }.connectTcpAndAwait("localhost", 7000)


5.2.2. Server Requester

서버에서 클라이언트로의 요청을 생성하기란, 연결된 클라이언트에 대한 요청자를 서버로부터 획득함의 문제이다.

어노테이티드 응답자에서는 @ConnectMapping 과 @MessageMapping 메서드는 RSocketRequester 아규먼트를 지원한다. 이 아규먼트를 사용하여 커넥션에 대한 요청자에 접근한다. @ConnectMapping 메서드는 요청 시작 전에 반드시 핸들링되어야하는 SETUP 프레임의 기본적인 핸들러이다. 그러므로 아주 초기의 요청자는 반드시 핸들링과 분리되어야 한다. 다음은 그 예제이다:

Java
@ConnectMapping
Mono<Void> handle(RSocketRequester requester) {
    requester.route("status").data("5")
        .retrieveFlux(StatusReport.class)
        .subscribe(bar -> { (1)
            // ...
        });
    return ... (2)
}
Kotlin
@ConnectMapping
suspend fun handle(requester: RSocketRequester) {
    GlobalScope.launch {
        requester.route("status").data("5").retrieveFlow<StatusReport>().collect { (1)
            // ...
        }
    }
    /// ... (2)
}


(1) 요청을 비동기로, 핸들링과 독립적으로 시작한다. (2) 핸들링을 수행하고, 완료된 Mono를 반환한다.

5.2.3. Requests

한 번 클라이언트 또는 서버 요청자를 가지면, 다음과 같이 요청을 생성할 수 있다:

Java
ViewBox viewBox = ... ;

Flux<AirportLocation> locations = requester.route("locate.radars.within") (1)
        .data(viewBox) (2)
        .retrieveFlux(AirportLocation.class); (3)
Kotlin
val viewBox: ViewBox = ...

val locations = requester.route("locate.radars.within") (1)
        .data(viewBox) (2)
        .retrieveFlow<AirportLocation>() (3)


(1) 요청 메시지의 메타데이터에 포함하기 위한 라우팅을 지정한다. (2) 요청 메시지를 위한 데이터를 제공한다. (3) 예상되는 응답을 선언한다.

상호동작 유형은 인풋과 아웃풋의 카디널리티로부터 암시적으로 결정된다. 위 예제는 Request-Stream이다. 왜냐하면 하나의 값이 전송되고 하나의 스트림 값을 수신하기 때문이다. 대부분의 경우 인풋과 아웃풋 선택이 RSocket 상호동작 유형 및 응답자가 기대하는 인풋과 아웃풋 타입에 매칭되는 한 이를 생각할 필요는 없다. 유효하지 않은 결합의 유일한 예는 many-to-one이다.

data(Object) 메서드는 또한 어떠한 리액티브 스트림 Publisher 든 받아들인다. 이 Publisher는 Flux와 Mono를 포함하며, ReactiveAdapterRegistry 에 등록된 값의 프로듀서는 무엇이든 포함한다. Flux와 같이 같은 타입의 값을 생산하는 다수 값 Publisher 에 대해서는 오버로딩된 data 메서드 중 하나를 사용하여 타입 체크 및 모든 엘리먼트에 대한 Encoder 검색 방지를 고려하자.

data(Object producer, Class<?> elementClass);
data(Object producer, ParameterizedTypeReference<?> elementTypeRef);


data(Object) 단계는 선택적이다. 데이터를 전송하지 않는 요청에서는 생략하자:

Java
Mono<AirportLocation> location = requester.route("find.radar.EWR"))
    .retrieveMono(AirportLocation.class);
Kotlin
import org.springframework.messaging.rsocket.retrieveAndAwait

val location = requester.route("find.radar.EWR")
    .retrieveAndAwait<AirportLocation>()


컴포짓 메타데이터(디폴트)를 사용하면서, 등록된 Encoder가 지원하는 값이라면 메타데이터 값을 추가할 수 있다. 다음은 그 예제이다:

Java
String securityToken = ... ;
ViewBox viewBox = ... ;
MimeType mimeType = MimeType.valueOf("message/x.rsocket.authentication.bearer.v0");

Flux<AirportLocation> locations = requester.route("locate.radars.within")
        .metadata(securityToken, mimeType)
        .data(viewBox)
        .retrieveFlux(AirportLocation.class);
Kotlin
import org.springframework.messaging.rsocket.retrieveFlow

val requester: RSocketRequester = ...

val securityToken: String = ...
val viewBox: ViewBox = ...
val mimeType = MimeType.valueOf("message/x.rsocket.authentication.bearer.v0")

val locations = requester.route("locate.radars.within")
        .metadata(securityToken, mimeType)
        .data(viewBox)
        .retrieveFlow<AirportLocation>()


Fire-and-Forget 에 대해서는 Mono<Void>를 반환하는 send() 메서드를 사용한다. 이 Mono는 메시지가 성공적으로 전송되었음을 알릴 뿐, 핸들링 대상이 되진 않는다.

5.3. 어노테이티드 응답자(Annotated Responders)

RSocket 응답자는 @MessageMapping 및 @ConnectMapping 메서드로서 구현될 수 있다. @MessageMapping 메서드는 각 요청을 개별적으로 핸들링하고, 반면 @ConnectMapping 메서드는 커넥션 레벨 이벤트(setup, 메타데이터 push)를 핸들링한다. 어노테이티드 응답자는 서버 및 클라이언트 측으로부터의 응답에 대해 대칭적으로 지원된다.

5.3.1. 서버 응답자(Server Responders)

서버 측에 어노테이티드 응답자를 사용하기 위해서는 RSockerMessageHandler를 스프링 설정에 추가하여 @Controller 빈과 @MessageMapping 및 @ConnectMapping 메서드를 함게 감지하도록 한다:

Java
@Configuration
static class ServerConfig {

    @Bean
    public RSocketMessageHandler rsocketMessageHandler() {
        RSocketMessageHandler handler = new RSocketMessageHandler();
        handler.routeMatcher(new PathPatternRouteMatcher());
        return handler;
    }
}
Kotlin
@Configuration
class ServerConfig {

    @Bean
    fun rsocketMessageHandler() = RSocketMessageHandler().apply {
        routeMatcher = PathPatternRouteMatcher()
    }
}


다음으로 자바 RSocket API를 통해 RSocket 서버를 시작하고, 응답자를 위해 RSocketMessageHandler를 연결한다:

Java
ApplicationContext context = ... ;
RSocketMessageHandler handler = context.getBean(RSocketMessageHandler.class);

CloseableChannel server =
    RSocketFactory.receive()
        .acceptor(handler.responder())
        .transport(TcpServerTransport.create("localhost", 7000))
        .start()
        .block();
Kotlin
import org.springframework.beans.factory.getBean

val context: ApplicationContext = ...
val handler = context.getBean<RSocketMessageHandler>()

val server = RSocketFactory.receive()
        .acceptor(handler.responder())
        .transport(TcpServerTransport.create("localhost", 7000))
        .start().awaitFirst()


RSocketMessageHandler는 메타데이터 구성라우팅을 기본으로 지원한다. 다른 마임 타입으로 스위칭하거나 추가적인 메타데이터 마임 타입을 등록해야 한다면 핸들러의 MetadataExtractor을 세팅할 수 있다.

메타데이터와 데이터 포맷팅 지원을 위한 Encoder와 Decoder를 세팅할 필요가 있다. 그리고 코덱 구현체를 위해 spring-web 모듈이 필요할 수 있다.

디폴트로 SimpleRouteMatcher를 사용하여 AntPathMatcher를 통한 라우팅 매칭을 수행한다. 효과적인 라우팅 매칭을 위해 spring-web의 PathPatternRouteMatcher 사용을 추천한다. RSocket 라우팅은 계층적으로 동작할 수 있지만, URL 경로는 아니다. 두 라우팅 매칭은 기본적으로 "."를 구분자로 사용하도록 설정된다. 그리고 HTTP URL 과 마찬가지로 URL 디코딩은 없다.

RSocketMessageHandler는 RSocketStrategies를 통해 설정된다. RSocketStrategies는 같은 프로세스 안에서 클라이언트와 서버 사이의 설정을 공유할 때 유용할 수 있다:

Java
@Configuration
static class ServerConfig {

    @Bean
    public RSocketMessageHandler rsocketMessageHandler() {
        RSocketMessageHandler handler = new RSocketMessageHandler();
        handler.setRSocketStrategies(rsocketStrategies());
        return handler;
    }

    @Bean
    public RSocketStrategies rsocketStrategies() {
        return RSocketStrategies.builder()
            .encoders(encoders -> encoders.add(new Jackson2CborEncoder()))
            .decoders(decoders -> decoders.add(new Jackson2CborDecoder()))
            .routeMatcher(new PathPatternRouteMatcher())
            .build();
    }
}
Kotlin
@Configuration
class ServerConfig {

    @Bean
    fun rsocketMessageHandler() = RSocketMessageHandler().apply {
        rSocketStrategies = rsocketStrategies()
    }

    @Bean
    fun rsocketStrategies() = RSocketStrategies.builder()
            .encoders { it.add(Jackson2CborEncoder()) }
            .decoders { it.add(Jackson2CborDecoder()) }
            .routeMatcher(PathPatternRouteMatcher())
            .build()
}


5.3.2. 클라이언트 응답자(Client Responders)

클라이은트 측 어노테이티드 응답자는 RSocketRequester.Builder 에서 설정되어야 한다. 자세한 내용은 클라이언트 응답자를 보라.

5.3.3. @MssageMapping

서버 또는 클라이언트 응답자가 한 번 설정되면, @MessageMapping 메서드는 다음과 같이 사용된다:

Java
@Controller
public class RadarsController {

    @MessageMapping("locate.radars.within")
    public Flux<AirportLocation> radars(MapRequest request) {
        // ...
    }
}
Kotlin
@Controller
class RadarsController {

    @MessageMapping("locate.radars.within")
    fun radars(request: MapRequest): Flow<AirportLocation> {
        // ...
    }
}


위의 @MessageMapping 메서드는 "locate.radars.within" 라우팅을 가진 Request-Stream 상호동작에 응답한다. 이 메서드는 다음 메서드 아규먼트를 선택적으로 사용하는 유연한 메서드 시그니처를 지원한다:

메서드 아규먼트 설명
@Payload 요청의 페이로드. Mono 또는 Flux와 같이 비동기 타입의 구체적인 값이 될 수 있다. 유의사항: 이 어노테이션 사용은 선택적이다. 메서드 아규먼트가 단순 타입이 아니면서, 다른 지원되는 아규먼트도 아닌 경우에는 예상된 페이로드로 상정한다.
RSocketRequester 원격 종료 요청을 위한 요청자.
@DestinationVariable 매핑 패턴 안의 변수에 기반한 라우팅으로부터 추출된 값. 예: @MessageMapping("find.radar.{id}")
@Header MetadataExtractor에 설명된 추출물을 위해 등록된 메타데이터 값.
@Headers Map<String, Object> MetadataExtractor에 설명된 추출물을 위해 등록된 모든 메타데이터 값.


반환값은 응답 페이로드로 시리얼라이징될 하나 이상의 객체로 예상된다. Mono 또는 Flux와 같이 비동기 타입이 될 수 있으며, 구체적인 값 또는 void 또는 no-value 비동기 타입이 될 수 있다(Mono).

@MessageMapping 메서드가 지원하는 RSocket 상호동작 유형은 인풋(예: @Payload 아규먼트)과 아웃풋의 카디널리티로부터 결정된다. 여기서 카디널리티란 다음을 의미한다:

카디널리티 설명
1 명시적인 값, 혹은 Mono<T> 와 같은 비동기 단일 값.
Many Flux<T> 와 같은 다수 값 비동기 타입.
0 인풋에 대해서는 이 메서드가 @Payload 아규먼트를 가지지 않았음을 의미한다. 아웃풋에 대해서는 void 또는 no-value 비동기 타입을 의마한다(Mono<Void>).


아래 테이블은 모든 인풋과 아웃풋의 결합 그리고 그에 대응하는 상호동작 유형을 보여준다:

인풋 카디널리티 아웃풋 카디널리티 상호동작 유형
0, 1 0 Fire-and-Forget, Request-Response
0, 1 1 Request-Response
0, 1 Many Request-Stream
Many 0, 1, Many Request-Channel


5.3.4. @ConnectMapping

@ConnectMapping은 RSocket 커넥션 시작 시점의 SETUP 프레임을 핸들링한다. 그리고 METADATA_PUSH 프레임을 통해 이어지는 메타데이터 푸시 알림을 핸들링한다. 예: io.rsocket.RSocket의 metadataPush(Payload).

@ConnetMapping 메서드는 @MessageMapping와 동일한 아규먼트를 지원하지만, SETUP, METADATA_PUSH 프레임의 메타데이터 및 데이터에 기반한다.@ConnetMapping은 핸들링 대상 커넥션의 범위를 좁히는 패턴을 가질 수 있다. 이 커넥션은 메타데이터의 라우팅을 가질수 있고, 혹은 패턴이 없는 경우라면 모든 커넥션이 매칭된다.

@ConnetMapping 메서드는 데이터를 반환할 수 없고, 반드시 void 또는 Mono<Void>를 반환해야 한다. 만약 신규 커넥션 핸들링에서 에러를 반환하면 그 커넥션은 거부된다. RSocketRequester 에 커넥션을 위한 요청을 보내기 위해 처리를 보류해서는 안된다. 서버 요청자에서 더 자세한 내용을 찾아볼 수 있다.

5.4. MetadataExtractor

응답자는 반드시 메타데이터를 해석해야 한다. 컴포짓 메타데이터는 각각 자신의 마임 타입으로 독립적으로 포맷팅된 메타데이터 값(예: 라우팅, 보안, 추적) 을 허용한다. 마임 타입 지원 및 추출된 값에 접근하기 위한 어플리케이션 설정 방법이 필요하다.

MetadataExtractor는 시리얼라이징된 메타데이터에 접근하고 디코딩된 이름-값 쌍을 반환하는 계약이다. 이 이름-값 쌍은 헤더처럼 이름을 사용해 접근할 수 있다. 예를 들어 핸들러 메서드에 적용된 @Header와 같다.

DefaultMetadataExtractor는 메타데이터 디코딩을 위한 Decoder 인스턴스를 가질 수 있다. DefaultMetadataExtractor는 특별한 "message/x.rsocket.routing.v0"을 내장형으로 지원한다. String으로 디코딩하고 "route" 을 키로 저장한다. 다른 마임 타입에 대해서는 Decoder를 제공하고 다음과 같이 마임 타입을 등록해야 한다:

Java
DefaultMetadataExtractor extractor = new DefaultMetadataExtractor(metadataDecoders);
extractor.metadataToExtract(fooMimeType, Foo.class, "foo");
Kotlin
import org.springframework.messaging.rsocket.metadataToExtract

val extractor = DefaultMetadataExtractor(metadataDecoders)
extractor.metadataToExtract<Foo>(fooMimeType, "foo")


컴포짓 메타데이터는 독립된 메타데이터 값을 잘 결합한다. 하지만 요청자가 컴포짓 메타데이터를 지원하지 않을 수 있고, 혹은 이를 사용하지 않을 수도 있다. 이런 경우에 대해 DefaultMetadataExractor는 디코딩된 값을 아웃풋 맵에 매핑하지 위한 커스텀 로직이 필요할 수 있다. 아래는 메타데이터로 JSON을 사용하는 예제이다:

Java
DefaultMetadataExtractor extractor = new DefaultMetadataExtractor(metadataDecoders);
extractor.metadataToExtract(
    MimeType.valueOf("application/vnd.myapp.metadata+json"),
    new ParameterizedTypeReference<Map<String,String>>() {},
    (jsonMap, outputMap) -> {
        outputMap.putAll(jsonMap);
    });
Kotlin
import org.springframework.messaging.rsocket.metadataToExtract

val extractor = DefaultMetadataExtractor(metadataDecoders)
extractor.metadataToExtract<Map<String, String>>(MimeType.valueOf("application/vnd.myapp.metadata+json")) { jsonMap, outputMap ->
    outputMap.putAll(jsonMap)
}


RSocketStrategies를 사용하여 MetadataExtractor를 설정할 때는 RSocketStrategies.Builder에게 설정된 디코더와 함께 추출자를 생성하도록 할 수 있다. 그리고 다음과 같이 콜백을 사용하여 등록 작업은 커스터마이징한다:

Java
RSocketStrategies strategies = RSocketStrategies.builder()
    .metadataExtractorRegistry(registry -> {
        registry.metadataToExtract(fooMimeType, Foo.class, "foo");
        // ...
    })
    .build();
Kotlin
import org.springframework.messaging.rsocket.metadataToExtract

val strategies = RSocketStrategies.builder()
        .metadataExtractorRegistry { registry: MetadataExtractorRegistry ->
            registry.metadataToExtract<Foo>(fooMimeType, "foo")
            // ...
        }
        .build()


6. 리액티브 라이브러리(Reactive Libraries)

spring-webflux는 reactor-core 에 의존하여 내부적으로 이를 사용해 비동기 로직을 구성하고 리액티브 스트림 지원을 제공한다. 일반적으로, 웹플럭스 API는 Flux 또는 Mono를 반환한다(내부적으로 이 둘이 사용되기에). 그리고 어떠한 리액티브 스트림 Publisher 구현체든 인풋으로 받아들인다. Flux의 사용이 Mono 에 비해 더 중요하다. Flux는 카디널리티 표현을 돕기 때문이다. 예를 들어, 예상되는 비동기 값이 단일 값일 수도, 다수 값일 수도 있다. 그리고 이는 어떤 결정을 내리는 데에 기본이 될 수 있다(예로, HTTP 메시지 인코딩 또는 디코딩 시).

어노테이티드 컨트롤러에 대해, 웹플럭스는 어플리케이션에 선택된 리액티브 라이브러리로 투명하게 연결된다. 여기에는 ReactiveAdapterRegistry의 도움을 받는다. 이 레지스트리는 리액티브 라이브러리와 다른 비동기 타입을 위한 장착형 지원을 제공한다. 그리고 RxJava 및 CompletableFuture를 내장형으로 지원하면서, 다른 것들도 추가로 등록할 수 있다.

함수형 API 에 대해(함수형 엔드포인트, 웹클라이언트 그리고 기타 등등), 웹플럭스 API를 위한 일반 규칙을 적용한다 - 반환값으로는 Flux 및 Mono를, 인풋으로는 리액티브 스트림 Publisher를 취한다. Publisher가 주어지면, Publisher는 밝혀지지 않은 시맨틱스(0..N)를 가진 스트림으로 취급된다. 만약 시맨틱스을 알고 있다면 그냥 Publisher를 전달하는 대신, 이를 Flux 또는 Mono.from(Publisher) 로 래핑할 수 있다.

예를 들어서, Mono가 아닌 Publisher가 주어지면, Jackson JSON message writer는 다수 값을 예상한다. 만약 미디어 타입이 무한정한 스트림을 나타낸다면(예: application/json+stream), 값들은 개별적으로 작성되고 플러싱된다. 그렇지 않으면 값들은 리스트 안에 버퍼링되고 JSON 배열로 렌더링된다.

=== 원문 정보: Version 5.2.2.RELEASE 최종 수정 2019-12-03 08:48:18 UTC

+ Recent posts