오늘은 Spring의 WebClient의 사용법에 대해서 몇가지 알아보도록 하자. 사용 API만 살펴 볼 예정이므로 reactive streams(reactor..) 들의 개념과 사용법은 다른 블로그를 살펴보길 바란다. reactive streams 대한 내용을 알고 보면 좋지만 몰라도 코드를 보는데는 문제가 없을 듯 하다.

WebClient는 Spring5 에 추가된 인터페이스다. spring5 이전에는 비동기 클라이언트로 AsyncRestTemplate를 사용을 했지만 spring5 부터는 Deprecated 되어 있다. 만약 spring5 이후 버전을 사용한다면 AsyncRestTemplate 보다는 WebClient 사용하는 것을 추천한다. 아직 spring 5.2(현재기준) 에서 AsyncRestTemplate 도 존재하긴 한다.

기본 문법

기본적으로 사용방법은 아주 간단하다. WebClient 인터페이스의 static 메서드인 create()를 사용해서 WebClient 를 생성하면 된다. 한번 살펴보자.

@Test
void test1() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

create()는 두가지 메서드가 있는데 baseUrl를 지정해주는 것과 그렇지 않은 것 두가지가 존재한다. 원하는 API를 사용하면 되겠다.

@Test
void test1() {

    WebClient webClient = WebClient.create();
    Mono<String> hello = webClient.get()
            .uri("http://localhost:8080/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

API 자체도 명확하다. get(), post(), put(), patch(), 등 http method들을 모두 정의되어 있다.

webClient.get()
  .///
webClient.post()
  .///
webClient.put()
  .///
webClient.method(HttpMethod.GET)
  .///

또는 위와 같이 HttpMethod를 지정할 수 있다.

uri 또한 여러 메서드가 존재한다. 단순하게 string 으로 uri을 만들 수 도 있고 queryParam, pathVariable 등 명확하게 uri을 만들 수 도 있다. 위의 코드를 사실 RestTemplate 클래스를 자주 사용했다면 익숙한 문법일 수 있다.

@Test
void test1_3() {

    WebClient webClient = WebClient.create();
    Mono<String> hello = webClient.get()
            .uri("http://localhost:8080/sample?name=wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}


@Test
void test1_3() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", Map.of("name", "wonwoo"))
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

@Test
void test1_3() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

@Test
void test1_3() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.get()
            .uri(it -> it.path("/sample")
                    .queryParam("name", "wonwoo")
                    .build()
            ).retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

위와 같이 여러 방법이 존재하니 각자가 원하는 어떤것을 사용해도 좋다. 마지막 S uri(Function<UriBuilder, URI> uriFunction) API는 좀 더 세세하게 컨트롤 할 수 있으니 세세하게 컨트롤 할 일이 있다면 이 API를 사용하는게 좋다.

다음은 retrieve() 메서드인데 이 메서드는 request를 만들고 http request를 보내는 역할을 한다. 이 메서드 말고 exchange()가 존재하는데 약간 다르다. 사실 API만 살짝 다를뿐이지 retrieve() 내부에선 exchange() 메서드를 호출한다.

retrieve() 메서드는 ResponseSpec 타입을 리턴하고 exchange() 메서드는 Mono<ClientResponse> 를 리턴하고 있다.

@Test
void test2() {

    WebClient webClient = WebClient.create("http://localhost:8080");

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .exchange()
            .flatMap(it -> it.bodyToMono(String.class));

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

위의 test1_3 메서드와 test2 메서드는 동일한 동작을 한다. 위에서 말했다시피 exchange() 메서드는 ClientResponse를 사용해 좀 더 세세한 컨트롤을 하면 된다.

@Test
void test2() {

    WebClient webClient = WebClient.create("http://localhost:8080");

    Mono<String> hello = webClient.get()
            .uri("/sample1?name={name}", "wonwoo")
            .exchange()
            .flatMap(it -> {
                if(it.statusCode() == HttpStatus.NOT_FOUND) {
                    throw new NotFoundException("....");
                }
                return it.bodyToMono(String.class);
            });

    StepVerifier.create(hello)
            .verifyError(NotFoundException.class);
}

이렇게 기본문법에 대해서 알아봤다. 그리 어려운 내용도 없는 듯 하다. 좀 더 범용성있게 사용하려면 아직은 부족하다. 좀 더 살펴보자.

formData 와 message

위에서 알아보지 않은게 있는데 바로 post나 put 기타 http 메서드에 자주 사용하는 formdata 와 message에 대해서 알아보자.
만약 formData 로 server에 보낸다면 다음과 같이 작성하면 된다.

import static org.springframework.web.reactive.function.BodyInserters.fromFormData;

@Test
void test3() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.post()
            .uri("/sample")
            .body(fromFormData("name", "wonwoo"))
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("wonwoo")
            .verifyComplete();
}

fromFormData란 static 메서드를 사용해서 전달하면 된다. 만약 좀 더 많은 내용이 있다면 .with(key, value) 메서드를 체이닝해 사용하면 된다.

.body(fromFormData("name", "wonwoo").with("foo","bar").with("...","..."))

또는 MultiValueMap를 이용해서 fromFormData에 넣어도 된다.

이번엔 message에 대해서 알아보자. 일반적으로 json, xml 기타 message로 보낼때 유용하다. 한번 살펴보자.

@Test
void test3_1() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.put()
            .uri("/sample")
            .bodyValue(new Sample("wonwoo"))
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("wonwoo")
            .verifyComplete();
}

위와같이 bodyValue를 이용해서 message를 전달할 수 있다. 참고로 spring 5.2 이전버전에선 syncBody를 이용해야 한다. spring 5.2에선 syncBody는 Deprecated 되었다.

만약 전달하는 message가 Publisher 타입일 수 도 있다. 그럼 다음과 같이 작성하면 된다.

@Test
void test3_2() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.put()
            .uri("/sample")
            .body(Mono.just(new Sample("wonwoo")), Sample.class)
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("wonwoo")
            .verifyComplete();
}

filter

filter이용하면 client를 호출하기전에 인터셉터해서 추가적인 작업을 할 수 있다. 예를들면 로그를 출력 할 수도 있고 헤더정보 혹은 인증정보를 넣어 호출 할 수 있다.

필터를 사용하려면 ExchangeFilterFunction 인터페이스를 구현하면 된다. 추상 메서드는 하나뿐이라 람다를 이용해도 좋다.

@Test
void test4() {

    WebClient webClient = WebClient.builder().filter((request, next) -> next.exchange(ClientRequest.from(request)
            .header("foo", "bar").build())).baseUrl("http://localhost:8080")
            .build();

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();

}

위의 코드는 헤더 정보 를 추가하는 코드이다. 또한 한번에 여러 필터를 적용할 수 도 있다.

WebClient.builder().filters(exchangeFilterFunctions ->
        exchangeFilterFunctions.add(0, (request, next) -> {
            return next.exchange(request);
        }));

위의 코드는 0번째에 해당 필터를 삽입하는 코드이다. 물론 filter를 계속 체이닝해서 써도 상관 없다.

ClientHttpConnector

현재 spring에서는 reactive http client가 2개밖에 존재하지 않는다. netty와 jetty이다. 사실 spring을 사용한다면 그냥 netty를 사용하는게 정신건강에 좋을 듯 싶다.

<dependency>
    <groupId>org.eclipse.jetty</groupId>
    <artifactId>jetty-reactive-httpclient</artifactId>
</dependency>

위와 같이 jetty reactive httpclient를 먼저 디펜더시 받은 후에 다음과 같이 작성하면 된다.

@Test
void test5() {
    WebClient webClient = WebClient.builder().clientConnector(new JettyClientHttpConnector())
            .baseUrl("http://localhost:8080").build();

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

추가 적인 설정은 JettyClientHttpConnector 클래스와 org.eclipse.jetty.client.HttpClient 클래스를 살펴보면 되겠다.

default values

만약 기본으로 헤더정보 쿠키정보등 값을 지정하고 싶다면 다음과 같이 작성하면 된다.

@Test
void test6() {

    WebClient webClient = WebClient.builder().baseUrl("http://localhost:8080")
            .defaultHeader("foo", "bar")
            .defaultCookie("foo", "BAR")
            .defaultRequest(it -> it.header("test", "sample")).build();

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();

}

위와 같이 작성하면 기본으로 위와 같은 값이 같이 전송된다. defaultRequest() 메서드를 사용하면 좀더 세세하게 컨트롤이 가능하니 참고하면 되겠다.

retrieve

위에서 잠깐 언급한 retrieve 메서드를 이용하는 경우에 보다 상세한 에러 코드들을 컨트롤 할 수 있다. 원한다면 사용해도 좋다.

@Test
void test7() {
    WebClient webClient = WebClient.create("http://localhost:8080");

    Mono<String> hello = webClient.get()
            .uri("/sample1?name={name}", "wonwoo")
            .retrieve()
            .onStatus(HttpStatus::is4xxClientError, __ -> Mono.error(new IllegalArgumentException("4xx")))
            .onStatus(HttpStatus::is5xxServerError, __ -> Mono.error(new IllegalArgumentException("5xx")))
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .verifyErrorMessage("4xx");

} 

onStatus 메서드를 이용해서 해당 코드를 작성후에 Mono type의 exception을 던지면 된다. 위의 코드는 4xx 에러 코드일땐 4xx라는 메시지를 던지고 5xx 에러 코드일 땐 5xx라는 메세지를 던진다는 코드이다.

onStatus() 메서드 말고도 onRawStatus() 메서드도 존재한다. 이것은 위와 같이 HttpStatus 코드가 아닌 int로된 코드를 리턴한다.

@Test
void test8() {
    WebClient webClient = WebClient.create("http://localhost:8080");

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .onRawStatus(it -> it == 400, __ -> Mono.error(new IllegalArgumentException("aaa")))
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .verifyErrorMessage("400");
}

이렇게 기본문법과 사용법에 대해서 알아봤다. 물론 좀 더 많은 메서드들이 있지만 필자가 자주 사용할만한 API 위주로 살펴봤다. 다른 궁금한 점이 있다면 해당 문서를 찾아보길 추천한다.

마지막으로 Spring boot를 사용할 때에 WebClient는 어떻게 사용해야 될까? 사실 기본적인 설정은 되어있다. 그래서 아주 쉽고 간단하게 사용할 수 있다.

spring boot

spring boot 를 사용할 때는 WebClient.Builder 인터페이스가 기본적으로 bean으로 등록 되어있다. 그래서 우리는 이걸 사용하면 된다.

@RestController
public class WebClientController {

    private final WebClient webClient;

    public WebClientController(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("http://localhost:9999").build();
    }

    @GetMapping("/users")
    public Mono<String> findByName() {
        return webClient.get()
                .uri("/users")
                .retrieve()
                .bodyToMono(String.class);
    }
}

딱히 설명할 것도 없다. 만약 필터나 default values 가 필요하면 위에서 했던 그 방법을 그대로 이용하면 된다.

public WebClientController(WebClient.Builder builder) {
    this.webClient = builder
            .filter((request, next) -> next.exchange(request))
            .defaultHeader("foo", "bar")
            .baseUrl("http://localhost:8080")
            .build();
}

그리고 만약 전역적으로 커스텀할 코드들이 있다면 WebClientCustomizer 인터페이스를 이용해서 커스텀할 수 있다.

@Bean
WebClientCustomizer webClientCustomizer() {
    return builder -> builder.filter((request, next) -> next.exchange(request));
}

위와 같이 WebClientCustomizer 빈으로 등록하여 커스터마이징하면 된다.

번외로 kotlin 코드도 한번 살펴보자.

bodyToFlux(), bodyToMono(), body(), awaitExchange(), bodyToFlow(), awaitBody() 등 확장함수로 된 함수들이 몇가지 존재하니 참고하면 되겠다. 몇가지는 코루틴 관련 확장함수이다.

오늘은 이렇게 Spring의 WebClient에 대해서 살펴봤다. 이정도만 알아도 사용하기엔 충분할 듯 싶다. Spring 5에서 Non blocking http client를 사용한다면 꼭 WebClient를 사용하도록 하자!