Spring Framework

Spring Webflux - WebClient

흥부가귀막혀 2022. 3. 14. 12:55

WebClient

생성

create

  • WebClient 는 간단하게 static 메서드로 생성이 가능하다.
  • static method 로 생성할 경우 디폴트로 Reactor Netty HttpClient 를 사용하므로 reactor-netty 라이브러리에 대한 의존성이 필요하다.
WebClient webClient1 = WebClient.create();
WebClient webClient2 = WebClient.create("http://naver.com");

builder

  • 몇가지 옵션을 설정하려면 builder 를 사용하여 생성하는것을 권장한다.
    • uriBuilderFactory: base URL 과 관련하여 UriBuilderFactory 를 커스텀하게 구현했을 경우.
    • defaultUriVariables: 모든 요청에 핸들링 가능한 URI variable 설정.
    • defaultHeader: 모든 요청에 사용할 header 설정.
    • defaultCookie: 모든 요청에 사용할 cookie 설정.
    • defaultRequest: 모든 요청의 request 를 커스터마이징 하는 설정.
    • filter: 모든 요청의 filter 설정.
    • exchangeStrategies: HTTP message reader/writer 의 커스터마이징 설정.
    • clientConnector: HTTP client library 설정.
WebClient client = WebClient.builder()
      .exchangeStrategies(builder -> {
              return builder.codecs(codecConfigurer -> {
                  //...
              });
      })
      .build();
  • WebClient 는 한번 build 를 수행하면 상태를 변경할 수 없다. 기존 WebClient 에 설정을 변경하고 싶다면, mutate() 함수를 통해 복제를 하여 설정을 추가한다.
WebClient client1 = WebClient.builder()
      .filter(filterA).filter(filterB).build();

WebClient client2 = client1.mutate()
      .filter(filterC).filter(filterD).build();

중요한 Configuration

Connection

  • WebClient 에서 사용할 Connection 관련 설정은 ConnectionProvider.Builder 를 통해 설정하여 ConnectionProvider 를 생성한 뒤 Reactor Netty HttpClient 생성시 주입하면 된다.
  • ConnectionProvider.Builder 를 통해 설정할 수 있는 Connection 관련 설정은 다음과 같다.
    • evictInBackground : background thread 가 connection 종료 체크를 얼마나 자주 할건지에 대한 설정.
    • maxConnections : max connection 수. WebClient 에서 connection 수 이상의 요청을 하게될 경우 queue 에 들어가 대기하게 된다.
    • pendingAcquireMaxCount : connection 이 없을 때 queue 에 요청을 담을 최대 갯수. -1 로 설정할 경우 제한 없음.
    • pendingAcquireTimeout : connection 이 없어 queue 에 담아놓을 시간(단위: ms).
    • maxIdleTime : connection 의 최대 idle time(단위: ms). -1 일 경우 제한 없음.
    • maxLifeTime : connection 의 최대 life time(단위: ms). -1 일 경우 제한 없음.
    • getLeasingStrategy : Connection Acquire operation 수행시 connection 대여 전략. fifo, lifo 가 있다.(default: fifo)
    • metrics : Connection 관련 metric 활성화 여부. 활성화를 할 경우 /actuator/metrics 를 통해 metric 확인 가능.
ConnectionProvider.Builder connectionProviderBuilder = ConnectionProvider.builder(name);
// 설정 수행
...
HttpClient httpClient = HttpClient.create(connectionProviderBuilder.build());

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

MaxInMemorySize

  • 스프링 웹플럭스는 어플리케이션 메모리 이슈를 방지하기 위해 코덱의 메모리 버퍼 사이즈를 제한한다. 디폴트는 256KB로 설정돼 있는데, 버퍼가 부족하면 다음과 같은 에러가 발생한다.
org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer
  • codec 설정을 통해 MaxInMemorySize 를 수정할 수 있다.
  • -1 로 설정할 경우 무제한으로 설정된다.
WebClient webClient = WebClient.builder()
      .exchangeStrategies(builder ->
          builder.codecs(codecs ->
              codecs.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)
          )
      )
      .build();

Timeout

  • Connect, Read/Write Timeout 은 Reactor Netty HttpClient 생성을 통해 다음과 같이 설정할 수 있다.
import io.netty.channel.ChannelOption;

HttpClient httpClient = HttpClient.create()
      .tcpConfiguration(client ->
              client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
                    .doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(10000, TimeUnit.MILLISECONDS))
					       .addHandlerLast(new WriteTimeoutHandler(10000, TimeUnit.MILLISECONDS))));

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

Secure

  • 타겟 서버의 ssl 인증서 체크를 무시하고자 한다면 아래와 같이 설정한다.
HttpClient httpClient = HttpClient.create()
	.secure(
		sslContextSpec -> {
			try {
				sslContextSpec.sslContext(
					SslContextBuilder.forClient()
						.trustManager(InsecureTrustManagerFactory.INSTANCE)
						.build()
				);
			} catch (SSLException e) {
				throw new ServiceException("Insecure sslContext create fail.", e);
			}
		}
	);

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

Metric

  • WebClient 의 요청 url 별로 metric 을 얻고 싶다면 Reactor Netty HttpClient 생성시 metrics 함수를 통해 metric 활성화 및 url 별 tag 설정을 해준다.
  • tag 설정이 되었다면 /actuator/metrics/{http.client.metric.key}?tag=key:value 형태로 요청하여 tag 에 맞는 metric 조회가 가능하다.
HttpClient httpClient = HttpClient.create()
	.metrics(properties.isMetricsEnabled(), uri -> {
		// uri 별 tag name 정의
		return "my-api";
	})

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

Request Body

  • Mono, Flux 를 통해 비동기적으로 request body serialize & write 가 가능하다
Mono<Person> personMono = ... ;

Mono<Void> result = client.post()
      .uri("/persons/{id}", id)
      .contentType(MediaType.APPLICATION_JSON)
      .body(personMono, Person.class)
      .retrieve()
      .bodyToMono(Void.class);
  • 비동기적으로 하지 않아도 된다면, bodyValue 함수를 사용한다.
Person person = ... ;

Mono<Void> result = client.post()
      .uri("/persons/{id}", id)
      .contentType(MediaType.APPLICATION_JSON)
      .bodyValue(person)
      .retrieve()
      .bodyToMono(Void.class);
  • form data 의 경우 MultiValueMap<String, String> 형태로 bodyValue 를 호출하거나, BodyInserters 를 사용한다.
// bodyValue 사용
MultiValueMap<String, String> formData = ... ;

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

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

⚠️ Form Data 를 보낼 경우 FormHttpMessageWriter 에서 자동으로 Content-Type 을 application/x-www-form-urlencoded 으로 정의하기 때문에 contentType 함수를 사용해서 정의하지 않아도 된다.

Request & Response Handle

retrive()

  • retrive() 는 response body 를 받아 디코딩하는 간단한 함수이다.
  • 4xx, 5xx 응답 코드를 받으면 디폴트는 WebClientResponseException 또는 각 HTTP 상태에 해당하는 WebClientResponseException.BadRequest, WebClientResponseException.NotFound 등의 하위 exception을 던진다.
  • http status 별 핸들링을 다르게 하고 싶다면 onStatus 메소드로 커스텀할 수도 있다.
Mono<Person> result = client.get()
      .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
      .retrieve()
      .onStatus(HttpStatus::is4xxClientError, response -> ...)
      .onStatus(HttpStatus::is5xxServerError, response -> ...)
      .bodyToMono(Person.class);

⚠️ response body 가 있을 경우 onStatus 에서 소비하지 않으면 onStatus 호출 이후 response body 가 리소스 반환을 위해 비워지기 때문에 response body 를 볼 수 없게 된다. onStatus 를 사용하게 될 경우 response body 도 반드시 소비해야한다.

exchange()

  • 직접 ClientResponse 를 핸들링 해야할 필요가 있다면 exchange() 함수를 통해 요청한다.
  • exchange() 함수는 retrive() 함수와는 다르게 4XX, 5XX http status 에 대해 자동으로 에러 응답을 주지 않는다. 사용자가 직접 http status 를 확인하고 핸들링 하는 로직을 구현해야한다.

특정 model 객체로 변환시

Mono<Person> result = client.get()
      .uri("/persons/{id}", id).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));

⚠️ retrieve()와는 다르게 exchange()는 모든 시나리오에서(성공, 오류, 예기치 못한 데이터 등) 어플리케이션이 직접 response body를 컨슘해야 한다. 그렇지 않으면 메모리 릭이 발생할 수 있다. ClientResponse javadoc을 참고하면 body를 컨슘할 수 있는 모든 옵션이 나와 있다.