WebClient 사용할때 주의 (8편)
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 를 활용 할 수 있게 됐다.