이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Circuit Breaker, Bulkhead, Fallback은 각각 어떤 장애 전파를 막는가?
  • breaker가 open되었다는 사실은 장애 해결인가, 보호 동작인가?
  • fallback을 설계할 때 사용자 경험과 데이터 정확성을 어떻게 나누어 판단하는가?

개요

Circuit Breaker, Bulkhead, Fallback은 외부 의존성 장애가 전체 서비스로 번지는 것을 막는 resilience 패턴이다. timeout이 “얼마나 기다릴 것인가”를 정하고 retry가 “다시 시도할 것인가”를 정한다면, circuit breaker는 “지금은 호출하지 않는 것이 낫다”를 결정한다. bulkhead는 “이 dependency가 가져갈 수 있는 자원을 제한한다”를 결정하고, fallback은 “실패했을 때 사용자에게 무엇을 줄 것인가”를 결정한다.

이 세 가지는 장애를 없애는 기능이 아니라 장애 전파를 제한하는 기능이다. 결제 API가 죽었을 때 circuit breaker가 열리는 것은 결제가 정상화되었다는 뜻이 아니라, 주문 서비스의 thread와 connection을 보호하기 위해 결제 호출을 빠르게 차단한다는 뜻이다.

왜 백엔드 개발자가 알아야 하는가

현대 백엔드 서비스는 여러 내부/외부 dependency 위에 서 있다. 추천, 검색, 결제, 인증, 배송, 쿠폰, 알림 중 하나가 느려졌을 때 전체 API가 같이 느려지면 사용자는 “서비스 전체 장애”로 느낀다.

  • 추천 API 장애가 상품 상세 페이지 전체를 느리게 만든다.
  • 결제 API 장애가 주문 thread를 모두 점유해 장바구니 조회까지 늦어진다.
  • Redis 장애가 인증, rate limit, 캐시 기능을 동시에 흔든다.
  • fallback이 오래된 데이터를 정상 데이터처럼 보여 고객 CS를 만든다.
  • breaker open을 성공 응답 감소로만 보고 실제 downstream 장애를 늦게 발견한다.

백엔드 개발자는 어떤 dependency 실패를 빠르게 실패시킬지, 어떤 dependency는 fallback을 줄지, 어떤 dependency는 전체 요청을 실패시킬지 구분해야 한다.

요청 흐름에서의 위치

resilience 패턴은 애플리케이션이 downstream dependency를 호출하는 경계에 둔다.

Controller / Service
→ Bulkhead
→ Circuit Breaker
→ Retry with timeout
→ HTTP Client / DB / Cache
→ Fallback or final failure

순서는 라이브러리와 정책에 따라 달라질 수 있지만, 의미는 분명해야 한다. timeout 없이 breaker만 두면 느린 호출이 오래 자원을 점유하고, bulkhead 없이 retry만 두면 특정 dependency 장애가 전체 thread를 잡아먹을 수 있다.

원리

각 패턴은 해결하는 문제가 다르다.

패턴해결하려는 문제핵심 질문
Circuit Breaker실패 중인 dependency에 계속 호출하는 문제실패율/느린 호출 비율이 얼마면 차단할 것인가?
Bulkhead한 dependency가 전체 자원을 점유하는 문제dependency별 동시성 상한을 얼마로 둘 것인가?
Fallback실패 시 사용자 경험을 어떻게 낮출 것인가대체 응답이 안전하고 정직한가?
TimeLimiter호출 점유 시간이 길어지는 문제언제 포기하고 자원을 회수할 것인가?
Rate Limiter유입량이 dependency 한계를 넘는 문제단위 시간당 호출 수를 얼마로 제한할 것인가?

Circuit Breaker의 기본 상태는 보통 closed, open, half-open이다.

상태의미
closed호출을 허용하며 실패율을 관찰
open호출을 빠르게 차단
half-open제한된 테스트 호출로 회복 여부 확인

half-open 상태에서 모든 traffic을 한 번에 열면 회복 중인 dependency를 다시 쓰러뜨릴 수 있다. 제한된 probe와 gradual recovery가 필요하다.

실무에서 자주 만나는 문제

  • fallback이 데이터 품질을 숨긴다.
    • 오래된 캐시나 기본값을 정상 데이터처럼 보여주면 사용자는 잘못된 의사결정을 한다.
    • fallback 응답에는 freshness, 제한, 일부 기능 불가를 표현한다.
  • breaker open을 장애 해결로 착각한다.
    • caller는 빨리 실패하지만 downstream은 여전히 장애일 수 있다.
    • breaker state와 downstream health를 별도로 모니터링한다.
  • 모든 dependency에 같은 breaker 설정을 쓴다.
    • 결제와 추천은 실패 비용과 fallback 가능성이 다르다.
    • dependency별 threshold, window, fallback을 다르게 둔다.
  • bulkhead 없이 circuit breaker만 둔다.
    • breaker가 열리기 전까지 느린 호출이 전체 thread를 점유할 수 있다.
    • 동시성 제한과 timeout을 함께 둔다.
  • fallback에서 다시 같은 dependency를 호출한다.
    • fallback이 실패 경로를 더 복잡하게 만들고 장애를 증폭한다.
    • fallback dependency도 별도 budget과 timeout을 둔다.

디버깅 방법

resilience 장애를 볼 때는 최종 HTTP status만 보지 않는다.

dependency:
call count:
failure rate:
slow call rate:
timeout count:
bulkhead rejected count:
breaker state:
fallback count:
fallback reason:
user-visible response:

특히 다음 지표가 필요하다.

  • circuit breaker state transition
  • calls not permitted count
  • slow call rate
  • failure rate
  • bulkhead available concurrent calls
  • bulkhead rejected count
  • fallback success/failure count
  • downstream latency와 error rate

장애 중에는 이런 질문을 던진다.

  • breaker가 열리기 전 thread/pool이 이미 고갈되었는가?
  • breaker open 이후 사용자에게 어떤 응답이 갔는가?
  • fallback이 너무 성공처럼 보이지 않는가?
  • half-open probe가 downstream 회복을 방해하지 않는가?
  • retry가 breaker 앞/뒤에서 어떻게 동작하는가?

코드로 확인하기

Resilience4j 설정은 dependency별로 분리한다.

CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
        .slidingWindowSize(50)
        .minimumNumberOfCalls(20)
        .failureRateThreshold(50)
        .slowCallRateThreshold(50)
        .slowCallDurationThreshold(Duration.ofSeconds(1))
        .waitDurationInOpenState(Duration.ofSeconds(10))
        .permittedNumberOfCallsInHalfOpenState(5)
        .build();
 
BulkheadConfig bulkheadConfig = BulkheadConfig.custom()
        .maxConcurrentCalls(30)
        .maxWaitDuration(Duration.ofMillis(100))
        .build();
 
TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
        .timeoutDuration(Duration.ofSeconds(2))
        .cancelRunningFuture(true)
        .build();

fallback은 안전한 경우에만 둔다.

ProductRecommendation fallbackRecommendations(String productId, Throwable cause) {
    log.warn("recommendation fallback productId={} cause={}", productId, cause.toString());
    return ProductRecommendation.emptyWithMessage("추천 정보를 잠시 불러올 수 없습니다.");
}

결제 승인처럼 정확성이 핵심인 작업은 임의 fallback보다 명확한 실패와 상태 조회가 낫다. 추천 영역처럼 부가 기능이면 fallback이 사용자 경험을 지킬 수 있다.

주니어가 자주 하는 오해

  • circuit breaker를 넣으면 downstream 장애가 해결된다고 생각한다.
    • breaker는 caller 보호 장치이고, downstream 복구는 별도 문제다.
  • fallback은 항상 좋은 UX라고 생각한다.
    • 잘못된 fallback은 사용자에게 거짓 정상 상태를 보여줄 수 있다.
  • breaker threshold를 낮게 잡으면 안전하다고 생각한다.
    • 너무 민감하면 작은 변동에도 open되어 가용성을 불필요하게 낮춘다.
  • bulkhead와 thread pool을 같은 것으로만 본다.
    • bulkhead는 dependency별 동시성 격리 전략이고 semaphore, thread pool, connection pool 등으로 구현될 수 있다.

시니어의 설계 판단 기준

  • dependency를 critical, important, optional로 나누고 실패 정책을 다르게 둔다.
  • fallback은 데이터 정확성, 사용자 고지, freshness, 감사 가능성을 기준으로 허용한다.
  • circuit breaker 설정은 traffic volume이 충분한 endpoint와 낮은 volume endpoint에서 다르게 잡는다.
  • half-open 회복은 제한된 probe와 점진적 traffic 복귀를 고려한다.
  • breaker open alert는 caller 보호 신호이면서 downstream 장애 신호로 함께 다룬다.

인프라 협업 포인트

  • Service Mesh나 Gateway의 circuit breaking, outlier detection, retry가 앱 정책과 중복되지 않는지 확인한다.
  • Envoy cluster circuit breaker의 max connections, pending requests, requests, retries 설정을 앱 pool과 맞춘다.
  • breaker open 시 traffic이 다른 region이나 fallback dependency로 이동한다면 capacity를 함께 계산한다.
  • 인프라 팀에는 dependency별 breaker state, rejected count, downstream host, route, cluster 이름을 전달한다.

실전 팁

  • fallback 응답은 로그와 metric에 반드시 표시한다. 조용한 fallback은 장애를 숨긴다.
  • breaker open을 error budget 소모와 연결해 운영 알림을 설계한다.
  • optional dependency에는 fallback을, critical dependency에는 명확한 실패와 복구 안내를 주는 편이 낫다.
  • chaos test나 dependency delay test로 breaker, bulkhead, fallback이 실제로 동작하는지 확인한다.
  • 개인 프로젝트라도 외부 API 하나에는 timeout, bulkhead, breaker, fallback을 의도적으로 설정해 보는 것이 좋다.

위험 신호!

  • 모든 외부 호출이 같은 circuit breaker 설정을 공유한다.
  • fallback count가 늘었는데 alert가 없다.
  • breaker open을 성공률 개선으로만 보고 downstream 장애를 조사하지 않는다.
  • bulkhead rejected가 늘어도 사용자 응답 정책이 없다.
  • fallback이 오래된 데이터를 최신 데이터처럼 표시한다.

확인 질문

확인 질문

  • Circuit Breaker가 open되었다는 것은 무엇을 의미하는가?
    • downstream 호출을 계속 보내는 것이 위험하다고 판단해 caller를 보호하기 위해 빠르게 차단한다는 뜻이다.
  • Bulkhead의 목적은 무엇인가?
    • 특정 dependency 장애가 전체 thread, connection, pool을 점유하지 못하도록 자원을 격리하는 것이다.
  • Fallback을 설계할 때 가장 조심할 점은 무엇인가?
    • 대체 응답이 사용자에게 잘못된 정상 상태로 보이지 않게 정확성, freshness, 제한을 명확히 해야 한다.

참고 문서