본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[Spring] Web on Reactive Stack 문서(1) - Spring WebFlux

이 문서는 Netty, Undertow, 서블릿 3.1+ 컨테이너와 같은 논 블로킹 서버 위에 구동되는, Reactive Streams(이하 리액티브 스트림) API 기반의 리액티브 스택 웹 애플리케이션 지원에 대해 다룬다. 각 챕터는 Spring WebFlux(스프링 웹플럭스) 프레임워크, 리액티브 WebClient(웹 클라이언트), 테스팅 그리고 리액티브 라이브러리를 주제로한다. 서블릿-스택 웹 애플리케이션에 대해서는 Web on Serverlet 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의 어노테이션만큼이나 자바 세계의 새로운 기회를 제공한다. 람다 표현식은 비동기 로직을 서술적 구성으로 작성 가능하도록 하는, 논 블로킹 애플리케이션과 continuation-style APIs(CompletableFuture 및 ReactiveX로 보급된)를 위한 유용한 도구이다. 프로그래밍 모델 레벨에서, 자바 8은 스프링 웹플럭스에서 함수형 웹 엔드포인트를 어노테이티드 컨트롤러와 함께 제공하는 일을 가능하게 한다.

1.1.1. "리액티브"의 정의

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

 

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

 

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

 

리액티브 스트림은 자바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냐, 웹플럭스냐?

 

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

다음 사항에 잘 주목하길 바란다.

  - 잘 작동 중인 기본 스프링 MVC 애플리케이션이 있다면, 변경할 필요가 없다. 명령형 프로그래밍은 작성하고 이해하고 디버깅 하기에 가장 쉬운 방법이며, 라이브러리 선택에 있어 최대한의 선택지를 가지게 된다. 대부분 블로킹 방식이기 때문에 그렇다.

  - 이미 논 블로킹 웹 스택을 찾고 있다면, 스프링 웹플럭스는 다른 웹 스택과 동일한 실행 모델이라는 이점과 함께, 서버에 있어서의 선택지(네티, 톰캣, 제티, 언더토, 서블릿 3.1+ 컨테이너), 프로그래밍 모델에 있어서의 선택지(어노테이티드 컨트롤러, 함수형 웹 엔드포인트), 리액티브 라이브러리에 있어서의 선택지(리액터, RxJava 또는 그 외)를 제공한다.

  - 자바 8 람다 또는 코틀린과 함께 사용할 경량 함수형 웹 프레임워크를 원한다면, 스프링 웹플럭스 함수형 웹 엔드포인트를 사용할 수 있다. 또한 더 작은 애플리케이션이나, 더 훌륭한 명료성과 제어(control)라는 이점을 보다 적은 복잡도로 제공하는 마이크로서비스에도 스프링 웹플럭스는 좋은 선택지가 된다.

  - 마이크로서비스 아키텍처에서 스프링 MVC 또는 스프링 웹플럭스 컨트롤러 또는 스프링 웹플럭스 함수형 엔드포인트 각각으로 만들어진 서로 다른 애플리케이션을 혼합하여 사용할 수 있다. 이 두 프레임워크 모두 동일한 어노테이션 기반 프로그래밍 모델을 지원한다는 점은 올바른 도구를 적재적소에 사용하는 동시에 기존지식을 재사용하기 쉽게 만들어준다.

  - 애플리케이션을 평가하는 간단한 방법은 애플리케이션의 의존성을 확인하는 것이다. 블로킹 영속성 API(JPA, JDBC) 또는 네트워킹 API를 사용하고 있다면, 스프링 MVC는 적어도 공통 아키텍처에 있어서는 최고의 선택이 된다. 스프링 MVC는 기술적으로 개별 쓰레드에 대해 리액터와 RxJava를 사용하여 블로킹 호출을 수행하는 것이 가능하지만, 논 블로킹 웹 스택을 최대한 활용하지는 못한다.

  - 기존 스프링 MVC 애플리케이션이 원격 서비스 호출(remoting)을 수행한다면, 리액티브 WebClient를 사용해보라. 스프링 MVC 컨트롤러 메서드로부터 리액티브 타입(Rector, 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 모듈은 리액티브 웹 애플리케이션 지원을 위한 다음의 제반 사항을 포함한다.

 

1. 서버 요청 처리에 대해서는 두가지 레벨의 지원이 있다.

  - HttpHandler: HTTP 요청 핸들링을 위한 논 블로킹 I/O와 리액티브 스트림 기반의 기본 핸들러. 리액터 네티, 언더토, 톰캣, 제티, 서블릿 3.1+ 컨테이너를 지원하는 어댑터와 함께 작동한다.

  - WebHandler API: 요청 처리를 위한 좀 더 고수준의, 다용도 웹 API. 어노테이티드 컨트롤러와 함수형 엔드포인트와 같은 구체적인 프로그래밍 모델 위에 존재한다.

 

2. 클라이언트 사이드에는 논 블로킹 I/O, 리액티브 스트림 백프레셔로 HTTP 요청을 수행하기 위한 기본 CleintHttpConnector 계약이 있다. 리액터 네티 및 리액티브 제티 HttpClient를 위한 어댑터를 사용해 작동한다. 여기에는 더 고수준의 웹클라이언트가 사용된다.

 

3. 클라이언트와 서버는 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(hadler);
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 UndertowHttpHandler(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()

 

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

1.2.2. WebHandler API

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

 

HttpHandler의 목적은 서로 다른 HTTP 서버에서의 사용을 위한 추상화에 있기에, WebHandler API는 웹 애플리케이션에서 일반적으로 사용되는 기능들보다 더 많은 기능을 제공한다. 웹 애플리케이션에서 일반적으로 사용되는 기능들은 다음과 같다.

  - 사용자 세션(세션 어트리뷰트)

  - 리퀘스트 어트리뷰트

  - 요청에 대한 리졸빙된 Locale or Principal

  - 파싱되고 캐싱된 폼 데이터 접근

  - 멀티파트 데이터 추상화

  - 기타 등등..

 

스페셜 빈 타입

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

빈이름 빈 타입 카운트 설명
<any> WebExceptionHandler 0..N WebFilter 인스턴스들과 타겟 WebHandler에서 발생한 익셉션에 대한 핸들링을 제공한다.
<any> WebFilter 0..N 인터셉터 스타일 로직을 적용하여 타겟 WebHandler의 전/후 동작을 담당한다.
webHandler WebHandler 1 요청을 처리한다.
webSessionManager WebSessionManager 0..N ServerWebExchange의 메서드를 통해 노출된 WebSession 인스턴스를 관리한다. 디폴트는 DefaultWebSessionManager.
serverCodecConfigurer ServerCodecConfigurer 0..1 HttpMessageReader로 접근하여 ServerWebExchange의 메서드를 통해 노출된 폼 데이터와 멀티파트 데이터를 파싱하는 컴포넌트. 디폴트는 ServerCodecConfigurer.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>로 디코딩할 수 있다.

 

포워디드 헤더 (Forwarded Headers)

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

 

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

 

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

 

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

 

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

1.2.3. 필터

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

 

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

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를 통해서 바이트 컨텐츠와 고수준 객체 사이의 직렬화/역직렬화를 지원한다.

 

  - Encoder 및 Decoder는 HTTP와 독립적으로 콘텐츠를 인코딩/디코딩하기 위한 클래스이다.

  - HttpMessageReader와 HttpMessageWriter는 HTTP 메시지 콘텐츠를 인코딩/디코딩하기 위한 클래스다.

  - EncodingHttpMessageWriter는 Encoder를 래핑하여 웹애플리케이션에 사용하고, 같은 이유로 DecoderHttpMesageReader는 Decoder를 래핑한다.

  - DataBuffer는 서로 다른 바이트버퍼들을 추상화한다. 그리고 모든 코덱은 여기서 작동한다. 이에 대해서는 "스프링 코어" 섹션의 Data Buffers and Codes에서 더 알아볼 수 있다.

 

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

 

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

 

Jackson JSON

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

 

Jackson2Decoder는 다음과 같이 동작한다:

  - 잭슨의 비동기, 논블로킹 파서는 바이트 청크 스트림을 각각 JSON 객체를 나타내는 TokenBuffer로 종합하기 위해 사용된다.

  - 단일 값 퍼블리셔(Mono)을 디코딩하는 경우, 하나의 TokenBuffer가 존재하다.

  - 다수 값 퍼블리셔(Flux)를 디코딩하는 경우, 각 TokenBuffer는 완전하게 포맷팅된 객체가 되기 충분한 바이트를 받은 시점에 ObjectMapper에게 전달된다. 인풋 컨텐츠는 JSON 배열이 될수 있고, 컨텐츠 타입이 "applicaton/stream+json"인 경우 line-delimited JSON이 될 수 있다.

  - SSE의 경우 Jackson2Encoder는 이벤트마다 실행되며 그 아웃풋은 지연 없이 플러싱된다.

 

Jackson2Encoder는 다음과 같이 동작한다.

  - 단일 값 퍼블리셔(Mono)의 경우, 이를 간단히 ObjectMapper로 시리어라이징한다.

  - 다수값 "application/json" 퍼블리셔의 경우, 기본적으로 값들을 Flux#collectToList()로 모은뒤 그 결과를 시리얼라이징한다.

  - 다수값 스트리밍 미디어 타입 퍼블리셔의 경우 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 프로퍼티 설정이 가능한지 확인하고, 가능하다면 관련 자바독에 기본값에 대한 상세 정보가 있을 것이다. 웹플럭스에는 ServerCodeConfigurer가 기본 코덱의 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 레벨에 대해 각기 다른 수준의 디테일을 보인다.

 

좋은 로깅이란 로그 사용 경험으로부터 나온다.

 

민감한 데이터

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

 

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

@Configuration
@EnableWebFlux
class MyConfig: WebFluxConfigurer {
    
    override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) {
        configurer.defaultCodecs().enableLoggingRequestDetails(true)
    }
}

 

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

val consumer: (ClientCodecConfigurer) -> Unit = { configurer -> configurer.defaultCodecs().enableLoggingRequestDetails(true) }

val webClinet = WebClient.builder()
        .exchangeStrategies({ strategis -> strategis.codecs(consumer) })
        .build()

 

커스텀 코덱

지원되는 미디어 타입을 추가하거나 기본 코덱에서 지원되지 않는 동작을 지원하기 위해 애플리케이션에 커스텀 코덱을 등록할 수 있다. 개발자가 조정 가능한 몇가지 설정 옵션은 기본 코덱에 적용된다. 커스텀 코덱은 버퍼링 사이즈 제한 혹은 민감한 데이터 로깅처럼 이러한 기호(preferences)를 필요로 할 수 있다.

 

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

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에 따라 요청 처리 체인을 구성한다.

 

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

  - 빈 이름이 webHandler으로 선언된 DispatcherHandler

  - webFilter와 webExceptionHandler 빈

  - DispatcherHandler 스페셜 빈

  - 기타 등등

 

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

val context: ApplicationContet = ...
val handler = WebHttpHandlerBuilder.applicationContext(context).build()

 

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

1.3.1. 스페셜 빈 타입

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

 

다음 테이블은 DispatcherHandler에 의해 감지되는 스페셜 빈 목록을 보여준다. 로우 레벨에서는 이 밖에 다른 빈들도 존재한다.

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

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

1.3.2. 웹플럭스 설정

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

 

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

1.3.3. 처리

DispatcherHandler는 다음에 따라 요청을 처리한다.

  1. 각 HandlerMapping에게 매칭 핸들러를 요청한다. 처음 매칭된 핸들러가 사용된다.

  2. 핸들러를 찾으면, 적절한 HandlerAdapter를 통해 이 핸들러를 실행한다. 핸들러 실행의 반환값은 HandlerResult이다.

  3. HandlerAdapter로부터 반환된 HandlerResult는 적절한 HandlerResultHandler에게 주어지며, 직접 응답을 주거나, 혹은 뷰를 사용하여 요청 처리를 완료한다.

1.3.4. 결과 핸들링

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

타입 반환값 디폴트 적용 순서
ResponseEntityResultHandler ResponseEntity, 보통 @Controller 인스턴스로부터 반환된다. 0
ServerResponseResultHandler ServerResponse, 보통 함수형 엔드포인트로부터 반환된다. 0
ResponseBodyResultHandler @ResponseBody 메서드나 @RestController 클래스로부터 반환값을 핸들링한다. 100
ViewResolutionResultHandler CharSequence, View, Model, 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는 ViewResolver 인스턴스를 사용하여 논리적 뷰 이름을 나타내는 String을 View 인스턴스로 매핑한다. 이 View는 응답 렌더링에 사용된다.

 

핸들링

ViewResolutionResultHandler로 전달된 HandlerResult는 핸들러의 반환값과 모델이 가진, 요청 처리 과정에서 추가된 어트리뷰트를 포함한다. 이 반환값은 다음과 같이 처리된다.

  - String, CharSequence: 논리적 뷰 이름으로, 설정된 ViewResolver 구현체 목록에서 View를 가져온다.

  - void: 요청 경로에 기반하여 기본 디폴트 뷰 이름을 선택한다. 경로 처음과 끝 슬래시를 뺀 값을 View로 한다. 반환된 뷰 이름이 없을 경우나(예를 들어, 모델 어트리뷰터가 반환되지 않을때), 값이 비동기 반환값일때도 똑같이 동작한다(예를 들어, Mono가 빈 값으로 완료될때).

  - Rendering: 뷰 리솔루션 시나리오 API. 통합개발환경(IDE)의 코드 컴플리션의 옵션을 탐색한다.

  - Mode, 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)하거나 특정 인터페이스를 구현할 필요가 없다.

 

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

@RestController
class HelloController {
    
    @GetMapping("/hello")
    fun handle() = "Hello WebFlux"
}

 

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

1.4.1. @Controller

표준 스프링 빈 정의에 따라 컨트롤러 빈을 정의할 수 있다. @Controller 스테레오타입은 클래스패스의 @Component 클래스 자동 감지 및 빈 등록을 허용한다. 또한 웹 컴포넌트임을 나타내는 어노테이션 적용 클래스의 스테레오 타입 역할을 한다. @RestController는 스스로 @Controller와 @ResponseBody를 적용하는 composed annotation이다. 타입 레벨으로 모든 메서드에 @ResponseBody가 적용되는 컨트롤러임을 나타내고, 고로 뷰 리솔루션과 HTML 템플릿 렌더링을 수행하지 않고 응답 본문을 직접 작성한다.

1.4.2. 요청 매핑

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

 

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

URI 패턴

글롭 패턴(glob pattern)과 와일드카드를 사용하여 요청을 매핑할 수 있다.

  - ? 는 한 문자와 매칭된다.

  - * 는 한 경로 세그먼트 안에서 0개 이상의 문자와 매칭된다.

  - ** 는 경로 세그먼트를 포함하여 0개 이상의 문자와 매칭된다.

 

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

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

 

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

@Controller
@RequestMapping("/owners/{ownerId}") // 클래스 레벨 URI 매핑
class OwnerController {
    
    @GetMapping("/pets/{petId}") // 메서드 레벨 URI 매핑
    fun findPet(@PathVariable ownerId: Long, @PathVariable petId: Long): Pet {
        // ...
    }
}

 

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

 

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

 

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

 

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

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

 

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

 

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

 

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

패턴 비교

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

 

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

 

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

소비형(consumable) 미디어 타입

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

@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와 컨트롤러 메서드가 생산하는 컨텐츠 타입 목록으로 요청 매핑을 좁힐 수 있다.

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

HTTP HEAD, OPTIONS

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

 

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

 

HTTP 메서드 선언이 없는 @RequestMapping의 경우, Allow 헤더는 GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS 로 설정된다. 컨트롤러 메서드는 언제나 지원되는 HTTP 메서드로 선언되어야 한다.

 

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

커스텀 어노테이션

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

명시적 등록

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

@Configuration
class MyConfig {
    
    @Autowired
    // (1) 타겟 헨들러와 핸들러 매핑을 컨트롤러에 주입한다.
    fun setHandlerMapping(mapping: RequestMappingHandlerMapping, handler: UserHandler) {
        // (2) 요청 매핑 메타데이터를 준비한다.
        val info = RequestMappingInfo.paths("/user/{id}").methods(RequestMethod.GET).build()
        
        // (3) 핸들러 메서드를 얻는다.
        val method = UserHandler::class.java.getMethod("getUser", Long::class.java)
        
        // (4) 등록한다.
        mapping.registerMapping(info, handler, method)
    }
}

1.4.3. 핸들러 메서드

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

메서드 아규먼트

다음 테이블은 지원되는 컨트롤러 메서드 아규먼트를 보여준다. 리액티브 타입(Reactor, RxJava, 기타 등) 블로킹 I/O를 요구하는 아규먼트를 지원한다. 리액티브 타입은 블로킹을 요구하는 아규먼트에는 존재하지 않는다.

 

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

컨트롤러 메서드 아규먼트 설명
ServerWebExchange ServerWebExchange 전체에 접근한다. HTTP 요청과 응답, 요청과 세션 어트리뷰트, checkNotModified 메서드, 그리고 기타 등등을 얻을 수 있다.
ServerHttpRequest ServerHttpResponse / HTTP 요청이나 응답에 접근한다.
WebSession 세션에 접근한다. 따로 추가된 어트리뷰트가 없다면, 새 세션의 시작을 강제하지는 않는다. 리액티브 타입을 지원한다.
java.security.Principal 현재 인가된 유저 - 알려진 특정 Principal 구현체가 있다면 그것이 될것이다. 리액티브 타입을 지원한다.
org.springframework.http.HttpMethod 요청의 HTTP 메서드.
java.util.Locale 현재 요청의 로케일. 사용 가능한 가장 구체적인 LocalResolver에 의해 결정된다. 사실상, 설정된 LocaleResolver / LocaleContextResolver가 된다.
java.util.TimeZone + java.timeZondId 현재 요청과 관련된 타임존. LocaleContextResolve에 의해 결정된다.
@PathVariable URI 템플릿 변수로 접근하기 위해 사용된다. (URI Patterns 참고)
@MatrixVariable URI 경로 세그먼트의 이름-값 쌍으로 접근하기 위해 사용된다. (Matrix Variables 참고)
@RequestParam 서블릿 요청 파라미터에 접근하기 위해 사용된다. 파라미터 값은 선언된 메서드 아규먼트 타입으로 컨버팅 된다. @RequestParam 사용은 선택적이다.
@RequestHeader 요청 헤더에 접근하기 위해 사용된다. 헤더 값은 선언된 아규먼트 타입으로 컨버팅된다.
@CookieValue 쿠키에 접근하기 위해 사용된다. 쿠키 값은 선언된 메서드 아규먼트 타입으로 컨버팅 된다.
@RequestBody 요청 본문에 접근하기 위해 사용된다. 본문 내용은 선언된 메서드 아규먼트 타입으로 컨버팅된다. 이 컨버팅에는 HttpMessageReader 인스턴스가 사용된다.
HttpEntity 요청 헤더와 본문에 접근하기 위해 사용된다. 본문은 HttpMessageReader 인스턴스를 사용하여 컨버팅된다. 리액티브 타입을 지원한다.
@RequestPart multipart/form-data 요청의 파트에 접근하기 위해 사용된다. 리액티브 타입을 지원한다. Multipart Content와 Multipart Data 참고
java.util.Map, org.springframework.ui.Model, org.springframework.ui.ModelMap HTML 컨트롤러에 사용되고 뷰 렌더링의 일부로서 템플릿이 되는 모델에 접근하기 위해 사용된다.
@ModelAttribute 데이터 바인딩과 검증이 적용되는 모델(없다면 초기화한다)에 존재하는 어트리뷰트에 접근하기 위해 사용된다. @ModelAttribute 그리고 Model 및 DataBinder를 확인한다. @ModelAttribute는 선택적이다.
Errors, BindingResult 밸리데이션과 커맨드 객체(@ModelAttribute 아규먼트)로의 데이터 바인딩, 혹은 @RequestBody, @RequestPart 아규먼트의 밸리데이션에서 발생한 에롤의 접근을 위해 사용된다. Errors 또는 BindingResult 아규먼트는 반드시 벨리데이션 대상 메서드 아규먼트의 바로 다음에 선언되어야 한다.
SessionStatus + class-level @SessionAttributes 요청 처리 완료를 표시하기 위해 사용된다. 요청 처리 완료시 클래스 레벨 @SessionAttribute 어노테이션을 통해 선언된 세션 어트리뷰트를 비운다.
UriComponentsBuilder 현재 요청의 호스트, 포트, 스킴, 경로에 연관된 URL을 준비하기 위해 사용된다. URI Links 참고
@SessionAttribute 세션 어트리뷰트에 접근하기 위해 사용된다.  클래스 레벨 @SessionAttribute의 결과로 세션에 저장된 모델 어트리뷰트와 대조된다.
@RequestAttribute 요청 어트리뷰트에 접근하기 위해 사용된다. 
이 외의 아규먼트 메서드 아규먼트가 위에서 다른 아규먼트들과 매칭되지 않는다면, 단순 타입의 경우 기본적으로 @RequestParam을 통해 리졸빙된다. 단순 타입 여부는 BeanUtils#isSimpleProperty를 통해 결정된다. @RequestParam이 아니면 @ModelAttribute가 된다.

반환값

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

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

@ModelAttribute는 선택적이다.
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 또는 Formatters를 FormattingConversionService와 등록함으로써 이를 통해 커스터마이징 가능하다.

매트릭스 변수(Matrix Variables)

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

 

매트릭스 변수는 어떠한 경로 세그먼트에서도 존재할 수 있다. 각 변수는 세미콜론으로 구분되며, 콤마로 나뉘어진 다수의 값으로 이루어져 있다. 다수 값은 반복적인 이름과 함께 지정될 수도 있다.

 

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

// GET /pets/42;q=11;r=22
@GetMapping("/pets/{petId}")
fun findPet(@PathVariable petId: String, @MatrixVariable q: Int) {
    // petId == 42
    // q == 11
}

 

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

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

@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
}

 

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

// GET /pets/42

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

 

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

// 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을 사용하여 컨트롤러에서 쿼리 파라미터를 메서드 아규먼트로 바인딩할 수 있다.

 

(1) @RequestParam 사용.

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

 

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

 

타겟 메서드 파라미터 타입이 String이 아닐 경우, 타입 컨버젼은 자동으로 적용된다.

 

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

 

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

@RequestHeader

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

@GetMapping("/demo")
fun handle(
        @RequestHeader("Accept-Encoding") encoding: String,
        @RequestHeader("Keep-Alive") keepAlive: Long) {
    // ...
}

 

타겟 메서드 파라미터 타입이 String이 아닐 경우, 타입 컨버젼은 자동으로 적용된다. @RequestHeader 어노테이션이 Map<String, String>이나 MultiValueMap<String, String> 아규먼트에 선언되면, 이 맵에 모든 쿼리 파라미터를 담는다.

 

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

@CookieValue

@CookieValue를 사용하여 HTTP 쿠키를 메서드 아규먼트로 바인딩할 수 있다. 다음은 쿠키값을 가져오는 샘플이다.

@GetMapping("/demo")
fun handle(@CookieValue("JSESSIONID") cookie: String) {
    // ...
}

 

타겟 메서드 파라미터 타입이 String이 아닐 경우, 타입 컨버젼은 자동으로 적용된다.

멀티파트 컨텐츠

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

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

@Controller
class FileUploadController {

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

 

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

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

첫번째 @RequestPart로 metadata를 얻는다. 두번째 @RequestPart로 file을 얻는다.

 

원본 파트 컨텐츠를 디시리얼라이징하기 위해, Part대신 구체적인 타겟 Object를 선언할 수 있다. (@RequestPart로 metadata를 얻고 있다)

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

 

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

@PostMapping("/")
fun handle(@Valid @RequestPart("meta-data") metadata: MetaData): String {
    // ...
}

 

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

@PostMapping("/")
fun handle(@RequestBody parts: MultiValueMap<String, Part>): String {
}

 

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

@PostMapping("/")
fun handle(@RequestBody parts: Flow<Part>): String {
    // ...
}

@RequestBody

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

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

 

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

@PostMapping("/accounts")
fun handle(@RequestBody accounts: Flow<Account>) {
    // ...
}

 

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

 

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

@PostMapping("/accounts")
fun handle(@Valid @RequestBody account: Mono<Account>) {
    // ...
}

HttpEntity

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

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

@ResponseBody

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

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

 

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

 

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

 

@ResponseBody 메서드를 JSON 시리얼라이징 뷰와 함께 사용할 수 있다. WebFlux Config의 HTTP message codes 옵션을 사용하여 메시지 작성을 설정하고 커스터마이징할 수 있다.

ResponseEntity

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

@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를 사용할 수 있다. 이 어노테이션으로 시리얼라이징 뷰 클래스를 활성화한다.

@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를 통해 컨트롤러 간 공유되어 사용될 수 있다.

 

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

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

 

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

 

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

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 {
    // ...
}

1.4.5. DataBinder

@Controller, @ControllerAdvice 클래스는 @InitBinder 메서드로 WebDataBinder 인스턴스를 초기화할 수 있다. 이 인스턴스는 아래와 같이 사용된다.

  - 요청 파라미터(폼 데이터나 쿼리)를 모델 객체에 바인딩한다.

  - String 기반 요청 값(요청 파라미터, 경로 변수, 헤더, 쿠키, 기타 등등)을 타켓 컨트롤러 메서드 아규먼트 타입으로 컨버팅한다.

  - HTML 폼 렌더링 시 모델 객체값들을 String 값으로 포맷팅한다.

 

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

 

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

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

 

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

@Controller
class FormController {
    
    @InitBinder
    fun initBinder(binder: WebDataBinder) {
        binder.addCustomFormatter(DateFormatter("yyyy-MM-dd"))
    }
    // ...
}

1.4.6. 익셉션 관리

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

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

 

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

 

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

 

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

 

REST API 익셉션

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

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 메서드는 모든 요청에 적용되지만(모든 컨트롤러에 적용), 어노테이션에 어트리뷰트를 사용하여 컨트롤러 적용 범위를 좁힐 수 있다.

// 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 ExmapleAdvice2 {}

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

 

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


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() 는 라우터를 생성하는 라우터 빌더를 제공한다.

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

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

class PersonalHandler(private val repository: PersionRepository) {
    
    // ...
    
    suspend fun listPeople(request: ServerRequest): ServerResponse {
    }
    
    suspend fun createPersion(request: ServerRequest): ServerResponse {
    }
    
    suspend fun getPerson(request: ServerRequest): ServerResponse {
    }
}

 

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

  - RouterFunctions.toHttpHandler(RouterFunction)

  - RouterFunctions.toHttpHandler(RouterFunction, HandlerStrategies)

 

대부분의 애플리케이션은 웹플럭스 자바 설정을 통해 실행할 수 있다.

1.5.2. HandlerFunction

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

ServerRequest

ServerRequest는 HTTP 메서드, URI, 헤더, 쿼리 파라미터에의 접근을 제공하며, 본문에의 접근은 body 메서드를 통해 이루어진다. 다음 예제는 요청 본문을 Mono<String>으로 추출한다.

val string = request.awaitBody<String>()

 

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

val people = request.bodyToFlow<Person>()

 

위의 예제는 더 일반적인 ServerRequest.body(BodyExtractor) 사용 예다. ServerRequest.body(BodyExtractor)는 BodyExtractor 함수형 전략(strategy) 인터페이스를 받는다. 유틸리티 클래스 BodyExractors는 다량의 인스턴스에의 접근을 제공한다.

val string = request.body(BodyExtractors.toMono(String::class.java)).awaitFirst()
val people = request.body(BodyExtractors.toFlux(Person::class.java)).asFlow()

 

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

val map = request.awaitFormData()

 

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

val map = request.awaitMultipartData()

 

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

val parts = request.body(BodyExtractors.toParts()).asFlow()

ServerResponse

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

val person: Person = ...
ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(person)

 

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

val location: URI = ...
ServerResponse.created(location).build()

 

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

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

핸들러 클래스(Handler Classes)

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

val helloWorld = HandlerFunction<ServerResponse>{ ServerResponse.ok().bodyValue("Hello World") }

 

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

class PersonHandler(private val repository: PersonRepository) {
    
    suspend fun listPeople(request: ServerRequest): ServerResponse {
        val people: Flow<Person> = repository.allPeople()
        return ok().contentType(APPLICATION_JSON).bodyAndAwait(people)
    }
    
    suspend fun createPerson(request: ServerRequest): ServerResponse {
        val person = request.awaitBody<Person>()
        repository.savePerson(person)
        return ok().buildAndAwait()
    }
    
    suspend fun getPerson(request: ServerRequest): ServerResponse {
        val personId = request.pathVariable("id").toInt()
        return repository.getPerson(personId)?.lent { ok().contentType(APPLICATION_JSON).bodyValueAndAwait(it) }
            ?: ServerResponse.notFound().buildAndAwait()
    }
}

밸리데이션

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

class PersonHandler(private val repository: PersonRepository) {
    
    private val validator = PersonValidator() // Valiator 인스턴스를 생성한다.
    
    // ...
    
    suspend fun createPerson(request: ServerRequest): ServerResponse {
        val person = request.awaitBody<Person>()
        validate(person) // 밸리데이션을 적용한다.
        repository.savePerson(person)
        return ok().buildAndAwait()
    }
    
    private fun validate(person: Person) {
        val errors: Errors = BeanPropertyBindingResult(person, "person")
        validator.validate(person, errors);
        if (erros.hasErrors()) {
            throw ServerWebInputException(errors.toString()) // 400 응답을 위한 익셉션을 발생시킨다.
        }
    }
}

 

핸들러는 LocalValidatorFactoryBean에 기반한 전역 Validator 인스턴스를 생성하고 주입하여 표준 빈 밸리데이션 API를 사용할 수 있다.

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 헤더에 기반한 제약조건을 생성한다.

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) - RouterFunctions.and() 와 중첩된 RouterFunctions.route()의 간결한 형태

 

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

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)
    GET("/person", accept(APPLICATION_JSON), handler::listPeople)
    POST("/person", handler::createPerson)
}.and(otherRoute)

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 메서드를 통해 공유될 수 있다. 예를 들어, 다음과 같이 개선할 수 있다.

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

 

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

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(RouterFunction)

  - RouterFunctions.toHttpHandler(RouterFunction, HandlerStrategies)

 

반환된 HttpHandler와 서버 어댑터를 서버 특징적 명령을 위한 HttpHandler에 따라 사용할 수 있다. HttpHandler는 요청과 응답을 처리하는 싱글 메서드를 가진 단순한 규약이다. 의도적으로 작게 설계되었으며, 이것의 목적은 오로지 각기 다른 HTTP 서버 API에 대응하는 최소형의 추상화다.

 

더 일반적인 스프링 부트에서 사용하는 옵션은 WebFlux Config를 통한 DispatcherHandler 기반 설정을 사용하는 것이다. 이 방법은 스프링 설정을 사용하여 요청 처리에 필요한 컴포넌트를 선언한다. 웹플럭스 자바 설정은 다음의 컴포넌트를 선언하여 함수형 엔드포인트를 지원한다.

  - RouterFunctionMapping: 스프링 설정에서 하나 이상의 RouterFunction 빈을 감지하고, RouterFunction.andOther를 통해 이 빈들을 결합한다. 그리고 이 결과로 구성된 RouterFunction에 요청을 라우팅한다.

  - HandlerFunctionAdapter: DispatcherHandler가 요청에 매핑된 HandlerFunction을 실행하도록 하는 단순한 어댑터이다.

  - ServerResponseResultHandler: ServerResponse의 writeTo 메서드를 통해 HandlerFunction을 실행한 결과를 핸들링한다.

 

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

 

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

@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) {
        // configrue CORS ..
    }
    
    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        // configure view resolution for HTML redering...
    }
}

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

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

val route = router {
    "/person".nest {
        GET("/{id}", handler::getPerson)
        GET("", handler::listPerson)
        before {  // (1) 커스텀 요청 헤더를 추가하는 before 필터는 두 GET 라우팅에만 적용된다.
            ServerRequest.from(it)
                    .header("X-RequestHeader", "Value").build()
        }
        POST("/person", handler::createPerson)
        after { _, response -> // (2) 응답 로긴을 수행하는 after 필터는 중첩 라우팅을 포함한 모든 라우팅에 적용된다.
            logResponse(response)
        }
    }
}

 

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

 

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

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 URI 링크

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

1.6.1. UriComponents

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

val uriComponents = UriComponentsBuilder
        .fromUriString("https://example.com/hotels/{hotel}") // URI 템플릿을 지정하는 스태틱 팩토리 메서드다.
        .queryParam("q", "{q}")  // URI 컴포넌트를 추가하거나 교체한다.
        .encode()  // URI 템플릿과 URI 변수를 인코딩한다.
        .build()  // UriComponents를 빌드한다.

val uri = uriComponents.expand("Westin", "123").toUri()  // 변수를 추가하고 URI를 얻는다.

 

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

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

 

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

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

 

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

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 설정 방법이다.

// 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 설정 방법이다.

// 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를 사용하는 것과 비슷하지만, 이는 스태틱 팩토리 메서드 대신, 설정을 담은 실제 인스턴스이다. 다음은 그 예제이다.

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는 두 가지 레벨의 인코딩 옵션을 노출한다.

  - UriComponentsBuilder#encode(): URI 템플릿을 먼저 인코딩하고, URI 변수를 적용 시에 인코딩한다.

  - UriComponents#encode(): URI 컴포넌트를 URI 변수가 추가된 뒤에 인코딩한다.

 

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

 

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

 

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

 

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

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로 직접 이동하여 더 단축할 수 있다.

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

 

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

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

 

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

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#encodeUriVariables를 사용하여 URI 변수를 템플릿에 적용하기 전에 인코딩한다.

  - URI_COMPONENTS: 두번째 옵션, UriComponents#encode()를 사용하여 URI 변수를 적용한 뒤에 URI 컴포넌트를 인코딩한다.

  - NONE: 아무 인코딩도 적용하지 않는다.

 

RestTemplate은 히스토릭한 사유로, 그리고 하위 호환성을 취해 EncodingMode.URI_COMPONENTS를 설정한다. WebClient는 기본값으로 DefaultUriBuilderFactory를 갖는다. 인코딩 방식은 버전 5.0.x에선 Encoding.URI_COMPONENTS 였고, 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 명세는 예비, 단순, 실제 요청을 구분한다. 스프링 웹플럭스 HandlerMapping 구현체는 CORS를 내장형으로 지원한다. 요청을 핸들러에 성공적으로 매핑한 뒤, HandlerMapping은 주어진 요청과 핸들러에 대한 CORS 설정을 체크하고 다음 동작을 수행한다. 예비 요청은 직접 핸들링하고, 단순 그리고 실제 CORS 요청은 인터셉팅하고, 검증하고, 필요한 CORS 응답 헤더를 설정한다.

 

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

 

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

 

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

 

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

 

소스 혹은 고급 커스터마이징은 아래를 찾아본다.

  - CorsConfiguration

  - CorsPropcessor, DefaultCorsProcessor

  - AbstractHandlerMapping

1.7.3. @CrossOrigin

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

@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를 한시간으로 설정한다.

@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을 클래스와 메서드 레벨 모두에 적용할 수 있다.

@CrossOrigin(maxAge = 3600) // @CrossOrigin을 클래스 레벨에 사용한다.
@RestController
@RequestMapping("/account")
class AccountController {
    
    @CrossOrigin("https://domain2.com")   // @CrossOrigin을 메서드 레벨에 사용한다.
    @GetMapping("/{id}")
    suspend fun retrieve(@PathVariable id: Long): Account {
        // ...
    }
    
    @DeleteMapping("/{id}")
    suspend fun remove(@PathVariable id: Long) {
        // ...
    }
}

1.7.4. 전역 설정

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

 

전역 설정은 다음을 활성화한다.

  - 모든 오리진

  - 모든 헤더

  - GET, HEAD, POST 메서드

 

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

@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를 빈의 생성자로 전달하여 필터를 설정할 수 있다.

@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)

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

  - WebFlux Security

  - WebFlux Test Support

  - CSRF Protection

  - Security Response Headers


1.9. 뷰 기술(View Technologies)

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

1.9.1. 타임리프(Thymeleaf)

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

 

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

1.9.2. 프리마커(FreeMarker)

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

뷰 설정

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

@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 사용 예제이다.

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

폼 핸들링

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

매크로 바인딩(The Bind Macros)

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

 

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

1.9.3. 스크립트 뷰

스프링 프레임워크는 스프링 웹플럭스와 JSR-223 자바 스크립팅 엔진 위에서 작동하는 템플릿 라이브러리를 함께 사용하기 위한 내장형 통합을 제공한다. 다른 스크립트 엔진과 통합하는 기본 규칙은, 반드시 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 자바스크립트 엔진을 사용한다.

@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 에의 접근을 제공한다.

 

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

 

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

@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 r객체만을 정의한다.

var window = {};

 

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

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

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 g헤더의 보다 정교한 대체제가 된다.

 

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

1.10.1. CacheControl

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

 

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

// Cache for an hour - "Cache-Control: max-age=3600"
val ccCacheOneHour = CacheControlmaxAge(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 설정을 ResponseEntity에 추가할 수 있다.

@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 헤더를 응답에 추가해 보낸다.

 

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

@RequestMapping
fun myHandleMethod(exchange: ServerWebExchange, model: Model): String? {
    
    val eTag: Long = ... // (1) 애플리케이션 특징적 계산.
    
    if (exchange.checkNotModified(eTag)) {
        return null      // (2) 304(NOT_MODIFIED) 응답 설정. 더이상의 처리는 없다.
    }
    
    model.addAttribute(...)  // (3) 요청 처리.
    return "myViewName"
}

 

조건부 요청에 대한 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를 자바 설정에 추가한다.

@Configuration
@EnableWebFlux
class WebConfig

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

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

자바 설정에 WebFluxConfigurer 인터페이스를 구현할 수 있다.

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

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

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

 

다음 예제는 커스텀 포맷터와 컨버터 등록 방법을 보여준다. FotmatterRegistrar 구현체를 언제 사용할지에 관한 더 자세한 내용은 FormatterRegistrar SPI와 FormattingConversionServiceFactoryBean을 보도록 한다.

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun addFormatters(registry: FormatterRegistry) {
        // ...
    }
}

1.11.4. 밸리데이션(Validation)

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

 

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun getValidator(): Validator {
        // ...
    }
}

 

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

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

 

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

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

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

 

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun configureContentTypeResolver(builder: RequestedContentTypeResolverBuilder) {
        // ...
    }
}

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

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) {
        // ...
    }
}

 

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

 

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

  - DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES를 무효화한다.

  - MapperFeature.DEFAULT_VIEW_INCLUSION을 무효화한다.

 

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

  - jackson-datatype-joda: Joda-Time 타입 지원.

  - jackson-datatype-jsr310: 자바 8 Date, Time API 타입 지원.

  - jackson-datatype-jdk8: Optional과 같은, 자바 8 타입 지원.

  - jackson-module-kotlin: 코틀린 클래스와 데이터 클래스 지원.

1.11.7. 뷰 리졸버(View Resolvers)

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        // ...
    }
}

 

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun configureViewResolver(registry: ViewResolverRegistry) {
        registry.freeMarker()
    }
    
    // Configure Freemarker ...
    
    @Bean
    fun freeMarkerConfigurer() = FreeMarkerConfigurer().apply {
        setTemplateLoaderPath("classpath:/templates")
    }
}

 

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun configureViewResolvers(registry: ViewResolverRegistry) {
        val resolver: ViewResolver = ...
        registry.viewResolver(resolver)
    }
}

 

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun configureViewResolvers(registry: VeiwResolverRegistry) {
        registry.freeMarker()
        
        val encoder = Jackson2JsonEncoder()
        registry.defaultViews(HttpMessageWriterView(encoder))
    }
    
    // ...
}

 

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

1.11.8. 정적 자원(Static Resources)

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

 

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun addResourceHanlders(registry: ResourceHanlderRegistry) {
        registry.addResourceHandler("/resources/**")
            .addResourceLocations("/public", "classpath:/static/")
            .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS)
    }
}

 

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

 

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

 

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

@Configuration
@EnableWebFlux
class WebConfig: WebFluxConfigurer {
    
    override fun addResourceHanlders(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 예제이다.

@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 Configuration Mode)

@EnableWebFlux는 DelegatingWebFluxConfiguration을 임포트한다.

  - 웹플럭스 어플리케이션을 위한 디폴트 스프링 설정을 제공한다.

  - WebFluxConfigurer 구현체를 감지하고 위임을 통해 설정을 커스터마이징한다.

 

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

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

 

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


1.12. HTTP/2

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