서비스에서 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 의 문제 해결도 필요하지만 클라이언트에서 이러한 상황이 발생했을 경우 어떻게 핸들링할지를 목적으로 한다!
Spring Cloud Open Feign Document
Feign 은 기본적으로 Retryer.NEVER_RETRY
상태의 Bean 이 생성되어 Retry 를 시도하지 않기 때문에 Retry 를 시키려면 추가적인 설정이 필요하다.
Feign 이 제공하는 Retryer
은 IOException 이 발생한 경우에만 처리되어 일시적인 네트워크 관련 예외로 처리하고 ErrorDecoder
에서 throw 된 모든 RetryableException
을 처리한다.
아래와 같이 정의해 보았다.
재시도가 필요한 경우는 주로 일시적이고 간헐적인 장애로 인해 요청이 실패했을 때이며, 이러한 장애가 해결될 가능성이 있는 상황에서 유용하다.
그러나 서버가 장기적으로 문제가 있거나 요청이 항상 실패하는 상황에서는 재시도가 오히려 부하를 가중시키고, 문제를 해결하지 못할 수 있기 때문에 이러한 상황에서는 다른 적절한 대응 전략을 고려하는 것이 중요하다.
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);
}
}
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;
}
}
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 이 생성된다.
아래의 케이스를 검증해보자.
@Test
@DisplayName("서버 응답이 없습니다.")
void feignClientRetry() { stubFor(get(urlEqualTo("/store")).willReturn(aResponse().withFault(Fault.EMPTY_RESPONSE)));
assertThrows(FeignException.class, () -> storeApiClient.getStore());
verify(5, getRequestedFor(urlEqualTo("/store")));
}
재시도 로직이 수행되는 로그를 확인한다.
@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/