이번엔 Circuit Breaker를 이론 말고, 실제 적용한 방식 기준으로 정리해본다.

상황은 단순했다.

  • 외부 API가 간헐적으로 느려지거나 timeout
  • 우리 API 스레드가 기다리다가 묶임
  • 지연이 누적되면서 5xx가 같이 증가

그래서 Resilience4j를 넣었고, 운영에서 급할 때는 Circuit을 강제로 열고/닫을 수 있게
Spring Cloud 기반 제어를 같이 붙였다.

1) 왜 Circuit Breaker가 필요했는지

Failure cascade without circuit breaker

Circuit 없을 때는 장애가 아래처럼 번졌다.

  1. 다운스트림 timeout 증가
  2. 업스트림 대기 스레드 누적
  3. 전체 응답 지연 + 에러율 상승

핵심은 “느린 외부 의존성 하나”가 전체 서비스 품질을 같이 끌어내린다는 점이었다.

2) 적용 스택

  • spring-cloud-starter-circuitbreaker-resilience4j
  • spring-boot-starter-actuator
  • spring-cloud-starter-config (운영 제어용)

이 조합으로,

  • 코드 호출은 Spring Cloud CircuitBreaker API로 감싸고
  • 실제 상태 관리는 Resilience4j가 담당하고
  • 강제 OPEN/CLOSE는 Spring Cloud Config 값으로 제어했다.

여기서 많이 헷갈리는 포인트가 하나 있다.

  • Spring Cloud는 Circuit/Config를 위한 추상화 + 연동 레이어
  • 운영 화면(UI)은 기본 제공이 아니라 보통 Grafana/Spring Boot Admin/사내 도구로 구성

즉 Spring Cloud를 붙였다고 Circuit 전용 웹 콘솔이 자동으로 생기진 않는다.

3) 기본 설정값 (실무에서 많이 쓰는 시작점)

resilience4j:
  circuitbreaker:
    instances:
      orderApi:
        registerHealthIndicator: true
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 50
        minimumNumberOfCalls: 20
        failureRateThreshold: 50
        slowCallRateThreshold: 60
        slowCallDurationThreshold: 2s
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 10
        automaticTransitionFromOpenToHalfOpenEnabled: true

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,refresh,busrefresh

Resilience4j config mapping

이 값은 정답이 아니라 시작점이다.
중요한 건 운영 지표 보면서 계속 튜닝하는 거다.

4) 실제 호출 코드 예시

@Service
@RequiredArgsConstructor
public class OrderGateway {

    private final CircuitBreakerFactory<?, ?> circuitBreakerFactory;
    private final RestClient orderClient;

    public OrderResponse getOrder(String orderId) {
        CircuitBreaker cb = circuitBreakerFactory.create("orderApi");

        return cb.run(
                () -> orderClient.get()
                        .uri("/external/orders/{id}", orderId)
                        .retrieve()
                        .body(OrderResponse.class),
                throwable -> OrderResponse.fallback(orderId)
        );
    }
}

여기서 포인트는 fallback을 “무조건 성공처럼” 만들지 않는 거다.
프론트/클라이언트가 fallback 응답임을 구분할 수 있게 설계해야 운영에서 덜 꼬인다.

fallbackMethod 방식도 같이 쓴다

팀/코드베이스에 따라 annotation 방식이 더 읽기 쉬울 때가 있다.

@Service
public class OrderQueryService {

    private final ExternalOrderClient externalOrderClient;

    public OrderQueryService(ExternalOrderClient externalOrderClient) {
        this.externalOrderClient = externalOrderClient;
    }

    @io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker(
            name = "orderApi",
            fallbackMethod = "getOrderFallback"
    )
    public OrderResponse getOrder(String orderId) {
        return externalOrderClient.getOrder(orderId);
    }

    private OrderResponse getOrderFallback(String orderId, Throwable t) {
        return OrderResponse.fallback(orderId);
    }
}

정리하면 둘 다 가능하다.

  • Spring Cloud CircuitBreaker cb.run(..., fallback) 함수형 방식
  • Resilience4j annotation + fallbackMethod 방식

5) Circuit 상태 이해 (운영 기준)

Resilience4j circuit states

  • CLOSED: 정상 호출 + 통계 수집
  • OPEN: fail-fast, 다운스트림 호출 차단
  • HALF_OPEN: 제한된 probe 호출로 복구 여부 확인

그리고 운영 제어용으로 FORCED_OPEN, FORCED_CLOSED를 사용했다.

6) 강제 OPEN/CLOSE 제어를 Spring Cloud로 붙인 방식

요구사항이 이거였다.

  • 장애 대응 중 운영자가 circuit을 강제로 열거나 닫을 수 있어야 함
  • 재배포 없이 반영돼야 함

그래서 아래 흐름으로 구성했다.

Spring cloud forced control flow

  1. Spring Cloud Config 저장소에 모드 값 관리
  2. 운영자가 값 변경 (AUTO, FORCED_OPEN, FORCED_CLOSED)
  3. /actuator/refresh (단일 인스턴스) 또는 /actuator/busrefresh (다중 인스턴스) 호출
  4. 앱이 갱신된 모드를 읽고 Circuit 상태 전환

여기서 /actuator는 Prometheus 전용 경로가 아니다.
Actuator 관리 엔드포인트 공통 베이스 경로이고, prometheus, health, refresh 등이 그 아래에 붙는다.

예시:

# single instance refresh
curl -X POST http://127.0.0.1:18081/actuator/refresh
# multi instance refresh (Spring Cloud Bus)
curl -X POST http://127.0.0.1:18081/actuator/busrefresh

운영에서는 이 엔드포인트를 외부 공개하지 않고, 내부망 + 인증으로 제한하는 걸 전제로 사용했다.

운영 모드 프로퍼티

ops:
  circuit:
    order-api:
      mode: AUTO # AUTO | FORCED_OPEN | FORCED_CLOSED

RefreshScope + 상태 동기화 코드

@Getter
@Setter
@RefreshScope
@Component
@ConfigurationProperties(prefix = "ops.circuit.order-api")
public class CircuitControlProperties {
    private Mode mode = Mode.AUTO;

    public enum Mode {
        AUTO,
        FORCED_OPEN,
        FORCED_CLOSED
    }
}
@Service
@RequiredArgsConstructor
public class CircuitStateSynchronizer {

    private final CircuitBreakerRegistry registry;
    private final CircuitControlProperties properties;

    @Scheduled(fixedDelayString = "${ops.circuit.sync-delay-ms:3000}")
    public void syncState() {
        io.github.resilience4j.circuitbreaker.CircuitBreaker cb =
                registry.circuitBreaker("orderApi");

        switch (properties.getMode()) {
            case FORCED_OPEN -> cb.transitionToForcedOpenState();
            case FORCED_CLOSED -> cb.transitionToClosedState();
            case AUTO -> cb.reset();
        }
    }
}

FORCED_CLOSED는 보호막을 우회하는 동작이라 진짜 짧게만 써야 한다.
장애 대응에선 보통 FORCED_OPEN이 더 자주 필요했다.

7) 운영에서 실제로 본 지표

  • circuit state 전환 빈도
  • fallback 비율
  • 실패/느린 호출 비율

Circuit breaker metrics dashboard

이 지표를 안 보면 “설정 넣었으니 끝”이 되는데,
실제로는 상태 전환 패턴을 보고 threshold를 계속 조정해야 한다.

8) 롤아웃할 때 체크한 순서

Circuit breaker rollout plan

  1. 관측부터 붙이고(상태/실패/느린 호출)
  2. fallback 응답 품질 검증하고
  3. 온콜 runbook 정리하고
  4. 강제 제어 모드(OPEN/CLOSED)까지 운영 절차에 포함

정리하면 Circuit Breaker는 라이브러리 하나 추가하는 작업이 아니라,

  • 장애 전파를 끊는 런타임 장치
  • 운영자가 제어할 수 있는 대응 장치

이 두 개를 같이 만드는 작업이었다.

특히 Spring Cloud Config + refresh로 강제 상태 제어를 붙여두면,
긴급 대응 속도가 확실히 빨라진다.

References

  • Spring Cloud Circuit Breaker Reference: https://docs.spring.io/spring-cloud-circuitbreaker/reference/
  • Resilience4j CircuitBreaker Guide: https://resilience4j.readme.io/docs/circuitbreaker
  • Spring Cloud Config Reference: https://docs.spring.io/spring-cloud-config/docs/current/reference/html/
  • Spring Cloud Commons Refresh Scope: https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/application-context-services.html#refresh-scope
  • Spring Boot Actuator Endpoints: https://docs.spring.io/spring-boot/reference/actuator/endpoints.html