Spring

Spring WebFlux

Spring WebFlux

if(Kakao) dev 2018 스프링5 웹플럭스와 테스트 전략 - 이일민(Toby)

스프링 5.0에 새로 등장한 웹 프레임워크 + 리액티브 스택

  • spingMVC와 두 개의 웹 기술 트랙
  • 초기 이름은 스프링 웹 리액티브, 현재는 웹 플럭스로 바뀜

스프링 웹 플럭스 VS MVC

SpringMVC : 서블릿 스택 기반 웹 프레임워크
Spring WebFlux : 리액티브 스택

스프링 웹 플럭스와 MVC가 공유하는 부분과 고유한 부분 (이 부분 때문에 혼란스러울 것이다)

예제

SpringMVC - Hello API

@GetMapping("/hello/{name}")
public String hello(@PathVariable String name) {
    return "Hello "  + name;
}

스프링 웹 플럭스

@GetMapping("/hello/{name}")
public String hello(@PathVariable String name) {
    return "Hello "  + name;
}

웹 플럭스의 @Controller 코드는 스프링 MVC의 코드와 동일한 방식으로 작성 가능하다

  • 그렇지 않은 것도 있다.

스프링 웹 플럭스 도입이유

  • 100% (I/O, 데이터 처리) 논블록킹 개발
  • 확장성과 고효율성이 매우 중요
  • 업, 다운 스트리밍과 Back pressure가 필요
  • 고속 서비스 오케스트레이션 개발
  • 유사한 프로그래밍 모델의 경험
  • 유연하게 커스토마이징이 가능한 웹 프레임워크 구성
  • 본격적인 함수형 프로그래밍 모델 사용

=> 확장성과 고효율성이 매우 중요한 경우에 이 프레임워크를 활용하라, (기업입장)확장성을 높여서 돈을 절약하라

스프링 웹 플럭스를 사용하지 않는게 좋은 이유

  • 웹플럭스가 왜 필요한지 분명하게 모름
    • 비동기/논블록킹 방식 도입한다 (써야할 이유 x)
    • 리액티브 라이브러리(Reactor, RxJava) 사용한다 (써야할 이유 x)
  • 블록킹이 서버, 코드, 라이브러리에 존재
    • 블록킹 IO, 블록킹 서블릿 필터
    • JPA (x), JDBC (x)
    • ADBA (o), AoJ (?), R2DBC (?)
  • SpringMVC로 개발했더니 아무 문제 없음 (써야할 이유 x)

스프링 웹 플럭스는 스프링 MVC로 시작해도된다

스프링5 MVC는 웹 플럭스에서 제공되는 다양한 기능과 프로그래밍 모델 제공

  • 비동기/논블록킹 API 호출
  • 비동기/논블록킹 데이터 액세스
  • 리액티브 데이터 조회, 전송
  • 비동기 웹 요청 처리
  • 서버 스트리밍
  • 클라이언트 스트리밍
  • Reactor(Flux, Mono), RxJava, Flow 등을 이용하는 코드

MVC에서 WebClient 사용이 가장 좋은 출발점

  • RestTemplate 대체

스프링 웹 플럭스를 꼭 쓸 이유도, 그렇지 않을 이유도 없는 중간이라면 한번 써보는게 좋다. WebClient 하나만 알아가도 좋다

리엑티브 프로그래밍, 비동기/논블록킹

리엑티브 (함수형) 프로그래밍

  • 인터넷 시대에 복잡함을 해결하기 위해 등장
  • 연속적으로 일어나는 이벤트를 다루는 프로그래밍 기법

특징

  1. UI 이벤트, 비동기적인 I/O 이벤트, 통제 불가능한 이벤트 스트림 처리
  2. 동시성, 비동기/논블록킹 호출을 다루는데 탁월
  3. 조합 가능한 비동기 로직을 다루는 함수형 프로그래밍

비동기/논블록킹 API 호출 - 가장 단순한 리엑티브

동기/블록킹 API 호출 - RestTemplate

  • 장점 : 쉽고 간단함
  • 단점 : IO 동안 블록킹, 시스템 특성에 따라 매우 비효율적이 될 수도(돈으로 해결)

비동기/논블록킹 API 호출

  • 적은 서버 리소스만 사용해서 많은 요처을 처리하자는 것이 핵심
  • AsyncRestTemplate(Spring4)
  • Async/Await(Java8++)
  • WebClient(Spring5)
  • 장점 : 확장성이 뛰어나고 높은 처리율과 낮은 레이턴시를 가지는 효율적인 서버 구성이 가능하다.(돈 절약), 쉽고 직관적이고 간단하다
  • 단점 : 장점을 얻을 만한 경우가 많지 않음, 자칫하면 코드가 복잡하고 이해하기 어려움

비동기/논블록킹 API 진화

  • Future -> DeferedResult -> CompletableFuture
  • 자바에서도 Async/Await 사용가능
  • Reactor Flux/Mono를 이용할 수 있다.

Reactor Flux/Mono는 CompletableFuture와 유사해보이지만, 큰 장점이 있다.

  • Mono : 어떤 이벤트가 비동기적으로 넘어올 때 딱 1개까지만 허용한다.
  • Flux : 데이터를 Stream으로 지속적으로 받을 수 있다. 캐시에 담은 다음에 Mono에 한꺼번에 넣어서 넘겨도 된다.

CompletableFuture VS Reactor Flux/Mono

공통점

  • 람다식으로 사용하는 함수형 스타일
  • 비동기와 비동기 작업의 조화(compose, flatMap)
  • 비동기와 동기 작업의 적용(apply, map)
  • Exceptional 프로그래밍
  • 작업별 쓰레드 풀 지정 가능

Flux/Mono 방식의 장점

  • 데이터 스트림(Flux) <-> List/Collection
  • 강력한 연산자 제공
  • 지연 실행, 병합, 분산, 실행 제어
  • 유연한 스케줄러
  • ReactiveStreams, 자바 9+ 표준
  • 다양한 지원 라이브러리, 서비스, 서버

리액티브 애플리케이션의 데이터 흐름

리액티브 API 데이터 시퀀스 검증

Mono나 Flux를 가지고 있으면 이는 Reactive API라고 부를 수 있다.
단위 테스트를 할 수도 있지만, 그 뒷부분까지 통합 테스트를 할 수도 있다.

주의! : 테스트 코드는 항상 동기방식이어야 한다. 하지만 우리가 테스트할 대상은 비동기/논블로킹 특성을 가지고 있다.

subscribe() + assert

@Test
void mono() {
  Mono<Integer> mono = Mono.just(1)
    .subscribeOn(Schedulers.single());

  mono.subscribe(item -> assertThat(item).isEqualTo(1)); // 테스트 성공
}

@Test
void mono2() {
  Mono<Integer> mono = Mono.just(1)
    .subscribeOn(Schedulers.single());

  mono.subscribe(item -> assertThat(item).isEqualTo(2)); // 테스트 성공??????
}

Mono나 Flux는 데이터를 리턴 값으로 받는 것이 아니라, 일종의 콜백을 받는 것이다.
Exception이 발생하지 않기때문에 2를 넣어도 테스트가 성공한다.

Scheduler는 별도의 데몬 스레드에서 작동하는 것이기 때문에 subscribe() + assert 사용할때 조심해야 한다.

CountDownLatch

@Test
void mono3() throws InterruptedException {
  Mono<Integer> mono = Mono.just(1)
    .subscribeOn(Schedulers.single());

  CountDownLatch latch = new CountDownLatch(1);

  mono.subscribe(item -> {
    assertThat(item).isEqualTo(2);
    latch.countDOwn();
  });

  latch.await();  // 테스트가 끝나지 않음!
}

-> CountDownLatch를 이용하여 테스트에서 동시성을 제어해야한다.

// 테스트에서 동시성을 제어해야 하는 번거로움!
@Test
void mono4() throws InterruptedException {
  Mono<Integer> mono = Mono.just(1)
    .subscribeOn(Schedulers.single());

  CountDownLatch latch = new CountDownLatch(1);
  AtomicInteger item = new AtomicInteger();

  mono.subscribe(
    i -> item.set(i),
    e -> latch.countDown(),
    latch::countDown
  );

  latch.await();
  assertThat(item.get()).isEqualTo(1);
}

-> 테스트 코드를 계속 이렇게 작성해야하면 고통스러울 것이다. block() 사용하자

block() + assert

@Test
void mono5() throws InterruptedException {
  Mono<Integer> mono = Mono.just(1)
    .subscribeOn(Schedulers.single());

  Integer item = mono.block();
  assertThat(item).isEqualTo(1);
}

StepVerifer

비동기/논블록킹 테스트에는 StepVerifier을 이용하자

  • 비동기/논블로킹으로 동작하는 코드 테스트 툴
  • 데이터 스트림의 검증
  • 예외, 완료도 검증
  • 가상시간을 이용해 오랜 시간의 이벤트 테스트
@Test
void stepVerifier() {
  Mono<Integer> mono = Mono.just(1)
    .subscribeOn(Schedulers.single());

  StepVerifier.create(mono)
    .expectNext(1)          // 첫번째 데이터 아이템 값
    .verifyComplete();
}

@Test
void stepVerifier2() {
  var flux = Flux.just(1, 2, 3)
    .concatWith(Mono.error(new RuntimeException()))
    .subscribeOn(Schedulers.single())

  StepVerifier.create(flux)
    .expectNext(1)
    .expectNext(2)
    .expectNext(3)
    .expectError(RuntimeException.class)  // 에러나고 종료
    .verify();
}

리액티브 HTTP API 호출 테스트

RestTemplate

  • 동기/블록킹

AsyncRestTemplate

  • 비동기/논블록킹
  • Future 콜백, CompletionStage

WebClient

  • 비동기/논블록킹
  • Flux/Mono 요청, 응답
  • Streaming 지원
  • BackPressure 지원

WebClient를 사용하면 RestTemplate를 대신해서 비동기/논블록킹을 함수형 프로그래밍 스타일로 쉽게 작성할 수 있다

WebClient

@GetMapping("/api")
public Mono<String> helloAPi() {
  return client.get()
    .uri("/api/hello")
    .retrieve()
    .onStatus(HttpStatus::is4xxClientError,
      res -> Mono.error(new IllegalArgumentException()))
    .onStatus(HttpStatus::is5xxServerError,
      res -> Mono.error(new RuntimeException()))
    .bodyToMono(String.class)
    .map(body -> body.toUpperCase())  // 비지니스 로직 수행
    .switchIfEmpty(Mono.just("Empty"));
}

원격 리액티브 API 호출 - 통합테스트(feat. MockServer)

원격 테스트를 하려면 서버를 따로 띄워야하는데, MockServer를 사용하면 HTTP API 통합테스트를 할 수 있다.

MockServer를 포함하면, 간단하게 HTTP Request를 테스트 할 수 있는 서버를 생성할 수 있다.

WebClientIntegrationTests는 유용한 샘플이니 참고하면 좋다.

private MockWebServer server;
private WebClient webClient;

@Before
public void setup() {
  var connector = new ReactorClientHttpConnector();
  this.server = new MockWebServer();
  this.webClient = WebClient
    .builder()
    .clientConnector(connector)
    .baseUrl(this.server.url("/").toString())
    .build();
}

@Test
public void shouldReceiveResponseHeaders() {
  prepareResponse(response -> response
    .setHeader("Content-Type", "text/plain")
    .setBody("Hello Spring!"));

  Mono<HttpHeaders> result = this.webClient.get()
    .uri("/greeting?name=Spring").exchange()
    .map(response -> response.headers().asHttpHeaders());

  StepVerifier.create(result).consumeNextWIth(
    httpHeaders -> {
      assertEquals(MediaType.TEXT_PLAIN, httpHeaders.getContentType());
      assertEquals(13L, httpHeaders.getContentLength()); })
    .expectComplete()
    .verify(Duration.olfSeconds(3));

  expectRequestCount(1);
  expectRequest(request -> {
    assertEquals("*/*", request.getHeader(HttpHeaders.ACCEPT));
    assertEquals("/greeting?name=Spring", request.getPath());
  });
}

interface HelloService {
  Mono<String> hello();
}

@Component
public class RemoteHelloService implements HelloService {
  public Mono<String> Hello() {
    return client.get()
      .uri("/hello")
      .retrieve()
      .onStatus(HttpStatus::is4xxClientError,
        res -> Mono.error(new IllegalArgumentException()))
      .onStatus(HttpStatus::is5xxServerError,
        res -> Mono.error(new RuntimeException()))
      .bodyToMono(String.class);
  }
}

@Autowired HelloService helloService; // Mock 서비스로 대체 가능

@GetMapping("/api")
public Mono<String> helloApi() {
  return this.helloService.hello()
    .map(body -> body.toUpperCase())
    .switchIfEmpty(Mono.just("Empty"))
    .doOnError(c -> c.printStackTrace());
}

웹 플럭스의 새로운 아키텍처

기존 MVC는 서블릿 스팩과 서버의 제약 위에 개발

  • 프론트 컨트롤러 패턴, MVC 패턴, 전략 패턴

웹 플럭스는 독자적인 아키텍처를 가지는 프레임워크

  • 서블릿(3.1+) 컨테이너를 사용할 수 있으나 의존적이지 않음
  • Netty, Undertow 서버 지원
  • 논블록킹 네트워크/논블록킹 API
  • 논블록킹 데이터 스트림
  • 서버/기술 의존적이지 않은 프레임워크 재구성 손쉬움
  • 함수형 엔드포인트
  • 뛰어난 테스트 편의성

EnableWebFlux 어노테이션을 추가하면
![이미지 : 웹플럭스의 새로운 아케텍처 - @EnableWebFlux]

TestClient - @WebFluxText

Spring boot 2 애플리케이션의 Mock Test

bindToApplicationContext 이용

@RunWith(SpringRunner.class)
@WebFluxTest
public class WebCLientBootTest {
  @Autowired WebTestClient webTestClient;

  @Test
  public void hello() {
    webTestClient.get().uri("/hello/{name}", "Spring")
      .exchange()
      .expectStatus().isOk()
      .expectBody(String.class)
      .isEqualTo("Hello Spring");
  }
}

bindToRouterFunction: 함수형 엔드포인트에 대한 테스트

Mono<ServerResponse> handler(ServerRequest request) {
  return ServerResponse.ok().body(Mono.just("hello"), Spring.class);
}

@Test
void routerFunction() {
  ReouterFunction<ServerResponse> route =route(GET("/rf"), this::handler);

  WebTestClient client = WebTestClient.bindToRouterFunction(route).build();
  client.get().uri("/rf")
    .exchange()
    .expectStatus().isOk()
    .expectBody(String.class)
    .isEqualTo("hello");
}

bindToController: 특정 컨트롤러/핸들러만으로 테스트 대상 구성. 예를 들어 컨트롤러가 100개 있는 서버를 테스트하고 싶다면, 기존의 테스트 코드를 재사용 할 수 있다.

WebTestClient client = WebTestClient.bindToController(
  new MyController(), new HelloAPi()
).build();

client.get().uri("/hello/{name}", "Spring")
  .exchange()
  .expectStatus().isOk()
  .expectBody(String.class)
  .isEqualTo("Hello Spring");

bindToServer: static 메서드를 사용해서 실제 동작하는 서버에 연결해서 테스트할 수 있다.

함수형 엔드포인트 테스트 방법

  • bindToRouterFunction
  • \@WebFLuxTest
  • 함수 단위 테스트
    • 본격적인 함수형 스타일 웹 프로그래밍
    • 조합 가능한 마이크로 프레임워크
    • 작고 가벼운 비동기 논블록킹 웹

 

Reference