Feign client 의 retry

연어·2024년 10월 15일
0

dev

목록 보기
1/6

서비스에서 Feign client 를 통해 요청하는 다른 API 서비스가 일시적인 서버 상의 문제로 정상 응답을 주지 못할 때, 응답을 받기 위한 재시도를 수행할 수 있도록 한다.

목적

market-api 에서 store-api 로 가게 정보를 요청한다고 가정해 보자.
store-api 로 가게 정보 조회 시에 아래와 같은 에러가 발생한 경우이다.

  • Caused by: org.apache.http.NoHttpResponseException: store-api.store:80 failed to respond

NoHttpResponseException 이 발생한 원인으로는
store-api 의 불안정한 서버 상태로 일시적인 통신 문제가 발생했을 가능성이 있기 때문에
재시도를 수행해 보고, 최대 재시도 횟수를 넘기면 클라이언트에서 에러를 처리할 수 있도록 하고자 한다.

물론 에러가 발생한 store-api 의 문제 해결도 필요하지만 클라이언트에서 이러한 상황이 발생했을 경우 어떻게 핸들링할지를 목적으로 한다!

Open Feign Retryer

Spring Cloud Open Feign Document
Feign 은 기본적으로 Retryer.NEVER_RETRY 상태의 Bean 이 생성되어 Retry 를 시도하지 않기 때문에 Retry 를 시키려면 추가적인 설정이 필요하다.
Feign 이 제공하는 Retryer 은 IOException 이 발생한 경우에만 처리되어 일시적인 네트워크 관련 예외로 처리하고 ErrorDecoder 에서 throw 된 모든 RetryableException 을 처리한다.

아래와 같이 정의해 보았다.

  • 재시도는 아래의 경우 100ms 간격으로 5번 재시도한다.
    • NoHttpResponseException 이 발생
    • 503 httpStatus

재시도가 필요한 경우는 주로 일시적이고 간헐적인 장애로 인해 요청이 실패했을 때이며, 이러한 장애가 해결될 가능성이 있는 상황에서 유용하다.
그러나 서버가 장기적으로 문제가 있거나 요청이 항상 실패하는 상황에서는 재시도가 오히려 부하를 가중시키고, 문제를 해결하지 못할 수 있기 때문에 이러한 상황에서는 다른 적절한 대응 전략을 고려하는 것이 중요하다.

FeignRetryConfiguration (Retryer)

Retryer 인터페이스는 Feign 클라이언트에서 요청 재시도 로직을 정의하는 데 사용된다.

기본적으로 Retryer.Default라는 기본 구현을 제공하는데, 이 기본 구현은 재시도를 하지 않고 예외를 바로 전파한다. 이 기본 구현을 토대로 커스텀 구현을 통해 재시도 횟수와 대기 시간을 설정할 수 있다.

Retryer 의 continueOrPropagate(RetryableException e) 는 재시도를 수행할지, 예외를 전파할지 결정한다. RetryableException 이 인자로 전달되기 때문에 Feign 클라이언트에서 그 외의 Exception 이 발생할 경우 continueOrPropagate() 를 타지 않는다.

@Slf4j
public class FeignRetryConfiguration {
    @Bean
    public Retryer retryer() {
        return new CustomRetryer();
    }
    public static class CustomRetryer implements Retryer {
        private final int maxAttempts;
        private final long backoff;
        private int attempt;
        public CustomRetryer() {
            this(100, 5);// 100ms씩 5번 시도
        }
        public CustomRetryer(long backoff, int maxAttempts) {
            this.backoff = backoff;
            this.maxAttempts = maxAttempts;
            this.attempt = 1;
        }
        @Override
        public void continueOrPropagate(RetryableException e) {
            if (attempt++ >= maxAttempts) {
                log.error("Retry failed after {} attempts to {}", maxAttempts, e.request().httpMethod() + " " + e.request().url());
                throw e;
            }
            log.info("Retry attempt #{} after exception: {}, request: {} ", attempt, e.getMessage(), e.request().httpMethod() + " " + e.request().url());
            try {
                TimeUnit.MILLISECONDS.sleep(backoff);
            } catch (InterruptedException ignored) {
                Thread.currentThread().interrupt();
            }
        }
        @Override
        public Retryer clone() {
            return new CustomRetryer(backoff, maxAttempts);
        }
    }

FeignExceptionErrorDecoder (ErrorDecoder)

ErrorDecoder는 Feign 클라이언트 라이브러리에서 사용하는 인터페이스로, HTTP 응답에서 오류가 발생했을 때 해당 오류를 해석하여 예외를 생성하는 역할을 한다.

Feign 클라이언트 호출이 실패할 때 (에러 코드 4xx, 5xx 반환 시) decode() 가 호출되고, 별도로 커스텀 하지 않을 경우 기본 구현에 따라 Feign 클라이언트에서 반환한 Exception 을 반환한다.

문제가 되었던 store-api 에서 FeignException 이 발생했을 때
발생한 exception 이 NoHttpResponseException 이거나
HttpStatus 가 SERVICE_UNAVAILABLE(503) 일 경우
Retry 를 수행할 수 있도록 FeignExceptionErrorDecoder 를 추가해보자.

public class RetryableExceptionErrorDecoder implements ErrorDecoder {
    private final ErrorDecoder defaultErrorDecoder = new Default();
    @Override
    public Exception decode(String methodKey, Response response) {
        Exception e = defaultErrorDecoder.decode(methodKey, response);
        if(response.status() == 503 || e instanceof NoHttpResponseException) {
            throw new RetryableException(
                    response.status(),
                    response.reason(),
                    response.request().httpMethod(),
                    null,
                    response.request()
            );
        }
        return e;
    }
}

FeignClient configuration 설정

Feign 클라이언트의 configuration 설정에 FeignRetryConfiguration, RetryableExceptionErrorDecoder 클래스를 추가한다.

@FeignClient(name = "storeApi", url = "${api.service.store.url}", configuration = {FeignRetryConfiguration.class, RetryableExceptionErrorDecoder.class})
public interface StoreApiClient {
  ...
}

Feign 클라이언트에서 적용할 Configuration class 에 @Configuration 을 붙이고, @SpringBootApplication 또는 @ComponentScan 에서 탐색 가능한 경로에 있다면 모든 Feign 클라이언트에 적용된다.

클라이언트별로 적용되어야 할 경우 @Configuration 을 제거하고 @FeignClient 의 configuration 속성으로 명시할 수 있다. 특정 클라이언트의 독립적인 설정이 되는 만큼 설정한 클라이언트 수 만큼의 중복된 Bean 이 생성된다.

테스트

아래의 케이스를 검증해보자.

  • 재시도 정책에 적용한 100 ms 간격으로 5번의 재시도를 수행하는가?
@Test
@DisplayName("서버 응답이 없습니다.")
void feignClientRetry() { stubFor(get(urlEqualTo("/store")).willReturn(aResponse().withFault(Fault.EMPTY_RESPONSE)));
    assertThrows(FeignException.class, () -> storeApiClient.getStore());
	verify(5, getRequestedFor(urlEqualTo("/store")));
}

재시도 로직이 수행되는 로그를 확인한다.

  • 재시도 중 정상 응답을 받을 경우 Exception 반환 없이 재시도를 중단하는가?
	@Test
    @DisplayName("재시도 중 store-api 가 통신에 성공하면 응답을 반환합니다.")
    void success() {
        // given
        stubFor(get(urlEqualTo("/store"))
                .inScenario("Retry Scenario")
                .whenScenarioStateIs(STARTED)
                .willReturn(aResponse().withFault(Fault.EMPTY_RESPONSE))
                .willSetStateTo("Attempt 1 Failed"));

        stubFor(get(urlEqualTo("/store"))
                .inScenario("Retry Scenario")
                .whenScenarioStateIs("Attempt 1 Failed")
                .willReturn(aResponse().withFault(Fault.EMPTY_RESPONSE))
                .willSetStateTo("Attempt 2 Failed"));

        // 세 번째 시도 시 성공하는 스텁 설정
        stubFor(get(urlEqualTo("/store"))
                .inScenario("Retry Scenario")
                .whenScenarioStateIs("Attempt 2 Failed")
                .willReturn(aResponse()
                        .withHeader("Content-Type", "application/json")
                        .withStatus(HttpStatus.OK.value())
                        .withBody(new Store())));

        // when
        Store store = storeApiClient.getStore();
        
        // then
        assertThat(store).isNotNull();
        verify(3, getRequestedFor(urlEqualTo("/store")));
    }

3번의 재시도를 수행한 로그를 확인하고 정상 응답을 받아 검증에 통과함을 확인한다.


FeignClient의 retryer를 설정하여 요청을 여러 번 재시도하는 것은 일시적인 네트워크 문제나 서버 상태에 따른 오류를 해결하는 데 유용할 수 있지만, 재시도가 항상 최선의 선택은 아니다. 근본 원인을 분석하고 백오프 전략이나 서킷 브레이커 패턴과 같은 추가적인 방법도 고려할 수 있다.

참고

https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/

profile
끄적이는 개발자

0개의 댓글