Published on

Claude API 529 Overloaded 재시도·폴백 설계

Authors

Claude API를 운영 환경에 붙이다 보면, 요청이 정상인데도 간헐적으로 529(Overloaded) 응답을 만나는 순간이 있습니다. 이 코드는 애플리케이션 버그라기보다 공급자 측 과부하 또는 순간적인 용량 부족을 의미하는 경우가 많습니다. 문제는 “그럼 재시도하면 되지”로 끝나지 않는다는 점입니다.

  • 재시도가 동시에 몰리면 오히려 과부하를 악화시켜 더 오래 실패합니다.
  • 사용자 요청은 기다려주지 않습니다. 지연이 커지면 UX가 무너집니다.
  • 비동기 작업(요약, 분류, 임베딩 등)은 큐로 넘기는 편이 전체 안정성에 유리합니다.

이 글에서는 529를 포함한 과부하 계열 실패를 재시도 정책, 서킷 브레이커, 폴백(모델/기능/캐시/큐), 관측(Observability) 관점에서 한 덩어리로 설계하는 방법을 정리합니다.

문맥상 함께 보면 좋은 글:

529 Overloaded를 “일시 장애”로만 보면 생기는 함정

529는 대체로 “잠깐 기다렸다 다시 시도하면 된다”에 가깝지만, 운영에서는 다음 함정이 큽니다.

  1. 동시 재시도 폭풍(thundering herd)
  • 트래픽 피크에 529가 뜨면, 다수의 워커/서버가 동시에 재시도합니다.
  • 재시도 자체가 추가 부하가 되어 회복 시간을 늘립니다.
  1. 동일 요청의 중복 처리 비용
  • LLM 호출은 비싸고 느립니다.
  • 동일 입력을 여러 번 보내는 순간 비용과 지연이 동시에 증가합니다.
  1. 사용자 체감 지연의 급증
  • “최대 5회 재시도” 같은 단순 정책은 최악의 경우 응답 시간을 수십 초로 늘릴 수 있습니다.
  • 특히 동기 API(채팅, 검색 보조)에서는 타임아웃 전에 폴백이 필요합니다.

따라서 529 대응의 핵심은 “언제까지 재시도할지”와 “재시도 실패 시 무엇으로 대체할지”를 함께 정하는 것입니다.

재시도 설계의 기본: 지수 백오프 + 지터 + 예산

1) 지수 백오프(Exponential Backoff)

재시도 간격을 base * 2^n 형태로 늘려, 회복 시간을 줍니다.

2) 지터(Jitter)

모든 클라이언트가 같은 타이밍에 재시도하지 않도록 랜덤을 섞습니다.

  • Full jitter: sleep = random(0, backoff)
  • Equal jitter: sleep = backoff/2 + random(0, backoff/2)

3) 재시도 예산(Retry budget)

재시도는 “무제한”이 아니라 예산입니다.

  • 요청 단위: 최대 재시도 횟수, 최대 누적 대기 시간
  • 서비스 단위: 분당 재시도 총량 제한(재시도 트래픽이 원 트래픽의 몇 %를 넘지 않게)

아래는 Node.js(Typescript)에서 529에 대해 지수 백오프+지터+예산을 적용한 예시입니다.

type ClaudeCall<T> = () => Promise<T>

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms))
}

function isRetryableStatus(status: number) {
  // 과부하/일시 장애 계열을 묶어서 취급
  return status === 529 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504
}

export async function withRetry<T>(fn: ClaudeCall<T>, opts?: {
  maxAttempts?: number
  baseDelayMs?: number
  maxDelayMs?: number
  maxTotalSleepMs?: number
}) {
  const maxAttempts = opts?.maxAttempts ?? 5
  const baseDelayMs = opts?.baseDelayMs ?? 250
  const maxDelayMs = opts?.maxDelayMs ?? 5_000
  const maxTotalSleepMs = opts?.maxTotalSleepMs ?? 8_000

  let totalSleep = 0
  let lastErr: any

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn()
    } catch (err: any) {
      lastErr = err
      const status = err?.status ?? err?.response?.status

      if (!status || !isRetryableStatus(status) || attempt === maxAttempts) {
        throw err
      }

      const exp = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt - 1))
      const jitter = Math.floor(Math.random() * exp) // full jitter
      const delay = Math.min(jitter, maxTotalSleepMs - totalSleep)

      if (delay <= 0) {
        throw err
      }

      await sleep(delay)
      totalSleep += delay
    }
  }

  throw lastErr
}

포인트는 maxTotalSleepMs 같은 누적 대기 상한입니다. 동기 요청은 “재시도하다가 타임아웃”이 가장 나쁜 결말이므로, 일정 시간 내에 안 되면 폴백으로 넘어가야 합니다.

재시도만으로 부족할 때: 서킷 브레이커 + 동시성 제한

1) 서킷 브레이커(Circuit Breaker)

529가 일정 비율 이상 지속되면, 당분간 Claude 호출을 “빠르게 실패(fail fast)”시키고 폴백으로 보냅니다.

  • Closed: 정상 호출
  • Open: 일정 시간 호출 차단(즉시 폴백)
  • Half-open: 일부만 테스트 호출 후 회복 판단

이를 적용하면 장애 시 재시도 폭풍을 막고, 시스템 전체를 보호할 수 있습니다.

2) 동시성 제한(Concurrency limit)

한 프로세스/한 노드에서 Claude로 나가는 동시 요청 수를 제한합니다.

  • 동시성 제한은 “우리 서비스가 공급자에 주는 압력”을 일정하게 만들고, tail latency를 줄입니다.

간단한 세마포어 예시입니다.

class Semaphore {
  private queue: Array<() => void> = []
  private inUse = 0
  constructor(private readonly max: number) {}

  async acquire() {
    if (this.inUse < this.max) {
      this.inUse++
      return
    }
    await new Promise<void>((resolve) => this.queue.push(resolve))
    this.inUse++
  }

  release() {
    this.inUse--
    const next = this.queue.shift()
    if (next) next()
  }
}

const claudeSemaphore = new Semaphore(10)

export async function limitedClaudeCall<T>(fn: () => Promise<T>) {
  await claudeSemaphore.acquire()
  try {
    return await fn()
  } finally {
    claudeSemaphore.release()
  }
}

실전에서는 “요청 유형별”로 동시성 풀을 나누는 편이 좋습니다.

  • 채팅/검색 보조: 낮은 지연이 중요
  • 배치 요약/분류: 지연 허용, 처리량 중요

폴백 설계: 실패했을 때 무엇을 제공할 것인가

529 대응의 완성은 폴백입니다. 폴백은 “대체 모델 호출”만 의미하지 않습니다.

1) 모델 폴백(다른 모델, 다른 리전, 다른 공급자)

  • 1순위: 동일 공급자 내 더 가벼운 모델로 다운그레이드
  • 2순위: 다른 리전 엔드포인트(가능한 경우)
  • 3순위: 다른 LLM 공급자

모델 폴백은 품질 저하가 있을 수 있으므로, 기능별로 정책을 분리합니다.

  • 고객 응대 메시지: 톤/정확성 중요, 무리한 다운그레이드 금지
  • 단순 요약/키워드 추출: 다운그레이드 허용

2) 기능 폴백(기능 축소)

LLM이 반드시 필요한 기능만 남기고 나머지는 축소합니다.

  • 예: “긴 문서 요약” 실패 시 “핵심 문장 3개만 추출”로 축소
  • 예: Tool use가 실패하면 도구 호출 없이 일반 답변으로 전환

Tool use를 쓰는 경우에는 스키마 문제로 400이 나기도 하니, 과부하 대응과 별개로 스키마 검증 체계를 갖추는 게 좋습니다. 관련해서는 Claude Tool Use 400 invalid_tool_schema 해결 가이드도 함께 참고하면 설계가 단단해집니다.

3) 캐시 폴백(최근 응답/결과 재사용)

동일 프롬프트(또는 정규화된 입력)에 대한 결과를 캐시해두면, 과부하 시에도 “최소한의 응답”을 유지할 수 있습니다.

캐시 키 설계 팁:

  • 사용자 입력 원문 그대로는 위험(개인정보, 길이)
  • 정규화 후 해시: 공백 정리, 날짜/세션 값 제거, 중요 파라미터만 포함
import crypto from "crypto"

function cacheKey(input: {
  userId: string
  task: string
  modelFamily: string
  prompt: string
}) {
  const normalized = input.prompt.trim().replace(/\s+/g, " ")
  const raw = JSON.stringify({
    userId: input.userId,
    task: input.task,
    modelFamily: input.modelFamily,
    prompt: normalized,
  })
  return crypto.createHash("sha256").update(raw).digest("hex")
}

캐시는 “정답이 변하면 안 되는 작업”에 특히 유효합니다.

  • 문서 요약(원문이 동일)
  • 규칙 기반 분류(버전이 동일)

반대로 최신성이 중요한 작업(실시간 검색/시황 요약 등)은 TTL을 짧게 두거나 캐시 폴백을 제한합니다.

4) 큐잉 폴백(비동기 전환)

동기 요청에서 529가 나면, 즉시 “접수 완료”로 전환하고 백그라운드 큐에서 처리하는 전략이 강력합니다.

  • 동기 API: 202로 작업 ID 반환
  • 워커: 재시도/백오프를 길게 가져가도 사용자 UX에 영향이 적음
  • 결과: Webhook, SSE, 폴링, 알림 등으로 전달

이 패턴은 인프라 전반의 복원력 설계와 유사합니다. 인증/캐시 계층에서 장애를 흡수하는 방식은 Kong OIDC JWT 401 - JWKS 캐시·키회전 대응 같은 글에서 다룬 접근과도 결이 같습니다.

요청 타입별 권장 정책(실전 체크리스트)

LLM 호출을 한 정책으로 통일하면, 어떤 워크로드는 과도한 지연을 겪고 어떤 워크로드는 비용이 폭증합니다. 최소한 아래처럼 나누는 것을 권합니다.

1) 사용자 대면(온라인) 요청

  • 목표: p95 지연 안정화
  • 재시도: 12회, 누적 대기 짧게(예: 1s2s)
  • 폴백: 더 가벼운 모델 또는 기능 축소, 캐시 결과 제공
  • 서킷 브레이커: 적극적으로 Open 전환

2) 내부 자동화/배치 요청

  • 목표: 처리량과 비용 최적화
  • 재시도: 5~8회, 누적 대기 길게 가능
  • 폴백: 큐 재적재, 지연 처리
  • 서킷 브레이커: Open이어도 큐에 쌓고 천천히 소화

3) 고정확도 필수(컴플라이언스/정산)

  • 목표: 정확성, 재현성
  • 재시도: 충분히 하되, 결과가 늦어도 됨
  • 폴백: 다른 모델로의 무분별한 전환 금지(품질/정책 리스크)
  • 대안: 사람 검수 큐로 전환, 또는 “처리 지연”을 명시

관측(Observability): 529를 지표로 다루는 법

장애 대응은 로직만으로 끝나지 않고, “언제 나빠지는지”를 빨리 알아야 합니다.

필수 지표:

  • status=529 비율(분당, 5분 이동 평균)
  • 재시도 횟수 분포(평균이 아니라 p95, p99)
  • 누적 백오프 시간(p95)
  • 폴백 발생률(모델 폴백, 캐시 폴백, 큐잉 전환 등 태그)
  • 사용자 체감 지연(p95/p99)

로그/트레이싱 팁:

  • 요청마다 requestId를 부여하고 재시도 시 동일 ID로 묶기
  • “최초 실패 원인”과 “최종 결과(성공/폴백/실패)”를 분리 기록
  • 프롬프트 원문을 그대로 로깅하지 말고 해시/길이/토큰 수만 남기기

아키텍처 예시: 동기 API에서의 안전한 호출 흐름

아래는 온라인 요청에서 권장하는 흐름 예시입니다.

  1. 캐시 조회
  2. 서킷 브레이커 확인(Open이면 폴백)
  3. 동시성 제한 획득
  4. 짧은 예산으로 재시도
  5. 실패 시 폴백(경량 모델 또는 기능 축소)
  6. 그래도 실패 시 “비동기 전환” 또는 사용자 메시지로 종료
async function answerUserQuestion(input: { userId: string; prompt: string }) {
  const key = cacheKey({ userId: input.userId, task: "qa", modelFamily: "claude", prompt: input.prompt })

  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)

  if (circuitBreaker.isOpen("claude")) {
    return fallbackAnswer("temporary_overload")
  }

  try {
    const result = await limitedClaudeCall(() =>
      withRetry(
        () => callClaudeApi(input.prompt),
        { maxAttempts: 2, maxTotalSleepMs: 1200, baseDelayMs: 200, maxDelayMs: 1200 }
      )
    )

    await redis.setex(key, 60, JSON.stringify(result))
    return result
  } catch (e: any) {
    const status = e?.status ?? e?.response?.status

    if (status === 529 || status === 503 || status === 429) {
      circuitBreaker.recordFailure("claude")
      return fallbackAnswer("temporary_overload")
    }

    throw e
  }
}

위 예시는 개념을 보여주기 위한 것이고, 실제로는 callClaudeApi에서 타임아웃(클라이언트 타임아웃과 서버 타임아웃)을 명확히 두고, 폴백 응답에는 “정확도 저하 가능” 같은 제품 문구를 포함하는 편이 안전합니다.

운영 팁: 재시도 정책을 코드가 아니라 설정으로 빼기

장애 상황에서 가장 하고 싶은 일은 “재시도 횟수 줄이기”, “폴백 빠르게 태우기”, “동시성 낮추기”입니다. 이를 배포 없이 바꾸려면 정책을 설정화해야 합니다.

  • 환경 변수 또는 동적 설정(Feature flag)
  • 요청 타입별 정책 테이블
  • 서킷 브레이커 임계치(실패율, 최소 샘플 수, Open 유지 시간)

예를 들어 다음처럼 “작업 타입”을 키로 하는 설정을 두면, 운영 중에 qa만 빠르게 폴백시키는 식의 대응이 가능합니다.

{
  "qa": { "maxAttempts": 2, "maxTotalSleepMs": 1200, "concurrency": 10 },
  "summarize": { "maxAttempts": 6, "maxTotalSleepMs": 20000, "concurrency": 3 }
}

마무리: 529 대응의 정답은 조합이다

529 Overloaded는 흔히 “재시도하면 언젠가 된다”로 접근하지만, 운영에서는 재시도만으로는 비용과 지연, 그리고 장애 전파를 막기 어렵습니다.

  • 재시도는 지수 백오프 + 지터 + 예산으로 제한하고
  • 서킷 브레이커 + 동시성 제한으로 시스템을 보호하며
  • 실패 시에는 모델/기능/캐시/큐잉 중 하나로 반드시 폴백 경로를 준비하는 것

이 조합이 갖춰지면, Claude API가 일시적으로 과부하인 순간에도 서비스는 “느리게 죽는 것”이 아니라 “품질을 조절하며 살아남는 것”에 가까워집니다.