여러 팀이 각자 프로바이더 SDK를 직접 호출하는 구조로 LLM 애플리케이션이 늘어나면 어느 순간 관리 불가능해집니다. API 키가 코드베이스 곳곳에 흩어지고, 어느 팀이 얼마나 토큰을 쓰는지 아무도 모르며, 프로바이더 한 곳이 장애를 내면 의존 서비스가 통째로 멈춥니다. AI 게이트웨이는 이 모든 호출을 한 곳으로 모으는 역방향 프록시(reverse proxy)로, 마이크로서비스 앞의 API 게이트웨이가 하던 역할을 LLM 트래픽에 대해 수행합니다.
이 글에서는 AI 게이트웨이가 반드시 갖춰야 할 세 가지 핵심 기능을 다룹니다. 요청을 적절한 모델로 보내는 모델 라우팅, 소비를 통제하는 레이트리밋, 장애 상황에서 서비스를 유지하는 폴백(fallback)입니다. 예시 코드는 Anthropic Claude API를 기준으로 하지만, 게이트웨이가 다루는 문제 자체는 프로바이더에 종속되지 않습니다. 오히려 프로바이더 종속을 얇은 계층 뒤로 숨기는 것이 게이트웨이의 존재 이유이기도 합니다.
게이트웨이가 해결하는 문제
단순한 프록시로 보이지만, 게이트웨이의 실질적 가치는 제어점(control point)의 단일화에 있습니다.
- 자격 증명 격리: 실제 프로바이더 키는 게이트웨이만 알고 애플리케이션은 내부 토큰만 써서, 키 회전이 재배포 없이 가능합니다.
- 중앙 관측성: 모든 토큰 소비·지연·에러가 한 곳을 지나므로 팀·모델별 비용을 한 번에 집계합니다.
- 정책의 코드화: 레이트리밋·모델 화이트리스트 같은 정책을 애플리케이션마다 재구현하지 않고 한 곳에서 강제합니다.
- 프로바이더 추상화: 애플리케이션은 “가장 적합한 모델”만 요청하고 실제 대상 선택은 게이트웨이에 위임합니다.
LLM 호출 서비스가 하나뿐이라면 게이트웨이는 과잉 설계지만, 호출 주체가 셋 이상으로 늘어나는 순간 위 문제가 동시에 나타나며 도입 비용이 회수됩니다.
기본 프록시 골격과 순서 원칙
게이트웨이의 최소 형태는 요청을 받아 인증하고 실제 프로바이더로 전달한 뒤 응답을 돌려주는 것입니다. 구현에서 두 가지가 중요합니다. 첫째, 스트리밍을 깨뜨리지 않아야 합니다. LLM 응답은 대부분 SSE 스트림이라 청크를 즉시 흘려보내지 않고 버퍼링하면 첫 토큰 지연이 그대로 노출됩니다. 둘째, 인증·레이트리밋·라우팅은 상류 호출 이전에 완결되어야 합니다. 인증이 호출 뒤에 있으면 인증 실패한 요청도 상류 비용을 냅니다. 값싼 거부를 앞에 두는 이 원칙이 뒤에서 다룰 파이프라인 전체를 관통합니다.
모델 라우팅: 정적 규칙에서 시작
라우팅의 목표는 각 요청을 비용·품질·지연의 균형점에 맞는 모델로 보내는 것입니다. 학습 기반 라우터를 상상하기 쉽지만, 실무에서 대부분의 가치는 단순한 정적 규칙에서 나옵니다. 요청 메타데이터(테넌트, 태스크 종류, 컨텍스트 길이)만으로 결정론적으로 라우팅하면 디버깅과 예측이 쉽습니다.
# routing.yaml — 위에서 아래로 첫 매치가 이긴다(순서 = 우선순위)
rules:
- when: { task: "classify" } # 결정적 분류는 소형 모델로 충분
target: claude-haiku-4-8
- when: { min_input_tokens: 60000 } # 대용량 문서는 큰 컨텍스트 창 필요
target: claude-opus-4-8[1m]
- when: { task: "code" }
target: claude-sonnet-4-8
- when: {} # 기본값: 매치되는 규칙이 없을 때
target: claude-sonnet-4-8
이를 평가하는 라우터는 위에서 아래로 훑으며 첫 매치를 반환합니다. 규칙이 선언적이라 새 모델 추가나 비용 정책 변경을 코드 배포 없이 처리할 수 있습니다.
def route(tenant: str, body: dict) -> str:
task = body.get("metadata", {}).get("task")
input_tokens = estimate_input_tokens(body) # 대략적 토큰 추정
for rule in RULES: # 순서 = 우선순위
cond = rule["when"]
if "task" in cond and cond["task"] != task:
continue
if "min_input_tokens" in cond and input_tokens < cond["min_input_tokens"]:
continue
return rule["target"] # 첫 매치가 이긴다
raise HTTPException(500, "no route matched")
정적 라우팅의 한계는 “쉬운 질문인지 어려운 질문인지”를 사전에 알 수 없다는 점입니다. 이때는 캐스케이드(cascade) 패턴을 씁니다. 먼저 저비용 모델로 시도하고 응답이 신뢰 기준(자기평가, 스키마 검증 등)을 못 넘기면 상위 모델로 승격시킵니다. 실패 시 두 번 호출하므로 승격률을 측정해 손익을 검증해야 합니다.
레이트리밋: 요청 수가 아니라 토큰
일반 API의 레이트리밋은 초당 요청 수(RPS)만 세면 되지만, LLM은 요청 한 건의 비용이 토큰 수에 따라 수백 배 차이가 납니다. 요청 수만 제한하면 초당 1건이라도 매 요청이 20만 토큰일 때 예산이 순식간에 소진되므로, 토큰 단위로 제한해야 합니다.
가장 실용적인 알고리즘은 토큰 버킷입니다. 핵심 난점은 요청 시점에 출력 토큰 수를 모른다는 것입니다. 실무에서는 두 단계로 처리합니다. 요청 전에는 max_tokens를 상한으로 삼아 예약(reserve)하고, 응답이 끝나면 실제 소비량으로 정산(reconcile)합니다. 버킷 갱신은 Redis의 원자적 Lua 실행으로 경쟁 조건 없이 처리합니다.
async def rate_limit(tenant: str, body: dict):
reserved = estimate_input_tokens(body) + body.get("max_tokens", 1024)
remaining = await redis.evalsha( # 원자적 토큰 버킷(차감)
BUCKET_SHA, 1, f"rl:{tenant}",
TOKEN_RATE, TOKEN_CAP, now_seconds(), reserved, # 리필속도·용량·현재·요청량
)
if remaining < 0: # 버킷 부족 → 거부
raise HTTPException(429, "token budget exceeded",
headers={"Retry-After": "5"})
return reserved
# reconcile(): 응답 후 (reserved - 실제소비)만큼 REFUND_SHA로 버킷에 환급
예약-정산 방식은 예산 초과를 막으면서도 짧게 끝난 응답의 미사용분을 돌려줍니다. 예약 없이 사후 정산만 하면 동시에 몰린 대형 요청이 예산을 순간적으로 초과할 수 있습니다.
폴백: 장애를 흡수하는 계층
폴백의 목표는 프로바이더가 내는 529 Overloaded·503·타임아웃 같은 일시적 장애를 사용자에게 도달하기 전에 흡수하는 것입니다. 같은 모델을 다시 시도하는 재시도(retry)와 다른 모델로 갈아타는 대체(failover) 두 축이 있습니다. 재시도의 핵심 원칙은 지수 백오프 + 지터(jitter)입니다. 고정 간격으로 재시도하면 복구 순간에 모든 클라이언트가 동시에 몰리는 썬더링 허드(thundering herd)가 상류를 다시 무너뜨립니다.
RETRYABLE = {429, 500, 502, 503, 529} # 일시적 장애로 판단하는 상태 코드
async def call_with_retry(model: str, body: dict, max_attempts=4):
for attempt in range(max_attempts):
try:
resp = await client.post(UPSTREAM, json={**body, "model": model},
headers=provider_headers(model))
if resp.status_code not in RETRYABLE:
return resp # 성공 또는 재시도 불가 에러(4xx 등)
except httpx.TimeoutException:
pass # 타임아웃도 재시도 대상
backoff = min(2 ** attempt, 8) # 지수 백오프 + 풀 지터
await asyncio.sleep(random.uniform(0, backoff))
raise HTTPException(503, "upstream exhausted after retries")
여기서 모든 에러가 재시도 대상은 아니라는 점이 중요합니다. 400(잘못된 요청)이나 401(인증 실패)은 다시 보내도 결과가 같으므로 RETRYABLE에서 제외해 즉시 실패시켜야 합니다.
서킷 브레이커: 실패하는 상류를 격리
재시도만으로는 부족합니다. 특정 프로바이더가 지속적으로 죽어 있다면 매 요청이 재시도를 다 소진한 뒤에야 실패해 지연이 누적되고 커넥션이 고갈됩니다. 서킷 브레이커는 연속 실패가 임계치를 넘으면 회로를 열어(open) 해당 상류 호출을 즉시 실패시켜, 죽은 상류를 계속 두드리는 대신 바로 대체 경로로 넘어가게 합니다.
class CircuitBreaker:
def __init__(self, threshold=5, cooldown=30): # 실패 허용치·open 유지초
self.threshold, self.cooldown = threshold, cooldown
self.failures, self.opened_at = 0, None
def allow(self) -> bool:
if self.opened_at is None:
return True # closed: 정상 통과
if time.time() - self.opened_at > self.cooldown:
self.opened_at = None # half-open: 한 건만 시험 허용
return True
return False # open: 즉시 차단
def record(self, ok: bool):
if ok:
self.failures, self.opened_at = 0, None # 성공 → 회로 복구
elif (self.failures := self.failures + 1) >= self.threshold:
self.opened_at = time.time() # 임계 초과 → 회로 개방
서킷이 열린 동안 들어온 요청은 재시도로 시간을 낭비하지 않고 곧바로 대체 모델로 넘어갑니다. 쿨다운이 지나면 half-open 상태로 한 건만 시험 삼아 흘려보내, 성공하면 닫고 실패하면 다시 엽니다. 이 반개방 단계가 없으면 복구된 상류를 언제 다시 쓸지 판단할 수 없습니다.
라우팅·레이트리밋·폴백을 하나로 엮기
세 기능은 하나의 요청 파이프라인 안에서 정해진 순서로 실행됩니다. 인증(가장 값쌈) → 라우팅(대상 결정) → 레이트리밋(예약) → 상류 호출(재시도·서킷 포함, 여기서만 실제 비용) → 정산입니다.
async def handle(request: Request):
tenant = authenticate(request) # 1. 인증
body = await request.json()
chain = route_with_fallbacks(tenant, body) # 2. 라우팅 + 대체 후보 목록
reserved = await rate_limit(tenant, body) # 3. 예약
last_error = None
for model in chain: # 기본 → 대체 순으로 시도
if not breakers[model].allow(): # 4. 서킷 열림 → 이 모델 건너뜀
continue
try:
resp = await call_with_retry(model, body) # 재시도 포함
breakers[model].record(ok=True)
await reconcile(tenant, reserved, resp.usage) # 5. 정산
return resp
except HTTPException as e:
breakers[model].record(ok=False)
last_error = e # 다음 대체 모델로
await reconcile(tenant, reserved, {"input_tokens": 0, "output_tokens": 0})
raise last_error or HTTPException(503, "all candidates failed")
이 파이프라인에서 놓치기 쉬운 부분은 모든 경로에서 정산이 일어나야 한다는 점입니다. 폴백을 다 소진해 실패해도 예약한 토큰을 환급하지 않으면, 장애 상황에서 예약만 쌓여 정상 요청까지 레이트리밋에 걸리는 이차 장애가 생깁니다.
관측성: 게이트웨이가 존재하는 이유
게이트웨이의 가장 큰 실질적 이득은 관측성입니다. 모든 요청이 한 곳을 지나므로 각 요청마다 구조화된 로그를 남기면 비용·성능 분석이 한 번에 됩니다.
-- 팀·모델별 하루 토큰 소비 집계
SELECT tenant, routed_model,
sum(input_tokens + output_tokens) AS total_tokens,
count(*) FILTER (WHERE was_fallback) AS fallback_count -- 대체 전환 횟수
FROM gateway_requests
WHERE ts >= now() - interval '1 day'
GROUP BY tenant, routed_model
ORDER BY total_tokens DESC;
특히 was_fallback과 retry_count를 기록하면 프로바이더 안정성 추이를 관찰할 수 있습니다. 폴백 비율이 서서히 오른다면 특정 모델이 조용히 열화되고 있다는 신호이므로, 서킷이 열리기 전에 선제 대응할 수 있습니다.
마무리
AI 게이트웨이는 LLM 호출을 하나의 제어점으로 모아 흩어져 있던 자격 증명·정책·관측성을 통합합니다. 라우팅은 정적 규칙에서 시작해 필요할 때만 캐스케이드로 확장하고, 레이트리밋은 토큰을 예약-정산 방식으로 통제하며, 폴백은 재시도·서킷 브레이커·대체 모델을 계층으로 쌓아 장애를 흡수합니다. 값싼 거부는 앞에, 비용이 큰 호출은 뒤에 두며 어떤 경로로 끝나든 예약한 예산을 반드시 정산하는 것이 안정적 운영의 핵심입니다.