- Published on
AWS Bedrock Claude InvokeModel 429·Throttling 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 AWS Bedrock의 Claude(Anthropic) 모델을 InvokeModel로 호출하다 보면, 트래픽이 조금만 올라가도 HTTP 429 또는 SDK 예외로 **ThrottlingException**을 맞는 경우가 많습니다. 특히 “개발 환경에서는 멀쩡한데 배포 후 갑자기 터지는” 패턴이 흔합니다. 이유는 단순히 요청 수(RPS)만이 아니라 계정/리전 쿼터, 모델별 처리량, 토큰 소비량, 동시성, 재시도 폭주가 서로 얽혀 있기 때문입니다.
이 글에서는 429를 재현 → 원인 분해 → 즉시 적용 가능한 완화책 → 구조적 해결(아키텍처) 순서로 정리합니다. 예시는 Python(boto3) 기준이지만, 핵심 원리는 어떤 언어/런타임에서도 동일합니다.
1) 429/ThrottlingException의 정체: “요청 수”만의 문제가 아니다
Bedrock에서 429는 보통 다음 중 하나(혹은 복합)로 발생합니다.
1.1 계정/리전/모델별 쿼터 초과
Bedrock은 리전 단위로 모델 액세스와 처리량이 달라지고, 모델별로 요청 처리량/토큰 처리량이 사실상 제한됩니다. 같은 RPS라도 프롬프트/응답 토큰이 늘어나면 처리량을 더 빨리 소진합니다.
- 짧은 요청을 많이 보내는 서비스보다
- 긴 컨텍스트(수천~수만 토큰)로 적게 보내는 서비스가
오히려 더 빨리 429를 맞을 수 있습니다.
1.2 동시성 폭주(특히 Lambda/EKS에서)
- Lambda: 동시 실행이 순간적으로 튀면 Bedrock 호출이 동시에 몰립니다.
- EKS/ASG: HPA가 확 늘면서 Pod 수가 증가하면, 각 Pod가 동시에 Bedrock을 두드려 **집단 폭주(thundering herd)**가 발생합니다.
1.3 “잘못된 재시도”가 429를 증폭
429를 받았을 때 모든 워커가 동시에 즉시 재시도하면, 실제로는 회복할 틈 없이 더 큰 폭주를 만들어 429가 지속됩니다.
핵심: 429를 “재시도하면 언젠간 되겠지”로 접근하면, 트래픽이 조금만 커져도 시스템이 자기 자신을 공격하는 구조가 됩니다.
2) 먼저 확인할 것: 관측(Observability) 체크리스트
원인을 정확히 잡으려면 아래 지표를 최소한으로 갖춰야 합니다.
2.1 요청 단위 로깅(필수 필드)
modelId(예:anthropic.claude-3-5-sonnet-20240620-v1:0)- 입력 토큰 추정치(가능하면 실제)
- 출력 토큰 상한(
max_tokens) - 응답 지연 시간(latency)
- 에러 코드(429/ThrottlingException)
- 재시도 횟수
2.2 CloudWatch에서 봐야 할 것
- 애플리케이션 레벨: 429 비율, 재시도율, p95/p99 latency
- 인프라 레벨: Lambda 동시성, EKS Pod 수/HPA 이벤트, 큐 적체량
EKS에서 HPA/네트워크 이슈가 섞이면 gRPC나 스트리밍에서 다른 형태의 에러도 동반될 수 있습니다. 스트리밍 기반이라면 프록시/게이트웨이 튜닝도 같이 보세요: LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트
3) 즉시 효과 있는 1차 처방: “올바른 재시도” + “동시성 제한”
가장 먼저 적용할 것은 **클라이언트 측 레이트리밋/동시성 제한 + 지수 백오프(Full Jitter)**입니다.
3.1 boto3 InvokeModel 예제: 지수 백오프 + Full Jitter
아래 예제는 Bedrock Runtime의 invoke_model 호출에서 429/Throttling 계열을 잡아 안전하게 재시도합니다.
import json
import random
import time
from typing import Any, Dict
import boto3
from botocore.exceptions import ClientError
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
RETRYABLE_ERROR_CODES = {
"ThrottlingException",
"TooManyRequestsException",
"ProvisionedThroughputExceededException",
"RequestLimitExceeded",
}
def invoke_claude_with_retry(
model_id: str,
payload: Dict[str, Any],
*,
max_attempts: int = 8,
base_delay: float = 0.25,
max_delay: float = 8.0,
) -> Dict[str, Any]:
"""Bedrock InvokeModel with exponential backoff + full jitter.
- base_delay: initial backoff window (seconds)
- max_delay: cap for backoff window
"""
for attempt in range(1, max_attempts + 1):
try:
resp = brt.invoke_model(
modelId=model_id,
body=json.dumps(payload).encode("utf-8"),
contentType="application/json",
accept="application/json",
)
body = resp["body"].read()
return json.loads(body)
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
status = e.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
# 429/Throttling 계열만 재시도
if code in RETRYABLE_ERROR_CODES or status == 429:
if attempt == max_attempts:
raise
# Exponential backoff window
cap = min(max_delay, base_delay * (2 ** (attempt - 1)))
# Full jitter: [0, cap)
sleep_s = random.random() * cap
time.sleep(sleep_s)
continue
raise
# Claude Messages API 형태(예시)
payload = {
"anthropic_version": "bedrock-2023-05-31",
"messages": [
{"role": "user", "content": [{"type": "text", "text": "요약해줘"}]}
],
"max_tokens": 512,
"temperature": 0.2,
}
result = invoke_claude_with_retry(
model_id="anthropic.claude-3-5-sonnet-20240620-v1:0",
payload=payload,
)
print(result)
포인트
attempt가 증가할수록 대기 상한(cap)을 키우되, 랜덤 지터로 재시도 타이밍을 분산합니다.- 429는 “잠깐 쉬면 되는” 경우도 있지만, 지속적인 과부하라면 재시도만으로는 해결되지 않습니다. 이때는 아래의 동시성 제한이 필수입니다.
3.2 프로세스/Pod 단위 동시성 제한(세마포어)
애플리케이션이 비동기/멀티스레드라면, Bedrock 호출 자체에 세마포어를 걸어야 합니다.
import asyncio
from contextlib import asynccontextmanager
import aioboto3
SEM = asyncio.Semaphore(8) # 서비스 상황에 맞게 조정
@asynccontextmanager
async def bedrock_client():
session = aioboto3.Session()
async with session.client("bedrock-runtime", region_name="us-east-1") as c:
yield c
async def invoke_with_sem(client, model_id, payload):
async with SEM:
resp = await client.invoke_model(
modelId=model_id,
body=json.dumps(payload).encode(),
contentType="application/json",
accept="application/json",
)
data = await resp["body"].read()
return json.loads(data)
여기서 중요한 건 “세마포어 값”을 어떻게 정하느냐입니다. 보통은 다음 순서로 잡습니다.
- 현재 쿼터/모델 처리량을 확인
- 평균 요청의 입력/출력 토큰과 평균 latency 측정
동시성 ≈ 처리량(초당 처리 가능 요청 수) × 평균 latency로 대략적인 상한을 계산- 안전 계수(예: 0.5~0.8)를 곱해 운영 시작 후 점진적으로 상향
4) 토큰 버짓을 줄이면 429가 눈에 띄게 줄어든다
429는 “요청 수”가 아니라 “처리량” 병목인 경우가 많으므로, 토큰을 줄이는 것이 곧 처리량을 확보하는 방법입니다.
4.1 max_tokens를 무의식적으로 크게 잡지 말 것
max_tokens=4096을 습관적으로 넣으면, 모델은 그만큼의 최악의 경우를 대비하는 형태로 자원을 쓰게 됩니다(실제 동작은 모델/플랫폼마다 다르지만, 운영 관점에서는 보수적으로 보는 게 안전).- 실제 필요한 출력 길이를 측정해서 상한을 낮추면, 같은 쿼터에서 더 많은 요청을 처리할 수 있습니다.
4.2 프롬프트/컨텍스트 절감
- 시스템 프롬프트를 템플릿화하고 불필요한 장문 지시를 제거
- 대화 히스토리 전체를 매번 보내지 말고 요약/압축
- RAG를 쓴다면 top-k를 무작정 키우지 말고, chunk 크기/중복 제거
이렇게 하면 429 빈도 감소 + 비용 절감 + latency 감소가 같이 옵니다.
5) 구조적 해결: 큐잉 + 글로벌 레이트리밋(분산 환경 필수)
Pod가 20개로 늘면 각 Pod의 로컬 세마포어는 의미가 약해집니다. 결국 Bedrock 앞단에 전역 제어 장치가 필요합니다.
5.1 SQS로 완충(Backpressure) 만들기
가장 단순하고 강력한 패턴은:
- API 서버는 요청을 SQS에 넣고 빠르게 응답(또는 폴링)
- 워커가 SQS를 소비하면서 Bedrock 호출
- 워커 동시성/오토스케일로 처리량을 “서서히” 늘림
이렇게 하면 갑작스런 트래픽 스파이크가 와도 Bedrock을 직접 때리지 않고, 큐에서 흡수합니다.
5.2 Redis/ElastiCache 기반 토큰 버킷 레이트리밋
“초당 N요청” 같은 제한을 서비스 전체에서 공유해야 한다면 Redis 토큰 버킷이 실용적입니다.
import time
import redis
r = redis.Redis(host="redis", port=6379, decode_responses=True)
# 간단한 fixed-window 예시(운영에선 token bucket/lua 권장)
def allow_request(key: str, limit: int, window_s: int) -> bool:
now = int(time.time())
bucket = f"{key}:{now // window_s}"
n = r.incr(bucket)
if n == 1:
r.expire(bucket, window_s)
return n <= limit
# 사용 예
if not allow_request("bedrock:claude", limit=20, window_s=1):
raise RuntimeError("rate limited")
운영에서는 경쟁 조건을 피하기 위해 Lua 스크립트 기반 토큰 버킷을 권장합니다. 핵심은 “요청을 버리거나(429) 대기시키거나(큐잉)”를 정책적으로 결정해 Bedrock 429를 애플리케이션 429로 흡수하는 것입니다.
6) Lambda에서 특히 자주 터지는 케이스: 동시성 + 콜드 스타트 + 재시도
Lambda는 트래픽이 튀면 동시성이 급격히 늘고, 동시에 콜드 스타트가 발생하면서 지연이 늘어납니다. 지연이 늘면 클라이언트 타임아웃/재시도가 겹쳐 Bedrock 호출이 더 폭주할 수 있습니다.
- Lambda 동시성 제한(Reserved Concurrency)로 상한을 걸고
- Provisioned Concurrency로 콜드 스타트를 줄여
- 재시도는 백오프+지터로 분산
이 조합이 효과적입니다. Lambda의 콜드 스타트 최적화는 아래 글이 같은 결의 실전 접근을 제공합니다: AWS Lambda Python 콜드 스타트가 갑자기 2~5초로 늘어날 때 컨테이너 이미지 레이어 의존성 import 병목과 Provisioned Concurrency로 80% 줄이는 실전 가이드
7) EKS/HPA 환경에서의 주의점: “스케일아웃이 곧 폭주”
EKS에서 HPA가 CPU/메모리만 보고 스케일아웃하면, LLM 호출 같은 외부 의존성(Bedrock)의 쿼터는 고려하지 못합니다. 결과적으로:
- Pod 수가 늘수록 Bedrock 호출이 늘고
- 429가 늘수록 재시도가 늘고
- 재시도는 CPU를 더 쓰고
- HPA는 더 스케일아웃
이라는 악순환이 생깁니다.
7.1 HPA 입력 지표를 “큐 길이/요청 대기열”로 바꾸기
LLM 호출 워커는 CPU가 아니라 **큐 적체량(SQS ApproximateNumberOfMessagesVisible 등)**을 기준으로 스케일하는 편이 안전합니다.
7.2 metrics-server/지표 이상으로 HPA가 오작동하는지도 확인
HPA가 기대대로 늘지 않거나 이상하게 동작하면, 지표 수집 문제부터 점검해야 합니다: Kubernetes HPA가 안 늘 때 metrics-server 0값 해결
8) 운영에 바로 쓰는 “429 대응 정책” 템플릿
마지막으로, 팀에서 합의해두면 좋은 정책 템플릿을 제안합니다.
8.1 애플리케이션 레벨 정책
- (필수) Bedrock 호출은 전부 지수 백오프 + Full Jitter
- (필수) 워커/프로세스 단위 동시성 상한
- (권장) 전역 레이트리밋(Redis) 또는 큐잉(SQS)
- (권장)
max_tokens및 컨텍스트 길이 상한을 서비스별로 명시
8.2 실패 처리 정책
- 재시도 가능한 429는 최대 N회까지만
- N회 실패 시:
- 사용자 요청이면 “잠시 후 재시도” 안내(애플리케이션 429)
- 배치 작업이면 DLQ로 보내고 나중에 재처리
8.3 배포/스케일 정책
- Lambda: Reserved Concurrency로 Bedrock 호출 상한을 보장
- EKS: HPA를 CPU 대신 큐 길이 기반으로, 또는 워커 수 상한 설정
결론
AWS Bedrock Claude의 InvokeModel에서 발생하는 429/Throttling은 단순한 “요청이 많아서”가 아니라 쿼터/토큰/동시성/재시도가 결합된 처리량 문제인 경우가 대부분입니다. 해결의 우선순위는 명확합니다.
- 지수 백오프+지터로 재시도 폭주를 막고
- 동시성 제한으로 순간 트래픽을 제어하며
- 토큰 버짓 최적화로 처리량을 확보하고
- 분산 환경에서는 큐잉/전역 레이트리밋으로 구조적으로 흡수
이 4가지를 적용하면, “가끔 터지는 429”가 아니라 “예측 가능한 처리량”으로 시스템을 운영할 수 있습니다.