이번 주 발생한 두 차례의 Claude 네트워크 장애: 이를 통해 드러난 재시도 로직(Retry-logic)의 버그
요약
최근 Anthropic의 Claude 서비스에서 발생한 네트워크 장애 사례를 통해, 단순 연속 실패 기반의 서킷 브레이커가 가진 한계를 분석합니다. 서비스가 완전히 중단되지 않고 불안정한 상태(flaky)로 유지될 때 발생하는 재시도 로직의 문제점과 해결책을 다룹니다.
핵심 포인트
- 단순 연속 실패 기반 서킷 브레이커는 용량 감소형 장애를 감지하지 못함
- 불안정한 상태에서 재시도가 반복될 경우 불필요한 비용이 발생함
- 연속 실패 횟수 대신 이동 창(rolling window) 기반의 에러 비율 추적 권장
7월 29일부터 31일까지 약 36시간 동안 Claude의 서빙 인프라(serving infrastructure)에서 네 차례의 별개 사고가 발생했습니다. 단 한 번의 중단이 아니라, 각각 다른 형태와 범위를 가진 네 번의 사고였습니다.
독립적인 상태 추적에 따르면: 29일 저녁 모든 모델에 걸쳐 40분간 발생한 중단, 다음 날 아침 4시간 이상 지속된 오류 증가 구간, 그날 오후 Claude Opus 4.8에서 발생한 40분간의 "성능 저하(degraded performance)" 기간, 그리고 31일 아침 Claude Sonnet 5에서 발생한 9분간의 짧은 성능 저하 현상이 있었습니다. Anthropic의 자체 성명에 따르면 처음 두 사고의 메커니즘은 다음과 같았습니다: 여러 개의 별개 네트워크 장애(network failures)가 서빙 용량(serving capacity)을 감소시켰고, 경로 재설정(reroute) 응답 자체가 일부 요청을 실패하게 만들었습니다.
마지막 세부 사항은 구축 시 고려할 가치가 있는 부분입니다. 그 해결책이 자체적인 실패를 야기했습니다. 만약 당신의 서킷 브레이커(circuit breaker)가 "다운(down)" 상태를 감지하는 방법만 알고 있다면, 이 전체 구간은 다른 무언가, 즉 "불안정한(flaky)" 상태로 보일 것입니다.
두 가지 실패 형태, 두 가지 매우 다른 비용
하드 실패(Hard failure). 제공자가 연결 거부(connection refused), 503, 타임아웃(timeout)과 같이 빠르고 명확한 에러를 반환합니다. 모든 합리적인 재시도 라이브러리(retry library)는 첫 번째 또는 두 번째 시도에서 이를 감지하며, 강하게 백오프(back off)하거나 서킷을 엽니다. 이것이 대부분의 에이전트 프레임워크(agent frameworks)가 구축되는 실패 모드이며, 진정으로 쉬운 사례입니다.
용량 형태의 실패(Capacity-shaped failure). 이것이 실제 상황에서 "오류 증가(elevated errors)"와 "가용성 감소(reduced availability)"가 의미하는 바입니다: 제공자가 아무것도 서비스하지 않는 것이 아니라 감소된 용량으로 서비스를 제공하기 때문에, 몇 시간 동안 일부 호출은 성공하고, 일부는 타임아웃되며, 일부는 실패하는 현상이 뒤섞여 나타납니다. 단순한 연속 실패 기반의 브레이커(consecutive-failure breaker)는 이를 포착하도록 설계되지 않았습니다:
typescript
// N번의 연속적인 실패 시에만 작동합니다.
// 용량 형태의 사고는 좀처럼
// 5번의 연속된 실패를 발생시키지 않습니다.
// 대신 40번의 성공적인 호출 사이에
// 5번의 실패가 흩어져서 나타납니다.
class NaiveBreaker {
private consecutiveFailures = 0;
private readonly threshold = 5;
recordResult(ok: boolean) {
this.consecutiveFailures = ok ? 0 : this.consecutiveFailures + 1;
}
isOpen() {
return this.consecutiveFailures >= this.threshold;
}
}
중간에 섞여 들어오는 성공(success)이 카운터를 매번 초기화합니다. 서킷 브레이커(Breaker)는 절대 열리지 않습니다. 당신의 재시도 로직(Retry-logic)은 4시간 동안 불안정한 분포(flaky distribution)를 계속 샘플링하게 되며, 모든 샘플은 비용이 발생하는 호출(billed call)이 됩니다.
해결책은 연속된 실패 횟수를 세는 것을 멈추고, 일정 구간(window) 동안의 비율(rate)을 추적하는 것입니다:
typescript
// 실패가 연속적인지 여부와 상관없이,
// 이동 창(rolling window) 동안의 에러 비율(error RATE)에 따라 작동합니다.
class RollingRateBreaker {
private window: boolean[] = []; // true = 성공
private readonly windowSize = 20;
private readonly errorRateThreshold = 0.3;
recordResult(ok: boolean) {
this.window.push(ok);
if (this.window.length > this.windowSize) this.window.shift();
}
isOpen() {
if (this.window.length < this.windowSize) return false;
const errorRate = this.window.filter((r) => !r).length / this.window.length;
return errorRate >= this.errorRateThreshold;
}
}
20번의 호출 중, 해당 구간 내 어디에서든 6번 이상의 실패가 발생하면, 그 실패들이 몰려 있든 흩어져 있든 서킷 브레이커가 작동합니다. 이것이 용량 문제로 발생하는 장애(capacity-shaped incidents)의 실제 동작 방식과 일치합니다.
복구 이후에 기다리고 있는 청구서
두 번째 비용이 발생하는 지점은, 에이전트(agent)에 폴백 제공자(fallback provider)가 설정되어 있을 때만 나타납니다. 장애 발생 도중 당신의 하네스(harness)가 두 번째 모델로 폴백했다고 가정해 봅시다. 작업은 그곳에서 계속 실행됩니다. 그러다 기본 제공자(primary)가 복구되었을 때, 어떤 제공자가 실제로 작업을 완료했는지 추적하는 장치가 없다면, 양쪽 모두에서 동일한 작업을 중복 수행하게 될 위험이 있으며, 이는 하나의 논리적 작업(logical task)에 대해 두 번의 비용을 지불하는 결과로 이어집니다.
이는 가설이 아닙니다. 이번 주 발생한 장애 보고서(incident write-ups)에서 기본 제공자가 복구되었을 때 하네스 간에 작업을 중복하지 말라고 명시적으로 경고했을 정도로 구체적인 문제입니다.
해결책은 제공자(provider)가 아닌 논리적 작업(logical task)을 키(key)로 하는 작은 예약 장부(reservation ledger)를 만드는 것입니다:
typescript
interface TaskAttempt {
taskId: string;
provider: string;
status: 'in_flight' | 'completed';
}
const attempts = new Map();
function beforeCall(taskId: string, provider: string) {
const existing = attempts.get(taskId) ?? [];
const doneElsewhere = existing.find(
(a) => a.status === 'completed' && a.provider !== provider
);
if (doneElsewhere) {
throw new Error(
Task ${taskId} already completed on ${doneElsewhere.provider}
);
}
existing.push({ taskId, provider, status: 'in_flight' });
attempts.set(taskId, existing);
}
단 하나의 Map. 호출이 나가기 전 단 한 번의 체크. 이것은 "장애로 인해 4시간 동안 서비스 품질이 저하되었습니다"와 "장애로 인해 4시간 동안 서비스 품질이 저하되었을 뿐만 아니라, 여전히 실행 중인 것을 잊어버린 폴백 제공자(fallback provider)로부터 중복 청구서까지 받았습니다"의 차이를 만듭니다.
이번 주에 실제로 변경해야 할 사항
최소한 제공자 호출(provider calls)에 대해서는 연속 실패 차단기(consecutive-failure breakers)를 이동 평균 기반 차단기(rolling-rate breakers)로 교체하십시오.
폴백 제공자(fallback provider)를 운영한다면, 작업 완료 여부를 제공자(provider)가 아닌 작업 ID(task ID) 기준으로 관리하고, 첫 번째 호출뿐만 아니라 모든 호출 전에 이를 확인하십시오.
"성능 저하(degraded performance)" 상태 항목을 "다운(down)"과는 별개의 경고 카테고리로 취급하십시오. 이들은 우선순위가 낮은 것이 아닙니다. 오히려 여러분의 재시도 로직(retry logic)이 처리하기 가장 어려운 항목들입니다.
이 중 그 어떤 것도 네트워크 장애 자체를 방지하지는 못합니다. 이것은 네트워크 장애가 발생한 이후, 그 장애가 여러분에게 어떤 비용을 치르게 할지를 결정하는 계층입니다.
이것이 바로 AI CostGuard가 호출을 실행하기 전에 확인하는 정확한 유형의 장애입니다. 즉, 제공자(provider)가 작동 중인지가 아니라, 지금 나가려는 호출이 이미 다른 곳에서 비용을 지불한 호출인지를 확인하는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기