회복 탄력성을 위한 CuircuitBreaker, Retry 도입 - ekdan38/HotDealService GitHub Wiki

1. 회복 탄력성의 필요성

  • MSA 구조에서는 각 서비스가 네트워크를 통해 통신하기 때문에, 하나의 서비스 장애가 다른 서비스로 전파될 위험이 존재함
  • 특히 외부 서비스의 일시적인 장애나 느린 응답으로 인해 호출 실패가 발생하면, 사용자 경험에 큰 영향을 줄 수 있음

예시 상황:

사용자가 상품을 주문하면, OrderService는 HotDealService에 재고 점유 요청을 보내야 한다.
이때 HotDealService가 응답하지 않으면 OrderService도 정상 동작하지 못하고, 사용자에게 실패를 반환하게 된다.

보완해야할 상황

  • 장애가 발생한 외부 서비스로의 추가적인 호출을 차단하고, 시스템 전체 장애로 확산되지 않도록 보호
  • 일시적인 오류에 대해 자동으로 재시도하여 문제를 자체적으로 해결할 수 있도록 구성

2. CircuitBreaker & Retry 가 무엇인가?

Resilience4j 라이브러리를 활용하여 CircuitBreaker, Retry 를 적용함으로써 서비스 회복탄력성을 확보할 수 있음


CircuitBreaker란?

장애가 반복적으로 발생하는 외부 서비스에 대한 호출을 자동으로 차단하여, 불필요한 요청 및 리소스 낭비를 줄이고, 장애 전파를 막는 보호 장치

동작 흐름

  • 실패율이 일정 기준 이상이면 회로를 차단(OPEN) 하여 더 이상 외부 서비스 호출을 하지 않음
  • FallbackMethod 호출하여 빠른 응답
  • 이후 일정 시간이 지나면 일부 요청만 허용(HALF-OPEN)하여 서비스가 정상인지 판단함
상태 설명
CLOSED 정상 상태. 모든 요청을 외부 서비스로 전달
OPEN 실패율 임계치를 초과하면 회로 차단. 요청 차단 후 fallback 처리
HALF-OPEN 일정 시간 후 일부 요청을 보내 서비스 정상 여부 확인. 성공 시 CLOSED 복귀, 실패 시 다시 OPEN

CircuitBreaker가 필요한 이유

  • 외부 서비스 장애: 지속적인 실패 감지 후 차단
  • 응답 지연: 느린 호출 차단 → 시스템 부하 완화
  • 사용자 경험 저하: 빠르게 fallback 처리하여 응답

Retry란?

Retry는 외부 서비스 호출이 실패했을 때, 지정된 횟수와 간격으로 자동으로 재시도하는 기능이다.
Resilience4j의 Retry 모듈을 통해 적용할 수 있으며, 주로 일시적인 장애 에 유용하다.


Retry가 필요한 이유

문제 상황 Retry의 역할
일시적 네트워크 끊김 재시도를 통해 정상 요청으로 전환 가능
외부 서비스의 순간적인 부하 재요청 시 처리 성공 가능성 증가

예시 상황:

재고 점유 요청을 HotDealService로 보냈는데, 서비스가 과부하로 503을 반환했다면
바로 실패 처리하지 않고 500ms 간격으로 3번까지 재시도하여 회복 가능성 확보
그래도 실패한다면 fallback 응답


Retry 동작 흐름

  1. 외부 서비스 호출
  2. 지정된 예외 발생 (FeignException, IOException, CustomException등)
  3. Retry 정책에 따라 일정 횟수/시간 간격으로 재시도
  4. 모든 시도 실패 시 fallbackMethod 호출

3. CircuitBreaker & Retry 활용 사례

주문 생성 시, HotDealService에 재고 점유 요청을 보낼 때 Resilience4J의 CircuitBreaker 및 Retry 기능을 적용하고, 각각의 동작이 실제로 작동하는지 확인하기 위한 테스트를 수행

의존성 추가
    implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
    implementation 'io.github.resilience4j:resilience4j-all'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
Resilience4J 설정(테스트를 위한 임시)
resilience4j:
  circuitbreaker:
    instances:
      custom:
        base-config: default
    circuit-breaker-aspect-order: 1

    configs:
      default:
        failure-rate-threshold: 50
        wait-duration-in-open-state:
          seconds: 5
        sliding-window-type: count_based
        sliding-window-size: 10
        minimum-number-of-calls: 10
        permitted-number-of-calls-in-half-open-state: 5
        slow-call-duration-threshold:
          seconds: 3
        slow-call-rate-threshold: 70
        record-exceptions:
          - java.lang.RuntimeException
          - feign.FeignException
        ignore-exceptions:
          - com.hong.common.exception.custom.OrderException

  retry:
    instances:
      custom:
        base-config: default
    retry-aspect-order: 2

    configs:
      default:
        max-attempts: 3
        wait-duration: 500ms
        retry-exceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - feign.FeignException
        ignore-exceptions:
          - com.hong.common.exception.custom.OrderException
재고 점유 요청 메서드에 CircuitBreaker & Retry 적용
    /**
     * hotDealService 재고 점유 요청
     */
    @Retry(name = "custom", fallbackMethod = "fallbackForRetryReserveStock")
    @CircuitBreaker(name = "custom", fallbackMethod = "fallBackForCircuitBreakerReserveStock")
    public ProductReservationResponseDto reserveStock(ProductReservationRequestDto requestDto) {
        log.info("===[재고 점유 요청 실행]===");
        return hotDealServiceClient.reserveStock(requestDto);
    }

    // stock Reserve CircuitBreaker Fallback
    private ProductReservationResponseDto fallBackForCircuitBreakerReserveStock(ProductReservationRequestDto requestDto, Throwable throwable) {
        log.error("[CircuitBreaker Fallback] hotDeal-service 호출 실패. orderId={}, error={}", requestDto.getOrderId(), throwable.getMessage());
        if(throwable instanceof OrderException) throw (OrderException) throwable;
        return new ProductReservationResponseDto(true);
    }

    // stock Reserve Retry Fallback
    private ProductReservationResponseDto fallbackForRetryReserveStock(ProductReservationRequestDto requestDto, Throwable throwable) {
        log.error("[RETRY Fallback] hotDeal-service 호출 최종 재시도 실패. orderId={}, error={}", requestDto.getOrderId(), throwable.getMessage());
        if(throwable instanceof OrderException) throw (OrderException) throwable;
        throw new RuntimeException();
    }
FeignErrorDecoder를 통한 예외 처리
@Component
@Slf4j(topic = "FeignErrorDecoder")
public class FeignErrorDecoder implements ErrorDecoder {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Exception decode(String methodKey, Response response) {
        String errorMessage = "null";
        try {
            if (response.body() != null) {
                byte[] bodyBytes = response.body().asInputStream().readAllBytes();
                String responseBody = new String(bodyBytes);
                Map<String, String> errorMap = objectMapper.readValue(responseBody, new TypeReference<>() {
                });
                errorMessage = errorMap.getOrDefault("errorMessage", "Unknown ErrorMessage");
            } else {
                // body가 없는 경우
                errorMessage = String.format("Feign 호출 실패: 응답 body 없음 (methodKey: %s, status: %d)", methodKey, response.status());
            }
        } catch (IOException e) {
            log.info("Feign Client 응답 파싱 실패");
        }

        int status = response.status();
   

        /**
         * hotdealService
         */
        // 재고 점유 요청
        if (methodKey.contains("HotDealServiceClient#reserveStock")){
            if (status == 503) {
                log.error("HotDealServiceClient#reserveStock. 503 응답. StatusCode = {}, ErrorMessage = {}" ,status ,errorMessage);
                return FeignException.errorStatus(methodKey, response);
            }
            else if(status == 400){
                log.error("HotDealServiceClient#reserveStock. 400 응답. ErrorMessage = {}" ,errorMessage);
                throw new OrderException(ErrorCode.ORDER_RESERVE_STOCK_BAD_REQUEST, errorMessage);
            }
            else if(status == 404){
                log.error("HotDealServiceClient#reserveStock. 404 응답. ErrorMessage = {}" ,errorMessage);
                throw new OrderException(ErrorCode.ORDER_RESERVE_STOCK_NOT_FOUND, errorMessage);
            }
        }

//// 이하 생략
  • 503 오류: FeignException으로 래핑하여 Resilience4J의 Retry, CircuitBreaker의 감지 대상이 되도록 설정
  • 400, 404 오류: OrderException을 던져 비즈니스 예외로 간주하며 Resilience4J에 의해 무시됨

테스트 시나리오

현재 Resilience4J 설정

  • Retry : 재시도 대상에 500ms 간격, 3회 재시도

  • CirciutBreaker : 최소 10번 실행에 50%에 해당하는 5건이 실패하면 이후 OPEN

시나리오 설명 결과
재고 부족, 존재하지 않는 상품 (400, 404) (400, 404) 응답 -> ErrorDecoder 에서 OrderException 발생 → ignore-exceptions 대상 → Retry, CircuitBreaker 동작 안함 → fallback에서는 OrderException 다시 던짐 -> GlobalHandler 응답 Retry & CircuitBreaker X, Circuit CLOSED
서비스 불가 (503) ErrorDecoder 에서 FeignException 발생 → record-exceptions 대상 → Retry 3회 후 실패 → CircuitBreaker에 기록 실패 10회 초과 시 Circuit OPEN 전환
CircuitBreaker 동작 테스트 코드
@SpringBootTest
public class CircuitBreakerTest {

    @Autowired
    OrderService orderService;

    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;

    @MockitoBean
    HotDealServiceClient hotDealServiceClient;

    private Long userId = 1L;

    // CircuitBreaker 각 테스트 케이스 이전에 초기화
    @BeforeEach
    void resetCircuitBreaker() {
        circuitBreakerRegistry.circuitBreaker("custom").reset();
    }

    // Retry, CircuitBreaker 무시, OrderException 생겨야함, 최종 결과 CLOSED
    @Test
    public void Ignore(){
        //given
        OrderRequestDto orderRequestDto = generateOrderRequest();

        Mockito.when(hotDealServiceClient.reserveStock(any()))
                .thenThrow(new OrderException(ErrorCode.ORDER_HOTDEAL_SERVICE_FAILED, "요청 수량보다 재고가 부족합니다. productIds = [1]"));

        //when, then
        for(int i = 1; i <= 11; i++){
            Assertions.assertThatThrownBy(() -> orderService.createOrder(userId, orderRequestDto)).isInstanceOf(OrderException.class);
        }
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("custom");
        Assertions.assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
    }


    // Retry, CircuitBreaker 대상, 최종 결과 OPEN 
    @Test
    public void OPEN(){
        //given
        OrderRequestDto orderRequestDto = generateOrderRequest();

        Response response = generateResponse();
        FeignException feignException = FeignException.errorStatus("HotDealServiceClient#reserveStock", response);
        Mockito.when(hotDealServiceClient.reserveStock(any())).thenThrow(feignException);

        //when, then
        for(int i = 1; i <= 11; i++){
            Assertions.assertThatThrownBy(() -> orderService.createOrder(userId, orderRequestDto));
        }
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("custom");
        Assertions.assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
    }

    private OrderRequestDto generateOrderRequest() {
        return new OrderRequestDto("city", "street", "zipCode", List.of(new OrderProductRequest(1L, 1)));
    }

    private Response generateResponse() {
        return Response.builder()
                .status(503)
                .reason("Service Unavailable")
                .request(Request.create(Request.HttpMethod.POST, "/hotdeal/reserve", Map.of(), null, new RequestTemplate()))
                .headers(Map.of())
                .body("Service Down", StandardCharsets.UTF_8)
                .build();
    }
}

테스트 결과 및 로그

테스트 결과

Image

첫번째 테스트 로그

Image

  • OrderExceptionignore-exceptions 대상
  • Retry & CircuitBreaker에 기록되지 않음
  • RetryFallback에서 OrderException 그대로 던짐→ CircuitBreaker Fallback에서도 그대로 던져서 GlobalHandler 처리
  • CircuitBreaker 상태는 CLOSED 유지

로그를 보면 의도대로 Retry, CircuitBreaker에서 제외됨 Fallback 메서드는 무조건 실행되기에 RetryFallback에서 OrderException이 터지고 CIRCUITBREAKER FALLBCK에서 다시 예외를 던져 GLOBALHANDLER를 탐

두번째 테스트 로그

Retry 시도 로그

Image

  • FeignExceptionrecord-exceptions 대상
  • 1회 요청 → 실패 → Retry → 총 3회 요청

CircuitBreaker OPEN 전환 후 로그 Image

  • 10회 실패 이후, 11번째 요청 시 CircuitBreaker가 OPEN 상태가 되어 Retry는 실행되지 않고 바로 Fallback으로 진입

정리

  • Resilience4J 설정과 ErrorDecoder를 연동하여 상태 코드 기반 예외를 정확히 분리
  • 비지니스 로직은 회복 불가능한 예외로 간주하여 Retry/CircuitBreaker 무시
  • FeignException (503 등)은 Retry 및 CircuitBreaker 대상으로 처리
  • 테스트를 통해 Resilience4J의 작동 검증
⚠️ **GitHub.com Fallback** ⚠️