본문 바로가기
IT/Spring

2021-09-09-WebClient

by 봉즙 2023. 2. 28.

layout : post
title : "Web Client"

category : Spring

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-webflux
    implementation 'org.springframework.boot:spring-boot-starter-webflux:2.5.4'

WebFlux 설치 이후

@Component
public class BizApi {

    private final String API_SERVICE_KEY = "";

    private final WebClient webClient;

    public BizApi(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("https://api.odcloud.kr/api/nts-businessman/v1")
                .defaultHeader("Content-Type", "application/json;charset=utf-8")
                .defaultHeader("accept", "application/json;charset=utf-8")
                .build();
    }

    public Mono<BizDto.Res> isActive(BizDto.Req bizRequest) {
        return webClient.post()
//                .uri("?serviceKey=" + API_SERVICE_KEY)
                .uri(uriBuilder -> UriComponentsBuilder.fromUri(uriBuilder.build())
                .path("/status")
                .queryParam("serviceKey", "{serviceKey}")
                .encode()
                .buildAndExpand(API_SERVICE_KEY)
                .toUri())
                .body(Mono.just(bizRequest), BizDto.Req.class)
                .retrieve()
                .bodyToMono(BizDto.Res.class)
                                .retryWhen(Retry.max(5));
    }
}

기본적으로 uri로 사용하면되나

uri에 + 기호가 들어가있는 경우 인코딩이 되지 않는다

UriBuilder를 사용하여 처리한다

Mono는 0~1개 Flux는 다수의 데이터 처리시 사용한다.

retryWhen 을 이용하여 재시도 횟수 설정 가능하다.

Object data = webClient.post()
                .uri(uriBuilder -> UriComponentsBuilder.fromUri(uriBuilder.build())
                .path("/status")
                .queryParam("serviceKey", "{serviceKey}")
                .encode()
                .buildAndExpand(API_SERVICE_KEY)
                .toUri())
                .body(Mono.just(bizRequest), BizDto.Req.class)
                .retrieve()
                .bodyToMono(BizDto.Res.class)
                                .flux()
                    .toStream()
                    .findFirst()
                    .orElse(defaultValue);

Mono 같은경위 위처럼 데이터를 가져오고

List<SomeData> results =
    webClient.mutate()
             .baseUrl("https://some.com/api")
             .build()
             .get()
             .uri("/resource")
             .accept(MediaType.APPLICATION_JSON)
             .retrieve()
             .bodyToFlux(SomeData.class)
             .toStream()
             .collect(Collectors.toList());

Flux는 위와 같이 처리한다.

Spring에서 주로 사용하던 RestTemplate는 5.0 버전부터 deprecated 되었다.

WebClient 사용하기를 권고하고 있으며 아래의 특징을 가지고 있다.

- Non-blocking I/O
- Reactive Streams back pressure
- High concurrency with fewer hardware resources
- Functional-style, fluent API that takes advantage of Java 8 lambdas
- Synchronous and asynchronous interactions
- Streaming up to or streaming down from a server

Static factory 사용

가장 간편하게 사용할 수 있는 방법이다.
@Configuration을 사용하여 여러 빈에서 사용할 수 있도록 한다.

WebClient.create();
WebClient.create(String baseUrl);

그러나 header, cookie, default, filter, ConnectionTimeOut 등의 설정을 원하는 경우 Builder를 사용하는 것이 좋다.

- 모든 호출에 대한 기본 Header / Cookie 값 설정
- `filter`를 통한 Request/Response 처리
- Http 메시지 Reader/Writer 조작
- Http Client Library 설정

MaxInMemorySize

Spring WebFlux는 메모리 문제를 피하기위하여 codec 처리를 위한 in-memory buffer의 값이 256KB로 설정되어있다.
이보다 큰 HTTP 메세지 처리시에는 DataBufferLimitException이 발생하게 되며 메모리 사이즈를 늘려주기 위해서는 ExchageStrategies.builder()를 사용한다.

ExchangeStrategies exchangeStrategies =
    ExchangeStrategies
        .builder()
        .codecs(configurer->configurer.defaultCodecs()
                                     .maxInMemorySize(1024*1024*1))
        .build();

Logging

Debug레벨에서 formData, Trace레벨에서 header 정보는 보안적인 문제가 있을 수 있어 로그에서 확인할 수 없게 설정되어있다.
로그확인이 필요한 경우 ExchageStrateges, logging level을 아래와 같이 설정한다.

exchangeStrategies
    .messageWriters().stream()
    .filter(LoggingCodecSupport.class::isInstance)
    .forEach(writer->((LoggingCodecSupport)writer).setEnableLoggingRequestDetails(true));
logging:
  level:
    org.springframework.web.reactive.function.client.ExchangeFunctions: DEBUG

Client Filters

Request나 Response를 수정하기 위해서 WebClient.builder().filter()를 사용한다.

아래와 같이 수정하여 사용한다.

WebClient.builder()
        .filter(ExchangeFilterFunction.ofRequestProcessor(
            clientRequest->{
                log.debug("Request: {} {}", clientRequest.method(), clientRequest.url());
                clientRequest.headers()
                             .forEach((name, values)->values.forEach(value->log.debug("{} : {}", name, value)));
                return Mono.just(clientRequest);
            }
        ))
        .filter(ExchangeFilterFunction.ofResponseProcessor(
            clientResponse->{
                clientResponse.headers()
                              .asHttpHeaders()
                              .forEach((name, values)->
values.forEach(value->log.debug("{} : {}", name, value)));
                return Mono.just(clientResponse);
            }
        ))

HttpClient TimeOut

HttpClient, ConnectionTimeOut같은 설정값을 변경하기 위해서는 WebClient.builder().clientConnector()를 사용하여 변경해주어야 한다.

WebClient
  .builder()
    .clientConnector(
      new ReactorClientHttpConnector(
        HttpClient
          .create()
            .secure(
              ThrowingConsumer.unchecked(
                sslContextSpec->sslContextSpec.sslContext(
                  SslContextBuilder
                    .forClient()
                 .trustManager(InsecureTrustManagerFactory.INSTANCE)
                    .build()
                )
              )
            )
            .tcpConfiguration(
              client->client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 120_000)
      .doOnConnected(
        conn->conn.addHandlerLast(new ReadTimeoutHandler(180))
                    .addHandlerLast(new WriteTimeoutHandler(180))
                    )
            )
      )
    )

new ReactorClientHttpConnector()를 통해 옵션이 추가된 새로운HttpClient를 설정한다.

위 예제에서는 HTTPS 인증서를 검증하지 않고 바로 접속하는 설정과, TCP 연결 시 ConnectionTimeOut , ReadTimeOut , WriteTimeOut 을 적용하는 설정을 추가하였다.

Usage

WebClient 는 기존 설정값을 상속해서 사용할 수 있는 mutate()가 있다.
mutate() 를 통해 builder() 를 다시 생성하여 추가적인 옵션을 설정하여 재사용이 가능하기 때문에 @Bean 으로 등록한 WebClient는 각 Component 에서 의존주입하여 mutate()를 통해 사용 하는 것이 좋다.

WebClient a = WebClient.builder()
                       .baseUrl("https://some.com")
                       .build();
WebClient b = a.mutate()
               .defaultHeader("user-agent", "WebClient")
               .build();
WebClient c = b.mutate()
               .defaultHeader(HttpHeaders.AUTHORIZATION, token)
               .build();

위와 같이 설정 했을 경우 WebClient “c”는 “a”와 “b”에 설정된 baseUrl , user-agent 해더를 모두 가지고 있습니다.

@Bean 으로 등록된 WebClient 는 다음과 같이 사용 가능합니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class SomeService implements SomeInterface {

    private final WebClient webClient;    public Mono<SomeData> getSomething() {

    returnwebClient.mutate()
                    .build()
                    .get()
                    .uri("/resource")
                    .retrieve()
                    .bodyToMono(SomeData.class);
    }
}

retrieve() vs exchange()

HTTP 호출 결과를 가져오는 두 가지 방법으로 retrieve() 와 exchange() 가 존재합니다. retrieve 를 이용하면 바로 ResponseBody를 처리 할 수 있고, exchange 를 이용하면 세세한 컨트롤이 가능합니다. 하지만 Spring에서는 exchange 를 이용하게 되면 Response 컨텐츠에 대한 모든 처리를 직접 하면서 발생할 수 있는 memory leak 가능성 때문에 가급적 retrieve 를 사용하기를 권고하고 있다.

  • retrieve
Mono<Person> result = webClient.get()
                               .uri("/persons/{id}", id)
                               .accept(MediaType.APPLICATION_JSON)
                               .retrieve()
                               .bodyToMono(Person.class);
  • exchange
Mono<Person> result = webClient.get()
                               .uri("/persons/{id}", id)
                               .accept(MediaType.APPLICATION_JSON)
                               .exchange()
                               .flatMap(response ->
                                 response.bodyToMono(Person.class));

4xx and 5xx 처리

HTTP 응답 코드가 4xx 또는 5xx로 내려올 경우 WebClient 에서는 WebClientResponseException이 발생하게 됩니다. 이 때 각 상태코드에 따라 임의의 처리를 하거나 Exception 을 랩핑하고 싶을 때는 onStatus() 함수를 사용하여 해결 할 수 있다.

webClient.mutate()
         .baseUrl("https://some.com")
         .build()
         .get()
         .uri("/resource")
         .accept(MediaType.APPLICATION_JSON)
         .retrieve()
         .onStatus(status->status.is4xxClientError()
                          || status.is5xxServerError()
             , clientResponse->
clientResponse.bodyToMono(String.class)
                           .map(body->new RuntimeException(body)))
         .bodyToMono(SomeData.class)

GET

GET 호출은 앞서 보여진 예시에서 처럼 get() 함수를 통해 사용되며 uri() 를 통해 호출 리소스 정보를 전달해 줘야 한다. 만약 Query 파라미터가 존재한다면 다음과 같이 변수를 추가 줄 수 있다.

public Mono<SomeData> getData(Integer id, String accessToken) {
    return
        webClient.mutate()
                 .baseUrl("https://some.com/api")
                 .build()
                 .get()
                 .uri("/resource?id={ID}", id)
                 .accept(MediaType.APPLICATION_JSON)
                 .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
                 .retrieve()
                 .bodyToMono(SomeData.class)
        ;
}

uri() 함수를 제공하는 UriSpec 인터페이스는 아래와 같으며, Map 을 이용하거나 직접 UriBuilder 등을 통해 세세한 컨트롤도 가능하다.

POST

Body Contents를 전송할 수 있는 POST 호출 은 post() 함수를 통해 제공되며, RequestBody를 설정하기 위한 RequestBodySpec 인터페이스는 다음과 같다.

  • form 데이터 전송
webClient.mutate()
         .baseUrl("https://some.com/api")
         .build()
         .post()
         .uri("/login")
         .contentType(MediaType.APPLICATION_FORM_URLENCODED)
         .accept(MediaType.APPLICATION_JSON)
         .body(BodyInserters.fromFormData("id", idValue)
                            .with("pwd", pwdValue)
         )
         .retrieve()
         .bodyToMono(SomeData.class);

form 데이터를 생성하기 위해서는 BodyInserters.fromFormData() 를 이용할 수 있으며, bodyValue(MultiValueMap<String, String>) 을 통해서도 데이터를 전송 할 수 있습니다.

  • JSON body 데이터 전송
webClient.mutate()
         .baseUrl("https://some.com/api")
         .build()
         .post()
         .uri("/login")
         .contentType(MediaType.APPLICATION_JSON)
         .accept(MediaType.APPLICATION_JSON)
         .bodyValue(loginInfo)
         .retrieve()
         .bodyToMono(SomeData.class);

객체 자체를 RequestBody로 전달하기 위해서는 bodyValue(Object body) 를 사용하거나 body(Object producer, Class<?> elementClass) 를 통해서 사용할 수 있다.

또한 Mono 나 Flux 객체를 통해 RequestBody를 생성하기 위한

<T, P extends Publisher<T>> RequestHeadersSpec<?> body(P publisher, Class<T> elementClass);

함수도 존재한다.

PUT

PUT 호출은 POST 호출과 유사하다.

webClient.mutate()
         .baseUrl("https://some.com/api")
         .build()
         .put()
         .uri("/resource/{ID}", id)
         .contentType(MediaType.APPLICATION_JSON)
         .accept(MediaType.APPLICATION_JSON)
         .bodyValue(someData)
         .retrieve()
         .bodyToMono(SomeData.class);

DELETE

DELETE 호출은 GET 과 유사하며 delete() 함수를 통해 시작되고, delete() 함수 의 특성상 response는 Void.class 로 처리된다.

webClient.mutate()
         .baseUrl("https://some.com/api")
         .build()
         .delete()
         .uri("/resource/{ID}", id)
         .retrieve()
         .bodyToMono(Void.class);

Synchronous Use

WebClient 는 Reactive Stream 기반이므로 리턴값을 Mono 또는 Flux 로 전달받게 됩니다. Spring WebFlux를 이미 사용하고 있다면 문제가 없지만 Spring MVC를 사용하는 상황에서 WebClient 를 활용하고자 한다면 Mono 나 Flux 를 객체로 변환하거나 Java Stream 으로 변환해야 할 필요가 있다.

이럴 경우를 대비해서 Mono.block() 이나 Flux.blockFirst() 와 같은 blocking 함수가 존재하나 block() 을 이용해서 객체로 변환하면 Reactive Pipeline 을 사용하는 장점이 없어지고 모든 호출이 main 쓰레드에서 호출되기 때문에 Spring 측에서는 block() 은 테스트 용도 외에는 가급적 사용하지 말라고 권고하고 있다.

대신 완벽한 Reactive 호출은 아니지만 Lazy Subscribe 를 통한 Stream 또는 Iterable 로 변환 시킬 수 있는 Flux.toStream() , Flux.toIterable() 함수를 제공하고 있다.

List<SomeData> results =
webClient.mutate()
             .baseUrl("https://some.com/api")
             .build()
             .get()
             .uri("/resource")
             .accept(MediaType.APPLICATION_JSON)
             .retrieve()
             .bodyToFlux(SomeData.class)
             .toStream()
             .collect(Collectors.toList());

Flux.toStream() 을 통해 데이터를 추가 처리하거나 List로 변환하여 사용할 수 있다.

Mono 에 대해서는

SomeData data =
webClient.mutate()
             .baseUrl("https://some.com/api")
             .build()
             .get()
             .uri("/resource/{ID}", id)
             .accept(MediaType.APPLICATION_JSON)
             .retrieve()
             .bodyToMono(SomeData.class)
             .flux()
             .toStream()
             .findFirst()
             .orElse(defaultValue);

Mono.flux() 를 통해 Flux 로 변환하고 findFirst() 를 통해 Optional 처리 하는 것이 좋다. (이 때는 onError 처리가 필요)

'IT > Spring' 카테고리의 다른 글

2021-10-06-배포 방법  (0) 2023.02.28
2021-09-30-JPA 관계 찾지 못하는 경우  (1) 2023.02.28
2021-09-09-JSON Naming  (0) 2023.02.28
2021-09-01-flyway  (0) 2023.02.28
2021-08-23-EntityGraph  (0) 2023.02.28

댓글