Source: https://aiprimetech.io/ko/blog/rate-limits-and-retries/ 2026년 23일, 유월 · 7분 · API # LLM API의 호출 제한과 재시도 처리하기 A 작성자 [AI Prime Tech](https://aiprimetech.io/ko/) · AI 활용 화요일 09:12, 요약 파이프라인은 정상적으로 보였습니다. 워커 96개가 가동 중이었고, 모델 지연 시간의 중앙값은 약 2.8초였으며, 5xx 오류율도 높지 않았습니다. 그러다 한 고객이 밀려 있던 지원 티켓 38,000개를 업로드했습니다. 사분 만에 서버가 하나도 다운되지 않았는데 파이프라인이 “다운”되었습니다. 서로 다른 두 가지 제한에 동시에 걸리고 있었습니다: - requests_per_minute: API 호출이 너무 많음 - tokens_per_minute: 프롬프트와 응답의 총량이 너무 많음 단순한 재시도 로직이 상황을 악화시켰습니다. 모든 워커가 다음을 받았습니다: 429. 각 워커는 일 초 동안 기다린 뒤 동시에 재시도했습니다. 이로 인해 재시도 물결이 일어났고, 다음이 더 많이 발생했습니다: 429. 그러면서 또 다른 물결이 생겼습니다. 시스템은 의도치 않게 자신의 할당량을 상대로 동기화된 서비스 거부 공격을 만들어 낸 셈입니다. LLM 사용량 제한을 처리하는 것은 단순히 “지수 백오프를 추가”하는 일이 아닙니다. 프로덕션에서는 토큰, 큐, 동시성, 재시도, 멱등성, 대체 제공업체, 과금 동작을 하나의 시스템으로 고려해야 합니다. ## 중요한 두 가지 제한: RPM과 TPM 대부분의 LLM API는 다음 제한 중 일부를 조합하여 적용합니다: - RPM: 분당 요청 수 - TPM: 분당 토큰 수 - RPD / TPD: 일일 요청 수 또는 토큰 수 - 동시 요청 제한: 진행 중인 호출의 최대 수 - 모델별 제한: 소형 모델에는 더 높은 제한, 최첨단 모델에는 더 낮은 제한 중요한 점: RPM과 TPM은 서로 다른 방식으로 한계에 도달합니다. 작은 프롬프트를 많이 보내면 RPM 제한에 먼저 걸립니다. 큰 프롬프트를 적게 보내면 TPM 제한에 먼저 걸립니다. 예: ``` Limit: - 600 requests/minute - 300,000 tokens/minute Workload A: - 1,000 short classification calls/minute - 200 tokens each - 200,000 tokens/minute total Result: - TPM is fine - RPM fails Workload B: - 100 long document analysis calls/minute - 5,000 tokens each - 500,000 tokens/minute total Result: - RPM is fine - TPM fails ``` 흔히 놓치는 함정이 있습니다. 개발자는 요청 수는 제한하지만 토큰 수는 제한하지 않습니다. 프롬프트가 짧은 스테이징 환경에서는 이 방식이 작동합니다. 그러다 프로덕션 사용자가 PDF, 채팅 기록, 로그, 법률 계약서를 붙여 넣으면 토큰 예산이 순식간에 소진됩니다. 실무에서는 모든 LLM 호출이 두 가지 리소스를 예약하는 것으로 취급합니다: ``` cost = { requests: 1, tokens: estimated_prompt_tokens + max_output_tokens } ``` 모델이 반환하는 출력 토큰 수가 다음보다 적더라도 max_output_tokens, 상한만큼 예약하면 초과 할당을 방지할 수 있습니다. ## TPM을 무시하면 실제로 일어나는 일 다음과 같은 조건을 가정해 보겠습니다: ``` TPM limit: 120,000 Average request: 4,000 input tokens + 1,000 max output tokens Reserved cost: 5,000 tokens ``` 실제 최대 처리량은 “가능한 한 많은 워커”가 아닙니다. 다음과 같습니다: ``` 120,000 / 5,000 = 24 requests per minute ``` 이는 평균적으로 2.5초마다 요청 하나를 처리하는 수준입니다. 워커 100개를 실행하면 제공업체가 순간적인 트래픽 증가를 허용하는 경우 첫 번째 배치는 모두 성공적으로 시작될 수 있습니다. 그러다 계정이 엄격한 한도에 도달합니다. 이후 모든 요청은 다음을 받습니다: 429. 그리고 재시도가 쌓입니다. 해결책은 단순히 워커 수를 줄이는 것이 아닙니다. 토큰 예약을 이해하는 스케줄러가 필요합니다. ## 최소한의 토큰 인식 사용량 제한기 단일 프로세스에서는 일반적으로 토큰 버킷만으로 충분합니다. 시간이 지남에 따라 용량을 보충하고, 각 요청이 요청 슬롯과 토큰 슬롯을 모두 확보하도록 합니다. 다음은 단순화한 Python 예제입니다: ``` import asyncio import time from dataclasses import dataclass @dataclass class Bucket: capacity: float refill_per_second: float tokens: float updated_at: float def refill(self): now = time.monotonic() elapsed = now - self.updated_at self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second) self.updated_at = now async def acquire(self, amount: float): while True: self.refill() if self.tokens >= amount: self.tokens -= amount return missing = amount - self.tokens sleep_for = missing / self.refill_per_second await asyncio.sleep(min(sleep_for, 1.0)) class LLMRateLimiter: def __init__(self, rpm: int, tpm: int): now = time.monotonic() self.request_bucket = Bucket( capacity=rpm, refill_per_second=rpm / 60, tokens=rpm, updated_at=now, ) self.token_bucket = Bucket( capacity=tpm, refill_per_second=tpm / 60, tokens=tpm, updated_at=now, ) self.lock = asyncio.Lock() async def acquire(self, estimated_tokens: int): async with self.lock: await self.request_bucket.acquire(1) await self.token_bucket.acquire(estimated_tokens) ``` 사용법: ``` estimated_tokens = prompt_tokens + max_output_tokens await limiter.acquire(estimated_tokens) response = await client.chat.completions.create( model="gpt-5.5", messages=messages, max_tokens=max_output_tokens, ) ``` 이 예제는 의도적으로 단순하게 구성했습니다. 분산 시스템에서는 Redis, Postgres 어드바이저리 잠금 또는 중앙에서 작업을 배정하는 큐를 사용하세요. 중요한 원칙은 같습니다. API를 호출하기 전에 요청 수와 토큰 예산을 모두 기준으로 사용량을 제한해야 합니다. ## 재시도: 백오프는 필요하지만 충분하지 않습니다 재시도가 유용한 경우: - 일시적인 429 응답 - 네트워크 타임아웃 - 일시적인 500, 502, 503, 또는 504 오류 - 제공업체 측 과부하 - 연결 재설정 재시도가 위험한 경우: - 유효성 검사 오류 - 컨텍스트 길이 오류 - 인증 실패 - 안전 정책에 따른 거부 - 항상 같은 문제를 일으키는 잘못된 프롬프트 - 멱등성이 없는 도구 호출 재시도 정책은 먼저 오류를 분류해야 합니다. | 오류 유형 | 재시도 여부 | 일반적인 조치 | 429 rate_limit_exceeded | 예 | 백오프 적용, 재시도 헤더 준수, 동시성 축소 | 500/502/503/504 | 예 | 지터를 적용한 백오프, 시도 횟수 제한 | 응답 본문 수신 전 타임아웃 | 경우에 따라 가능 | 지원되는 경우 멱등성 키를 사용하여 재시도 | 컨텍스트 길이 초과 | 아니요 | 압축하거나, 청크로 나누거나, 더 긴 컨텍스트 모델로 라우팅 | 유효하지 않은 API 키 | 아니요 | 키 비활성화, 운영자에게 알림 | 콘텐츠/안전 정책에 따른 거부 | 아니요 | 통제된 실패를 반환하거나 요청을 다시 작성 | 도구의 부수 효과가 이미 실행됨 | 아니요/경우에 따라 가능 | 멱등성 설계 필요 기본적인 백오프 형태: ``` import random def retry_delay(attempt: int, base=0.5, cap=30.0): exponential = min(cap, base * (2 ** attempt)) return random.uniform(0, exponential) ``` 해당 random.uniform(0, exponential) 부분이 지터입니다. 생략하지 마세요. 지터가 없을 때: ``` 100 workers fail 100 workers sleep 1s 100 workers retry 100 workers fail 100 workers sleep 2s 100 workers retry ``` 지터가 있을 때: ``` 100 workers fail workers retry across a spread of 0-1s then 0-2s then 0-4s ``` 두 번째 패턴은 제공업체와 자체 큐가 회복할 여유를 줍니다. ### 재시도 헤더가 있으면 준수하기 많은 API는 다음과 같은 힌트를 포함합니다. ``` Retry-After: 12 ``` 또는 제공업체별 초기화 헤더를 포함합니다. API가 구체적인 대기 시간을 제공하면 이를 최소 대기 시간으로 사용하세요. ``` delay = max(header_retry_after_seconds, retry_delay(attempt)) await asyncio.sleep(delay) ``` 실무에서는 총 재시도 시간에도 상한을 둡니다. 사용자가 로딩 표시를 바라보는 동안 사용자 대상 채팅 요청이 아무런 안내 없이 두 분 동안 재시도를 계속해서는 안 됩니다. 백그라운드 배치 작업은 훨씬 더 오래 기다릴 수 있습니다. 정책 예시: ``` { "interactive_chat": { "max_attempts": 3, "max_total_retry_seconds": 8 }, "batch_summarization": { "max_attempts": 8, "max_total_retry_seconds": 300 } } ``` ## 워커의 동시 요청 폭주보다 큐 사용이 낫습니다 처리할 작업이 할당량보다 많다면 모든 워커가 LLM API를 직접 호출해서는 안 됩니다. 앞단에 큐를 배치하세요. 실용적인 아키텍처: ``` App/API -> Job queue -> Rate-aware dispatcher -> Provider client -> Result store ``` 디스패처가 담당하는 항목: - RPM 사용량 집계 - TPM 사용량 집계 - 모델 선택 - 재시도 정책 - 대체 제공업체로 전환 - 동시 실행 제한 - 큐 우선순위 이렇게 하면 과부하가 눈에 보입니다. 수천 개의 작업이 다음 오류로 실패하는 대신 429, 큐에 쌓인 작업 수와 예상 대기 시간이 늘어나는 것을 확인할 수 있습니다. Redis 기반 시스템에서는 ‘실행 준비 완료’ 작업과 ‘지연된 재시도’ 작업을 분리하는 방식을 선호합니다. ``` llm:ready llm:delayed llm:dead_letter ``` 실패한 작업은 즉시 다음으로 돌아가지 않습니다. ready. 대신 재시도 예정 시각이 지정됩니다. 작은 스케줄러가 예정 시각이 된 작업을 준비 큐로 다시 옮깁니다. ``` { "job_id": "sum_7f13", "model": "claude-sonnet-4.6", "estimated_tokens": 6200, "attempt": 2, "not_before": "2026-02-03T09:14:22Z" } ``` 해당 not_before 필드가 재시도 폭주를 방지합니다. ## 동시 실행 제어와 요청 한도 제한은 다릅니다 동시 실행 제한은 진행 중인 작업 수의 상한을 정합니다. 요청 한도 제한은 시간 구간별 작업량의 상한을 정합니다. 둘 다 필요합니다. 각 호출에 20초가 걸리고 RPM이 60이라고 가정해 보세요. RPM만 제한하면 초당 요청 하나가 허용됩니다. 20초 후에는 약 20개의 호출이 동시에 실행됩니다. 지연 시간이 90초로 늘어나면 동일한 RPM에서 90개의 호출이 동시에 실행됩니다. 이는 제공업체 또는 로컬 리소스의 한도를 초과할 수 있습니다. 동시 실행 제어에는 세마포어를 사용하세요. ``` provider_concurrency = asyncio.Semaphore(20) async def call_model(job): async with provider_concurrency: await limiter.acquire(job.estimated_tokens) return await send_request(job) ``` 흔히 겪는 함정이 있습니다. 세마포어를 너무 일찍 획득하면 워커가 요청 한도 토큰을 기다리는 동안 한정된 동시 실행 슬롯을 차지한 채 유휴 상태로 있게 됩니다. 일반적으로 요청 한도 용량을 먼저 확보한 다음, 네트워크 호출 직전에 동시 실행 슬롯을 확보하세요. 한도 제한기에서 오래 기다릴 수 있다면 그동안 워커 리소스를 점유하지 않도록 하세요. ## 점진적 성능 저하: 중요한 곳에 품질 자원 집중하기 요청 한도에 걸렸을 때 실패로 처리하는 것 외에도 선택지가 있습니다. 예를 들어 고객 지원 어시스턴트는 다음과 같이 서비스 수준을 낮출 수 있습니다. | 부하 수준 | 전략 | 사용자에게 미치는 영향 | 정상 | 설정된 모델 중 성능이 가장 뛰어난 모델 사용 | 최상의 품질 | 경미한 TPM 부하 | 줄이기 max_tokens, 검색으로 가져온 컨텍스트 축소 | 답변이 약간 짧아짐 | 중간 수준 부하 | 단순한 작업을 더 작은 모델로 라우팅 | 분류 및 라우팅이 쉬우면 영향이 미미함 | 높은 부하 | 긴급하지 않은 작업을 큐에 넣기 | 백그라운드 작업 지연 | 심각한 부하 | 캐시된 응답 또는 템플릿 응답 반환 | 품질은 낮지만 이용 가능 구체적인 예: - 티켓 태깅에는 최첨단 모델 대신 Haiku 4.5, Qwen 또는 소형 Llama 변형 모델을 사용하도록 라우팅하세요. - 일반적인 추론에는 Sonnet 4.6을 사용하고, Opus 4.8 또는 GPT-5.5는 상위 처리 경로에 사용하도록 남겨 두세요. - 프롬프트에 해당 컨텍스트 윈도우가 실제로 필요한 경우에만 긴 컨텍스트 분석을 Fable 5 또는 Gemini 3로 옮기세요. - 배치 작업에서는 대화형 트래픽에 영향을 주기 전에 우선순위가 낮은 테넌트의 작업을 일시 중지하세요. 그 대가로 복잡성이 늘어납니다. 모든 대체 경로에는 테스트, 관측 가능성, 제품 차원의 수용이 필요합니다. 일부 분야에서는 품질이 낮은 답변이 지연된 답변보다 더 큰 피해를 줄 수 있습니다. ## 여러 키와 여러 제공업체를 활용한 대체 경로 여러 키와 여러 제공업체를 통한 라우팅은 복원력을 높일 수 있지만, 잘못 구현하기 쉽습니다. 서로 구분되는 패턴은 세 가지입니다. | 패턴 | 해결하는 문제 | 위험 | 동일한 제공업체 및 계정의 여러 키 | 운영 격리 | 한도 회피에 사용하면 제공업체 약관을 위반할 수 있음 | 여러 계정 및 리전 | 장애 격리 | 청구 및 거버넌스 복잡성 증가 | 여러 제공업체 및 모델 | 가용성과 비용 선택의 유연성 | 출력 편차, 프롬프트 비호환성 여러 키를 사용해 한도를 몰래 우회하지 마세요. 테넌트, 환경, 제품 또는 청구 경계를 정당하게 분리하는 용도로 사용하세요. 명확한 라우팅 정책은 다음과 같을 수 있습니다. ``` { "task": "support_ticket_summary", "primary": { "provider": "anthropic", "model": "claude-sonnet-4.6" }, "fallbacks": [ { "provider": "openai", "model": "gpt-5.5", "when": ["provider_5xx", "rate_limit_after_retry_budget"] }, { "provider": "google", "model": "gemini-3", "when": ["context_length_needed", "primary_unavailable"] } ], "degrade_to": { "provider": "open_model_cluster", "model": "qwen", "when": ["batch_low_priority"] } } ``` 여러 모델에 접근하기 위해 애그리게이터나 AI Prime Tech 같은 플랫폼을 사용하더라도 동일한 엔지니어링 원칙이 적용됩니다. 멱등성, 예산, 대체 경로 전환 조건, 최종 청구 처리 규칙을 명시적으로 추적하세요. ## 대체 경로 전환 시 이중 청구 방지하기 대체 경로 전환에서 어려운 부분은 첫 번째 요청이 완료되었는지 아는 것입니다. 다음 순서를 살펴보세요. ``` Client sends request to Provider A Provider A processes it successfully Network connection drops before client receives response Client retries with Provider B Both providers bill Two completions may exist ``` 제공업체가 멱등성 키 또는 요청 조회를 지원하지 않는 한 클라이언트 측에서 이 문제를 완전히 해결할 수는 없습니다. 하지만 피해를 줄일 수는 있습니다. ### 지원되는 경우 멱등성 키 사용하기 논리적 작업마다 일관된 키를 생성하세요. ``` import hashlib import json def idempotency_key(task_name, tenant_id, input_payload): canonical = json.dumps(input_payload, sort_keys=True, separators=(",", ":")) digest = hashlib.sha256(canonical.encode()).hexdigest() return f"{tenant_id}:{task_name}:{digest}" ``` 전송 전에 작업을 저장하세요. ``` { "operation_id": "acme:summarize:9d2a...", "status": "in_flight", "provider": "anthropic", "model": "claude-sonnet-4.6", "created_at": "2026-02-03T09:12:01Z" } ``` 그런 다음 완료 후에 업데이트하세요. ``` { "operation_id": "acme:summarize:9d2a...", "status": "completed", "provider_request_id": "req_abc123", "usage": { "input_tokens": 4180, "output_tokens": 612 } } ``` 다른 곳에서 재시도하기 전에 작업 테이블을 확인하세요. 다른 워커가 이미 완료했다면 저장된 결과를 반환하세요. ### 타임아웃 유형 구분하기 모든 타임아웃이 같지는 않습니다. - 연결 타임아웃: 요청이 제공업체에 도달하지 않았을 가능성이 높음 - 헤더 수신 전 읽기 타임아웃: 불확실함 - 토큰 스트리밍 중 타임아웃: 제공업체가 요청을 처리했을 것이 거의 확실함 - 클라이언트 취소: 취소가 전파되었는지에 따라 달라짐 불확실한 경우에는 보수적인 재시도 정책을 선호합니다. ``` interactive: - retry connect timeout - do not cross-provider retry after partial stream - show recoverable error if status unknown batch: - retry after reconciliation delay - prefer same provider with idempotency key - fallback only after retry budget expires ``` ### 스트리밍을 신중하게 처리하기 스트리밍은 체감 지연 시간을 줄여 주지만 재시도를 복잡하게 만듭니다. 사용자에게 출력의 일부를 전송한 뒤 다른 모델로 재시도하면 이어지는 내용이 달라질 수 있습니다. 스트리밍 채팅에서는 보통 ‘첫 토큰 수신’을 사용자 모르게 재시도할 수 없는 시점으로 봅니다. 그 이후의 실패는 사용자에게 드러나는 실패로 처리합니다. ``` "The response was interrupted. Retry?" ``` 이는 사용자 모르게 복구하는 방식보다 매끄럽지는 않지만, 서로 다른 모델의 호환되지 않는 출력을 이어 붙이는 일을 방지합니다. ## 관측 가능성: 실제로 도움이 되는 지표 성공률만으로는 부족합니다. 최소한 다음 항목을 추적하세요: ``` llm_requests_total{provider,model,status} llm_tokens_reserved_total{provider,model} llm_tokens_used_total{provider,model} llm_retries_total{provider,model,error_type} llm_queue_depth{priority,task_type} llm_queue_wait_seconds{priority,task_type} llm_rate_limit_hits_total{provider,model,limit_type} llm_fallbacks_total{from_provider,to_provider,reason} llm_duplicate_suppressed_total{task_type} ``` 실제 장애 상황에서 가장 유용한 대시보드 패널은 지연 시간이 아닌 경우가 많습니다. 바로 다음과 같은 패널입니다. ``` queue_wait_seconds p50 / p95 rate_limit_hits by provider and model tokens_reserved vs tokens_used fallback rate by reason ``` 만약 tokens_reserved 이 다음보다 지속적으로 훨씬 높다면 tokens_used, 예약량을 지나치게 보수적으로 잡은 것입니다. 실제 사용량과 너무 비슷하거나 그보다 낮다면, 처리 용량을 초과 배정하고 있는 것이므로 예상치 못하게 TPM 한도에 도달하게 됩니다. ## 실용적인 재시도 래퍼 다음은 재시도 가능한 실패만 재시도하는 간결한 패턴입니다. ``` import asyncio import random import time RETRYABLE_STATUS = {429, 500, 502, 503, 504} class LLMError(Exception): def __init__(self, status, message="", retry_after=None): self.status = status self.retry_after = retry_after super().__init__(message) def jittered_backoff(attempt, base=0.5, cap=20): return random.uniform(0, min(cap, base * (2 ** attempt))) async def call_with_retries(fn, max_attempts=4, max_elapsed=20): started = time.monotonic() for attempt in range(max_attempts): try: return await fn() except LLMError as error: elapsed = time.monotonic() - started if error.status not in RETRYABLE_STATUS: raise if attempt == max_attempts - 1 or elapsed >= max_elapsed: raise delay = jittered_backoff(attempt) if error.retry_after is not None: delay = max(delay, error.retry_after) remaining = max_elapsed - elapsed await asyncio.sleep(min(delay, remaining)) ``` 이 래퍼는 의도적으로 단순하게 만들었습니다. 정교한 처리는 끝없이 시도하는 마법 같은 재시도 루프가 아니라 분류, 큐 처리, 멱등성, 라우팅에 구현해야 합니다. ## 실무 핵심 요점 - 속도 제한을 다음 두 가지 기준으로 모델링하세요. RPM과 TPM. 토큰을 고려한 스로틀링은 대형 프롬프트로 인한 예상치 못한 문제를 방지합니다. - 다음을 사용하세요: 지터를 적용한 지수 백오프를 사용하고, 제공업체가 재시도 헤더를 제공하면 이를 따르세요. - 다음을 배치하세요. 큐와 디스패처 배치 또는 대량 워크로드에서는 앱과 LLM 제공업체 사이에 배치하세요. - 제어 수단 동시성을 별도로 관리하세요. 속도 제한과 별도로 관리해야 합니다. 그렇지 않으면 지연 시간이 급증할 때 진행 중인 호출이 너무 많아질 수 있습니다. - 디자인 점진적인 기능 축소 를 의도적으로 설계하세요. 더 작은 모델, 더 짧은 컨텍스트, 큐에 넣은 백그라운드 작업 또는 캐시된 응답을 활용할 수 있습니다. - 취급하세요 여러 제공업체 간 폴백 을 중복 요청을 무분별하게 보내도 된다는 허가가 아니라 신뢰성을 위한 기능으로 다루세요. - 다음을 사용하세요: 멱등성 키와 작업 기록 을 사용해 이중 과금을 줄이고 중복 생성을 방지하세요. - 토큰이 이미 출력된 뒤 스트리밍 응답이 실패하면 사용자에게 솔직하게 알리세요. - 다음 항목을 중심으로 대시보드를 구성하세요. 큐 대기 시간, 토큰 예약량, 속도 제한 도달 횟수, 재시도, 폴백 사유. - 재시도 로직은 단순하게 유지하세요. 실제 운영에서 중요한 일은 예산 관리, 스케줄링, 그리고 재시도하지 말아야 할 때를 판단하는 것입니다. [API](/ko/blog/tag/api/) A [AI Prime Tech](https://aiprimetech.io/ko/) 이 블로그는 AI Prime Tech가 발행합니다. 기사는 AI의 도움을 받아 초안을 작성합니다. 모델 ID, 가격, 한도 등 버전별 세부 정보는 활용하기 전에 공식 문서와 대조해 확인하세요. 더 저렴하게 Claude API 이용하기 API 키 하나로 Claude Opus 5.5, Sonnet 5, Haiku 4.5, Fable 5.1 및 GPT-6개 모델을 이용하세요. 구독 없이 사용한 만큼만 결제합니다. [API 키 받기 →](/register) AI Prime Tech는 독립적인 타사 API 게이트웨이입니다. Claude™ 및 Anthropic®는 Anthropic, PBC의 상표입니다. 제휴 또는 보증 관계를 의미하지 않습니다.