운영 환경을 망가뜨리지 않고 멀티 모델 API 장애에 대응하는 방법
요약
LLM API 플랫폼 장애 시 단순한 모델 폴백(fallback)과 재시도 로직이 오히려 시스템 부하를 가중시키는 문제를 분석합니다. 공유 인프라 장애 상황에서 발생하는 '재시도 폭풍'의 위험성을 경고하고 대응 방안을 제시합니다.
핵심 포인트
- 모델 다양성이 반드시 시스템의 회복탄력성을 보장하지 않음
- 공유 인프라 장애 시 모델 전환(Opus→Sonnet)은 해결책이 될 수 없음
- 공격적인 재시도 로직은 장애 상황에서 트래픽 증폭기로 작용함
- 플랫폼 수준의 장애를 고려한 정교한 에러 핸들링 설계 필요
대시보드가 빨간색으로 변합니다. 단 하나의 모델이 아닙니다. 전부 다 그렇습니다. 재시도(Retries)가 급증합니다. 지연 시간(Latency)이 상승합니다. 아무것도 복구되지 않습니다.
모든 Claude 호출이 한꺼번에 실패하기 시작할 때
LLM API를 기반으로 진지한 서비스를 출시했다면, 여러분도 이런 순간을 경험해 보았을 것입니다.
몇 분 전까지만 해도 잘 작동하던 요청들이 이제 에러를 반환합니다. 모델 플래그(model flag)를 바꿔봅니다. 똑같습니다. 재시도 횟수를 늘려봅니다. 에러율은 낮아지지 않고 오히려 올라갑니다. 큐(Queues)가 쌓입니다. 다운스트림 서비스(Downstream services)가 그 영향을 받습니다.
이것은 프롬프트(prompt) 문제가 아닙니다. 잘못된 배포(deploy) 문제도 아닙니다. 이것은 클라이언트 측에서 바라본 플랫폼 수준의 장애(platform-level incident)가 어떤 모습인지 보여주는 것입니다.
최근 "여러 모델에 걸친 에러율 상승"으로 명명된 Anthropic의 장애는 운영 환경에서 가장 중요한 실패 모드(failure mode)의 명확한 예시입니다. 멀티 모델(Multi-model)에 미치는 영향. 수치도 없고, 조절할 수 있는 노브(knobs)도 없습니다. 그저 에러뿐입니다.
팀들이 저지르는 실수는 모델의 다양성이 곧 회복탄력성(resilience)을 의미한다고 가정하는 것입니다. 그렇지 않습니다.
플랫폼 장애 발생 시 모델 폴백(fallback)이 조용히 실패하는 이유
대부분의 LLM 클라이언트 스택은 단순한 아이디어를 기반으로 구축됩니다: 모델 A가 실패하면, 모델 B를 시도한다.
이 방식은 실패의 범위가 특정 모델에 국한될 때는 작동합니다. 하지만 영향 범위(blast radius)가 공유 인프라(shared infrastructure)일 때는 실패합니다.
내부적으로 모델들은 종종 프론트 도어 API 게이트웨이(API gateways), 인증(auth) 및 할당량(quota) 시스템, 라우팅 계층(routing layers), 그리고 공유 추론 클러스터(inference clusters) 또는 스케줄러(schedulers)를 공유합니다.
여러 모델에 걸쳐 에러율이 상승한다는 것은 보통 모델 상위의 무언가가 저하되었음을 의미합니다. Opus에서 Sonnet으로 전환하는 것은 문제를 우회하지 못합니다. 왜냐하면 문제는 Opus나 Sonnet이 아니기 때문입니다.
문서들은 이 점을 강조하지 않습니다. SDK 예제들은 모델 간의 독립성을 가정하는 재시도 및 폴백 루프(retry and fallback loops)를 적극적으로 권장합니다. 운영 환경에서 이러한 가정은 무시할 수 없을 정도로 자주 틀립니다.
실제 시스템에서 이러한 실패가 나타나는 모습
이러한 장애 상황에서 제가 흔히 목격하는 일반적인 시퀀스는 다음과 같습니다. 에러율이 1% 미만에서 두 자릿수로 급증합니다. 클라이언트들은 공격적으로 재시도(retry)를 수행합니다. 재시도 트래픽은 이미 성능이 저하된 시스템의 부하를 증폭시킵니다. 지연 시간(latency)이 증가하며 타임아웃(timeout)을 유발합니다. 큐(queue)가 쌓입니다. 백그라운드 작업(background jobs)이 뒤처집니다. 엔지니어들은 수동으로 스로틀링(throttling)을 시작합니다.
가장 위험한 부분은 기술적으로 아무것도 "다운(down)"되지 않았다는 점입니다. 상태 확인(health check)은 통과합니다. 일부 요청은 성공합니다. 이것이 재시도 폭풍(retry storm)을 계속 유지시킵니다.
만약 당신이 재시도 로직만 구축했다면, 당신은 트래픽 증폭기를 구축한 것입니다.
상황을 악화시키는 순진한 재시도 루프
이것은 제가 여전히 운영 코드에서 보고 있는 패턴입니다:
from anthropic import Anthropic
import time
...
이것은 합리적으로 보입니다. 하지만 그렇지 않습니다.
플랫폼 장애가 발생하는 동안, 모든 재시도는 동일한 실패 지점에 도달하며, 백오프(backoff)를 사용하더라도 여전히 동기화된 재시도 파동을 만들어내어, 최악의 순간에 전역적인 부하에 기여하게 됩니다. 이 패턴은 낮은 트래픽 규모에서는 버틸 수 있습니다. 하지만 대규모(scale)에서는 연쇄적인 장애(cascade)를 일으킵니다.
진정한 해결책은 재시도가 아니라 서킷 브레이커(circuit breaker)에서 시작됩니다
당신에게 가장 먼저 필요한 제어 장치는 더 많은 재시도가 아닙니다. "이 API를 호출하지 마세요"라고 말할 수 있는 서킷 브레이커(circuit breaker)입니다.
서킷 브레이커는 반복되는 실패를 빠른 로컬 결정으로 전환합니다. 다음은 Redis를 공유 상태(shared state)로 사용하는 최소한의 브레이커 예시입니다:
import redis
import time
...
브레이커는 워커(worker)들 간에 공유됩니다. 오픈(open) 상태는 자동으로 만료됩니다. 느린 실패보다는 빠른 실패(fail fast)가 언제나 승리합니다. 이것만으로도 당신의 시스템을 스스로 초래한 피해로부터 구할 수 있습니다.
재시도가 여전히 중요한 이유, 하지만 경계 내부에서만
재시도 자체가 악한 것은 아닙니다. 경계가 없는(unbounded) 재시도가 악한 것입니다.
효과적인 패턴은 브레이커 내부에 감싸진, 적은 재시도 횟수와 타이트한 타임아웃(timeout)을 사용하는 것입니다:
def safe_claude_call(prompt):
def attempt():
return client.messages.create(
...
무엇이 빠져 있는지 주목하십시오: 30초까지 늘어나는 지수 백오프(exponential backoff)도 없고, 10회가 넘는 재시도 횟수도 없으며, 끈기가 성공을 보장한다는 맹목적인 믿음도 없습니다. 장애 규모가 커질수록, 더 짧고 더 적은 재시도가 승리합니다.
모델 폴백(Model fallback)은 회복 탄력성이 아닙니다, 제공자 폴백(provider fallback)이 회복 탄력성입니다
만약 여러 모델이 동시에 장애를 일으킨다면, 유일한 진정한 격리(isolation)는 제공자(provider) 경계에서 이루어집니다. 이는 여러분의 폴백 그래프(fallback graph)가 모델 제품군(model families)이 아닌, 벤더(vendors)를 넘나들어야 함을 의미합니다.
def generate(prompt):
try:
return safe_claude_call(prompt)
...
이것은 선호도의 문제가 아닙니다. 장애 도메인(failure domains)에 관한 문제입니다. 만약 여러분의 비즈니스가 저하된 LLM 출력(LLM output)을 견딜 수 없다면, 멀티 제공자(multi-provider) 지원이라는 복잡성 비용(complexity tax)을 지불해야 합니다. 여기에는 지름길이 없습니다.
격벽(Bulkheads)은 하나의 기능 장애가 전체를 침몰시키는 것을 방지합니다
장애 발생 시 나타나는 또 다른 실패 패턴은 자원 고갈(resource starvation)입니다. 요약(summarization) 작업이 모든 워커(workers)를 점유해 버리면, 사용자 대상 요청(user-facing requests)이 타임아웃(time out)됩니다. 모두가 불만족스러워집니다.
격벽(Bulkheads)은 워크로드(workloads)를 격리함으로써 이를 방지합니다:
queues:
llm_user_requests:
concurrency: 50
...
오류율이 높아지는 동안에도 사용자 트래픽은 응답성을 유지하고, 백그라운드 작업은 느려질 뿐 모든 것이 멈추지는 않습니다. 만약 단 하나의 큐(queue)만 사용하고 있다면, 여러분은 격리(isolation)를 갖추고 있지 않은 것입니다.
멱등성(Idempotency)은 재시도 부작용으로부터 여러분을 보호합니다
요청에 부작용(side effects)이 수반될 때 재시도(retries)는 위험합니다. 만약 LLM 호출이 쓰기(writes), 알림(notifications), 또는 과금 이벤트(billing events)를 트리거한다면, 재시도는 작업을 중복시킬 수 있습니다.
멱등성 키(idempotency keys)를 일관되게 사용하세요:
idempotency_key = f"summary:{document_id}"
client.messages.create(
...
장애가 발생하면 중복 요청이 흔히 발생합니다. 멱등성(Idempotency)은 혼돈을 정확성(correctness)으로 바꿉니다.
오류율이 높을 때 실제로 중요한 모니터링 지표는 무엇인가
대부분의 팀은 요청 수(request count)와 평균 지연 시간(average latency)을 모니터링합니다. 그것만으로는 충분하지 않습니다. 장애 상황에서는 제공자별 오류율(error rate by provider), 서킷 브레이커 상태(circuit breaker state), 재시도 볼륨(retry volume), 그리고 큐 깊이 증가량(queue depth growth)이 필요합니다.
충분한 가치를 증명하는 간단한 지표는 재시도 증폭(retry amplification)입니다:
select
sum(retry_attempts) / sum(original_requests) as retry_factor
from llm_request_metrics
...
만약 이 수치가 1.5를 넘어간다면, 여러분은 아마 상황을 더 악화시키고 있는 것입니다.
상태 페이지(status pages)가 당장 상황을 해결해 주지 못하는 이유
상태 페이지(status pages)는 두 가지 사실을 알려줍니다. 무언가 잘못되었으며, 누군가가 이를 수정하고 있다는 사실입니다. 하지만 상태 페이지는 재시도(retries)가 얼마나 오래 실패할지, 어떤 엔드포인트(endpoints)가 안전한지, 혹은 부분적인 복구(partial recovery)가 실제로 이루어지고 있는지에 대해서는 알려주지 않습니다.
여러분의 시스템은 불확실성을 가정해야 합니다. 이것이 바로 외부 신호보다 로컬 제어 메커니즘(local control mechanisms)이 더 중요한 이유입니다. 만약 여러분의 유일한 대응책이 상태 표시가 초록색으로 변하기를 기다리는 것이라면, 여러분은 이미 뒤처진 것입니다.
우아한 성능 저하(Graceful degradation)가 완전한 장애(hard failure)보다 낫다
LLM(Large Language Models)을 사용할 수 없을 때, 많은 제품이 기본적으로 에러를 반환합니다. 이는 종종 불필요한 조치입니다. 캐시된 응답(cached responses)을 반환하거나, 기본적인 작업을 위해 더 작은 로컬 모델(local models)을 사용하거나, 중요하지 않은 데이터 보강(enrichment)을 건너뛰거나, 생성을 비동기 작업(async jobs)으로 미룰 수 있습니다.
간단한 캐시 폴백(cache fallback) 예시:
cached = cache.get(prompt_hash)
if cached:
return cached
...
장애 발생 중에는 캐시 히트율(cache hit rates)이 올라갑니다. 이는 여러분에게 시간을 벌어줍니다.
이 조언이 적용되지 않는 경우
이 중 어떤 것도 도움이 되지 않는 경우가 있습니다. 워크로드(workload)가 실시간이며 캐싱이 불가능한 경우, 출력의 정확성이 법적으로 매우 중요한 경우, 또는 계약상 단일 제공자(provider)에게 종속되어 있는 경우입니다.
그러한 경우, 여러분의 유일한 선택지는 승인 제어(admission control)입니다. 초기에 거절하십시오. 시스템의 나머지 부분을 보호하십시오. 모든 요청이 타임아웃(timeout)되는 것보다 요청을 깔끔하게 거절(rejecting)하는 것이 더 낫습니다.
서킷 브레이커(breakers)만으로 부족할 때의 승인 제어(Admission control)와 부하 차단(load shedding)
서킷 브레이커(Circuit breakers)는 장애 발생 후 호출을 중단합니다. 승인 제어(Admission control)는 장애 발생 전 호출을 중단합니다. 플랫폼 장애가 발생하는 동안 여러분이 할 수 있는 최악의 행동은 완료할 수 없는 무제한의 작업을 수락하는 것입니다. 여러분에게는 엄격한 상한선(hard cap)이 필요합니다.
제공자별로 간단한 토큰 버킷(token bucket)을 사용하는 것이 효과적입니다:
import time
from collections import deque
...
사용법:
if not claude_gate.acquire():
raise RuntimeError("Claude overloaded locally")
try:
...
부분적인 장애가 발생하는 동안, 에러율(error rate)이 급증하기 전에 대기 시간(latency)이 종종 두 배로 늘어납니다. 진행 중인 요청(Inflight requests)들이 조용히 쌓이게 됩니다. 제공자가 결국 에러를 반환하더라도 여러분의 워커 풀(worker pool)은 포화 상태가 됩니다.
제가 목격한 구체적인 수치는 다음과 같습니다: 정상적인 p95 지연 시간(latency)은 1.2초였으나, 장애 발생 시 p95 지연 시간은 8~12초로 치솟았고, 90초 이내에 워커(worker)가 고갈되었습니다. 부하 차단(Load shedding)은 꼬리 지연 시간(tail latency)을 제한된 범위 내로 유지하고 핵심 경로(critical paths)를 위한 용량을 보존합니다. 다소 투박한 방법이지만, 효과가 있습니다.
헤징 요청(Hedged requests)은 똑똑해 보이지만 LLM API에서는 대개 역효과를 냅니다
일부 팀은 헤징 요청(hedged requests)을 시도합니다. 즉, 동일한 프롬프트를 두 개의 모델이나 제공자에게 동시에 보내고 먼저 도착하는 응답을 사용하는 방식입니다. 이는 단일 요청의 지연 시간 변동성(latency variance)을 해결하는 데 도움이 될 수 있습니다. 하지만 장애 상황에서는 불에 기름을 붓는 격입니다.
하지 말아야 할 예시:
def hedged_generate(prompt):
return first_completed([
lambda: call_claude(prompt),
...
용량이 제한된 바로 그 순간에 트래픽을 두 배로 늘리게 되며, 속도 제한(rate limits)을 더 빨리 유발하고, 상류(upstream)의 재시도 폭풍(retry storms)을 증폭시킵니다. 게이트(gate)를 두지 않는다면 제공자 간 헤징(cross-provider hedging)조차 위험합니다:
def cautious_hedge(prompt):
if system_healthy():
return call_claude(prompt)
...
이를 안전하게 유지하기 위한 규칙은 다음과 같습니다: 지연 시간 임계값(latency threshold)을 넘었을 때만 헤징을 수행하고, 재시도(retries) 과정 내부에서는 절대 헤징을 하지 마십시오. 또한 에러율(error rate)이 상승하면 자동으로 헤징을 비활성화해야 합니다. 헤징은 최적화(optimization)입니다. 장애 상황은 최적화를 할 때가 아닙니다.
운영 환경이 대신 망가지기 전에 실제로 테스트하는 방법
대부분의 LLM 장애 처리 코드는 테스트되지 않은 상태입니다. 그렇기 때문에 스트레스 상황에서 실패하는 것입니다. 여러분은 세 가지 레버를 사용하여 로컬에서 플랫폼 장애를 시뮬레이션할 수 있습니다: 강제 에러 응답, 인위적인 지연 시간, 그리고 부분적 성공률입니다.
import random
import time
...
그 다음 부하 테스트(load tests)를 실행하며 서킷 브레이커(circuit breaker)의 개방률(open rate), 시간에 따른 큐 깊이(queue depth), 수락 거부(admission rejections), 그리고 재시도 증폭 지표(retry amplification metric)를 관찰하십시오. 서킷 브레이커가 빠르게 열리고, 진행 중인 요청 수(inflight count)가 평탄하게 유지되며, 비핵심 큐(non-critical queues)가 가장 먼저 느려지는 것을 확인해야 합니다. 만약 테스트 결과 트래픽이 멈춘 후에야 복구가 시작된다면, 여러분의 제어 장치는 너무 약한 것입니다.
이는 단순히 혼란을 위한 혼란(chaos for chaos' sake)이 아닙니다. 여러분의 장애 처리 로직이 부하를 재분배하는 대신 실제로 부하를 줄여주는지 검증하는 과정입니다.
마치며
여러 모델에 걸쳐 발생하는 높은 에러율 (error rates)은 더 이상 예외적인 상황이 아닙니다. 이는 플랫폼 규모의 AI 서비스에서 발생하는 일반적인 장애 모드 (failure modes)입니다.
만약 여러분의 회복 탄력성 (resilience) 전략이 단순히 모델을 전환하고 운에 맡기는 것이라면, 매번 동일한 장애를 반복해서 겪게 될 것입니다.
해결책은 영리한 프롬프트 (prompts)나 더 나은 SDK 기본 설정이 아닙니다. 이는 결제 시스템, 검색 백엔드 (search backends), 그리고 메시지 브로커 (message brokers)를 부하 상황에서도 안정적으로 유지시키는 것과 동일한 규율입니다.
LLM을 그 자체로 중요한 인프라 (critical infrastructure)처럼 취급하십시오. 앞으로 발생할 장애가 여전히 고통스럽겠지만, 적어도 모든 시스템을 한꺼번에 무너뜨리지는 않을 것입니다.
리소스 및 참고 문헌 (Resources & References)
- Anthropic Incident: Elevated error rate across multiple models
- AWS Architecture Blog: Exponential Backoff and Jitter
- Google SRE Book: Handling Overload
- Stripe Engineering: Designing APIs for failure
소통하기 (Stay in Touch)
X에서의 짧은 글과 토론 → https://x.com/sebuzdugan
YouTube에서의 실용적인 AI / ML 영상 → https://www.youtube.com/@sebuzdugan/
파트너십 및 협업 → sebuzdugan@gmail.com
원문은 Medium에 게시되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기