Amazon Bedrock을 통해 실제 LLM 트래픽을 구동할 경우, 속도 제한(throttling)은 예외적인 상황이 아닙니다. 이는 대규모 운영 환경에서 발생하는 일반적인 조건입니다. ThrottlingException 또는 TooManyRequestsException은 Bedrock이 해당 모델의 특정 리전에서 요청률이나 토큰 사용량이 할당량을 초과했음을 알려주는 것입니다. 해결책은 단 하나의 설정으로 끝나지 않습니다. 이는 계층적 방어 체계(layered defense)입니다: 할당량(quota)을 이해하고, 지능적으로 재시도하며(retry), 동시성(concurrency)을 제한하고, 우아하게 성능 저하를 처리하며(degrade gracefully), 시간 초과(timeout)를 처리하고, AWS에 에스컬레이션할 방법을 아는 것입니다.
이것은 제가 실제 LLM 워크로드를 운영하면서 사용했던 전략들을 일반적인 가이드 형태로 정리한 것입니다. 특정 워크로드의 세부 사항은 없으며, 각 패턴과 그것이 필요한 경우를 제시합니다.
요약 (TL;DR)
- 먼저 할당량을 파악하세요: Service Quotas를 확인하여 어떤 제한이 조정 가능(soft)하고 어떤 것이 아닌지(hard) 학습해야 합니다.
- 속도 제한 오류 발생 시 지수 백오프(exponential backoff)와 전체 지터(full jitter)를 사용하여 재시도합니다. 이것은 최소한의 방어선일 뿐, 최대치가 아닙니다.
- 할당량이 흡수할 수 없는 부하 생성을 멈추도록 동시성(concurrency)을 제한합니다.
- 서킷 브레이커(circuit breaker)를 추가하여 속도 제한 폭풍이 무작정 때리는 대신 점진적으로 뒤로 물러나게 합니다.
- 시간 초과를 별도로 처리합니다: 모델 시간 초과 시 배치 크기를 줄이고, 클라이언트 읽기 시간 초과는 튜닝하고 재시도합니다.
- Cross-region inference profile을 사용하여 토큰 수요를 여러 리전으로 분산합니다.
- 온디맨드(on-demand) 사용량과 위의 모든 조치로도 충분하지 않을 때만 프로비저닝된 처리량(provisioned throughput)을 예약합니다.
1단계: 도달한 할당량을 파악하기
재시도 코드를 작성하기 전에, 실제 제한치를 찾아야 합니다. Bedrock의 할당량은 모델별, 리전별로 적용되며, 토큰/분(TPM), 요청/분(RPM) 등 유형별로 분할됩니다. 일부 모델의 경우 입력/출력 토큰에 대한 별도의 할당량이 있습니다.
소프트 제한 vs 하드 제한
이 구분은 전체 전략을 결정합니다.
- 소프트 제한 (Soft limits, 조정 가능). Amazon Bedrock 엔드포인트 및 할당량 테이블에서
Adjustable: Yes로 표시됩니다. 높일 수 있습니다. 서비스 할당량(Service Quotas)을 통해 증가를 요청할 수 있으며, 작은 증가는 자동 승인되는 경우가 많고 큰 증가는 검토를 위해 AWS Support로 전달됩니다.
- 하드 제한 (Hard limits, 조정 불가).
Adjustable: No로 표시됩니다. 아무리 요청해도 변경되지 않습니다. 유일한 방법은 아키텍처적 접근입니다. 즉, 수요를 줄이거나, 여러 리전으로 분산시키거나, 접근 패턴을 변경하는 것입니다.
실질적인 규칙: 제한이 소프트하고 지속적으로 그 근처에 있다면, 증가를 요청함과 동시에 아래의 방어책도 구축해야 합니다. 왜냐하면 증가는 시간이 걸리고 스로틀링(throttling)을 영원히 불가능하게 만들지 않기 때문입니다. 만약 제한이 하드하다면, 요청 자체를 건너뛰고 바로 아키텍처에 집중해야 합니다.
자주 놓치는 세부 사항: Bedrock은 요청 시작 시 입력 토큰과 maxTokens를 기반으로 TPM 할당량을 예약합니다. 만약 maxTokens를 설정하지 않으면 모델의 최대값으로 기본 설정될 수 있으며, 실제 호출이 필요로 하는 것보다 훨씬 많은 할당량을 예약하여 실제 토큰 사용량보다 일찍 스로틀링을 유발할 수 있습니다. maxTokens를 현실적인 상한선으로 설정하세요.
레벨 1: 백오프 및 지터(Jitter)를 이용한 재시도
가장 기본적인 방어책입니다. 속도 제한 오류 코드(ThrottlingException, TooManyRequestsException)를 포착하고, 기다린 후 다시 시도합니다. 좋은 재시도와 해로운 재시도를 구분하는 두 가지 세부 사항이 있습니다:
- 지수 백오프 (Exponential backoff). 각 시도는 더 오래 기다립니다:
base * 2^attempt이며, 최대 한계치(ceiling)로 제한됩니다. 이는 할당량 창(quota window)이 채워질 시간을 줍니다.
- 전체 지터 (Full jitter). 그 시간 간격 내에서 지연 시간을 무작위화합니다. 지터가 없으면, 속도 제한된 모든 호출자가 같은 순간에 재시도를 하여 다시 속도 제한을 유발하는 동기화된 돌진(synchronized stampede)이 발생합니다. 지터가 있으면, 재시도가 분산됩니다.
간결한 패턴:
import random, time
from botocore.exceptions import ClientError
...
boto3 클라이언트 자체의 재시도에 대한 참고 사항입니다. Boto3에는 botocore.config.Config(retries=...)를 통해 설정할 수 있는 내장 재시도 모드(standard 또는 adaptive)가 있습니다. 이를 활용하거나, 비활성화하고 (max_attempts=0) 직접 재시도 루프를 구현할 수 있습니다. 백오프, 지터, 오류 분류를 시스템의 나머지 부분과 일치시키고 싶다면, 두 개의 재시도 계층이 서로 싸우는 것보다 직접 구현하는 노력이 가치가 있습니다.
Step Functions 워크플로우에서의 동일한 재시도
Bedrock 호출을 Step Functions 상태 머신 내에서 실행하는 경우, 백오프 루프를 수동으로 작성할 필요가 없습니다. 작업의 Retry 블록에 선언하면 서비스가 지터와 함께 지수 백오프를 자동으로 적용합니다. Step Functions 오류 처리는 JitterStrategy 필드를 추가하여 대기 시간이 동기화되는 대신 분산되도록 했으며, 이는 코드에서 전체 지터가 중요한 것과 같은 이유입니다.
"InvokeBedrock": {
"Type": "Task",
"Resource": "arn:aws:states:::bedrock:invokeModel",
...
필드들은 코드 버전에 매핑됩니다. IntervalSeconds는 기본 지연 시간이고, BackoffRate는 지수 승수이며, MaxDelaySeconds는 최대 제한값입니다. MaxAttempts는 재시도 횟수이고, JitterStrategy: FULL은 각 대기 시간을 무작위화합니다. Catch는 재시도가 모두 소진되면 폴백(fallback) 경로로 연결됩니다. 예를 들어 사람 검토 단계가 될 수 있으며, 이 경우 재시도 실패가 전체 실행을 실패시키는 대신 우아하게 저하됩니다.
레이어 2: 동시성 제한 (cap concurrency)
재시도는 이미 실패한 요청을 처리합니다. 반면, 동시성 제한은 애초에 할당량(quota)이 흡수할 수 있는 것보다 더 많은 부하를 생성하는 것을 막아줍니다. 만약 50명의 워커가 동시에 같은 모델을 호출한다면, 아무리 백오프(backoff) 전략이 뛰어나도 속도 제한(throttle)에 걸립니다.
진행 중인 Bedrock 호출에 천장(ceiling)을 설정하세요. 비동기 Python에서는 asyncio.Semaphore를 사용합니다:
import asyncio
semaphore = asyncio.Semaphore(10) # 최대 동시 Bedrock 호출 수
...
애플리케이션 상위 계층에서도 인프라 레이어에서 동시성을 제한해야 합니다. 만약 문서들이 이벤트로 도착한다면, SQS 큐에 넣고 SQS-to-Lambda 이벤트 소스 매핑이 maximum_concurrency를 강제하도록 하세요. 작업은 슬롯이 비워질 때까지 큐에 대기하며, 폴링(polling)이나 사용자 지정 카운터가 필요 없습니다:
resource "aws_lambda_event_source_mapping" "sqs_to_worker" {
event_source_arn = aws_sqs_queue.work.arn
function_name = aws_lambda_function.worker.arn
...
동시성 제한은 이론적으로 실행할 수 있는 워커 수가 아니라, 할당량에 맞춰야 합니다. 적절한 숫자는 정상 상태 토큰 속도(steady-state token rate)를 TPM 한도 아래로 유지하는 숫자입니다.
레이어 3: 스로틀 폭풍을 위한 회로 차단기 (circuit breaker for throttle storms)
백오프와 동시성 제한은 일반적인 압력을 처리합니다. 지속적인 스로틀 폭풍에는 더 강하게 후퇴할 수 있는 무언가가 필요합니다. 회로 차단기는 속도 제한률(throttle rate)을 감시하고, 이 값이 특정 시간 창 내에서 임계값을 초과하면 모든 호출을 냉각기(cooldown) 동안 일시 중지시킨 다음, 감소된 동시성으로 재개한 후 전체 처리량을 복원합니다.
상태 머신:
- Closed (정상): 호출이 흐르며, 속도 제한(throttles)이 계산됩니다.
- Open (트립됨): 해당 시간 창(window) 내에서 N번의 속도 제한에 도달하면, 모든 호출을 일정 기간 동안 중단하고 동시성 한계(concurrency limit)를 절반으로 줄입니다.
- Half-open (회복 중): 냉각기(cooldown)가 끝난 후, 감소된 한계로 재개합니다. 만약 오랫동안 문제가 없다면 전체 동시성을 복원하고, 다시 속도 제한이 걸리면 'Open' 상태로 돌아갑니다.
이는 속도 제한을 악화시키는 피드백 루프(재시도 → 속도 제한 → 재시도 → 속도 제한)를, Bedrock이 회복될 때까지 부하를 적극적으로 줄여나가는 방식으로 전환합니다. 이는 동시성 한계 설정기(concurrency limiter)와 함께 사용됩니다: 브레이커가 한계를 낮추고 다시 올리는 역할을 합니다.
레이어 4: 타임아웃 처리, 이것은 속도 제한이 아닙니다
속도 제한은 혼자 발생하는 경우가 드물며, 가장 큰 이웃은 바로 타임아웃입니다. 여기에는 두 가지 구별되는 종류가 있으며, 각각 상반된 해결책을 요구합니다.
모델 타임아웃: 작업량 줄이기
ModelTimeoutException은 요청 자체가 모델의 시간 예산 내에서 완료하기에 너무 크다는 것을 의미합니다. 이는 속도 문제가 아니므로, 동일한 페이로드를 재시도해도 다시 시간 초과가 발생할 것입니다. 해결책은 작업을 줄이는 것입니다: 배치(batch)를 절반으로 줄이거나 입력을 청크(chunk)하여 더 작은 단위로 만든 후 재시도해야 합니다.
async def call_shrinking(fn, items, *, max_retries=3):
batch = list(items)
for attempt in range(max_retries + 1):
...
클라이언트 타임아웃: 예산 조정 후 일시적 오류 재시도
클라이언트 측의 ReadTimeoutError 또는 ConnectTimeoutError는 또 다릅니다. SDK가 모델이 응답하기 전에 기다리기를 포기한 경우입니다. 긴 프롬프트와 장시간 생성은 boto3 기본 읽기 타임아웃을 습관적으로 초과하며, 이는 실패처럼 보이지만 실제로는 조급한 클라이언트 때문에 발생하는 시간 초과를 만듭니다.
두 가지 방법:
- boto3 클라이언트에 현실적인
read_timeout 설정: 느리지만 유효한 응답이 잘리는 것을 방지합니다.
- 일시적 연결 타임아웃을 속도 제한과 함께 재시도 가능한 것으로 처리: 이를 하드 실패로 보고하지 않도록 합니다.
from botocore.config import Config
client = boto3.client(
...
사전 분류(Classify)하고 대응하세요. 속도 제한(throttle)은 백오프(backoff)를 원하고, 모델 시간 초과(timeout)는 더 작은 배치 크기(batch)를 원하며, 클라이언트 시간 초과는 더 긴 예산(budget)과 재시도(retry)를 원하고, ValidationException은 아예 재시도를 원하지 않습니다.
레이어 5: 여러 리전 추론 프로필로 수요 분산시키기
할당량(Quotas)은 리전별로 적용됩니다. 크로스-리전 추론 프로필을 사용하면 단일 온디맨드 호출이 여러 리전 중 하나에서 처리될 수 있어, 토큰 수요가 한 곳에 몰리는 대신 각 리전의 할당량에 분산됩니다. 온디맨드 워크로드의 경우 이것이 가장 효과적인 변경 사항인 경우가 많습니다. 구매할 수 있는 용량이 없고, 여러 지역 풀(pool)에서 가져오기 때문에 실질적인 상한선이 높아지기 때문입니다.
무한한 여유 공간은 아니며, 단일 리전 내 모델당 하드 제한을 바꾸지는 않지만, 급격하게 변동하는 온디맨드 트래픽에는 실제적인 공간을 확보해 줍니다.
레이어 6: 필요할 때만 프로비저닝된 처리량 사용하기
온디맨드 방식과 위의 모든 방법을 적용했음에도 예측 가능한 높은 부하에서 여전히 속도 제한이 걸린다면, Bedrock은 프로비저닝된 처리량(Provisioned Throughput)을 제공합니다. 이는 약정(commitment)에 의해 구매하는 보장된 토큰 비율을 가진 예약 용량(모델 유닛)입니다. 이 용량은 사용자 소유이므로 해당 할당분(slice)의 속도 제한을 제거합니다.
이를 첫 번째 레이어가 아닌 마지막 레이어로 취급하세요. 사용 여부와 관계없이 비용이 발생하므로, 꾸준하고 예측 가능한 볼륨에만 의미가 있습니다. 급격하거나 예측 불가능한 트래픽의 경우, 온디맨드 방식에서의 재시도, 동시성 제한(concurrency caps), 그리고 크로스-리전 프로필을 사용하는 것이 일반적으로 경제적입니다.
실제 적용 시 튜닝하기
위의 레이어들은 도구 상자(toolbox)와 같습니다. 각 노브를 얼마나 세게 돌릴지는 워크로드에 따라 다르며, 명확히 언급할 가치가 있는 몇 가지 패턴이 있습니다.
재시도(retries)의 공격성을 놓친 호출 비용에 맞추세요. 무겁고 비싼 호출은 더 많은 재시도 시도, 높은 백오프(backoff) 상한선, 그리고 더 엄격한 동시성(concurrency)을 정당화합니다. 왜냐하면 하나를 잃는 것이 비용이 크기 때문입니다. 대용량의 저렴한 작업은 동시성을 제한하는 업스트림 큐와 수동 검토 폴백(manual-review fallback)으로 끝나는 워크플로우 수준의 자체 재시도에 의해 더 잘 처리되며, 개별 호출 재시는 가볍게 유지됩니다. 단 하나의 정답 숫자는 없으며, 올바른 설정은 해당 작업 부하(workload)의 할당량(quota) 내에서 안정 상태 토큰 속도를 유지하는 것입니다.
쉽게 실수하기 쉬운 두 가지 주의사항이 있습니다: