MSA 환경에서 장애 전파 문제 관리하기

pitseleh·2025년 5월 27일
post-thumbnail

MSA 기반 프로젝트를 진행하면서 외부 API 호출 시 서비스 연쇄 장애 문제에 대해 관심이 생겼다. 금융 업무와 관련된 대다수 API에서 외부 API를 호출하다보니 외부 API에 대한 의존성이 상당히 강했기 때문이다. 하나의 외부 서비스가 다운되면 이를 호출하는 모든 서비스가 함께 응답 불가 상태가 되는 장애 전파(Cascading Failure) 현상을 어떻게 해결할 수 있을까? 이러한 문제를 방지하기 위해 Circuit Breaker 패턴을 도입하게 되었다.

1. Circuit Breaker 패턴

🔍 Circuit Breaker 패턴이란?

장애가 발생한 서비스로의 호출을 차단하여 시스템을 보호하는 메커니즘 (전기 회로의 차단기를 생각하면 된다)

🔍 Circuit Breaker 패턴의 상태

CLOSED (정상)

  • 모든 요청을 정상적으로 전달
  • 실패율을 모니터링

OPEN (차단)

  • 모든 요청을 즉시 차단하고 fallback 실행
  • 일정 시간 후 HALF_OPEN으로 전환

HALF_OPEN (반열림)

  • 제한된 수의 요청만 전달하여 복구 상태 확인
  • 성공 시 CLOSED, 실패 시 다시 OPEN

2. 구현 과정

1) 설정

# bank-service-prod.yml
resilience4j:
  circuitbreaker:
    instances:
      commonCircuit:
        slidingWindowType: COUNT_BASED
        registerHealthIndicator: true           # Actuator를 통한 상태 모니터링
        slidingWindowSize: 10                   # 최근 10개 호출 기준으로 실패율 계산
        failureRateThreshold: 50                # 실패율 50% 초과 시 OPEN
        waitDurationInOpenState: 10s            # OPEN 상태에서 10초 후 HALF_OPEN 전환
        recordExceptions:                       # Circuit Breaker 트리거 예외 목록
          - org.springframework.web.client.HttpServerErrorException
          - org.springframework.web.server.ServerErrorException
        permittedNumberOfCallsInHalfOpenState: 3 # HALF_OPEN에서 허용할 호출 수
  retry:
    instances:
      commonRetry:
        maxAttempts: 3                         # 최대 3회 재시도
        waitDuration: 2s                       # 재시도 간격 2초
        retryExceptions:
          - java.io.IOException
          - org.springframework.web.client.ResourceAccessException
  • slidingWindowSize: 10
    • 최근 10개 호출에 대해 실패율 계산
    • 실패율 계산: 5/10 = 50%
  • failureRateThreshold: 50
    • 실패율 50% 초과 시 OPEN
    • 값이 낮을수록 민감하게 반응
  • waitDurationInOpenState: 10s
    • OPEN → HALF_OPEN 전환 대기시간
    • 너무 길면 복구가 지연되고 너무 짧으면 불필요한 시도가 많아짐
  • permittedNumberOfCallsInHalfOpenState: 3
    • HALF_OPEN에서 테스트 호출 수
    • HALF_OPEN 상태에서 3회 모두 성공 → CLOSED 전환
    • HALF_OPEN 상태에서 3회 미만 성공 → OPEN으로 복귀
  • recordExceptions
    • 실패로 카운트하는 예외
    • 반대 개념 : ignoreExceptions
  • registerHealthIndicator: true
    • /actuator/health에 Circuit Breaker 상태 포함
  • maxAttempts: 3
    • 최초 호출을 포함한 재시도 횟수
  • waitDuration: 2s
    • 재시도 간격
    • 너무 짧으면 외부 서비스에 부하를 줄 수 있고 너무 길면 사용자 대기 시간이 증가함
  • retryExceptions
    • 재시도하는 예외
# bootstrap.yml
management:
  endpoints:
    web:
      exposure:
        include: refresh, beans, health, circuitbreakers, info

이게 있어야 Circuit Breaker 모니터링을 위한 엔드포인트를 외부에서 접근 가능하도록 노출시킬 수 있다.

2) 적용

@Service
@Slf4j
@RequiredArgsConstructor
public class ApiService {

    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;
    private final ApiRequestHelper apiRequestHelper;

    // Fallback 메서드 정의
    public void fallbackForVoid(String apiUrl, Map<String, Object> requestBody, Throwable t) {
        log.error("CircuitBreaker 발동 - void method", t);
        throw new BusinessException("서버 내 오류가 발생했습니다. 나중에 시도해주세요", "C-999");
    }

    public JsonNode fallbackForJson(String apiUrl, Map<String, Object> requestBody, Throwable t) {
        log.error("CircuitBreaker 발동 - JsonNode method", t);
        throw new BusinessException("서버 내 오류가 발생했습니다. 나중에 시도해주세요", "C-999");
    }

    /*
     * 응답 데이터가 필요 없는 API 호출
     */
    @Retry(name = "commonRetry")
    @CircuitBreaker(name = "commonCircuit", fallbackMethod = "fallbackForVoid")
    public void processApiRequest(String apiUrl, Map<String, Object> requestBody) {
        HttpEntity<Map<String, Object>> entity = apiRequestHelper.createHttpEntity(requestBody);
        restTemplate.postForEntity(apiUrl, entity, String.class);
    }

    /*
     * 응답 데이터가 필요한 API 호출
     */
    @Retry(name = "commonRetry")
    @CircuitBreaker(name = "commonCircuit", fallbackMethod = "fallbackForJson")
    public JsonNode processApiRequestRaw(String apiUrl, Map<String, Object> requestBody) {
        HttpEntity<Map<String, Object>> entity = apiRequestHelper.createHttpEntity(requestBody);

        try {
            ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, entity, String.class);
            JsonNode responseJson = objectMapper.readTree(response.getBody());

            if (responseJson.has("REC")) {
                return responseJson.get("REC");
            }
            return objectMapper.createObjectNode()
                    .put("message", "REC 데이터가 없습니다.")
                    .put("code", "500");

        } catch (JsonProcessingException e) {
            log.error("JSON 파싱 오류 발생: ", e);
            return objectMapper.createObjectNode()
                    .put("message", "JSON 응답을 처리하는 중 오류가 발생했습니다.")
                    .put("code", "500");
        }
    }
}

주의할 점은 Resilience4j의 fallbackMethod는 원래 메서드의 파라미터 + Throwable 을 인자로 받아야 한다는 점이다. 따라서 fallback() 메서드를 오버로드해서 정확히 맞는 시그니처로 두 개 만들어야 한다.
로컬에서는 문제가 없었는데 배포 환경에서 No fallback method match found 에러가 발생해서 찾아보니 이 부분이 원인이었다.

3) 결과

OPEN

  • 장애 감지 단계
  • 외부 API가 실패하여 임계값을 넘어갈 경우 Circuit Breaker OPEN 상태로 전환 및 이후 요청들은 즉시 차단됨

HALF_OPEN

  • 복구 테스트
  • 10초 대기 후 HALF_OPEN 상태로 전환

ERROR

  • Circuit Breaker가 OPEN 상태일 때 실행된 fallback 메서드의 응답
  • 외부 API 호출 없이 즉시 반환된 에러 응답

CLOSED

  • 테스트 호출이 성공하여 CLOSED 상태로 복구
  • 다시 정상적인 API 호출이 가능한 상태

플로우를 정리하면 아래와 같다.

장애 발생 단계

외부 API 10회 연속 실패 
→ 실패율 100% (임계값 50% 초과)
→ Circuit Breaker OPEN
→ 이후 요청들은 즉시 차단 (notPermittedCalls) : 3번째 차단이 발생할 때 fallback 응답이 반환됨

복구 감지 단계

10초 대기 후 HALF_OPEN 전환
→ 첫 번째 테스트 호출 실패
→ 다시 OPEN으로 복귀 
→ 10초 대기
→ 두 번째 테스트 호출 성공
→ CLOSED 상태로 완전 복구 

상태가 3가지로 나뉘다보니 헷갈릴 수 있는데 실제로 테스트해보면 어떻게 상태가 전환되는지 이해할 수 있다!

0개의 댓글