본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 21. 10:55

우리의 재시도 루프가 장애를 악화시켰다: 서킷 브레이커(Circuit Breaker)가 연쇄 장애를 막았다

요약

Anthropic API의 일시적인 장애 상황에서 과도한 재시도 정책이 오히려 Rate limiting을 유발하며 복구를 지연시킨 사례를 분석합니다. 이를 방지하기 위해 실패 임계값에 따라 호출을 차단하고 상태를 관리하는 Rust 기반의 경량 서킷 브레이커(Circuit Breaker) 구현 방식을 소개합니다.

핵심 포인트

  • 과도한 재시도 예산(Retry budget) 설정은 API 복구 후에도 백로그를 생성하여 연쇄 장애를 유발할 수 있음
  • 서킷 브레이커는 Closed, Open, HalfOpen의 세 가지 상태를 통해 시스템을 보호함
  • Open 상태에서는 API 호출을 즉시 차단하여 불필요한 리소스 낭비와 Rate limiting을 방지함
  • HalfOpen 상태에서 단 한 번의 시험 호출(Trial call)을 통해 시스템 복구 여부를 검증함
  • 400라인 미만의 간단한 Rust 코드로도 효과적인 장애 전파 방지 로직 구현 가능

몇 주 전, Anthropic이 높은 비율의 5xx 응답을 반환했던 22분간의 구간이 있었습니다. 완전한 장애(Outage)는 아니었지만, 성능 저하(Degraded) 상태였습니다. 우리의 에이전트 서비스에는 5xx 응답 시 백오프(Backoff)를 수행하고 다시 시도하는 재시도 정책(Retry policy)이 있었습니다. 6개의 워커(Worker)와 제가 너무 높게 설정해 두었던 공유 재시도 예산(Shared retry budget)으로 인해, 우리는 API가 에러를 반환하는 속도만큼 빠르게 실패한 호출을 다시 요청하고 있었습니다. API가 복구되었을 때, 우리는 진행 중인 재시도(In-flight retries)의 백로그(Backlog)를 가지고 있었고, 이는 우리를 즉시 다시 속도 제한(Rate limiting) 상태로 몰아넣었습니다. 잘못된 결정의 총 비용은 Anthropic 호출 약 18,000회 낭비와 그들의 측면이 복구된 후 추가로 발생한 9분의 복구 시간이었습니다. 사용자에게 직접적으로 드러나는 폭발적인 문제는 없었지만, 저는 마음이 좋지 않았습니다. 그래서 다음 날 llm-circuit-breaker를 작성했습니다. 이것은 작습니다. 전체 크레이트(Crate)는 400라인 미만의 Rust 코드로 이루어져 있습니다. 이는 llm-retry와 함께 사용할 수 있습니다.

상태 머신(State machine) 실패 횟수 >= 임계값(Threshold)

+-------+
| Closed|
+-------+ ----------------------> +------+
| | Open |
+-------+ <---------------------- +------+
^ half_open success | | | | half_open failure v | <----------------------- +-----------+ +---------------------------- | HalfOpen | cooldown elapsed +-----------+ Closed

  • Closed: 호출이 통과됩니다. 실패 횟수가 집계됩니다.
  • Open: API를 호출하지 않고 즉시 BreakerError::Open을 반환합니다. 쿨다운(Cooldown) 기간이 지나면 브레이커는 HalfOpen 상태로 전환됩니다.
  • HalfOpen: 정확히 하나의 시험 호출(Trial call)만 허용됩니다. 성공하면 다시 Closed로 돌아갑니다. 실패하면 쿨다운이 재설정된 채로 다시 Open으로 돌아갑니다.

이것이 상태 머신의 전부입니다. 리키 버킷(Leaky bucket)도 없고, 화려한 슬라이딩 윈도우(Sliding window)도 없습니다. 그저 통제 불능의 재시도가 부분적인 장애를 완전한 장애로 만드는 것을 막기에 충분할 뿐입니다.

코드에서 어떻게 보이는지 살펴보겠습니다. llm_circuit_breaker ::{ Breaker , BreakerConfig };를 사용합니다. use std :: time :: Duration ;을 사용합니다. let breaker = Breaker :: new ( BreakerConfig { failure_threshold : 5 , success_threshold : 1 , cooldown : Duration :: from_secs ( 30 ), });와 같이 초기화합니다. let result = breaker .call (|| async { client .messages () .create ( payload ) .await }) .await ;를 호출하고 그 결과를 확인합니다. 결과에 따라 다음과 같은 처리를 합니다: match result { Ok ( resp ) => handle ( resp ), Err ( BreakerError :: Open ) => { // 전체 호출을 건너뛰고, 캐시된 응답이나 대체(fallback) 값을 반환 } Err ( BreakerError :: Inner ( e )) => { // 상위 시스템에 장애가 발생했으므로, 브레이커가 카운트를 증가시킵니다 } } 모든 내부 요소는 Arc<Mutex<...>>로 되어 있어 여러 태스크에서 안전하게 공유할 수 있습니다. 또한 락을 오래 잡지 않고도 비용이 적게 드는 사전 확인(pre-check)을 위한 is_open() 메서드도 제공됩니다. 임계값 조정에 대해 솔직히 말하자면, 처음에는 숫자를 실수로 조정해서 잘못 설정했습니다. 몇 번의 반복 끝에 작동했던 내용은 다음과 같습니다. Anthropic messages.create를 단일 워커 프로세스에서 사용할 경우: failure_threshold: 5. 세 개는 너무 민감합니다. 열 개는 너무 느립니다. 다섯 개는 장애 호출이 발생하기까지 몇 초가 걸리는 정도입니다. cooldown: Duration::from_secs(30). 재개방 탐색(half-open probe)을 위해 스팸처럼 요청하지 않을 만큼 충분히 길고, 복구가 빠를 만큼 짧습니다. success_threshold: 1. 좋은 응답 하나만으로도 충분합니다. 이 브레이커는 건강 상태 시스템이 아니라 손절매(stop-loss)입니다. 여러 워커 풀에서 하나의 브레이커를 공유하는 경우 (현재 프로덕션 환경에서 우리가 사용하는 방식): failure threshold를 워커 수의 제곱근에 비례하여 조정해야 하며, 선형적으로 조정해서는 안 됩니다. 여섯 개의 워커가 작동하려면 30번의 장애가 발생할 필요는 없습니다. 아마도 12회 정도면 충분할 것입니다. cooldown은 동일하게 유지합니다. cooldown은 사용자 측이 아니라 상위 시스템에 관한 것이기 때문입니다. 제가 힘든 경험을 통해 배운 점: 워커별 브레이커(per-worker breaker)는 공유 브레이커(shared breaker)와 다릅니다. 워커별 방식에서는 모든 워커가 독립적으로 상위 시스템의 장애를 학습해야 합니다. 공유 방식에서는 한 워커의 실패가 다른 워커들을 보호합니다. 현재 우리는 업스트림 제공업체당 하나의 공유 브레이커를 사용하고 있습니다. 재시도(retry)는 지수 백오프(exponential backoff)와 지터(jitter)를 사용하여 구성됩니다. llm-circuit-breaker는 브레이커가 열렸을 때 재시도를 차단합니다.

이 두 가지를 함께 사용하면 드문 일시적 오류(rare-flake) 케이스(재시도가 처리)와 연쇄 장애(cascading-failure) 케이스(브레이커가 처리)를 모두 방지할 수 있습니다.

use llm_retry::{retry, RetryConfig};
use llm_circuit_breaker::Breaker;

let cfg = RetryConfig {
    max_attempts: 4,
    base_delay: Duration::from_millis(500),
    max_delay: Duration::from_secs(8),
    ..Default::default()
};

retry(cfg, || async {
    breaker.call(|| async {
        client.messages().create(p.clone()).await
    }).await
}).await

브레이커가 열려(open) 있다면, 내부 클로저(closure)는 즉시 BreakerError::Open을 반환합니다. llm-retry는 이를 재시도 불가능한 오류로 인식하여(그렇게 설정되어 있습니다) 빠르게 종료합니다. 재시도 폭풍(retry storm)이 발생하지 않습니다.

제가 수행한 드라이 런(dry-run) 수치입니다. 처음 60초 동안은 5xx를 반환하고 그 이후에는 200을 반환하는 모의 서버(mock server)를 대상으로 시뮬레이션을 실행했습니다.

  • 브레이커 없이 4회 재시도 정책을 사용할 경우: 장애 구간 동안 1,140개의 요청이 낭비되었으며, 복구 시점에 백로그(backlog)가 가득 찼습니다.
  • 브레이커 사용 시 (임계값(threshold)=5, 쿨다운(cooldown)=30s): 19개의 요청만 낭비되었으며, 하나의 반개방(half-open) 프로브(probe)를 통해 깔끔하게 복구되었습니다.

이것이 바로 "장애를 인지했다"와 "장애를 스스로 더 악화시켰다"의 차이입니다.

이것이 해결하지 못하는 것

  • 상위 시스템(upstream)이 에러를 내지는 않지만 느려진 상태는 감지하지 못합니다. 모든 호출이 25초가 걸리지만 결국 200을 반환한다면, 브레이커는 닫힌(closed) 상태를 유지합니다. 이를 위해서는 지연 시간 기반의 트리프와이어(latency-based tripwire)가 필요하며, 저는 아직 이를 추가하지 않았습니다. agenttrace-rs를 사용하면 최소한 p95를 드러내 주므로 인지할 수는 있을 것입니다.
  • 프로세스 간에 조정되지 않습니다. 서비스의 각 복제본(replica)은 자신만의 브레이커를 가집니다. 전역적으로 조정되는 브레이커를 원한다면 공유 저장소(Redis 등)가 필요하며, 이 크레이트(crate)는 그것을 제공하지 않습니다.
  • 쿨다운(cooldown)이 고정되어 있습니다. 반복적인 트리핑(tripping) 발생 시 늘어나는 적응형 쿨다운(adaptive cooldown)은 없습니다. 고려해 보았으나, 고정 쿨다운이 충분히 정직하다고 판단했습니다.
  • 반개방(half-open) 상태에서는 정확히 하나의 프로브만 허용합니다. 트래픽이 매우 높아서 복구 과정이 단일 프로브에 의해 병목 현상이 생기는 것을 원치 않는다면, 토큰 버킷(token-bucket) 방식의 반개방이 필요할 것입니다. 현재 구현되어 있지 않습니다.
  • 이 크레이트는 특정 비동기 런타임(async-runtime)에 종속되지 않습니다.

이것은 tokio, async-std 또는 sync 환경에서 작동합니다 (클로저 형태가 변경됩니다). Repo: https://github.com/MukundaKatta/llm-circuit-breaker crates.io: llm-circuit-breaker = "0.1". 제가 발행하는 LLM 배관 작업(plumbing)을 위한 소규모 Rust 크레이트 스택(retry, budget, repair, cost, trace)의 일부입니다. llm-retry 및 agenttrace-rs와 깔끔하게 결합됩니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0