Published on

Azure OpenAI 429/503 재시도·백오프 설계 가이드

Authors

운영 환경에서 Azure OpenAI를 붙이면 가장 먼저 마주치는 장애 유형이 429 Too Many Requests503 Service Unavailable 입니다. 둘 다 “잠깐 쉬었다 다시 시도하면 성공할 가능성이 높다”는 공통점이 있지만, 무작정 재시도하면 더 큰 장애(요청 폭주, 비용 증가, 꼬리 지연, 중복 처리)를 만들기 쉽습니다.

이 글에서는 Azure OpenAI의 429/503을 대상으로 재시도·백오프를 설계하는 방법을 실무 관점에서 정리합니다. 단순한 sleep 루프가 아니라, 지터(jitter), 재시도 예산, 멱등성, 타임아웃, 큐잉, 관측(Observability)까지 포함해 “서비스로서” 안정화하는 접근입니다.

관련해서 요청 페이로드가 커서 실패하는 경우에는 재시도보다 청크 전략이 먼저일 수 있습니다. 대용량 업로드/이미지 처리에서 413이 난다면 아래 글도 함께 참고하세요.

429와 503을 같은 재시도로 처리하면 안 되는 이유

429의 의미: “당신이 너무 빨라요”

429는 대개 쿼터/토큰 처리량 제한, 동시 요청 제한, 분당 요청 제한 같은 레이트 리밋에 걸렸다는 신호입니다. 이때 재시도는 효과가 있지만, 백오프를 제대로 설계하지 않으면 같은 제한에 계속 재충돌합니다.

핵심은 다음입니다.

  • Retry-After 헤더가 있으면 최우선으로 존중
  • 없으면 지수 백오프 + 지터로 동시 재시도 폭주를 방지
  • “요청량을 줄이거나(샘플링/캐시/배치)” “큐로 밀어 넣어(버퍼링)” 상위 계층에서 흡수

503의 의미: “지금 서버가 바빠요/불안정해요”

503은 서비스가 일시적으로 불가용하거나, 내부적으로 과부하/장애/배포/의존성 문제를 겪는 상황일 수 있습니다. 이때도 재시도는 유효하지만, 무한 재시도는 장애를 악화시킵니다.

핵심은 다음입니다.

  • 짧은 재시도 윈도우(예: 10~60초)에서만 시도
  • 그 이후에는 서킷 브레이커 또는 폴백(다른 모델/캐시 응답/기능 축소)
  • 장애 전파를 막기 위한 타임아웃, 동시성 제한이 필수

재시도 설계의 목표: “성공률”이 아니라 “시스템 안정성”

재시도는 성공률을 올리지만, 잘못 설계하면 다음 비용을 치릅니다.

  • 중복 호출로 인한 비용 증가(특히 스트리밍/툴 호출/멀티턴)
  • 꼬리 지연 증가(P95/P99 악화)
  • 장애 시 트래픽 증폭(재시도 폭풍)
  • 다운스트림(결제/DB/큐)까지 연쇄 장애

따라서 목표를 이렇게 재정의하는 게 좋습니다.

  1. 사용자 경험: 제한된 시간 안에만 재시도해서 체감 실패를 줄인다
  2. 서비스 안정성: 장애 시 트래픽을 추가로 증폭시키지 않는다
  3. 비용 통제: 재시도 예산을 두고, 중복 처리를 막는다

권장 백오프: 지수 백오프 + 풀 지터(Full Jitter)

가장 널리 쓰이는 패턴은 Exponential Backoff with Full Jitter 입니다. 지수 백오프만 쓰면 여러 인스턴스가 동일한 간격으로 재시도해 다시 충돌합니다. 풀 지터는 매 시도마다 대기 시간을 무작위로 분산시켜 동시 폭주를 완화합니다.

  • 기본 지수 백오프: base * 2^attempt
  • 풀 지터: sleep = random(0, cap)

여기서 cap은 상한(예: 8초, 15초)을 둡니다. 429는 비교적 “짧고 잦은 제한”이 많아 상한을 짧게, 503은 상황에 따라 상한을 조금 더 길게 둘 수 있습니다.

반드시 고려해야 할 6가지 체크리스트

1) Retry-After 우선

서버가 Retry-After를 주면, 그 값이 가장 좋은 힌트입니다. 다만 그대로 따르면 “동시에 모두가 같은 시각에 재시도”할 수 있으니, 작은 지터(예: 0~250ms)를 추가하는 것을 권장합니다.

2) 재시도 가능한 요청만 재시도

3) “총 재시도 시간”과 “최대 시도 횟수”를 함께 제한

운영에서는 “최대 5회” 같은 횟수 제한만 두면, 각 시도가 오래 걸릴 때 전체 지연이 폭증합니다.

  • 예: maxAttempts = 6
  • 예: maxElapsedMs = 20_000

둘 중 하나라도 넘으면 즉시 실패 처리 또는 큐로 전환합니다.

4) 멱등성(Idempotency)과 중복 방지

재시도는 같은 요청을 여러 번 보낼 수 있습니다. 특히 다음 상황에서 중복 부작용이 큽니다.

  • 툴 호출이 외부 시스템(결제/예약/티켓 발권)을 건드릴 때
  • “한 번만 보내야 하는” 알림/이메일

해결책은 두 가지입니다.

  • 애플리케이션 레벨에서 idempotencyKey를 만들고 결과를 캐시/저장
  • 외부 부작용이 있는 작업은 아예 큐 기반으로 전환해 “처리 상태”를 저장

5) 동시성 제한(클라이언트 측 레이트 리밋)

재시도만 잘해도, 동시에 200개 요청을 날리면 결국 429는 반복됩니다. 가장 효과적인 안정화는 동시성 제한 + 큐잉입니다.

  • 인스턴스 당 동시 요청 수를 제한(예: 5~20)
  • 작업 큐(예: Redis, SQS, Service Bus)로 버퍼링

쿠버네티스에서 워커가 과도하게 재시도하다가 죽는 패턴은 CrashLoopBackOff로 표면화되기도 합니다. 워커/프로브 설계는 아래 글이 도움이 됩니다.

6) 관측 지표(메트릭/로그/트레이싱)

재시도는 “조용히” 시스템을 느리게 만들 수 있어 관측이 중요합니다.

권장 메트릭:

  • azure_openai_requests_total{status}
  • azure_openai_retries_total{reason}
  • azure_openai_backoff_ms_bucket (히스토그램)
  • queue_depth, worker_concurrency
  • 사용자 체감 지표: P95/P99 latency

로그에는 최소한 다음을 남기세요.

  • 요청 ID(또는 트레이스 ID)
  • 모델/배포명
  • 상태코드, 에러 바디(민감정보 제외)
  • 시도 횟수, 대기 시간, 총 경과 시간

Node.js(Typescript) 예제: 429/503 재시도 + 풀 지터

아래 예제는 “특정 함수 호출”을 재시도 래퍼로 감싸는 형태입니다. Azure OpenAI SDK를 쓰든, REST를 쓰든 동일하게 적용할 수 있습니다.

주의: MDX에서 부등호 문자가 본문에 노출되면 빌드 에러가 날 수 있으므로, 코드 블록 안에만 부등호를 사용합니다.

type RetryOptions = {
  maxAttempts: number;
  maxElapsedMs: number;
  baseDelayMs: number;
  capDelayMs: number;
};

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

function isRetryableStatus(status: number) {
  return status === 429 || status === 503 || status === 408;
}

function parseRetryAfterMs(headers: Record<string, string | undefined>) {
  const v = headers["retry-after"] ?? headers["Retry-After"];
  if (!v) return null;

  // Retry-After can be seconds or HTTP date. Here we handle seconds.
  const seconds = Number(v);
  if (!Number.isFinite(seconds)) return null;
  return Math.max(0, seconds * 1000);
}

function fullJitterDelayMs(capMs: number) {
  return Math.floor(Math.random() * (capMs + 1));
}

export async function withRetry<T>(
  fn: () => Promise<T>,
  getLastError: () => any,
  opts: RetryOptions
): Promise<T> {
  const started = Date.now();
  let attempt = 0;

  while (true) {
    attempt += 1;
    try {
      return await fn();
    } catch (err: any) {
      const elapsed = Date.now() - started;
      const last = getLastError?.() ?? err;

      const status = last?.status ?? last?.response?.status;
      const headers = last?.response?.headers ?? {};

      const retryable = typeof status === "number" ? isRetryableStatus(status) : true;
      const shouldStop = attempt >= opts.maxAttempts || elapsed >= opts.maxElapsedMs;

      if (!retryable || shouldStop) {
        throw err;
      }

      // Prefer Retry-After if present
      const retryAfterMs = parseRetryAfterMs(headers);

      const expCap = Math.min(
        opts.capDelayMs,
        opts.baseDelayMs * Math.pow(2, attempt - 1)
      );

      const delay = retryAfterMs != null
        ? retryAfterMs + fullJitterDelayMs(250)
        : fullJitterDelayMs(expCap);

      await sleep(delay);
    }
  }
}

실제 호출부 예시(REST)

async function callAzureOpenAI(payload: any) {
  const res = await fetch(process.env.AZURE_OPENAI_URL!, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "api-key": process.env.AZURE_OPENAI_KEY!,
    },
    body: JSON.stringify(payload),
  });

  if (!res.ok) {
    const text = await res.text();
    const err: any = new Error(`Request failed: ${res.status} ${text}`);
    err.status = res.status;
    err.response = { status: res.status, headers: Object.fromEntries(res.headers.entries()) };
    throw err;
  }

  return res.json();
}

let lastErr: any = null;

const result = await withRetry(
  async () => {
    try {
      const r = await callAzureOpenAI({ /* ... */ });
      return r;
    } catch (e) {
      lastErr = e;
      throw e;
    }
  },
  () => lastErr,
  {
    maxAttempts: 6,
    maxElapsedMs: 20_000,
    baseDelayMs: 250,
    capDelayMs: 8_000,
  }
);

“재시도”보다 먼저 해야 하는 것: 부하를 줄이는 설계

429가 자주 뜬다면 재시도로 해결할 문제가 아니라, 요청 패턴 자체를 바꿔야 합니다.

1) 캐시

  • 동일 프롬프트/동일 컨텍스트라면 결과 캐시
  • 임베딩은 특히 캐시 효율이 좋습니다

2) 배치/디바운스

  • 사용자 타이핑 이벤트마다 호출하지 말고 300~800ms 디바운스
  • 백엔드에서 짧은 윈도우로 배치 처리

3) 토큰 최적화

  • 시스템 프롬프트/컨텍스트를 줄이면 처리량 제한에 덜 걸립니다
  • 불필요한 로그/JSON 덩어리 제거

큐 기반 워커 패턴: 429를 “사용자 요청”에서 분리

실무에서 가장 안정적인 구조는 “동기 API는 빠르게 응답하고, LLM 호출은 큐로 넘기는 방식”입니다.

  • API 서버: 요청 검증, 작업 생성, 202 Accepted 반환
  • 큐: 작업 버퍼
  • 워커: 제한된 동시성으로 Azure OpenAI 호출, 실패 시 재시도, 최종 실패는 DLQ

이 구조의 장점:

  • 사용자 요청 경로에서 재시도 지연을 제거
  • 워커에서만 재시도를 수행하므로 폭주 제어가 쉬움
  • DLQ로 실패를 모아 재처리/원인분석 가능

503 대응: 서킷 브레이커와 폴백

503이 연속적으로 발생하면, “지금은 안 된다”는 신호일 수 있습니다. 이때는 재시도보다 빠른 실패(fail fast) 가 전체 시스템을 살립니다.

권장 전략:

  • 연속 실패 N회 또는 실패율 X%를 넘으면 서킷 오픈
  • 오픈 상태에서는 즉시 폴백
    • 캐시된 최근 답변
    • 기능 축소(요약 대신 키워드만)
    • 다른 배포/다른 리전(가능한 경우)
  • 일정 시간 후 하프 오픈으로 소량 트래픽만 시험

운영 파라미터 추천(출발점)

서비스 특성에 따라 달라지지만, 시작점으로는 다음 조합이 무난합니다.

  • 429:

    • maxAttempts = 6
    • maxElapsedMs = 20_000
    • baseDelayMs = 250
    • capDelayMs = 8_000
    • Retry-After 존중 + 소량 지터
  • 503:

    • maxAttempts = 4
    • maxElapsedMs = 10_000 (사용자 동기 경로라면 더 짧게)
    • baseDelayMs = 500
    • capDelayMs = 5_000
    • 일정 실패율이면 서킷 브레이커

흔한 실수 7가지

  1. 429를 만나자마자 즉시 재시도(대기 없이)해서 더 큰 폭주를 유발
  2. 지수 백오프만 적용하고 지터를 넣지 않아 동시 재시도 충돌
  3. 최대 재시도 횟수만 제한하고 총 경과 시간을 제한하지 않음
  4. 네트워크 타임아웃을 길게 잡아 재시도 전환이 늦어짐
  5. 멱등성 키 없이 툴 호출을 재시도해 중복 부작용 발생
  6. 워커 동시성을 무제한으로 두고 “재시도 로직이 있으니 괜찮다”고 판단
  7. 재시도 횟수/백오프 시간을 메트릭으로 남기지 않아 장애 원인 파악 불가

마무리

Azure OpenAI의 429/503은 “에러”라기보다 “흔한 운영 신호”에 가깝습니다. 중요한 건 재시도 자체가 아니라, 재시도를 시스템 안정성의 일부로 설계하는 것입니다.

  • Retry-After 우선
  • 지수 백오프 + 풀 지터
  • 재시도 예산(횟수 + 총 시간)
  • 멱등성/중복 방지
  • 동시성 제한과 큐잉
  • 메트릭 기반 튜닝

이 6가지만 갖추면, 같은 트래픽에서도 실패율과 꼬리 지연이 눈에 띄게 내려가고, 장애 시에도 서비스가 “버티는” 형태로 바뀝니다.