Spring

WebClient 사용할때 주의 (8편)

ybs 2022. 9. 18. 02:27
반응형

1. WebClient config 기본 구조 설명

WebClient 를 사용하기 위해선 WebClientBuilder 를 활용해 만드는데 clientConnector 인자로 ReactorClientHttpConnector 객체를 생성해 넣어준다.

return webClientBuilder.baseUrl(apiClientProperties.getUrl())
	.clientConnector(
		// ReactorClientHttpConnector 를 넣어줘야함
	)
	.build();

 

cf) ReactorClientHttpConnector 객체를 생성하기 위해선 reactorResourceFactory 가 필요한데, 따로 빈 등록해야한다. 물론 없어도 객체 생성이 가능하지만 내가 만든 코드에서는 필요했다.

@Bean
ReactorResourceFactory reactorResourceFactory() {
	return new ReactorResourceFactory();
}

 

다음으로 ReactorClientHttpConnector 객체를 아래와 같은 방식으로 생성했다. reactorResourceFactory 와 Function<HttpClient, HttpClient> 람다 body 를 전달했다.

new ReactorClientHttpConnector(
	reactorResourceFactory,
	defaultHttpClient -> defaultHttpClient
		.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectionTimeoutMillis)
		.doOnConnected(connection ->
			connection.addHandlerLast(
				new ReadTimeoutHandler(readTimeoutMillis, TimeUnit.MILLISECONDS)
			)
		)
);

 

다시말해 아래 ReactorClientHttpConnector 생성자를 활용했다. 생성자에서 HttpClient.create(provider) 가 먼저 수행되고 defaultInitializer 를 거쳐 위 람다 body 로 오게 되고 그 defaultHttpClient 에 추가 설정을 해주는 방식이다.

cf) 동작방식이 잘 이해가 안간다면 이 글이 도움이 된다.

public class ReactorClientHttpConnector implements ClientHttpConnector {

  // 여기 compress 설정 때문에 이슈 발생했음!!
  private final static Function<HttpClient, HttpClient> defaultInitializer = client -> client.compress(true);

  public ReactorClientHttpConnector(ReactorResourceFactory factory, Function<HttpClient, HttpClient> mapper) {
    ConnectionProvider provider = factory.getConnectionProvider();
    Assert.notNull(provider, "No ConnectionProvider: is ReactorResourceFactory not initialized yet?");
    this.httpClient = defaultInitializer.andThen(mapper).andThen(applyLoopResources(factory))
      .apply(HttpClient.create(provider));
  }

 

2. 요구사항 변경

WebClient 마다 maxIdleTime 과 maxLifeTime 을 따로 설정하도록 수정했다. 그래서 ConnectionProvider 를 새롭게 만들었다. 하지만 defaultHttpClient 에 있는 기존 ConnectionProvider 를 교체하는 api 가 존재하지 않았다. 그래서 HttpClient.create 로 새롭게 만들 수 밖에 없었다.

new ReactorClientHttpConnector(reactorResourceFactory, defaultHttpClient -> {
  // ConnectionProvider 을 새롭게 만듦
  ConnectionProvider connectionProvider = ConnectionProvider.builder("ybs")
    .maxConnections(500)
    .pendingAcquireMaxCount(1000)
    .maxIdleTime(Duration.ofMillis(maxIdleTimeoutMillis))
    .maxLifeTime(Duration.ofMillis(maxLifeTimeoutMillis))
    .build();

  // defaultHttpClient 를 활용 못함. 새롭게 create 해야함
  return HttpClient.create(connectionProvider)
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectionTimeoutMillis)
    .doOnConnected(connection ->
      connection.addHandlerLast(
        new ReadTimeoutHandler(readTimeoutMillis, TimeUnit.MILLISECONDS)
      )
    );
  });

 

3. 문제 발생

gzip 으로 압축된 response 를 WebClient 가 제대로 처리하지 못하는 문제가 발생했다. defaultHttpClient 는 compress 옵션이 true 로 되어 있었지만 HttpClient.create 로 새롭게 만들면서 해당 옵션을 누락한게 원인이었다.

private final static Function<HttpClient, HttpClient> defaultInitializer = client -> client.compress(true);

 

엄밀히 말하면 WebClient 구현체인 reactor-netty 가 제대로 처리하지 못했다. compress 옵션을 true 로 해줌으로써 acceptGzip 이 true 가 되는데 그에 따라 HttpContentDecompressor 코덱이 파이프라인에 추가된다. HttpContentDecompressor 의 역할은 gzip 으로 압축된 HttpMessage 와 HttpContent 를 디코딩 하는것이다.

public final HttpClient compress(boolean compressionEnabled) {
	if (compressionEnabled) {
		if (!configuration().acceptGzip) {
			HttpClient dup = duplicate();
			HttpHeaders headers = configuration().headers.copy();
			headers.add(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.GZIP);
			dup.configuration().headers = headers;
			dup.configuration().acceptGzip = true;
			return dup;
		}
	}

 

결국 이 코덱이 추가됨에 따라 gzip으로 압축된 응답을 디코딩을 해줄 수 있다.

ChannelPipeline pipeline = ch.pipeline();

if (acceptGzip) {
	pipeline.addLast(NettyPipeline.HttpDecompressor, new HttpContentDecompressor());
}

 

4. 해결방법

compress 옵션을 true 로 해주면 문제는 쉽게 해결된다.

new ReactorClientHttpConnector(reactorResourceFactory, defaultHttpClient -> {
  ConnectionProvider connectionProvider = ConnectionProvider.builder("ybs")
    .maxConnections(500)
    .pendingAcquireMaxCount(1000)
    .maxIdleTime(Duration.ofMillis(maxIdleTimeoutMillis))
    .maxLifeTime(Duration.ofMillis(maxLifeTimeoutMillis))
    .build();

  return HttpClient.create(connectionProvider)
    .compress(true) // 추가!!
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectionTimeoutMillis)
    .doOnConnected(connection ->
      connection.addHandlerLast(
        new ReadTimeoutHandler(readTimeoutMillis, TimeUnit.MILLISECONDS)
      )
    );
  });

 

 

하지만 defaultHttpClient 를 사용하지 않고 새롭게 HttpClient 를 만드는건 리스크가 있을 수 밖에 없다. compress 옵션은 어쩔 수 없지만 ConnectionProvider 만큼은 defaultHttpClient 에 있는 기본 설정들을 이어받고 원하는 것만 바꾸고 싶었다.

 

지금이야 디버깅해서 옵션들을 비교하고 맞춰주면 되지만 reactor-netty 가 버전업을 해서 새로운 옵션들이 추가 되거나 삭제될 때 우리쪽에서 같이 맞춰가는건 불가능하기 때문이다. 

 

그래서 defaultHttpClient 를 이용하기 위한 방법을 찾던중 아래와 같이 mutate api 를 제공하는것을 발견했다. 그런데 해당 api 는 항상 null 을 리턴하게 되어있었다.

return new ReactorClientHttpConnector(reactorResourceFactory, defaultHttpClient -> {
    // builder 가 항상 null !!
    Builder builder = defaultHttpClient.configuration().connectionProvider().mutate();
  }
}

 

구현체인 HttpConnectionProvider 가 mutate 를 오버라이딩하지 않아서 ConnectionProvider mutate 가 사용되기 때문이다.

@FunctionalInterface
public interface ConnectionProvider extends Disposable {
	@Nullable
	default Builder mutate() {
		return null;
	}
}

 

그래서 reactor-netty 에 해당 이슈를 제기했다. 나는 HttpConnectionProvider 에서 mutate 를 오버라이딩하자는 의견을 냈지만 reactor-netty 메인테이너는 다른 방식으로 해결했다. 1.0.24 버전부터는 mutate 를 활용 할 수 있게 됐다.

 

 

반응형