헤더 누적
미리 webClient 를 생성해놓고 필요할 때마다 재사용을 할 때
this.webClient = WebClient.builder()
.clientConnector(connector)
.baseUrl("https://yangbongsoo.tistory.com")
.build()
.post();
사용하고자 하는 쪽에서 아래와 같이 header 메서드를 쓴다면 reqest header 가 계속 누적되어 append 되는 문제가 발생한다.
this.webClient
.uri(uriBuilder -> uriBuilder
.path("/api")
.queryParam("txId", route.getId() + "-" + System.currentTimeMillis())
.build()
)
.header("ybs", "123") // 주의 !!
.contentType(APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromValue("body"))
.retrieve()
.toEntity(String.class)
.doOnError(result -> {
logger.warn(result.getMessage());
});
cf) 만약 3번 호출했다면 하나씩 쌓여, 최종 request header 는 아래와 같다.
ybs: 123
ybs: 123
ybs: 123
문제 해결은 간단하다. 고정된 헤더값을 보내기 위해서는 미리 webClient 를 생성할 때 defaultHeader 를 써서 넣어주면 된다.
this.webClient = WebClient.builder()
.clientConnector(connector)
.baseUrl("https://yangbongsoo.tistory.com")
.defaultHeader("ybs", "123")
.build()
.post();
하지만 구체적으로 누적 문제가 왜 발생했는지 알아보자.
public RequestBodyUriSpec post() {
return this.methodInternal(HttpMethod.POST);
}
private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) {
return new DefaultWebClient.DefaultRequestBodyUriSpec(httpMethod);
}
아까 webClient를 미리 생성할 때 post 메서드(get 메서드도 마찬가지)가 있었는데 내부적으로 새롭게 DefaultRequestBodyUriSpec 클래스를 생성한다. 그리고 DefaultRequestBodyUriSpec 의 멤버 변수로 HttpHeaders 객체가 있기 때문에 webClient 생성을 build() 까지만 하고 실제 사용하는 곳에서 post 나 get 메서드를 사용하면, 요청 때마다 새로운 DefaultRequestBodyUriSpec 객체를 생성해서 HttpHeaders 객체도 항상 새로운거기 때문에 문제가 없다.
하지만 post 나 get 까지 미리 만들어 놓는다면 동일한 DefaultRequestBodyUriSpec 객체를 사용하게 되고, 아래 코드에서 getHeaders() 할 때 같은 HttpHeaders 객체가 나와 계속 add가 되면서 쌓인다.
@Override
public DefaultRequestBodyUriSpec header(String headerName, String... headerValues) {
for (String headerValue : headerValues) {
getHeaders().add(headerName, headerValue);
}
return this;
}
private HttpHeaders getHeaders() {
if (this.headers == null) {
this.headers = new HttpHeaders();
}
return this.headers;
}
exchange 를 쓸때
1) memory leak
exchange 메서드는 retrieve 보다 세밀한 컨트롤이 가능한 대신 memory leak 을 주의 해야 한다.
아래의 코드를 한번 보자.
// 문제가 있는 코드
.exchange()
.flatMap(res -> res.toEntity(String.class))
.doOnSuccess(
result -> {
if (result.getStatusCode() == HttpStatus.OK) {
String body = result.getBody();
doProcess(body);
} else {
// empty
}
});
api 요청을 보내고 응답이 200 OK 인 경우만 body를 읽어서 처리 한다.
하지만 200 상태코드 외에도 얼마든지 body 정보는 넘어 올 수 있다.
만약 응답 body 를 소비(consume) 하지 않으면 memory leak 이나 connection leak 이 발생할 수 있다.
스프링 메인테이너는 .retrieve().toEntity(String.class) 를 쓰라고 하는데 4xx-5xx 상태코드일 때 response body content를 포함하는 WebClientResponseException 로 전환시켜주기 때문.
https://github.com/reactor/reactor-netty/issues/1401#issuecomment-736393872
cf) WebClient connector 구현체로 다른것들이 있지만 reactor-netty 를 기준으로 설명했다. 그리고 현재는 exchange 가 deprecated 됐다. 추가적인 설명은 https://yangbongsoo.tistory.com/29 에 정리했다.
2) retry / 예외처리
먼저 retry 하는 로직은 다음과 같다. api 응답이 200 이 아닐 경우 한번 더 보내도록 해놨다.
.exchange()
.flatMap(res -> res.toEntity(String.class))
.doOnNext(result -> {
if (result.getStatusCode() != HttpStatus.OK) {
throw new RequestFailException();
}
})
.retryWhen(Retry.any().fixedBackoff(Duration.ofMillis(500)).retryMax(1)
.doOnRetry((context) -> {
logger.info("just for log : ", context.exception());
}))
.doOnSuccess(
result -> {
String resultBody = result.getBody();
if (result.getStatusCode() == HttpStatus.OK) {
boolean flag = hasApiData(resultBody);
if (flag) {
doSuccessProcess(); // 성공
} else {
// 비지니스 로직 상 실패
}
} else {
// 응답은 제대로 왔지만 응답코드가 성공이 아닌것
}
})
.onErrorResume(error -> {
// 네트워크 실패
});
그 다음 api 결과를 받고 성공이 아닐 때 예외처리를 어떻게 할 것인가가 문제다.
webClient 는 non blocking IO 라서 예외를 throw 하고 호출 부분에서 try-catch 하는게 불가능하다.
좀 더 자세히 설명하면, 아래와 같이 apiCall 메서드에서 webClient를 실행시켜도 결과를 기다리지 않고 메서드를 끝낸다. 그러므로 try-catch 에서 잡힐 수가 없다.
public task() {
try {
Mono<ResponseEntity<String>> result = apiCall(this.webClientBuilder);
} catch (Exception e) {
System.out.println(e);
}
}
public Mono<ResponseEntity<String>> apiCall(WebClient.Builder webClientBuilder) {
return
webClientBuilder
.build()
/// 디테일한 설정 생략
.exchange()
.flatMap(res -> res.toEntity(String.class))
.doOnNext(result -> {
if (result.getStatusCode() != HttpStatus.OK) {
throw new RequestFailException();
}
})
.retryWhen(Retry.any().fixedBackoff(Duration.ofMillis(500)).retryMax(1)
.doOnRetry((context) -> {
logger.info("just for log : ", context.exception());
}))
.doOnSuccess(
result -> {
String resultBody = result.getBody();
if (result.getStatusCode() == HttpStatus.OK) {
boolean flag = hasApiData(resultBody);
if (flag) {
doSuccessProcess(); // 성공
} else {
throw new ApiResultFailException();
}
} else {
throw new ApiResultFailException();
}
})
.onErrorResume(error -> {
throw new ApiResultFailException();
});
}
나는 이 예외처리 해결은 Spring Cloud Gateway 특수성을 이용했다.
Spring Cloud Gateway 는 filter chain 방식으로 동작하는데 예외가 발생할 경우 속성값을 추가해준 다음에 다음 filter 에서 처리하도록 했다(then 을 썼기 때문에 다음 filter 에서는 apiCall 결과가 있다는게 보장된다).
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
Mono<ResponseEntity<String>> result = apiCall(exchange, this.webClientBuilder);
return result.then(chain.filter(exchange));
}
public Mono<ResponseEntity<String>> apiCall(ServerWebExchange exchange, WebClient.Builder webClientBuilder) {
return
webClientBuilder
.build()
/// 디테일한 설정 생략
.exchange()
.flatMap(res -> res.toEntity(String.class))
.doOnNext(result -> {
if (result.getStatusCode() != HttpStatus.OK) {
throw new RequestFailException();
}
})
.retryWhen(Retry.any().fixedBackoff(Duration.ofMillis(500)).retryMax(1)
.doOnRetry((context) -> {
logger.info("just for log : ", context.exception());
}))
.doOnSuccess(
result -> {
String resultBody = result.getBody();
if (result.getStatusCode() == HttpStatus.OK) {
boolean flag = hasApiData(resultBody);
if (flag) {
doSuccessProcess(); // 성공
exchange.getAttributes().put("API_SUCCESS", true);
} else {
exchange.getAttributes().put("API_SUCCESS", false);
}
} else {
exchange.getAttributes().put("API_SUCCESS", false);
}
})
.onErrorResume(error -> {
exchange.getAttributes().put("API_SUCCESS", false);
return just(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR));
});
}
다음 필터에서는 API_SUCCESS 값이 false 이면 Mono.error 를 리턴하도록 만들었다.
'Spring' 카테고리의 다른 글
Spring Web MVC 구조 논의 1편 (0) | 2021.06.06 |
---|---|
Spring Cloud Gateway CORS 주의사항 (4) | 2021.01.24 |
useInsecureTrustManager 옵션 (0) | 2021.01.20 |
request body memory leak (0) | 2021.01.20 |
개발 이슈 모음 (0) | 2021.01.19 |