Thumbnail image

Table of Contents

[스프링인액션] 리액티브 API 개발

개요


스프링인액션 11장을 읽고 리액티브 WebFlux를 사용하여 리액티브 API를 개발하는 방법에 대해 공부하였다.

  • 스프링 WebFlux 사용하기
  • 리액티브 컨트롤러와 클라이언트 작성 및 테스트
  • REST API 소비하기
  • 리액티브 웹 어플리케이션의 보안

에 대해 작성하였다.

WebFlux 사용


비동기 웹 프레임워크는 적은 수의 스레드로 높은 확장성을 가지고 있다.
이벤트 루핑이라는 기법을 적용하여 한 스레드 당 많은 요청을 처리할 수 있어서 스레드 관리 부담이 줄어들고 확장이 용이하다.

비동기 웹 프레임워크는 이벤트 루핑을 통해 적은 수의 스레드로 많은 요청을 처리한다.

비동기 웹 프레임워크는 이벤트 루핑을 통해 적은 수의 스레드로 많은 요청을 처리한다.

스프링 5에서는 프로젝트 리액터를 기반으로 한 비동기 웹 프레임워크인 스프링 WebFlux가 존재한다.

WebFlux 빌드 명세 추가

1implementation 'org.springframework.boot:spring-boot-starter-webflux'

스프링 WebFlux를 사용하기 위해서는 spring-boot-starter-web 대신 스프링 부트 WebFlux 스타터 dependency를 추가해야 한다. WebFlux를 사용할 경우에는 내장 서버가 톰캣 대신 Netty가 된다.

리액티브 컨트롤러 생성

리액티브 타입인 Flux 객체를 반환하는 컨트롤러를 생성한다. WebFlux 컨트롤러에서는 Flux와 같은 리액티브 타입을 받을 때 프레임워크에서 subscirbe()를 호출해주기 때문에 별도로 subscirbe()를 호출할 필요가 없다.

End-to-End 리액티브 스택

리액티브 컨트롤러가 리액티브 End-to-End 스택의 제일 끝에 위치해야 하며 리퍼지터리에서 Flux를 반환하도록 작성되어야 한다.

기본 예제

1@GetMapping("/recent")
2public Flux<Taco> recentTacos() {
3    return tacoRepo.findAll().take(12);
4}

페이징 처리를 위해 take() 메서드를 사용한 예제이다. (repository에서 Flux 타입 반환)

RxJava 타입 사용

1@GetMapping("/recent")
2public Observable<Taco> recentTacos() {
3    return tacoService.getRecentTacos();
4}

스프링 WebFlux를 사용할 때는 Observable이나 Single 같은 Rxjava 타입을 사용할 수 있다. 이뿐만 아니라 리액터의 Mono 타입과 동일한 Rxjava의 Completable 타입, Observable이나 Flux 타입의 대안으로 Flowable 타입을 반환할 수도 있다.

입력처리

1@PostMapping(consumes = "application/json")
2@ResponseStatus(HttpStatus.CREATED)
3public Mono<Taco> postTaco(@RequestBody Mono<Taco> tacoMono) {
4  return tacoRepo.saveAll(tacoMono).next();
5}

요청을 처리하는 핸들러 메서드의 입력으로 Mono나 Flux 타입을 받을 수 있다.
saveAll() 메서드는 리액티브 스트림의 Publisher 인터페이스를 구현한 타입(Mono, Flux)을 인자로 받을 수 있다.

  • next() : saveAll() 메서드가 반환하는 Flux가 하나의 객체만 포함하고 있기 때문에 해당 메서드를 통해 Mono 타입 객체를 받을 수 있다.

“MVC 패턴과 다르게 requestBody로부터 객체가 분석되지 않고 즉시 호출된다.”

함수형 요청 핸들러 정의


MVC나 WebFlux 같은 어노테이션 기반 프로그래밍의 단점(확장, 디버깅의 어려움)을 개선하기 위해 리액티브 API를 정의하는 함수형 프로그래밍 모델이 존재한다.

함수형 프로그래밍 모델 기본 타입

Type Description
RequestPredicate 처리될 요청의 종류 선언
RouterFunction 일치하는 요청이 어떻게 핸들러에게 전달되는지 선언
ServerRequest HTTP 요청을 나타내며, header와 body 정보 사용
ServerResponse HTTP 응답을 나타내며, header와 body 정보 포함

기본 예제

 1import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
 2import static org.springframework.web.reactive.function.server.RouterFunctions.route;
 3import static org.springframework.web.reactive.function.server.ServerResponse.ok;
 4
 5@Configuration
 6public class RouterFunctionConfig {
 7  @Bean
 8  public RouterFunction<?> helloRouterFunction() {
 9    return route(GET("/hello"), 
10        request -> ok().body(just("Hello!"), String.class))
11        .andRoute(GET("/bye"),
12        request -> ok().body(just("See ya!"), String.class));
13  }
14}

함수형 타입을 생성하는 데 사용하는 도우미 클래스를 static import한다.
route() 메서드는 (1) RequestPredicate 객체와 (2) 일치하는 요청을 처리하는 함수, 두 가지 인자를 가진다.

다른 종류의 요청을 처리해야 할 경우는 andRoute() 메서드를 호출한다.

함수형 방식을 사용하여 컨트롤러 생성

 1@Configuration
 2public class RouterFunctionConfig {
 3
 4  @Autowired
 5  private TacoRepository tacoRepo;
 6
 7  @Bean
 8  public RouterFunction<?> routerFunction() {
 9    return route(GET("/design/taco"), this::recents)
10        .andRoute(POST("/design"), this::postTaco);
11  }
12
13  public Mono<ServerResponse> recents(ServerRequest request) {
14    return ServerResponse.ok().body(tacoRepo.findAll().take(12), Taco.class);
15  }
16
17  public Mono<ServerResponse> postTaco(ServerRequest request) {
18    Mono<Taco> taco = request.bodyToMono(Taco.class);
19    Mono<Taco> savedTaco = tacoRepo.save(taco);
20    return ServerResponse.created(URI.create("http://localhost:8080/design/taco/" +
21        savedTaco.getId()))
22        .body(savedTaco, Taco.class);
23  }
24}

/design/taco GET 요청이 들어왔을 경우 recents() 메서드에서 처리되고, /design POST 요청이 들어왔을 경우 postTaco() 메서드에서 처리한다.

리액티브 컨트롤러 테스트

WebFlux를 사용하는 리액티브 컨트롤러의 테스트를 쉽게 작성할 수 있게 도와주는 테스트 유틸리티로 WebTestClient가 있다.

GET 요청 테스트

 1@Test
 2public void shouldReturnRecentTacos() {
 3  Taco[] tacos = {
 4      testTaco(1L), testTaco(2L),
 5      testTaco(3L), testTaco(4L),
 6      testTaco(5L), testTaco(6L)};      // 테스트 데이터 생성
 7  Flux<Taco> tacoFlux = Flux.just(tacos);
 8  
 9  TacoRepository tacoRepo = Mockito.mock(TacoRepository.class);
10  when(tacoRepo.findAll()).thenReturn(tacoFlux);  // 모의 TacoRepository
11  
12  WebTestClient testClient = WebTestClient.bindToController(
13      new DesignTacoController(tacoRepo))
14      .build();    // WebTestClient 생성
15  
16  testClient.get().uri("/design/recent")
17    .exchange()    // 가장 최근의 객체 요청
18    .expectStatus().isOk()    // 기대한 응답인지 검사
19    .expectBody()
20      .jsonPath("$").isArray()
21      .jsonPath("$").isNotEmpty()
22      .jsonPath("$[0].id").isEqualTo(tacos[0].getId().toString())
23      .jsonPath("$[0].name").isEqualTo("Taco 1")
24      .jsonPath("$[12]").doesNotExist();
25}

expectStatus() 메서드를 호출하여 응답이 200(OK) 상태 코드를 가지는지 확인한다.
jsonPath() 메서드를 호출하여 응답 몸체의 JSON이 기대값을 가지는지 검사한다.

POST 요청 테스트

 1@Test
 2public void shouldSaveATaco() {
 3  TacoRepository tacoRepo = Mockito.mock(TacoRepository.class);
 4  Mono<Taco> unsavedTacoMono = Mono.just(testTaco(null));
 5  Taco savedTaco = testTaco(null);
 6  savedTaco.setId(UUID.randomUUID());
 7  Mono<Taco> savedTacoMono = Mono.just(savedTaco);
 8  
 9  when(tacoRepo.save(any())).thenReturn(savedTacoMono);  // 모의 TestRepository
10  
11  WebTestClient testClient = WebTestClient.bindToController(  // WebTestClient 생성
12      new DesignTacoController(tacoRepo)).build();
13  
14  testClient.post()    // post 처리
15      .uri("/design")
16      .contentType(MediaType.APPLICATION_JSON)
17      .body(unsavedTacoMono, Taco.class)
18    .exchange()
19    .expectStatus().isCreated()  // 응답을 검사한다
20    .expectBody(Taco.class)
21      .isEqualTo(savedTaco);
22}

post() 메서드를 통해 POST 요청을 제출하고 application/json 타입의 몸체와 페이로드를 같이 전달한다.
exchange() 실행 후 응답이 CREATED(201) 상태 코드를 가지는지, 응답으로 객체와 동일한 페이로드를 가지는지 검사한다.

실행 중인 서버로 테스트

 1@RunWith(SpringRunner.class)
 2@SpringBootTest(webEnvironment=WebEnvironment.RANDOM_PORT)
 3public class DesignTacoControllerWebTest {
 4
 5  @Autowired
 6  private WebTestClient testClient;
 7
 8  @Test
 9  public void shouldReturnRecentTacos() {
10    testClient.get().uri("/design/recent")
11      .accept(MediaType.APPLICATION_JSON).exchange()    // 가장 최근의 객체 요청
12      .expectStatus().isOk()    // 기대한 응답인지 검사
13      .expectBody()
14        .jsonPath("$[?(@.id == 'TACO1')].name").isEqualTo("Carnivore")
15        .jsonPath("$[?(@.id == 'TACO2')].name").isEqualTo("Bovine Bounty")
16        .jsonPath("$[?(@.id == 'TACO3')].name").isEqualTo("Veg-out")
17  }
18}

WebTestClient의 통합 테스트를 위해서는 @RunWith와 @SpringBootTest 어노테이션을 지정해야 한다.

  • WebEnvironment.RANDOM_PORT : 무작위로 선택된 포트로 실행 서버가 리스닝하도록 요청

자동 연결되는 WebTestClient를 사용하기 때문에 인스턴스를 생성할 필요가 없다는 특징이 있다.

REST API 사용


RestTemplate이 제공하는 모든 메서드는 도메인 타입이나 컬렉션 타입을 처리하기 때문에 스프링 5에서는 RestTemplate 대신 리액티브 타입을 사용할 수 있는 대안으로 WebClient가 존재한다.


일반적인 WebClient 패턴

  1. WebClient 인스턴스를 생성한다.
  2. 요청을 전송할 HTTP 메서드를 지정한다.
  3. 요청에 필요한 URI와 헤더를 지정한다.
  4. 요청을 제출한다.
  5. 응답을 사용한다.

GET 사용

1Mono<Ingredient> ingredient = WebClient.create()
2    .get()
3    .uri("http://localhost:8080/ingredients/{id}", ingredientId)
4    .retrive()
5    .bodyToMono(Ingredient.class); // 단일 값
6
7ingredient.subscribe(i -> {});

create() 메서드로 새로운 WebClient 인스턴스를 생성하고, get()과 uri()를 통해 GET 요청을 정의한다.
bodyToMono() 메서드 호출을 통해 response body의 페이로드를 Mono로 추출한다.

  • bodyToMono() : 단일 값 반환 요청
  • bodyToFlux() : 컬렉션 값 반환 요청

※ Mono에 추가적으로 오퍼레이션을 적용하려면 subscribe() 메서드를 호출한다.

요청 타임아웃

1ingredients.timeout(Duration.ofSeconds(1))
2  .subscribe(i -> {}, e -> { // handle timeout error });

클라이언트 요청이 지체되는 것을 방지하기 위해 timeout() 메서드를 사용할 수 있다.
경과 시간을 1초로 지정하여 1초보다 오래 걸리면 타임아웃이 되어 subscribe 두번째 인자로 지정한 에러 핸들러가 호출된다.

리소스 전송

1Mono<Ingredient> ingredientMono= ...;
2Mono<Ingredient> result = WebClient.create()
3    .post()
4    .uri("http://localhost:8080/ingredients/")
5    .body(ingredientMono, Ingredient.class)
6    .retrive()
7    .bodyToMono(Ingredient.class); 
8
9result.subscribe(i -> {});

post()과 uri()를 통해 POST 요청을 정의하고 body() 메서드를 호출하여 Mono를 responsebody에 포함시킨다.

도메인 객체 전송

1Ingredient ingredient = ...;
2Mono<Ingredient> result = WebClient.create()
3    .post()
4    .uri("http://localhost:8080/ingredients/")
5    .syncBody(ingredient)
6    .retrive()
7    .bodyToMono(Ingredient.class); 

Mono 타입 대신 도메인 객체를 responsebody에 포함시켜 전송하려면 syncBody() 메서드를 사용한다.

리소스 삭제

1Mono<Ingredient> ingredient = WebClient.create()
2    .delete()
3    .uri("http://localhost:8080/ingredients/{id}", ingredientId)
4    .retrive()
5    .bodyToMono(Void.class)
6    .subscribe();

DELETE 요청은 응답 페이로드를 가지지 않기 때문에 Mono를 반환하고 subscribe()로 구독해야 한다.

에러 처리

에러가 생길 수 있는 Mono나 Flux를 구독할 때는 subscribe() 메서드 내에 에러 컨슈머를 등록할 필요가 있다.

1ingredientMono.subscribe(
2    ingredient -> {
3      // 데이터 처리
4    },
5    error -> {
6      // 에러 처리
7    });

404 같은 상태 코드가 반환되면 두번째 인자로 전달된 에러 컨슈머가 실행되어 기본적으로 WebClientResponseException을 발생시킨다.
WebClientResponseException : 구체적인 예외를 나타내지 않기 때문에 커스텀 에러 핸들러가 필요!

커스텀 에러 핸들러 구현

1Mono<Ingredient> ingredient = WebClient.create()
2    .get()
3    .uri("http://localhost:8080/ingredients/{id}", ingredientId)
4    .retrive()
5    .onStatus(HttpStatus::is4xxClientError,
6            response -> Mono.just(new UnknownIngredientException()))
7    .bodyToMono(Ingredient.class);

HTTP 상태 코드가 400 수준의 상태코드일 경우 UnknownIngredientException을 포함하는 Mono가 반환되고 ingredient 실행이 실패하게 된다.

요청 교환

retrieve() 메서드는 ResponseSpec 타입을 반환하는데, 응답의 헤더나 쿠키 값을 사용할 경우에는 ResponseSpec 타입으로 처리할 수 없다는 단점이 존재한다.
이 단점을 해결하기 위해 ClientResponse 타입의 Mono를 반환하는 exchange() 메서드가 존재하는데, (1) 리액티브 오퍼레이션을 적용하거나, (2) 응답의 모든 부분을 사용할 수 있다.

기본 예제

 1Mono<Ingredient> ingredient = WebClient.create()
 2    .get()
 3    .uri("http://localhost:8080/ingredients/{id}", ingredientId)
 4    .exchange()
 5    .flatMap(cr -> {
 6      if (cr.headers().header("X-UNAVAILABLE").contains("true")) {
 7        return Mono.empty();
 8      }
 9      return Mono.just(cr);
10    })
11    .flatMap(cr -> cr.bodyToMono(Ingredient.class));

“X-UNAVAILABLE” 헤더가 존재한다면 비어있는 Mono를 반환하고, 존재하지 않는다면 ClientResponse를 포함하는 새로운 Mono를 반환한다.

리액티브 웹 API 보안


스프링 시큐리티는 스프링 MVC와 리액티브 스프링 WebFlux 어플리케이션 모두 사용할 수 있다. 이를 도와주는 것이 스프링 WebFilter이다.

  • WebFilter : 서블릿 API에 의존하지 않는 스프링의 서블릿 필터

리액티브 웹 보안 구성

스프링 WebFlux에서 스프링 시큐리티를 구성하는 코드이다.

 1@Configuration
 2@EnableWebFluxSecurity
 3public class SecurityConfig {
 4  @Bean
 5  public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
 6    return http.authorizeExchange()
 7        .pathMatchers("/design", "/orders").hasAuthority("USER")
 8        .anyExchange().permitAll()
 9        .and().build();
10  }
11}

스프링 MVC와 다르게 @EnableWebSecurity 대신 @EnableWebFluxSecurity가 지정되어 있다. 또한 인자로 HttpSecurity 대신 SecurityHttpSecurity를 사용해서 authorizeExchange()를 호출하였다.
중요한 점은 프레임워크 메서드를 오버라이딩 하는 대신 build() 메서드를 호출하여 모든 보안 규칙을 반환해야 한다.

ReactiveUserDetailsService 구현

 1@Service
 2public ReactiveUserDetailsService userDetailsService(UserRepository userRepo) {
 3    return new ReactiveUserDetailsService() {
 4        @Override
 5        public Mono<UserDetails> findByUsername(String username) {
 6            return userRepo.findByUsername(username).map(user -> {
 7                return user.toUserDetails();
 8            });
 9        }
10    };
11}

userDetailsService의 리액티브 버전인 ReactiveUserDetailsService 빈을 선언하였다.
userRepo.findByUsername() 에서 반환된 Mono가 발행하는 User 객체의 toUserDetails() 메서드를 호출하여 User 객체를 UserDetails 객체로 변환한다.

Posts in this Series