AI API에서 429 에러가 발생하면 어떻게 될까? 실제로 작동하는 재시도 전략
요약
AI API 호출 시 발생하는 429(Rate Limit) 에러에 대응하는 효율적인 재시도 전략을 다룹니다. 단순 반복이 아닌 에러 분류, 지수 백오프, 지터(Jitter) 적용 및 Retry-After 헤더 활용의 중요성을 설명합니다.
핵심 포인트
- 에러 코드(429, 503 등)에 따른 차별화된 대응 전략 필요
- 천둥 치는 들소 문제를 방지하기 위한 지터(Jitter) 도입 필수
- 지수 백오프(Exponential Backoff)를 통한 점진적 대기 시간 증가
- API 제공업체의 Retry-After 헤더를 준수하여 비용과 부하 최적화
AI API에서 429 에러가 발생하면 어떻게 될까? 실제로 작동하는 재시도 전략
지난달, 저는 단 한 오후 만에 AI API 크레딧으로 200달러를 써버렸습니다. 과도한 사용 때문이 아니라, 잘못된 재시도 (retries) 때문이었습니다.
저는 다섯 개의 서로 다른 LLM 제공업체를 호출하는 단순한 while True: try/except 루프를 사용하고 있었습니다. 하나가 429 (rate limit, 속도 제한)를 반환하면, 제 스크립트는 당황하여 즉시 재시도했습니다. 다른 하나가 503을 반환했을 때도 즉시 재시도했습니다. 제가 알아차렸을 때는 이미 OpenAI 청구서에 산맥처럼 솟아오른 급격한 비용 상승이 찍혀 있었습니다.
이를 해결하기 위해 제가 구축한 것과 그 과정에서 배운 점을 소개합니다.
문제점: 기본 재시도는 어리석다
대부분의 HTTP 라이브러리에는 일종의 재시도 메커니즘이 포함되어 있습니다. Requests에는 Retry가 있고, httpx에는 limits가 있습니다. 하지만 문제는 — 이들은 모든 에러에 대해 똑같은 방식으로 재시도한다는 점입니다.
429 (rate limit)와 503 (일시적인 과부하)은 일반적인 재시도 루프에게 똑같아 보입니다. 하지만 그러면 안 됩니다.
| 상태 코드 | 의미 | 스마트한 대응 |
|---|---|---|
| 429 | 너무 빠르게 요청을 보내고 있음 | Back off (물러나기) — 서버가 기다리라고 말함 |
| ... |
어리석은 재시도 루프는 이들을 모두 동일하게 취급합니다. 스마트한 루프는 상태 코드 (status code)를 읽고 그에 따라 행동합니다.
1단계: 재시도하기 전에 분류하라
물러나기(backing off)를 생각하기 전에, 에러를 분류하십시오:
from enum import Enum
class ErrorStrategy(Enum):
...
이것만으로도 수십 번의 낭비되는 호출을 아낄 수 있었습니다. 더 이상 만료된 API 키로 재시도할 필요가 없게 되었습니다.
2단계: 지터(Jitter)를 포함한 지수 백오프 (Exponential Backoff)
표준적인 조언은 "지수 백오프 (exponential backoff)를 사용하라"는 것입니다. 이는 1초, 2초, 4초, 8초와 같이 기다리는 것을 의미합니다. 하지만 문제가 하나 있습니다.
100개의 클라이언트가 동시에 속도 제한 (rate limit)에 걸렸다고 가정해 봅시다. 그들은 모두 1초를 기다린 후 재시도하며 — 정확히 1초 후에 **두 번째 스파이크 (spike)**를 만들어냅니다. 그다음에는 모두 2초를 기다리고 다시 스파이크를 일으킵니다. 이를 **천둥 치는 들소 문제 (thundering herd problem)**라고 합니다.
해결책은 지터 (jitter)입니다. 즉, 대기 시간에 무작위성을 추가하는 것입니다:
import random
import time
...
저는 세 개의 워커 프로세스가 재시도 시점을 완벽하게 동기화하여, 매 사이클마다 정확히 동일한 밀리초(millisecond)에 요청을 보내는 것을 목격한 후 이 사실을 뼈저리게 배웠습니다. Jitter (지터)는 즉시 그 패턴을 깨뜨려 주었습니다.
3단계: Retry-After 헤더 읽기
대부분의 규약을 잘 따르는 API는 429 응답과 함께 Retry-After 헤더를 보냅니다. 이를 활용하세요:
def get_retry_delay(response, attempt: int) -> float:
"""사용 가능한 경우 서버의 Retry-After 헤더를 준수합니다."""
retry_after = response.headers.get("Retry-After")
...
OpenAI, Anthropic, 그리고 Google 모두 Retry-After를 반환합니다. 이를 준수하는 것은 당신이 좋은 API 시민 (API citizen)임을 의미하며, 차단 상태를 더 빠르게 해제할 수 있게 해줍니다.
4단계: 서킷 브레이커 (Circuit Breaker) 추가
실패가 충분히 쌓이면, 시도를 중단하세요. 서킷 브레이커 (Circuit breaker)는 연쇄적인 실패 (cascading failures)를 방지합니다:
import time
from dataclasses import dataclass, field
...
회로가 열려(open) 있을 때, 제 코드는 API 호출을 시도조차 하지 않습니다. 대신 캐시된 폴백 (fallback)을 반환하거나 우아한 에러 (graceful error)를 반환합니다. 명백히 다운된 제공업체에 더 이상 크레딧을 낭비하지 않는 것입니다.
종합하기
제가 현재 사용하고 있는 전체 재시도 래퍼 (retry wrapper)는 다음과 같습니다:
import httpx
import random
import time
...
제가 배운 점
-
재시도하기 전에 에러를 분류하세요. 모든 실패가 일시적인 것은 아닙니다. 401 에러는 아무리 오래 기다려도 200으로 변하지 않습니다.
-
항상 Jitter (지터)를 사용하세요. 이것이 없으면 재시도 시점이 동기화됩니다. 저는 세 명의 워커가 매 사이클마다 정확히 동일한 밀리초에 동일한 API를 호출하는 것을 실제로 목격했습니다.
-
Retry-After헤더를 준수하세요. 서버는 이미 얼마나 기다려야 하는지 알려주었습니다. 그 지시를 따르세요. -
서킷 브레이커 (Circuit breakers)는 재앙을 방지합니다. 제공업체가 다운되었을 때는 호출을 중단하세요. 당신의 재시도가 그들의 인프라를 고쳐주지는 않습니다.
-
모든 것을 로그로 남기세요. 저는 모든 재시도 시도에 구조화된 로깅 (structured logging)을 추가했습니다. 한 달 후, 그 로그들은 어떤 제공업체의 신뢰성이 가장 낮은지를 정확히 보여주었습니다. 저는 이 데이터를 사용하여 라우팅 (routing) 결정을 내렸습니다.
그 200달러짜리 오후는 그 어떤 블로그 포스트보다 API 회복 탄력성 (resilience)에 대해 더 많은 것을 가르쳐 주었습니다. 해결책을 만드는 데는 약 100줄의 Python 코드가 필요했습니다. 단 1센트의 가치도 아깝지 않았습니다.
여러분의 재시도 전략 (retry strategy)은 어떤 모습인가요? 다른 분들은 이 문제를 어떻게 다루는지 궁금합니다 — 댓글을 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기