본문 바로가기
Baeldung번역&공부/Spring-Reactive

WebFlux-Retry(Guide to Retry in Spring WebFlux)

by ms727 2025. 2. 28.

원본 글: https://www.baeldung.com/spring-webflux-retry

클라우드 환경에서 분산 애플리케이션을 구축할때, 서로의 통신이 실패했을때의 방안으로 Retry(재시도)기법을 보통 사용합니다.
Spring WebFlux에서는 이러한 기법을 사용할 수 있는 몇 가지 툴들을 제공하고 있습니다.
이 글에서는 WebFlux 애플리케이션에서 어떻게 재시도 관련 설정을 추가하는지 알아봅니다.

1. Use Case

예를 들어서 임시로 서비스가 불가능했다가 다시 정상으로 돌아오는 경우를 생각해봅시다.

@Test
void givenExternalServiceReturnsError_whenGettingData_thenRetryAndReturnResponse() {

    mockExternalService.enqueue(new MockResponse()
      .setResponseCode(SERVICE_UNAVAILABLE.code()));
    mockExternalService.enqueue(new MockResponse()
      .setResponseCode(SERVICE_UNAVAILABLE.code()));
    mockExternalService.enqueue(new MockResponse()
      .setResponseCode(SERVICE_UNAVAILABLE.code()));
    mockExternalService.enqueue(new MockResponse()
      .setBody("stock data"));

    StepVerifier.create(externalConnector.getData("ABC"))
      .expectNextMatches(response -> response.equals("stock data"))
      .verifyComplete();

    verifyNumberOfGetRequests(4);
}

코드 구현은 중요하지 않습니다. 3번의 SERVICE_UNAVAILABLE을 받고 4번째에는 정상 응답을 받았다고 가정하는 테스트 코드입니다.

2. Adding Retries

2.1 Using retry

재시도 갯수를 설정한 retry 함수를 통하여 애플리케이션이 바로 error를 반환하는 것을 방지합니다.

public Mono<String> getData(String stockId) {
    return webClient.get()
        .uri(PATH_BY_ID, stockId)
        .retrieve()
        .bodyToMono(String.class)
        .retry(3);
}

해당 코드에서는 3번의 재시도를 진행하게 되어있습니다.

2.2 Using retryWhen

public Mono<String> getData(String stockId) {
    return webClient.get()
        .uri(PATH_BY_ID, stockId)
        .retrieve()
        .bodyToMono(String.class)
        .retryWhen(Retry.max(3));
}

retryWhen() 함수는 위의 retry() 함수보다는 재시도 간격 설정, 조건 등 상세한 설정을 할 수 있게 합니다.

두 방식 모두 재시도 요청은 가능한한 빨리 이뤄집니다.

3. Adding Delay

위의 재시도 기법의 단점은 서비스가 복구할 시간도 없이 바로바로 재시도 요청을 보낸다는점입니다.

3.1 Retrying with fixedDelay

fixedDelay 를 추가하여 재시도 간격에 지연시간을 추가할 수 있습니다.

public Mono<String> getData(String stockId) {
    return webClient.get()
      .uri(PATH_BY_ID, stockId)
      .retrieve()
      .bodyToMono(String.class)
      .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(2)));
}

이 옵션은 재시도할 때마다 2초의 딜레이를 줘서 문제를 해결하고자 합니다.
이 방식의 문제는 몇 초를 지정해줘야하는가? 에 대한 의문이 생기게끔 합니다. 만약 서비스 복구가 오래걸리면 값을 늘려야겠지만 특정 부분에 있어서는 값을 줄여야합니다. 이걸 사전에 알 수가 없을 가능성이 높기에 시간 단위를 고정해서 fix하는건 한계가 있습니다.

3.2 Retrying with backoff

backoff전략을 줘서 재시도 간격을 줄 수 있습니다.
backoff전략은 간단하게 말해서 특정 연산을 통해서 계속해서 값을 갱신하여 이를 전달하는 전략이라고 보시면 됩니다.
예를 들어서 backoff = backoff * 1.2이런식의 연산을 재시도 때마다 반복해 재시도 딜레이시간을 1.2배씩 늘리는 연산 같은겁니다.

public Mono<String> getData(String stockId) {
    return webClient.get()
      .uri(PATH_BY_ID, stockId)
      .retrieve()
      .bodyToMono(String.class)
      .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)));
}

이런식으로 backoff를 설정해줘서 서비스가 복구할 시간을 늘려줍니다. 재시도 간격이 점차적으로 늘어나기 때문입니다.

3.3 Retrying with jitter

jitter는 backoff전략 + 랜덤성 부여 라고 생각하시면 됩니다.
backoff전략을 사용하면 한 번에 여러 클라이언트가 요청시 서버의 부하가 들 수 있는데, jitter를 통하여 재시도 간격에 랜덤값을 추가합니다. 예를들어서 client1은 2^n초마다 요청 보내면 client2는 (2^n)+ random 값 만큼 요청을 지연시켜 보낼 수 있게 하여 부하를 줄입니다.

public Mono<String> getData(String stockId) {
    return webClient.get()
      .uri(PATH_BY_ID, stockId)
      .accept(MediaType.APPLICATION_JSON)
      .retrieve()
      .bodyToMono(String.class)
      .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)).jitter(0.75));
}

소수점은 랜덤값 비율입니다. 기본값은 0.5(최대 50%의 랜덤변동)이고 최대 1(최대 100%의 랜덤변동)입니다.

4. Filtering Errors

만약 특정 요청이 400: BadRequest나 401:Unauthorized를 반환한다고 하면 어떨까요?
이 응답에 대해서는 재시도 요청을 보낼 이유가 없습니다. 어차피 똑같은 응답을 받을거기 때문입니다.
때문에 필터를 통해서 이러한 요청을 재시도 전략에서 제외시켜줘야합니다.

public class ServiceException extends RuntimeException {

    public ServiceException(String message, int statusCode) {
        super(message);
        this.statusCode = statusCode;
    }
}

public Mono<String> getData(String stockId) {
    return webClient.get()
      .uri(PATH_BY_ID, stockId)
      .retrieve()
      .onStatus(HttpStatus::is5xxServerError, 
          response -> Mono.error(new ServiceException("Server error", response.rawStatusCode())))
      .bodyToMono(String.class)
      .retryWhen(Retry.backoff(3, Duration.ofSeconds(5))
          .filter(throwable -> throwable instanceof ServiceException));
}

위 코드는 5xx 에러가 발생한 경우에만 filter() 함수를 통해 재시도 요청을 하도록 지정합니다.

5. Handling Exhausted Retries

Retry 횟수를 모두 소진하면 Exhausted Retries 에러를 발생시킵니다. 이 에러를 클라이언트에 그대로 보여주는건 별로 좋은 전략은 아닙니다. 때문에 onRetryExhaustedThrow() 함수를 오버라이드해서 ServiceException을 발생시키도록 해봅니다.

public Mono<String> getData(String stockId) {
    return webClient.get()
      .uri(PATH_BY_ID, stockId)
      .retrieve()
      .onStatus(HttpStatus::is5xxServerError, response -> Mono.error(new ServiceException("Server error", response.rawStatusCode())))
      .bodyToMono(String.class)
      .retryWhen(Retry.backoff(3, Duration.ofSeconds(5))
          .filter(throwable -> throwable instanceof ServiceException)
          .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> {
              throw new ServiceException("External Service failed to process after max retries", HttpStatus.SERVICE_UNAVAILABLE.value());
          }));
}

이렇게 하면 retry 횟수를 모두 소진할 경우 ServiceException 에러를 발생시킵니다.

6. 결론

Spring WebFlux환경에서 retry, retryWhen을 통하여 재시도 전략을 취하는 방법에 대해 확인하였습니다. 또한 재시도 간격이나 재시도 횟수를 모두 소진했을때의 전략도 확인하였습니다.