본문으로 바로가기

Webflux Function Endpoints 정리

category Coding/Java Spring 2023. 8. 17. 19:19
반응형

개요

Webflux에서는 HandlerFunction이라는 것을 통해 HTTP Request를 다루게 된다. HandlerFunction은 ServerRequest를 인자로 받고 지연된 ServerResponse(Mono<ServerResponse>)를 리턴한다. Request/Response 모두 JDK8에서 제공하는 기능을 통해 불변 객체로써 동작한다. HandlerFunction은 어노테이션 기반 프로그래밍에서 등장하는 @RequestMapping과 동일한 역할을 수행한다.

클라이언트로부터 들어오는 Request는 RouterFunction을 통해 다뤄진다. 해당 객체는 위에서 설명한 것 처럼, ServerRequest를 받고 지연된 ServerResponse를 리턴한다. Request가 라우터에 등록된 핸들러와 매칭되면 핸들러의 리턴값을 응답하고 매칭되는 핸들러가 없는 경우 빈 Mono를 리턴한다. RouterFunction은 @RequestMapping 어노테이션과 동일하지만 데이터뿐만 아니라 동작까지 제공한다는 차이점이 있다.

RouterFunctions.route()는 다음 예제와 같이 빌더를 통해 쉽게 라우터를 생성할 수 있는 기능을 제공한다.

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

RouterFunction<ServerResponse> route = route()  // 1
	.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) {
		// ...
	}
}

1번을 보면 router()를 통해 라우터를 생성하는 모습을 볼 수 있다. 그리고 아래 라인부터 각 PATH에 PersonHandler의 메소드들을 연결시켜주고 있다.

HandlerFunction

ServerRequest와 ServerResponse는 JDK 8에서 제공하는 HTTP요청 및 응답에 대한 불변 인터페이스이다. 요청/응답 모두 응답 스트림에 대해 Reactive Streams Back pressure를 제공한다. Request body는 Reactor의 Flux 또는 Mono로 표시된다. 또한 Response body는 Flux또는 Mono를 포함한 Publisher로 표시된다.

ServerRequest

ServerRequest는  HTTP Method, URI, Headers, Query Parameter에 접근할 수 있도록 해준다. 반면, body에 대한 접근은 body 메소드를 통해 제공된다. 아래의 예제는 Request body를 Mono<String>으로 추출하는 코드이다.

Mono<String> string = request.bodyToMono(String.class);

아래의 예제는 Request body를 Flux<Person>으로 추출한다. 이 때 Person은 JSON또는 XML과 같은 형식에서 디코딩된다.

Flux<Person> people = request.bodyToFlux(Person.class);

아래의 예제는 BodyExtractor 함수형 인터페이스를 받고, ServerRequest.body(BodyExtractor)를 사용하는 좀 더 일반적인 예제이다.

Mono<String> string = request.body(BodyExtractors.toMono(String.class));
Flux<Person> people = request.body(BodyExtractors.toFlux(Person.class));

아래의 예제는 Form Data에 접근하는 방법을 보여준다.

Mono<MultiValueMap<String, String>> map = request.formData();

아래의 예제는 Multipart를 Map타입으로 접근하는 방법을 보여준다.

Mono<MultiValueMap<String, Part>> map = request.multipartData();

아래의 예제는 스트리밍 방식으로 Multipart 데이터에 한번에 하나씩 접근하는 방법을 보여준다.

Flux<PartEvent> allPartEvents = request.bodyToFlux(PartEvent.class);
allPartsEvents.windowUntil(PartEvent::isLast)
      .concatMap(p -> p.switchOnFirst((signal, partEvents) -> {
          if (signal.hasValue()) {
              PartEvent event = signal.get();
              if (event instanceof FormPartEvent formEvent) {
                  String value = formEvent.value();
                  // handle form field
              }
              else if (event instanceof FilePartEvent fileEvent) {
                  String filename = fileEvent.filename();
                  Flux<DataBuffer> contents = partEvents.map(PartEvent::content);
                  // handle file upload
              }
              else {
                  return Mono.error(new RuntimeException("Unexpected event: " + event));
              }
          }
          else {
              return partEvents; // either complete or error signal
          }
      }));

참고로 메모리 릭을 방지하기 위해선 PartEvent 객체의 body를 완전히 소비, 릴레이 또는 해제해야한다.

ServerResponse

ServerResponse는 HTTP Response에 접근할 수 있도록 해준다. 해당 객체는 불변이기에 build() 메소드를 통해 생성해야한다. 제공되는 빌더를 통해 응답 헤더를 설정하거나 Status code를 설정하는 등의 동작도 수행할 수 있다. 아래의 예제는 200 Status code를 JSON Content-Type으로 응답하는 예제이다.

Mono<Person> person = ...
ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person, Person.class);

아래의 예제는 Location 헤더, 201 Status code, 빈 응답을 내리는 예제이다.

URI location = ...
ServerResponse.created(location).build();

Handler Classes

HandlerFuction은 아래의 예제처럼 람다 형태로도 사용할 수 있다.

HandlerFunction<ServerResponse> helloWorld =
  request -> ServerResponse.ok().bodyValue("Hello World");

이것은 매우 편리하지만, 많은 Function이 존재할 때 여러줄의 인라인 람다식이 있다면 가독성에 좋지 않을 것이다. 아래의 예제는 Spring MVC에서 @Controller를 통해 핸들러를 매핑하는 방식과 동일한 작동을 한다.

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());
	}
}

Validation

Functional Enpoint는 Request body를 검증하기 위해 스프링의 Validation 기능을 사용할 수 있다. 예를 들어 Person 객체에 대해 커스텀 스프링 Validator를 구현하려면 아래와 같이 작성한다.

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
		}
	}
}
  1. Validator 인스턴스를 생성한다.
  2. Validation을 적용한다.
  3. 검증에 실패하면 예외를 발생시킨다.

핸들러는 LocalValidatorFactoryBean을 기반으로 한 전역적인 Validator 인스턴스를 통해 검증을 진행할수도 있다.

RouterFunction

RouterFunction은 Request를 매칭되는 HandlerFunction으로 연결시키기 위해 사용한다. 일반적으로는 RouterFunction를 직접 작성하지 않고 RouterFunction의 유틸 클래스의 메소드를 사용하여 생성한다. 파라미터가 없는 RouterFunctions.route()는 라우터 기능을 생성하기 위해 빌더를 제공하는 반면 파라미터가 있는 RouterFunctions.route(RequestPredicate, HandlerFunction)은 라우터를 생성하는 직접적인 방법을 제공한다.

일반적으로 route() 빌더를 사용하는것이 좋다. 이는 찾기 어려운 static import가 필요없고 일반적인 매핑 전략에서 유용하게 사용될 수 있기 때문이다. 예를 들어 RouterFunction은 GET요청에 대한 매핑을 생성하기 위해 GET(String, HandlerFunction) 메소드를 제공한다. POST(String, HandlerFunction)은 POST요청 용도이다.

Predicates

직접 RequestPredicate를 작성할수도 있지만 RequestPredicate는 path, HTTP 메소드, Content-Type 등에 대해 필요한 대다수의 기능을 제공해준다. 아래의 예제는 RequestPredicate를 사용하여 Accept헤더를 기반으로 제약조건을 생성한다.

RouterFunction<ServerResponse> route = RouterFunctions.route()
	.GET("/hello-world", accept(MediaType.TEXT_PLAIN),
		request -> ServerResponse.ok().bodyValue("Hello World")).build();
  • RequestPredicate.and(RequestPredicate) - 둘 다 매칭되어야 함
  • RequestPredicate.or(RequestPredicate) - 둘 중 하나만 매칭되어도 됨

위 and(), or()를 통해 여러개의 ReuqestPredicate를 조합할 수도 있고 대부분의 경우 이러한 유즈케이스로 사용된다. 예를 들어 RequestPredicate.GET(String)은 RequestPredicate.method(HttpMethod)와 RequestPredicate.path(String)에서 구성된다.  route() 빌더는 내부적으로 RequestPredicate.GET을 사용하므로 위 예제 또한 2개의 RequestPredicate를 사용하고 있다.

Routes

RouterFunction들은 순서대로 평가된다. 첫 번째 라우터가 매칭되지 않으면 두 번째 라우터가 매칭되는지 검사된다. 그러므로 최대한 구체적인 경로를 선언하는것이 좋다. 이는 나중에 설명하겠지만 RouterFunction을 스프링 빈으로 등록할때도 중요한 문제이다. 이러한 동작은 가장 구체적인 컨트롤러 메소드가 자동으로 매칭되는 어노테이션 기반과는 다르다. 

RouterFunction의 빌더를 사용할 때 정의된 모든 경로는 build() 메소드로부터 리턴되는 하나의 RouterFunction으로 구성된다. 물론 여러 라우터 기능을 함께 구성하는 방법도 존재한다.

  • RouterFunctions.route()에 add(RouterFunction)를 추가한다.
  • RouterFunction.andRoute(RequestPredicate, HandlerFunction) 은 RouterFunction.and()와 중첩된 RouterFunctions.route()와 동일하다.

아래의 예제는 4가지 경로의 라우터 구성을 보여준다.

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

RouterFunction<ServerResponse> otherRoute = ...

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

Running a Server

RouterFunction을 HTTP Server에서 실행시키려면 어떻게 해야할까? 간단한 옵션으로 RouterFunction을 HttpHandler로 변환하는 방법이 있다.

  • RouterFunctions.toHttpHandler(RouterFunction)
  • RouterFunctions.toHttpHandler(RouterFunction, HandlerStrategies)

스프링 부트 같은 경우 WebFlux Config를 통해 DispatcherHandler 기반의 설정을 해줄 수 있다. Webflux 설정은 Functional Endpoint를 지원하기 위해 다음과 같은 인프라 구성 요소를 선언한다.

  • RouterFunctionMapping: 하나 또는 여러개의 RouterFunction<?> 빈을 찾아 정렬하고 RouterFunction.andOther를 통해 합친 결과로 만들어진 RouterFunction으로 요청을 라우팅한다.
  • HandlerFunctionAdapter: DispatcherHandler가 요청에 매핑된 HandlerFunction를 실행할 수 있도록 해주는 간단한 어댑터이다.
  • ServerResponseResultHandler: ServerResponse의 writeTo() 매소드를 호출하여 HandlerFunction 호출의 결과를 처리한다.

위 구성 요소를 사용하면 Functional Endpoint가 DispatcherHandler 요청 라이프싸이클에 맞고 어노테이션이 달린 컨트롤러와 함께 실행될 수 있도록 해준다. Spring Boot Webflux starter 또한 위 방법을 사용하여 Function Endpoint가 동작하게 한다. 

아래의 예제는 Webflux 설정에 관한 코드이다.

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

Filtering Handler Functions

RoutingFunction 빌더의 before, after, filter를 통해 HandlerFunction을 필터링할수도 있다. 이는 어노테이션을 기반으로 프로그래밍할 때 자주 사용하는 @ControllerAdvice, ServletFilter등과 비슷한 기능을 한다. 해당 필터는 빌더를 통해 생성된 모든 라우터에 적용된다. 이는 중첩 라우터에 적용된 필터가 Top-level로 적용되지 않음을 의미한다.

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(handler::createPerson))
	.after((request, response) -> logResponse(response))  // 2
	.build();
  1. X-RequestHeader라는 커스텀한 헤더에 적용된 before() 필터는 오직 1번 위에 있는 2개의 GET에만 적용된다.
  2. after() 필터는 중첩 라우터를 포함한 모든 라우터에 적용된다.

라우터 빌더의 filter() 메소드는 HandlerFilterFunction을 사용한다. 해당 필터는 ServerRequest와 HandlerFunction를 받고 ServerResponse를 리턴한다. HandlerFunction의 파라미터는 체이닝된 다음 요소를 나타낸다. 이는 일반적으로 라우팅되는 핸들러이지만 여러 필터가 적용되는 경우 다른 필터가 될수도 있다.

이제 특정 경로가 허용되는지 여부를 결정할 수 있는 SecurityManager가 있다고 가정하고 간단한 필터를 경로에 추가해보자.

SecurityManager securityManager = ...

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

위 예제는 next.handle(ServerRequest) 가 선택 사항임을 나타낸다. 또한 접근이 허용된 경우에만 핸들러가 실행되도록 한다. RouterFunction 빌더에서 필터 메소드를 사용하는것 외에도 RouterFunction.filter(HandlerFilterFunction)을 통해 기존 RouterFunction에 필터를 적용할수도 있다.

Reference

https://docs.spring.io/spring-framework/reference/web/webflux-functional.html

반응형