본문 바로가기
🤖 1인 에이전트 구축기

API Rate Limit 대응: 429 재시도·백오프·동시성 제한 설계

by BRIEFER 2026. 5. 23.

API 호출이 갑자기 429 Too Many Requests로 실패한다면 무작정 재요청부터 해서는 안 됩니다. 서버가 보낸 Retry-After를 우선 따르고, 값이 없을 때는 상한이 있는 지수 백오프(exponential backoff)와 jitter를 적용해야 합니다. 그 앞단에는 동시성 제한과 토큰 버짓을 두고, 비동기 작업은 큐에서 흡수해야 합니다. 재시도 때문에 같은 업무가 두 번 실행되지 않도록 중복 방지도 필요합니다.

이 글은 Anthropic Claude API와 Google Gemini API의 공식 문서를 2026년 7월 19일 기준으로 확인해, 운영 환경에서 429를 줄이고 안전하게 복구하는 방법을 설명합니다. 특정 요금제의 숫자는 모델·계정·사용 등급에 따라 달라질 수 있으므로 고정값으로 복사하지 않고 각 콘솔의 현재 한도를 기준으로 삼습니다.

핵심 대응 순서

  1. 응답의 HTTP 상태, 공급자 오류 코드, Retry-After, 요청 ID를 기록합니다.
  2. Retry-After가 유효하면 그 시간 이상 기다립니다.
  3. 값이 없으면 min(최대 지연, 기본 지연 × 2^재시도 횟수) 범위에서 무작위 지연을 선택합니다.
  4. 재시도 횟수와 전체 소요 시간에 상한을 둡니다.
  5. 재시도만 늘리지 말고 동시성, 요청 수, 입력·출력 토큰을 입구에서 제한합니다.
  6. 실시간 요청과 배치 작업을 분리하고, 큐에 만료 시간과 중복 방지 키를 둡니다.
  7. 429 비율, 큐 지연, 토큰, 비용을 함께 관측합니다.

요약: 재시도는 안전망이지 용량 계획이 아닙니다. 지속적인 429는 호출량을 줄이거나 한도를 조정해야 한다는 신호입니다.

429는 무엇을 의미하나

HTTP 429는 현재 요청을 처리할 허용량이 부족하다는 뜻입니다. 다만 공급자마다 한도 단위와 오류 표현이 다릅니다.

공급자 공식 동작 운영 시 주의점
Anthropic Claude API Messages API 한도를 RPM(분당 요청), ITPM(분당 입력 토큰), OTPM(분당 출력 토큰)으로 측정합니다. 초과 시 429와 기다릴 시간을 나타내는 retry-after 헤더를 반환합니다. 급격한 사용량 증가로 가속 제한에 걸려도 429가 발생할 수 있습니다. 분당 한도가 있어도 더 짧은 구간으로 집행될 수 있습니다. 예를 들어 트래픽을 한 번에 몰아 보내지 말고 고르게 분산해야 합니다. 공식 문서는 토큰 버킷 방식으로 용량이 연속 보충된다고 설명합니다.
Google Gemini API 한도 초과는 429 RESOURCE_EXHAUSTED로 나타납니다. 한도는 사용 등급과 모델에 따라 달라지며 Google AI Studio에서 프로젝트의 현재 상태를 확인해야 합니다. API 키를 여러 개 만들어도 같은 프로젝트의 한도를 우회하는 수단으로 보아서는 안 됩니다. 429 또는 503처럼 재시도를 권하는 오류에는 지수 백오프를 적용합니다. Retry-After가 항상 온다고 가정하지 말고, 있으면 해석하고 없으면 자체 백오프를 사용합니다.

Anthropic의 공식 오류 문서에서 429는 rate_limit_error입니다. 500·529 같은 일시적 서버 오류도 재시도 후보지만, 인증 실패·잘못된 요청처럼 수정 전에는 성공할 수 없는 4xx를 같은 방식으로 재시도하면 안 됩니다. 스트리밍은 HTTP 200 이후 SSE 안에서 오류가 발생할 수 있으므로 스트림 오류 이벤트도 별도로 처리해야 합니다.

Retry-After를 가장 먼저 확인한다

Retry-After는 보통 다음 두 형식 중 하나입니다.

Retry-After: 7

위 값은 7초 후 재시도하라는 뜻입니다. HTTP 날짜가 올 수도 있습니다.

Retry-After: Sun, 19 Jul 2026 12:00:05 GMT

날짜 형식은 서버와 클라이언트 시계가 어긋날 수 있으므로 음수가 되면 0으로 보정합니다. 파싱에 실패하면 즉시 재시도하지 말고 자체 백오프로 전환합니다. 서버가 보낸 유효한 대기 시간은 클라이언트의 최대 백오프보다 길 수 있으므로 임의로 잘라내지 않는 편이 안전합니다.

지수 백오프에 jitter가 필요한 이유

모든 작업자가 1초 → 2초 → 4초 → 8초처럼 정확히 같은 시점에 재시도하면 동시에 다시 몰리는 재시도 폭풍이 생깁니다. jitter는 각 작업자의 대기 시간을 흩어 이를 완화합니다.

이 글의 예제는 full jitter를 사용합니다.

ceiling = min(max_delay, base_delay * 2^retry_number)
delay = random(0, ceiling)
final_delay = max(valid_retry_after, delay)

다음 경계도 함께 둡니다.

  • 최대 재시도 횟수: 첫 요청 이후 몇 번까지 다시 시도할지
  • 최대 지연: 자체 백오프가 무한히 커지는 것을 방지
  • 전체 데드라인: 사용자 요청의 허용 지연을 넘기면 중단
  • 재시도 가능 오류 목록: 429, 일부 5xx, 네트워크 단절 등으로 한정

Anthropic 공식 SDK는 연결 오류, rate limit, 5xx 같은 일시적 실패를 기본적으로 두 번 지수 백오프로 재시도하고, retry-after가 있으면 따릅니다. SDK 재시도 위에 애플리케이션 재시도를 그대로 겹치면 실제 호출 횟수가 예상보다 늘 수 있습니다. 한 계층을 책임자로 정하거나, 두 계층을 합친 전체 시도 횟수와 데드라인을 계산해야 합니다. Gemini 공식 SDK를 쓸 때도 설치된 SDK 버전의 재시도 기본값을 확인하고 중복 재시도를 피합니다.

실행 가능한 Python 예제

다음 코드는 외부 패키지와 실제 API 키 없이 실행할 수 있는 공급자 중립 재시도 모듈입니다. Retry-After의 초 단위와 HTTP 날짜를 모두 처리하고, 429 및 명시한 5xx만 재시도합니다. Python 3.10 이상을 기준으로 합니다.

rate_limit_client.py

"""Provider-neutral retry helper for HTTP API calls (Python 3.10+)."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import random
import time
from typing import Callable, Mapping, TypeVar

T = TypeVar("T")


class HTTPStatusError(RuntimeError):
    def __init__(
        self,
        status: int,
        headers: Mapping[str, str] | None = None,
        message: str = "",
    ) -> None:
        super().__init__(message or f"HTTP {status}")
        self.status = status
        self.headers = {key.lower(): value for key, value in (headers or {}).items()}


@dataclass(frozen=True)
class RetryPolicy:
    max_retries: int = 5
    base_delay: float = 0.5
    max_delay: float = 30.0
    retryable_statuses: frozenset[int] = frozenset({429, 500, 502, 503, 504})

    def __post_init__(self) -> None:
        if self.max_retries < 0:
            raise ValueError("max_retries must be >= 0")
        if self.base_delay < 0 or self.max_delay < 0:
            raise ValueError("delays must be >= 0")


def parse_retry_after(value: str | None, now: datetime | None = None) -> float | None:
    if not value:
        return None
    stripped = value.strip()
    try:
        return max(0.0, float(stripped))
    except ValueError:
        pass

    try:
        target = parsedate_to_datetime(stripped)
        if target.tzinfo is None:
            target = target.replace(tzinfo=timezone.utc)
        current = now or datetime.now(timezone.utc)
        if current.tzinfo is None:
            current = current.replace(tzinfo=timezone.utc)
        return max(0.0, (target - current).total_seconds())
    except (TypeError, ValueError, OverflowError):
        return None


def retry_delay(
    policy: RetryPolicy,
    retry_number: int,
    retry_after: str | None,
    rng: Callable[[float, float], float] = random.uniform,
    now: datetime | None = None,
) -> float:
    ceiling = min(policy.max_delay, policy.base_delay * (2**retry_number))
    jittered = rng(0.0, ceiling)
    server_minimum = parse_retry_after(retry_after, now) or 0.0
    return max(server_minimum, jittered)


def call_with_retries(
    operation: Callable[[], T],
    policy: RetryPolicy = RetryPolicy(),
    sleep: Callable[[float], None] = time.sleep,
    rng: Callable[[float, float], float] = random.uniform,
) -> T:
    retries_used = 0
    while True:
        try:
            return operation()
        except HTTPStatusError as error:
            if error.status not in policy.retryable_statuses:
                raise
            if retries_used >= policy.max_retries:
                raise
            delay = retry_delay(
                policy,
                retry_number=retries_used,
                retry_after=error.headers.get("retry-after"),
                rng=rng,
            )
            sleep(delay)
            retries_used += 1

실제 HTTP 라이브러리나 SDK를 연결할 때는 해당 예외에서 HTTP 상태와 응답 헤더를 꺼내 HTTPStatusError로 변환합니다. 인증 정보는 코드에 넣지 말고 서버 측 환경 변수나 비밀 저장소에서 읽습니다. 브라우저에 키를 포함해서는 안 됩니다.

test_rate_limit_client.py

from datetime import datetime, timezone
import unittest

from rate_limit_client import (
    HTTPStatusError,
    RetryPolicy,
    call_with_retries,
    parse_retry_after,
    retry_delay,
)


class RetryHelperTests(unittest.TestCase):
    def test_retry_after_delta_seconds(self):
        self.assertEqual(parse_retry_after("7"), 7.0)

    def test_retry_after_http_date(self):
        now = datetime(2026, 7, 19, 12, 0, 0, tzinfo=timezone.utc)
        self.assertEqual(
            parse_retry_after("Sun, 19 Jul 2026 12:00:05 GMT", now=now),
            5.0,
        )

    def test_invalid_retry_after_is_ignored(self):
        self.assertIsNone(parse_retry_after("not-a-delay"))

    def test_full_jitter_respects_retry_after_minimum(self):
        policy = RetryPolicy(base_delay=1, max_delay=30)
        delay = retry_delay(policy, 2, "10", rng=lambda low, high: high)
        self.assertEqual(delay, 10.0)

    def test_full_jitter_is_capped_without_server_hint(self):
        policy = RetryPolicy(base_delay=10, max_delay=12)
        delay = retry_delay(policy, 4, None, rng=lambda low, high: high)
        self.assertEqual(delay, 12.0)

    def test_retries_429_then_returns_result(self):
        attempts = 0
        sleeps = []

        def operation():
            nonlocal attempts
            attempts += 1
            if attempts < 3:
                raise HTTPStatusError(429, {"Retry-After": "2"})
            return "ok"

        result = call_with_retries(
            operation,
            RetryPolicy(max_retries=3, base_delay=0.1),
            sleep=sleeps.append,
            rng=lambda low, high: 0.0,
        )
        self.assertEqual(result, "ok")
        self.assertEqual(attempts, 3)
        self.assertEqual(sleeps, [2.0, 2.0])

    def test_does_not_retry_non_transient_400(self):
        attempts = 0

        def operation():
            nonlocal attempts
            attempts += 1
            raise HTTPStatusError(400)

        with self.assertRaises(HTTPStatusError):
            call_with_retries(operation, sleep=lambda _: None)
        self.assertEqual(attempts, 1)

    def test_stops_after_retry_budget(self):
        attempts = 0

        def operation():
            nonlocal attempts
            attempts += 1
            raise HTTPStatusError(503)

        with self.assertRaises(HTTPStatusError):
            call_with_retries(
                operation,
                RetryPolicy(max_retries=2),
                sleep=lambda _: None,
                rng=lambda low, high: 0.0,
            )
        self.assertEqual(attempts, 3)


if __name__ == "__main__":
    unittest.main()

두 파일을 같은 디렉터리에 저장한 뒤 실행합니다.

python3 -m unittest -v

이 글을 작성하면서 위 테스트를 실제 실행했습니다. Retry-After 두 형식, 잘못된 헤더, jitter 상한, 429 복구, 400 비재시도, 재시도 소진까지 8개 테스트가 모두 통과했습니다. 실제 공급자 호출 테스트는 키와 비용이 필요하므로 포함하지 않았고, 테스트가 네트워크에 의존하지 않도록 구성했습니다.

동시성 제한: 요청이 나가기 전에 막는다

재시도 로직만으로는 동시에 시작되는 요청 수를 줄일 수 없습니다. 프로세스마다 semaphore를 두고, 여러 인스턴스가 같은 한도를 공유하면 Redis 같은 중앙 저장소의 토큰 버킷이나 전용 게이트웨이를 사용합니다.

from concurrent.futures import ThreadPoolExecutor
from threading import BoundedSemaphore

MAX_IN_FLIGHT = 4  # 콘솔 한도와 실제 요청 크기를 보고 조정
slots = BoundedSemaphore(MAX_IN_FLIGHT)
pool = ThreadPoolExecutor(max_workers=MAX_IN_FLIGHT)


def guarded_call(fn):
    with slots:
        return fn()


future = pool.submit(guarded_call, your_api_operation)

MAX_IN_FLIGHT는 보편적인 권장 숫자가 아닙니다. 모델별 요청 시간, 평균 입력·출력 토큰, 계정 한도에 맞춰 측정해서 정합니다. 자동 확장으로 인스턴스가 10개가 되면 프로세스별 동시성 4는 전체 40이 된다는 점도 계산해야 합니다.

토큰 버짓: 요청 수와 토큰을 함께 예약한다

LLM API는 요청 한 건의 크기 차이가 큽니다. RPM만 지키고 큰 프롬프트를 동시에 보내면 TPM 계열 한도를 먼저 소진할 수 있습니다.

요청을 받기 전에 다음 값을 예약합니다.

reserved_tokens = counted_input_tokens + requested_max_output_tokens
  • 가능하면 공급자의 토큰 계산 기능이나 현재 SDK의 tokenizer를 사용합니다.
  • 글자 수를 일정 비율로 나누는 추정은 입장 제어용 보수적 근사일 뿐, 청구 토큰과 같다고 표시해서는 안 됩니다.
  • 응답 후 실제 사용량으로 예약분을 정산합니다.
  • max_output_tokens를 업무에 필요한 범위로 제한합니다.
  • 사용자·팀·기능별 일일 또는 월간 소프트 버짓을 별도로 둡니다.
  • 한도에 가까우면 낮은 우선순위 작업을 큐로 보내거나 거절합니다.

Anthropic은 RPM·ITPM·OTPM을 구분합니다. 또한 공식 문서는 대부분의 Claude 모델에서 캐시되지 않은 입력 토큰만 ITPM에 반영되는 cache-aware ITPM 동작을 설명합니다. 이 동작은 모델과 문서가 바뀔 수 있으므로 현재 모델의 콘솔과 공식 문서를 다시 확인해야 합니다. Gemini도 모델·등급마다 한도가 다르므로 현재 프로젝트 화면을 기준으로 버짓을 구성합니다.

큐로 순간 폭주를 흡수한다

사용자가 기다려야 하는 온라인 요청과 대량 요약·분류 같은 배치 작업을 분리합니다.

권장 큐 필드:

필드 목적
job_id 작업 추적
dedupe_key 같은 논리 작업의 중복 등록 차단
priority 실시간·유료·배치 우선순위 분리
not_before Retry-After 이후에 다시 꺼내기
expires_at 너무 늦으면 가치가 없는 작업 폐기
attempt_count 재시도 예산 집행
token_reservation 예상 토큰 용량 예약
status queued/running/succeeded/failed/dead-letter 상태 관리

작업자가 429를 받았을 때 스레드 안에서 오래 잠들기보다 not_before를 갱신해 큐에 돌려놓으면 실행 슬롯을 다른 작업에 쓸 수 있습니다. 최대 시도 횟수를 넘은 작업은 DLQ(실패 큐)로 보내 원인과 재처리 여부를 검토합니다. 오래된 작업은 무한 재시도하지 않습니다.

재시도 중복을 방지한다

타임아웃은 “공급자가 처리하지 않았다”는 뜻이 아닙니다. 응답만 잃었을 수 있어 같은 작업을 다시 보내면 결과 저장, 알림, 결제 같은 후속 동작이 두 번 일어날 수 있습니다.

  1. 사용자·업무 ID와 정규화한 입력의 해시로 dedupe_key를 만듭니다.
  2. 작업 테이블에 dedupe_key UNIQUE 제약을 둡니다.
  3. queued → running → succeeded 상태 전이를 트랜잭션 또는 원자 연산으로 처리합니다.
  4. 성공 결과를 키와 함께 저장하고 같은 요청에는 기존 결과를 반환합니다.
  5. lease 만료 후 작업을 회수할 수 있게 하되, 늦게 도착한 이전 작업자의 결과는 버전으로 거부합니다.
  6. 웹훅·메일·결제 등 후속 부작용도 별도의 idempotency key로 보호합니다.

자체 중복 방지는 애플리케이션의 이중 처리를 막는 방법입니다. 공급자가 공식 idempotency 기능을 명시하지 않는 한, 같은 추론 요청의 중복 과금까지 막아 준다고 가정해서는 안 됩니다.

모니터링: 429 숫자 하나만 보지 않는다

다음 지표를 공급자·모델·리전·워크로드별로 봅니다.

  • 전체 요청 수와 429 수·비율
  • 재시도 횟수, 재시도 후 성공률, 재시도 소진 건수
  • Retry-After 값과 실제 대기 시간
  • p50/p95/p99 지연 시간과 전체 데드라인 초과
  • 현재 동시 실행 수, 입장 거절 수
  • 큐 깊이, 가장 오래 기다린 작업의 나이, DLQ 수
  • 입력·출력·캐시 관련 토큰 사용량과 예약 오차
  • 기능·사용자·팀별 추정 비용 및 확정 비용
  • 공급자 요청 ID와 내부 trace ID

요청 ID를 메트릭 라벨로 넣으면 cardinality가 폭증하므로 로그나 trace에 보관합니다. API 키, 전체 프롬프트, 개인정보는 로그에 남기지 않습니다. 경보 임계치는 출처 없는 고정 수치 대신 서비스 SLO, 정상 기준선, 큐 허용 지연으로 정합니다.

429가 급증했을 때의 진단 순서는 다음과 같습니다.

  1. 특정 모델·워크로드·배포 버전에 집중됐는지 확인
  2. 갑작스러운 트래픽 증가와 자동 확장 여부 확인
  3. RPM 계열인지 입력·출력 토큰 계열인지 오류·헤더에서 확인
  4. 재시도가 증폭 요인이 됐는지 확인
  5. 큐·동시성·토큰 예약이 전체 인스턴스에서 공유되는지 확인
  6. 호출량을 줄이고 점진적으로 회복
  7. 지속 수요라면 공급자 콘솔에서 현재 등급과 한도 조정 절차 확인

비용 통제는 rate limit과 별개로 둔다

Rate limit은 짧은 구간의 처리량을 제한하지만 월간 예산을 보장하지 않습니다. 반대로 예산이 남아 있어도 순간 한도는 초과할 수 있습니다. 두 제어를 분리합니다.

  • 요청 전: 입력 토큰 계산, 출력 상한, 사용자·기능별 예산 확인
  • 요청 중: 스트리밍이 불필요하게 길어지면 중단 조건 적용
  • 요청 후: 실제 토큰과 현재 공식 단가로 비용 원장 기록
  • 예산 접근 시: 알림 → 저우선순위 큐 지연 → 저비용 모델 라우팅 → 신규 작업 차단 순서 정의
  • 재시도 비용: 최초 호출과 모든 재시도를 같은 업무 원가에 합산
  • 캐시: 결과 캐시와 공급자 프롬프트 캐시를 구분해 hit/miss 및 실제 절감액 측정

모델 가격은 바뀔 수 있으므로 글에 오래된 고정 단가를 박아 넣기보다 공식 가격 페이지를 참조하는 설정 테이블을 버전 관리합니다. 모델을 자동 전환할 때는 품질·안전 요구를 먼저 충족하는 허용 목록 안에서만 라우팅합니다.

운영 체크리스트

배포 전

  • 현재 프로젝트·조직·모델의 한도 단위를 콘솔에서 확인했다.
  • 429와 일시적 5xx만 제한적으로 재시도한다.
  • Retry-After의 초·HTTP 날짜 형식을 처리한다.
  • 상한 지수 백오프와 jitter를 적용했다.
  • SDK 기본 재시도와 애플리케이션 재시도의 중첩을 계산했다.
  • 최대 시도 횟수와 전체 데드라인이 있다.
  • 전체 인스턴스 기준 동시성 제한이 있다.
  • 입력 토큰과 최대 출력 토큰을 요청 전에 예약한다.
  • 큐에 우선순위, not_before, 만료, DLQ가 있다.
  • 중복 방지 키와 원자적 상태 전이가 있다.
  • 실제 키나 프롬프트 원문을 로그에 남기지 않는다.
  • 비용 예산과 단기 rate limit을 별도로 관리한다.

장애 중

  • 재시도 폭풍을 막기 위해 유입량과 동시성을 먼저 낮춘다.
  • 어떤 한도 차원이 소진됐는지 확인한다.
  • 낮은 우선순위 배치를 중지하거나 지연한다.
  • 큐의 가장 오래된 작업과 만료 작업을 확인한다.
  • 공급자 상태 페이지와 콘솔 한도를 확인한다.
  • 임시 완화 후 트래픽을 한 번에 원복하지 않고 점진적으로 올린다.

자주 묻는 질문

429는 모두 재시도해도 되나?

아닙니다. 제한된 횟수와 데드라인 안에서만 재시도해야 합니다. 지속적인 429는 동시성·토큰·호출량을 낮추거나 한도를 조정할 문제입니다. 만료된 사용자 요청은 재시도하지 않습니다.

Retry-After가 없으면 바로 한 번 더 시도해도 되나?

바로 재시도하면 폭주를 키울 수 있습니다. 상한 지수 백오프와 jitter를 사용합니다. Gemini 응답에서 해당 헤더가 항상 제공된다고 가정하지 않습니다.

API 키를 여러 개 쓰면 한도가 늘어나나?

한도 적용 범위가 프로젝트나 조직이라면 키를 늘려도 해결되지 않습니다. 우회 목적의 키 분산 대신 현재 콘솔에서 적용 범위와 한도를 확인하고, 트래픽 평탄화·큐·정식 한도 조정을 사용합니다.

semaphore만 있으면 충분한가?

아닙니다. 여러 프로세스·인스턴스에서는 각 semaphore의 합이 전체 동시성이 됩니다. 중앙 입장 제어가 필요할 수 있고, 요청 수뿐 아니라 토큰 버짓도 함께 관리해야 합니다.

재시도했는데 같은 결과가 두 번 저장되는 이유는?

첫 요청은 처리됐지만 응답만 타임아웃됐을 수 있습니다. 작업의 dedupe_key, UNIQUE 제약, 상태 전이, 후속 부작용의 idempotency key로 중복을 막습니다.

공식 출처

확인일: 2026-07-19

  1. Anthropic, Rate limits — RPM·ITPM·OTPM, 토큰 버킷, 429, retry-after, 가속 제한
  2. Anthropic, Claude API errors — 오류 유형, 429 rate_limit_error, SDK 기본 재시도, 스트리밍 오류
  3. Google AI for Developers, Gemini API rate limits — 프로젝트·모델·사용 등급별 한도와 429 RESOURCE_EXHAUSTED
  4. Google AI for Developers, Troubleshooting guide — 429·503와 지수 백오프 안내
  5. Google Cloud, Retry strategy for generative AI — truncated exponential backoff와 jitter의 운영 원칙. Gemini Developer API와 Vertex AI는 서로 다른 제품 경로이므로 Vertex AI 항목은 일반 재시도 설계 참고용입니다.

함께 읽을 가이드


공식 문서 확인 기준일: 2026년 7월 19일. 가격·모델·할당량은 변경될 수 있으므로 실행 전 연결된 공식 문서를 다시 확인하세요.