Spring WebClient, 제대로 사용하기 - retrieve

2022. 8. 24. 23:53Spring

반응형

Spring WebClient의 retrieve를 사용한 요청 방법과 Exception Handling 방법을 알아보고, 테스트해보는 것이 본 포스팅의 목표입니다.

 

| 이어지는 포스팅  |

#1. WebClient 소개                                 : Spring WebClient, 어렵지 않게 사용하기

#2. WebClient.retrieve() 통신 방법    :현재 포스팅 

#3.WebClient.exchange() 통신 방법 : Spring WebClient, 제대로 사용하기 - exchange

 

Reactive Programming의 전반적인 흐름 및 개념은 Reactive Programming, 제대로 이해하기를 참고해주세요.

 


 

안녕하세요.

지난 포스팅 "Spring WebClient, 어렵지 않게 사용하기"에 이어 이번 포스팅에서는

retrieve를 사용한 WebClient ErrorHandling을 하는 방법을 알아보겠습니다.

exchange를 이용한 방식은 다음 포스팅에서 소개합니다.

 

WebClient를 사용하다가 예외처리를 어떻게 잘할 수 있을지 고민을 많이 하게 됐는데요.

이것저것 해보면서 다듬은 실제 사용 코드를 소개하고자 합니다.

 

지난 포스팅에도 다뤘다시피, WebClient에서 응답을 받을 때에는 아래의 두 가지 메소드 중 하나를 선택해서 사용하시면 됩니다.

 

- retrieve() : ResponseEntity를 받아 디코딩

- exchange() : ClientResponse를 상태값 그리고 헤더와 함께 

 

받은 응답은 bodyToFlux, bodyToMono 형태로 가져와 각각 Flux와 Mono 객체로 바꿔줍니다.

Mono 객체는 0-1개의 결과를 처리하는 객체이고, Flux는 0-N개의 결과를 처리하는 객체입니다.

 

 

retrieve() 메소드를 사용하는 경우는 내부에 어떤 객체가 담겨있던지 간에, 요청 ResponseEntity를 받아 처리할 수 있습니다. 가령, 4xx, 5xx 오류를 받았을 때, 다른 예외로 감싸 사용자에게 다른 오류로 전환하여 전달할 수 있습니다.

 

실제 사용법에 대해 다루는 이 번 포스팅에서는 retrieve() 를 사용해서 요청하는 테스트를 진행해보았습니다.

먼저, WebClient의 사용법을 보기 전에 사전에 정의한 내용을 먼저 소개하겠습니다.

 

TestController

@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {


	@PostMapping(value = "/200")
	public ResponseEntity<ResResult> res200() {
		return new ResponseEntity<>(new ResResult(false, HttpStatus.OK, "request success"), HttpStatus.OK);
	}

	@PostMapping(value = "/400")
	public ResponseEntity<ResResult> res400(ReqDTO reqDTO) {
        return new ResponseEntity<>(new ResResult(false, HttpStatus.BAD_REQUEST, "invalid request data"), HttpStatus.BAD_REQUEST);
	}


	@PostMapping(value = "/500")
	public ResponseEntity<ResResult> res500() {
		return new ResponseEntity<>(new ResResult(false, HttpStatus.INTERNAL_SERVER_ERROR, "system error."), HttpStatus.INTERNAL_SERVER_ERROR);
	}


	@PostMapping(value = "/timeout")
	public ResponseEntity<ResResult> timeout(ResResult reqDTO) throws InterruptedException {
		Thread.sleep(10_000);
		return new ResponseEntity<>(new ResResult(false, HttpStatus.OK, "request success"), HttpStatus.OK);
	}
}

 

간단한 TestController인데요.

200, 4xx, 5xx 등을 재현하기 위한 간단한 API를 제작했습니다.

 

 

WebClient Configuration

이번엔 WebClient의 적절한 설정을 해주어야 요청을 할 수 있으니, WebClient 객체를 먼저 생성해봅시다.

실제로 사용할 때에는 Bean에 이름을 지정하고 @Qualifier로 명확한 Bean을 가져오도록 설정했습니다.

 

public class WebClientConfiguration {

    public final ObjectMapper OM = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).registerModule(new JavaTimeModule());

    @Bean
    public WebClient commonWebClient(ExchangeStrategies exchangeStrategies, HttpClient httpClient) {
        return WebClient
                .builder()
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
                .exchangeStrategies(exchangeStrategies)
                .build();
    }

    @Bean
    public HttpClient defaultHttpClient(ConnectionProvider provider) {

        return HttpClient.create(provider)
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5_000)
                .doOnConnected(conn ->
                        conn.addHandlerLast(new ReadTimeoutHandler(5)) //읽기시간초과 타임아웃
                        .addHandlerLast(new WriteTimeoutHandler(5)));
    }

    @Bean
    public ConnectionProvider connectionProvider() {

        return ConnectionProvider.builder("http-pool")
                .maxConnections(100)					     // connection pool의 갯수
                .pendingAcquireTimeout(Duration.ofMillis(0)) //커넥션 풀에서 커넥션을 얻기 위해 기다리는 최대 시간
                .pendingAcquireMaxCount(-1) 				//커넥션 풀에서 커넥션을 가져오는 시도 횟수 (-1: no limit)
                .maxIdleTime(Duration.ofMillis(2000L)) 		//커넥션 풀에서 idle 상태의 커넥션을 유지하는 시간
                .build();
    }

    @Bean
    public ExchangeStrategies defaultExchangeStrategies() {

        return ExchangeStrategies.builder().codecs(config -> {
            config.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(OM, MediaType.APPLICATION_JSON));
            config.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(OM, MediaType.APPLICATION_JSON));
            config.defaultCodecs().maxInMemorySize(1024 * 1024); // max buffer 1MB 고정. default: 256 * 1024
        }).build();
    }
}

 

위처럼 정의한 내용으로 webClient 요청 설정을 할 수 있습니다.

각각의 객체를 조립해서 사용하여 의존성을 낮추고자 했습니다.

이는 다른 설정 값으로 갈아끼우거나 테스트를 할 때에도 용이합니다.

 

 

BadWebClientRequestException

:: Custom Exception

따로 예외 처리할 때 커스텀하는 게 편해서 따로 제작했습니다.

이것 저것 예외처리를 하려고 하니, 기존 Exception 객체로는 한계가 보여서 커스텀 제작했습니다.

 

@Getter
public class BadWebClientRequestException extends RuntimeException {
	private static final long serialVersionUID = -2113106875266819123L;

	private final int statusCode;

	private String statusText;

	public BadWebClientRequestException(int statusCode) {
		super();
		this.statusCode = statusCode;
	}

	public BadWebClientRequestException(int statusCode, String msg) {
		super(msg);
		this.statusCode = statusCode;
	}

	public BadWebClientRequestException(int statusCode, String msg, String statusText) {
		super(msg);
		this.statusCode = statusCode;
		this.statusText = statusText;
	}
}

 

 

 

WebClient Request

테스트를 통해 retrieve의 사용법을 소개하고자 테스트 코드를 작성했습니다.

테스트 내용에 대해 정리하면 다음과 같습니다.

 

status2xx : 기본적인 사용법. 요청 후 성공을 리턴받음

 

기본적인 사용법은 위에서 봤으니, 이제 예외 처리는 어떻게 해야하는지 알아봐야겠죠?

 

status4xx : 클라이언트 요청 오류. 4xx번대의 status code를 받는다면 어떻게 처리할지를 고려합니다. 

status5xx : 요청하고자하는 서버에 문제가 있을 수도 있겠죠. 5xx번대의 status code를 받을 때의 처리를 어떻게 할지 고려합니다.

 

 

 

📌 retrievePostForMono

 

public class RequestRetrieveTest {
	// ...
    
	private Mono<ResponseEntity<ResDTO>> retrievePostForMono(String uri, MultiValueMap<String, String> body) throws WebClientResponseException {
            return webClient
                .post()
                .uri(uri)
                .bodyValue(body)
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, response ->
                    Mono.error(
                        new BadWebClientRequestException(
                            response.rawStatusCode(),
                            String.format("4xx 외부 요청 오류. statusCode: %s, response: %s, header: %s",
                                response.rawStatusCode(),
                                response.bodyToMono(String.class),
                                response.headers().asHttpHeaders())
                        )
                    )
                )
                .onStatus(HttpStatus::is5xxServerError, response ->
                    Mono.error(
                        new WebClientResponseException(
                            response.rawStatusCode(),
                            String.format("5xx 외부 시스템 오류. %s", response.bodyToMono(String.class)),
                            response.headers().asHttpHeaders(), null, null
                        )
                    )
                ).toEntity(ResResult.class);
    }
}

 

위와 같은 공통 요청 메소드인 retrievePostForMono를 작성했습니다.

대표적으로 post를 사용했는데요. 

 

GET 요청이나 다른 요청 메소드를 사용하고 싶다면 post() 부분만 수정하면 됩니다.

단, get() 을 사용할 경우에는 bodyValue() 당연히 사용할 수 없습니다.

 

위의 메소드는 제가 개발하면서 필요한 내용을 간단히 사용한 내용이라, 필요하신 부분은 수정하거나 추가하시면 됩니다. 

가령, 마지막의 toEntity와 같은 경우에는 ResponseEntity<ResDTO>를 가져오는데, 만약 객체를 직접 받고 싶다면 .bodyToMono(ResDTO)  혹은 .bodyToFlux(ResDTO)  와 같은 경우가 있습니다.

 

400대 예외와 500대 예외 발생 시 테스트를 위한 자세한 로그를 남기기 위해 Exception 객체 내에 많은 정보를 담았으며, 상황에 맞게 객체를 만들어 밖으로 던지시면 됩니다.

 

 

 

📌  Status Code 200 

Blocking

String URI = "/test/200";
MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();

// blocking
ResponseEntity resDTO = this.retrievePostForMono(URI, requestBody).block();

 

Non-Blocking

String URI = "/test/200";
MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();

// non-blocking
this.retrievePostForMono(URI, requestBody)
    .subscribe(resEntity -> {
    	// your logic
    });

 

Non-Blocking Test Code

@Test
public void exchange_NonBlock_200() {
    String URI = HOST + "/test/200";
    MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();

    StepVerifier.create(this.retrievePostForMono(URI, requestBody))
        .expectNextMatches(response -> Objects.equals(response.getBody(), ResResult.success("Request Success")))
        .verifyComplete();
}

 

 

다음과 같이 성공하는 것을 확인할 수 있습니다.

 

 

 

📌 Status Code 4xx

@Test
public void exchange_NonBlock_400() {
    String URI = HOST + "/test/400";
    MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();
    requestBody.add("test", "body");
    
    StepVerifier.create(this.retrievePostForMono(URI, requestBody))
        .expectErrorMatches(response ->
            response instanceof BadWebClientRequestException && response.getMessage().contains("4xx 외부 요청 오류"))
        .verify();
}

 

이번엔 4xx 대 에러를 확인해보겠습니다.

Body는 혹시 필요한 분이 있을 것 같아 예시 용으로 추가했습니다.

 

위에서 BadWebClientRequestException은 직접 생성한 CustomException입니다.

400 에러가 뜨게 되면  retrievePostForMono 메소드의 .onStatus(HttpStatus::is4xxClientError, response -> {...}) 메소드에 걸려 Mono.error를 반환하게끔 제작했고, Mono 내의 객체를 block으로 가져오면서 예외를 캐치하여 예외를 핸들링합니다.

 

 

빨간 줄의 Log Level - Error가 잘출력되었고, Assertions으로 테스트 검증을 성공적으로 마쳤습니다.

 

 

 

📌 Status Code 5xx

비슷하게 500대 에러를 발생시켜 에러 핸들링을 했습니다.

 

@Test
public void exchange_NonBlock_500() {
    String URI = HOST + "/test/500";
    MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();

    StepVerifier.create(this.retrievePostForMono(URI, requestBody))
        .expectErrorMatches(response ->
            response instanceof WebClientResponseException && response.getMessage().contains("5xx 외부 시스템 오류"))
        .verify();
}

 

WebClientResponseException이 정의되어 있길래 사용했습니다.

5xx대 에러는 호출 대상인 시스템에 문제가 발생한 경우입니다.

 

500 에러가 뜨게 되면  retrievePostForMono 메소드의 .onStatus(HttpStatus::is5xxServerError, response -> {...}) 메소드에 걸려 Mono.error를 반환하게끔 제작했고, Mono 내의 객체를 block으로 가져오면서 예외를 캐치하여 예외를 핸들링합니다.

 

 

📌 WebClient Error

이번엔 요청 시 StatusCode로 분기되는 예외가 아닌 WebClient 자체에서 발생하는 예외를 핸들링해보겠습니다.

 

@Test
public void exchange_NonBlock_Timeout() {
    String URI = HOST + "/test/timeout";
    MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();

    StepVerifier.create(this.retrievePostForMono(URI, requestBody))
        .expectError(WebClientRequestException.class)
        .verify();
}

 

 

WebClient 예외로는 타임아웃이나 가져오는 buffer 사이즈를 넘은 경우 등이 있습니다.

테스트로는 Timeout을 걸어봤습니다.

 

WebClient Configuration을 보시면 Connection Timeout을 5초로 잡아두었는데요.

/test/timeout으로 요청을 보내면 Thread.sleep으로 10초를 쉬게 됩니다.

그럼 요청 중 WebClient가 연결을 끊어내고 예외를 터뜨리겠죠.

 

 

이번에도 빨간 줄의 Log Level - Error가 잘 출력되었고,

Assertions으로 "ReadTimeoutException"이 포함된 메세지인지를 검증하는 테스트를 성공적으로 마쳤습니다.

 

 

Full Test Code

실제 테스트한 전체 코드는 아래 링크에서 확인할 수 있습니다.

🔗 Github Link

 

 

 

 

그럼 지금까지 WebClient의 사용법과 예외처리에 대해 알아보았습니다.

오타나 잘못된 내용을 댓글로 남겨주세요!

감사합니다 ☺️ 

 

반응형

Backend Software Engineer

Gyeongsun Park