본문 바로가기
Spring

WebClient 사용할때 주의 (1편)

by ybs 2021. 1. 18.
반응형

헤더 누적

미리 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 를 리턴하도록 만들었다.

 

참고 : docs.spring.io/spring-framework/docs/5.3.0-SNAPSHOT/spring-framework-reference/web-reactive.html#webflux-client-exchange

반응형

'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