회복 탄력성을 위한 CuircuitBreaker, Retry 도입 - ekdan38/HotDealService GitHub Wiki
- MSA 구조에서는 각 서비스가 네트워크를 통해 통신하기 때문에, 하나의 서비스 장애가 다른 서비스로 전파될 위험이 존재함
- 특히 외부 서비스의 일시적인 장애나 느린 응답으로 인해 호출 실패가 발생하면, 사용자 경험에 큰 영향을 줄 수 있음
예시 상황:
사용자가 상품을 주문하면, OrderService는 HotDealService에 재고 점유 요청을 보내야 한다.
이때 HotDealService가 응답하지 않으면 OrderService도 정상 동작하지 못하고, 사용자에게 실패를 반환하게 된다.
- 장애가 발생한 외부 서비스로의 추가적인 호출을 차단하고, 시스템 전체 장애로 확산되지 않도록 보호
- 일시적인 오류에 대해 자동으로 재시도하여 문제를 자체적으로 해결할 수 있도록 구성
Resilience4j
라이브러리를 활용하여 CircuitBreaker, Retry 를 적용함으로써 서비스 회복탄력성을 확보할 수 있음
장애가 반복적으로 발생하는 외부 서비스에 대한 호출을 자동으로 차단하여, 불필요한 요청 및 리소스 낭비를 줄이고, 장애 전파를 막는 보호 장치
- 실패율이 일정 기준 이상이면 회로를 차단(OPEN) 하여 더 이상 외부 서비스 호출을 하지 않음
-
FallbackMethod
호출하여 빠른 응답 - 이후 일정 시간이 지나면 일부 요청만 허용(HALF-OPEN)하여 서비스가 정상인지 판단함
상태 | 설명 |
---|---|
CLOSED |
정상 상태. 모든 요청을 외부 서비스로 전달 |
OPEN |
실패율 임계치를 초과하면 회로 차단. 요청 차단 후 fallback 처리 |
HALF-OPEN |
일정 시간 후 일부 요청을 보내 서비스 정상 여부 확인. 성공 시 CLOSED 복귀, 실패 시 다시 OPEN |
- 외부 서비스 장애: 지속적인 실패 감지 후 차단
- 응답 지연: 느린 호출 차단 → 시스템 부하 완화
- 사용자 경험 저하: 빠르게 fallback 처리하여 응답
Retry는 외부 서비스 호출이 실패했을 때, 지정된 횟수와 간격으로 자동으로 재시도하는 기능이다.
Resilience4j의 Retry 모듈을 통해 적용할 수 있으며, 주로 일시적인 장애 에 유용하다.
문제 상황 | Retry의 역할 |
---|---|
일시적 네트워크 끊김 | 재시도를 통해 정상 요청으로 전환 가능 |
외부 서비스의 순간적인 부하 | 재요청 시 처리 성공 가능성 증가 |
예시 상황:
재고 점유 요청을 HotDealService로 보냈는데, 서비스가 과부하로 503을 반환했다면
바로 실패 처리하지 않고 500ms 간격으로 3번까지 재시도하여 회복 가능성 확보
그래도 실패한다면 fallback 응답
- 외부 서비스 호출
- 지정된 예외 발생 (
FeignException
,IOException
,CustomException
등) -
Retry
정책에 따라 일정 횟수/시간 간격으로 재시도 - 모든 시도 실패 시
fallbackMethod
호출
주문 생성 시, 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();
}
}
테스트 결과
첫번째 테스트 로그
-
OrderException
은ignore-exceptions
대상 - Retry & CircuitBreaker에 기록되지 않음
- RetryFallback에서 OrderException 그대로 던짐→ CircuitBreaker Fallback에서도 그대로 던져서
GlobalHandler
처리 - CircuitBreaker 상태는
CLOSED
유지
로그를 보면 의도대로 Retry, CircuitBreaker에서 제외됨 Fallback 메서드는 무조건 실행되기에 RetryFallback에서 OrderException이 터지고 CIRCUITBREAKER FALLBCK에서 다시 예외를 던져 GLOBALHANDLER를 탐
두번째 테스트 로그
Retry 시도 로그
-
FeignException
은record-exceptions
대상 - 1회 요청 → 실패 → Retry → 총 3회 요청
CircuitBreaker OPEN 전환 후 로그
- 10회 실패 이후, 11번째 요청 시 CircuitBreaker가 OPEN 상태가 되어 Retry는 실행되지 않고 바로 Fallback으로 진입
- Resilience4J 설정과 ErrorDecoder를 연동하여 상태 코드 기반 예외를 정확히 분리
- 비지니스 로직은 회복 불가능한 예외로 간주하여 Retry/CircuitBreaker 무시
- FeignException (503 등)은 Retry 및 CircuitBreaker 대상으로 처리
- 테스트를 통해 Resilience4J의 작동 검증