WebClient 공식 문서 정리

최재혁·2022년 11월 30일
3

WebClient

목록 보기
2/3
post-thumbnail
post-custom-banner

다음 내용은 스프링 리액티브 웹 스택 공식 레퍼런스를 직접 번역하신
토리맘의 한글라이즈 프로젝트를 기반으로 제가 이해한 바를 요약하고, 여러 자료를 통해 내용을 조금 보충한 것입니다.

WebClient

Spring WebFlux는 리액티브, Non-Blocking HTTP 요청을 위한 WebClient를 제공한다. WebClient 내부에서는 HTTP 클라이언트 라이브러리에 처리를 위임한다. 디폴트로 Reactor Netty를 사용하고, Jetty reactive HttpClient를 기본으로 제공하며, 다른 라이브러리는 ClientHttpConnector에 등록할 수 있다.


WebClient 설정

WebClient 생성

WebClient를 생성하는 가장 간단한 방법은 스태틱 팩토리 메소드를 사용하는 것이다.

  • WebClient.create()
  • WebClient.create(String baseUrl)

위 메소드는 디폴트로 Reactor Netty HttpClient를 사용하므로, 클래스패스에 io.projectreactor.netty:reactor-netty가 있어야 한다.

다른 옵션을 사용하려면, WebClient.builder()를 사용한다.

  • uriBuilderFactory: base URL을 커스텀
  • defaultHeader: 모든 요청에 사용할 헤더
  • defaultCookie: 모든 요청에 사용할 쿠키
  • defaultRequest: 모든 요청을 커스텀할 Consumer
  • filter: 모든 요청에 사용할 클라이언트 필터
  • exchangeStrategies: HTTP 메세지 reader/writer 커스텀
  • clientConnector: HTTP 클라이언트 라이브러리 세팅

아래는 HTTP 코덱을 설정하는 예제이다.

WebClient client = WebClient.builder()
    .exchangeStrategies(builder -> {
        return builder.codecs(codeConfigurer -> {
            //...
        });
    })
    .build();

WebClient는 한 번 빌드하고 나면, 상태를 변경할 수 없다. 대신, 원본 인스턴스를 복사해서 설정을 추가하는 것은 가능하다.

// client1은 filterA, filterB를 가짐
WebClient client1 = WebClient.builder()
    .filter(filterA).filter(filterB).build();

// client2는 filterA, filterB에 더해 filterC, filterD도 가짐
WebClient client2 = client1.mutate()
      .filter(filterC).filter(filterD).build();

MaxInMemorySize(메모리 사이즈 제한)

Spring WebFlux는 어플리케이션의 메모리 이슈를 방지하기 위해, 코덱의 메모리 버퍼 사이즈를 제한할 수 있다. 디폴트는 256KB로 설정돼 있는데, 버퍼의 메모리 용량이 부족하면 다음 에러가 발생한다.

org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer

다음 코드를 사용하여, 코덱의 최대 버퍼 사이즈를 조절할 수 있다.

WebClient webClient = WebClient.builder()
    .exchageStrategies(builder ->
                      builder.codecs(codecs ->
                                    codecs.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)))
    .builder();

Reactor Netty 커스텀

HttpClient를 사용하여, WebClient의 디폴트 HTTP 클라이언트 라이브러리인 Reactor Netty를 커스텀할 수 있다.

HttpClient httpClient =
    HttpClient.create().secure(sslSpec -> ...);

WebClient webClient = WebClient.builder()
    .clientConnector(new
                    ReactorClientHttpConnector(httpClient))               
    .build();

Resources

기본적으로 HttpClientreactor.netty.http.HttpResources에 묶여 있는 Reactor Netty의 글로벌 리소스를 사용한다. 이는 이벤트 루프 쓰레드와 커넥션 풀도 포함하는데, 이벤트 루프로 동시성을 제어하려면 공유 리소스를 고정해 놓고 사용하는 것이 좋기 떄문이다. 이 모드에서는 프로세스가 종료될 때까지 공유 자원을 active 상태로 유지한다.

서버가 프로세스와 함께 중단된다면, 명시적으로 리소스를 종료할 필요는 없다. 하지만, 프로세스 내에서 서버를 시작하거나 중단할 수 있다면 (WAR로 배포한 스프링 MVC 어플리케이션과 같이), 스프링이 관리하는 ReactorResourceFactory 빈을 globalResource=true(디폴트) 옵션을 설정해야 스프링 컨테이너가 종료될 때 Reactor Netty 글로벌 리소스도 종료한다. 아래 코드처럼 설정하면 된다.

@Bean
public ReactorResourceFactory reactorResourceFactory(){
    return new ReactorResourceFactory(); // 기본값이 globalResource=true
}

원한다면 글로벌 Reactory Netty 리소스를 사용하지 않아도 되긴 하지만, 직접 Reactory Netty의 클라이언트와 서버 인스턴스가 공유 자원을 사용하도록 코드를 직접 작성해야 한다.

Timeouts

HTTP 커넥션 타임아웃은 다음과 같이 설정할 수 있다.

HttpClient httpClient = HttpClient.create()
    .tcpConfiguration(client -> client.option(ChannerOption.CONNECT_TIME_MILLIS, 10000);

read/write 타임아웃은 다음과 같이 설정할 수 있다.

HttpClient httpClient = HttpClient.create()
    .tcpConfiguration(client -> client.doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(10))
                                                    .addHandlerLast(new WriteTimeoutHandler(10))));

WebClient API

Response Body

1. retrieve()

retrieve()는 repsonse body를 받아 디코딩하는 가장 간단한 메소드다.

WebClient client = 
    WebClient.create("https://example.org");

Mono<Person> result = client.get()
    .uri("/persons/{id}",id)
    .accept(MediaType.APPLICATION_JSON)	// MediaType 설정
    .retrieve()	// 받은 응답 디코딩
    .bodyToMono(Person.class);	// response body -> Mono 인스턴스

응답을 다음과 같이 객체 스트림으로도 디코딩할 수 있다.

Flux<Quote> result = client.get()
    .uri("/quotes").accept(MediaType.TEXT_EVENT_STREAM)
    .retrieve()
    .bodyToFlux(Quote.class);	// response body -> Flux 

만약 응답 코드로 4xx, 5xx가 넘어오면 예외를 던진다. 디폴트는 WebClientResponseException(최상위 예외) 이고, 또는 각 HTTP 상태에 해당하는 WebClientResponseException.BadRequest, WebClientResponseException.NotFound 등의 하위 예외를 던진다. 다음과 같이 onStatus 메소드로 상태별 예외를 커스텀할 수도 있다.

Mono<Person> result = client.get()
    .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
    .retrieve()
    .onStatus(HttpStatus::is4xxClientError, response -> ...)	// 4xx 예외 커스텀
    .onStatus(HttpStatus::is5xxServerError, response -> ...)	// 5xx 예외 커스텀
    .bodyToMono(Person.class);

만약 onStatus를 사용할 때는, response에 body가 존재하면 onStatus 콜백에서 소비해야 한다. 그렇지 않으면 리소스 반환을 위해 body를 자동으로 비운다.

2. exchange()

exchange() 메소드는 retrieve보다 더 많은 기능을 제공한다. 다음 예제는 위의 retrieve() 코드와 동일하지만, ClientResponse 에 접근한다.

WebClient client = 
    WebClient.create("https://example.org");

Mono<Person> result = client.get()
    .accept(MediaType.APPLICATION_JSON)
    .exchange()
    .flatMap(response -> response.bodyToMono(Person.class));	

같은 레벨에서 ResponseEntity를 만들 수도 있다.

Mono<ResponseEntity<Person>> result = client.get()
    .uri("/persons/{id}", id)
    .accept(MediaType.APPLICATION_JSON)
    .exchange()
    .flatMap(response -> response.toEntity(Person.class)); 

exchange() 사용시 주의할 점

  • exchange()retrieve()와 달리 4xx, 5xx 응답을 자동으로 예외를 던져주지 않는다. 직접 상태 코드를 확인하고, 어떻게 처리할지 결정해야 한다.
  • 또한, exchange()retrieve()와 다르게 발생할 수 있는 모든 시나리오(성공, 오류, 예기치 못한 데이터 등)에서 어플리케이션이 직접 response body를 소비해야 한다. 그렇지 않으면 Memory 누수가 발생할 수 있다. ClientResponse javadoc에 response body를 소비할 수 있는 모든 옵션이 나와있긴 하다.

exchage()를 사용해서 응답 코드나 헤더를 봐야 로직을 결정할 수 있다거나, 아니면 직접 응답을 소비해야 한다거나 하는 특별한 이유가 없다면, retrieve()를 쓰는 것이 좋다.

Request Body

Request body는 Mono, 코틀린 코루틴 Deferred 등, ReactiveAdapterRegistry에 등록한 모든 비동기 타입으로 인코딩할 수 있다.

WebClient client = 
    WebClient.create("https://example.org");

Mono<Person> personMono = ...; 

Mono<Void> result = client.post()	// 보낼 Http 메서드
    .uri("/persons/{id}", id)
    .contentType(MediaType.APPLICATION_JSON)
    .body(personMono, Person.class)	// Body에 들어갈 내용
    .retrieve()
    .bodyToMono(Void.class);

다음 코드는 객체 스트림으로 인코딩하는 예제이다.

Flux<Person> personFlux = ...;

Mono<Void> result = client.post()
    .uri("/persons/{id}", id)
    .contentType(MediaType.APPLICATION_STREAM_JSON)
    .body(personFlux, Person.class)	 // Flux 인코딩
    .retrieve()
    .bodyToMono(Void.class);

만약 비동기 타입이 아닌 실제 값을 가지고 있다면, bodyValue를 사용하면 된다.

Person person = ...; 	// 실제 객체

Mono<Void> result = client.post()
    .uri("/persons/{id}", id)
    .contentType(MediaType.APPLICATION_JSON)
    .bodyValue(person)	// bodyValue 사용
    .retrieve()
    .bodyToMono(Void.class);

Form Data

Form으로 데이터를 보내려면, MultiValueMap<String, String>을 body로 사용해야 한다. 이 경우, Content-type은 FormHttpMessageWriter자동으로 applcation/x-www-form-urlencoded로 설정해준다.

MultiValueMap<String, String> formData = ...;	// 데이터 타입 확인

Mono<Void> result = client.post()
    .uri("/path", id)
    .bodyValue(formData)
    .retrieve()
    .bodyToMono(Void.class);	// ContentType을 따로 지정하지 않았다.

인라인으로 코드를 form 데이터를 만들어 전송하려면 BodyInserters를 사용한다

// 스태틱 import
import static org.springframework.web.reactive.function.BodyInserters.*;

Mono<Void> result = client.post()
      .uri("/path", id)
      .body(fromFormData("k1", "v1").with("k2", "v2"))
      .retrieve()
      .bodyToMono(Void.class);

Multipart Data

Multipart 데이터를 보낼 때는, MultiValueMap<String, ?>을 사용해서 각 value에 part 컨텐츠를 나타내는 Object 인스턴스 혹은, part의 컨텐츠와 헤더를 나타내는 HttpEntity를 담아야 한다. 이 때, MultipartBodyBuilder를 사용하면 좀 더 편리하다.

MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("fieldPart1", "fieldValue");
builder.part("fieldPart2", new 
            FileSystemResource("...logo.png"));	// 이미지 파일 담기
builder.part("jsonPart", new Person("Jason"));	// json 담기
builder.part("myPart", part);  // Server request 파트

MultiValueMap<String, HttpEntity<?>> parts = builder.build();

각 파트의 Content type은 HttpMessageWriter 또는 파일 확장자에 의해 자동으로 결정되므로 명시하지 않아도 된다. 필요할 경우, 빌더의 part 메소드 중 MediaType을 받는 메소드를 사용하면 된다.

다음은 생성한 MultiValueMapWebClient에 넘기는 코드이다.

MultipartBodyBuilder builder = ...;

Mono<Void> result = client.post()
    .uri("/path", id)
    .body(builder.build())
    .retrieve()
    bodyToMono(Void.class);

Multipart 컨텐츠를 인라인으로 작성하려면 Form Data와 같이 BodyInserters를 사용하면 된다.

// 스태틱 import
import static org.springframework.web.reactive.function.BodyInserters.*;

Mono<Void> result = client.post()
      .uri("/path", id)
      .body(fromMultipartData("fieldPart", "value").with("filePart", resource))
      .retrieve()
      .bodyToMono(Void.class);

Client Filter

WebClient 요청에 필터를 적용하려면 WebClient.Builder에서 클라이언트 필터인 ExchangeFilterFunction을 등록하면 된다.

WebClient client = WebClient.builder()
      .filter((request, next) -> {	// 나만의 필터 등록

          ClientRequest filtered = ClientRequest.from(request)
                  .header("foo", "bar")
                  .build();

          return next.exchange(filtered);
      })
      .build();

다음 코드는 스태틱 팩토리 메소드를 사용해서 기본 인증 필터를 추가하는 예제이다.

import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication;	// 스태틱 import

WebClient client = WebClient.builder()
      .filter(basicAuthentication("user", "password"))	// 필터 추가
      .build();

필터는 기본적으로 모든 요청에 전역적으로 적용되지만, 필터에서 특정 요청만 처리하고 싶다면 ClientRequest에 request attribute를 추가하고, 필터에서 해당 attribute에 접근하면 된다.

WebClient client = WebClient.builder()
      .filter((request, next) -> {	// 여기서 추가한 attribute에 접근
          Optional<Object> usr = request.attribute("myAttribute");      
          // ...
      })
      .build();

client.get().uri("https://example.org/")
      .attribute("myAttribute", "...")	// request attribute 추가
      .retrieve()
      .bodyToMono(Void.class);
  }

WebClient를 복제해서 필터를 추가하거나 삭제하는 것도 가능하다. 다음 코드는 첫 번째 순서로 인증 필터를 추가하는 예제이다.

import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication;

WebClient client = webClient.mutate()	// WebClient 복제
      .filters(filterList -> {	
          filterList.add(0, basicAuthentication("user", "password"));
      })	// 필터 추가
      .build();

WebClient 테스팅에 관해서는 또 다른 포스트로 찾아오겠습니다.
읽어 주셔서 감사합니다!!

profile
잘못된 고민은 없습니다
post-custom-banner

0개의 댓글