사이드 프로젝트를 진행하면서 batch 서버에서 외부 api와 연동에 실패하였을 때 첫 번째 실패의 경우는 1-2초 간의 간격을 두고 재시도를 하는 요건을 받았다.
이에 대한 해결을 retryWhen을 사용하여 요건을 해결하였다.
공식문서 : https://projectreactor.io/docs/core/release/api/
retryWhen 말고 retry를 이용하여도 재시도를 할 수가 있다. 그러나 retry의 경우는 retry(최대재시도횟수) 처럼 단순하게 사용이 가능하다.
그러나 요건상 연동 실패 후 재시도를 할때 일정 시간 간격을 두고 재시도를 하기 위해서는 다른 메소드를 사용해야 했고 그것이 retryWhen이다.
public final Flux<T> retryWhen(Retry retrySpec)
위는 retryWhen의 공식문서 spec이다. retryWhen은 파라미터로 retrySpec을 넘겨 실제 사용하고자 하는 방식대로 사용이 가능하다.
대표적으로 max, fixedDelay, backoff 같은 것들이 있으며 아래 설명을 달았다.
retryWhen(Retry.max(n))
retryWhen(Retry.fixedDelay(n,t))
retryWhen(Retry.backoff(n,t))
더 많은 종류 및 설명은 공식 문서를 참고하도록 하자.
외부 api와의 연동 실패를 통한 재시도 테스트는 현실적으로 하기 쉽지 않다. 테스트를 위해 mockWebServer를 사용하여 테스트를 하였다. 아래는 실제 테스트를 한 코드이다.
@Test
public void givenNormalCode_whenRetryWhen_thenSuccess() throws IOException {
String expectedBody = "Hi";
MockWebServer mockWebServer = new MockWebServer();
mockWebServer.enqueue(new MockResponse().setResponseCode(500));
mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(expectedBody));
mockWebServer.start();
HttpUrl testUrl = mockWebServer.url("/test");
WebClient webClient = WebClient.create();
Mono<String> testMono = webClient.get()
.uri(testUrl.toString())
.retrieve()
.bodyToFlux(String.class)
.log()
.next()
.timeout(Duration.ofMillis(3000))
.doOnError(error -> log.error("error has occurred: {}", error.getMessage()))
.retryWhen(Retry.fixedDelay(1, Duration.ofSeconds(2))
.doBeforeRetry(before -> log.info("retried at {}, RetrySignal info: {}", LocalTime.now(), before.toString())))
.onErrorResume(error -> Mono.empty());
StepVerifier.create(testMono)
.expectNext(expectedBody)
.verifyComplete();
}
위의 코드에서 retryWhen의 위치가 왜 저기에 위치해 있는지 설명하기 위해 retry 시도 흐름에 대해 설명해보겠다.
위의 fixedDelay 문서 그림을 가져와 설명해보겠다.
1. 메소드 체이닝의 차례대로 스트림을 하나씩 처리를 한다.
2. 처리된 스트림은 retryWhen을 거쳐서 에러 여부를 체크한다.
3. 에러가 발생하면 subscribe() 호출하여 구독을 함으로써 해당 스트림을 재구독한다.
4. 최대 재시도 횟수 안에 정상 처리가 된다면 스트림은 그대로 이어갈 것이며 만약 최대 재시도 횟수가 지났다면 retryWhen은 재구독을 하지 않고 스트림을 그대로 넘길것이다.
실제 코드 작성 시 트러블 슈팅 내용
실제 코드를 작성하면서 retryWhen의 사용 위치 때문에 조금 시간을 썼다. 처음에는 retryWhen을 timeout위에 위치를 시켰는데 그랬더니 timeout이 각 시도 횟수별로 제한되는 것이 아니라 첫시도, 재시도 모두에 전역으로 타임아웃이 먹혀있었다. 위치를 timeout아래에 위치시킴으로써 횟수별로 timeout이 각각 먹히도록 할 수 있었다.
두번째로 doOnError위에 retryWhen을 위치 시켰더니 에러가 났을 경우 doOnError를 안타고 바로 다시 구독을 함으로써 에러 로깅이 처리 되지 않는 문제가 생겼다. 이를 해결하기 위해 onErrorResume이 처리된 이후에 retry가 호출하여 에러 로깅 후 재시도를 하게끔 하였다.
추가로 말하자면 onErrorResume 아래에 retryWhen이 위치할 경우에는 에러가 발생하여 retry를 하기 전에 onErrorResume이 처리가 되어 empty 결과로 반환하기 때문에 retry 시도조차 하지 않는다. 그러므로 결과값 리턴 전에는 retry 시도할 수 있도록 onErrorResume 위에 위치시켰다.