dev notes

LLM API Rate Limit 리스크를 분석하다가 전제가 틀렸다는 걸 알게 된 과정

2026-04-0821 min read
공유

배경#

처음에는 한 서비스에만 LLM을 붙였습니다. 사용자 입력을 받아서 OpenAI API를 호출하고, 결과를 반환하는 단순한 구조. 서비스 안에 LLM 호출 모듈을 만들어서 썼고, 그걸로 충분했습니다.

그런데 다른 서비스들에서도 LLM이 필요해지기 시작했습니다. 문서 요약, 데이터 분석, AI 기반 추천. 서비스가 3개, 4개로 늘어나면서 각 서비스마다 독립적으로 LLM 모듈을 만들었습니다. 처음엔 한 서비스에만 쓸 거라고 생각해서 공통 모듈로 분리하지 않았던 게 원인이었는데, 결과적으로 4개 서비스가 각각 자기만의 방식으로 OpenAI와 Anthropic API를 호출하는 구조가 됐습니다.

동작은 했습니다. 각 서비스에 프로젝트별 API 키를 발급받아서 쓰고 있었고, 트래픽도 많지 않아서 rate limit에 걸릴 일이 없었습니다. 참고로 OpenAI와 Anthropic 모두 프로젝트별 키를 분리해도 Organization 레벨 rate limit은 전체 프로젝트의 합산에 적용됩니다. (Anthropic도 Workspace 레벨에서 동일한 구조.)

하지만 k8s 환경으로 마이그레이션을 준비하면서, 이 구조가 괜찮은 건지 의문이 들기 시작했습니다.

리스크 분석: HPA가 rate limit을 터뜨릴 것이다#

k8s로 옮기면 HPA(Horizontal Pod Autoscaler)가 트래픽에 따라 pod을 자동으로 늘려줍니다. 기존 환경에서는 인스턴스 수가 고정이거나 스케일아웃이 느려서 동시 호출량이 예측 가능했는데, HPA가 빠르게 pod을 늘리면 어떻게 될까.

이런 시나리오를 그렸습니다:

09:00 업무 시작 → 트래픽 급증 → HPA가 4개 서비스 동시 스케일아웃
  - 서비스 A: 2 → 6 pods
  - 서비스 B: 2 → 5 pods
  - 서비스 C: 2 → 4 pods
  - 서비스 D: 2 → 5 pods
총 20개 pod이 동일 Organization의 API 키로 동시 호출
→ Organization 레벨 rate limit 초과 (429)
→ 전 서비스 LLM 기능 장애

특히 우려했던 건 MapReduce 체인을 쓰는 서비스였습니다. 사용자 요청 1건이 LLM을 10번 이상 호출하는 구조라서, pod이 늘어나면 호출 증폭 효과까지 합쳐져서 rate limit을 빠르게 소진할 거라고 예상했습니다.

리스크 매트릭스를 만들고, 시나리오별 시뮬레이션 수치를 계산하고, 대응 방안으로 중앙화된 LLM Gateway를 제안하는 문서를 썼습니다. 10페이지짜리. 꽤 그럴듯했습니다.

그런데 문서만으로는 부족하다는 생각이 들었습니다. 숫자가 맞는지, 실제로 저렇게 되는지, 직접 확인하고 싶었습니다.

검증: 목 프로젝트를 만들어서 돌려보자#

검증을 위해 목 프로젝트를 만들었습니다.

구성:

  • Mock LLM 서버 2개 (OpenAI용, Anthropic용) — Token Bucket 알고리즘으로 rate limit 시뮬레이션, 2-5초 latency, Prometheus 메트릭, 런타임에 rate limit 동적 변경 가능
  • simple-service — 단일 LLM 호출 + Provider Fallback
  • mapreduce-service — MapReduce 체인 (1요청 → N호출 증폭)
  • minikube 3노드 클러스터에 배포
  • k6로 부하 테스트, Grafana로 시각화

테스트 계획은 간단했습니다:

  1. 고정 스케일 (pod 2개 고정)로 k6 부하 테스트 → 429 안 나는 거 확인
  2. HPA 활성화해서 같은 부하 → pod 늘어나면서 429 터지는 거 확인
  3. Grafana에서 "pod 증가 → RPM 급증 → 429" 상관관계 시각화

Before/After를 비교해서 "HPA가 rate limit을 터뜨린다"는 걸 데이터로 증명하려는 거였습니다.

반전: HPA가 트리거되지 않았다#

k6로 300명의 동시 사용자를 쏟아부었습니다.

고정 스케일 결과:

항목수치
429 발생0건
에러율0.53%
응답 시간 p9512분 44초
처리량3.95 req/s

pod이 2개뿐이니까 처리 못하는 요청은 큐에 쌓였고, 응답이 엄청 느려졌습니다. 하지만 429는 안 났습니다. 예상대로.

HPA 활성화 후 결과:

항목수치
429 발생0건
에러율0.00%
응답 시간 p955.27초
처리량30.2 req/s

HPA가 pod을 3개로 늘렸고, 처리량이 7.6배 증가했고, 응답 시간이 극적으로 개선됐습니다. 여기까진 예상대로인데 — 429가 안 났습니다.

rate limit을 낮춰서 다시 테스트했습니다. RPM을 10,000에서 1,000으로. 그래도 429가 안 나길래, 100으로까지 낮췄습니다. 그제서야 429가 발생했는데, 고정 스케일에서도 똑같이 429가 났습니다.

뭔가 이상했습니다. HPA가 pod을 늘렸는데 왜 rate limit 초과에 차이가 없지?

HPA 상태를 확인했습니다:

NAME                    TARGETS       REPLICAS
simple-service-hpa      cpu: 1%/50%   2

CPU 사용률 1%. HPA 임계값이 50%인데 1%밖에 안 쓰고 있었습니다. pod이 3개로 늘어난 건 이전 테스트의 잔여 효과였고, 실제로는 HPA가 LLM 트래픽 때문에 스케일업할 이유가 없었습니다.

원인: LLM 호출은 I/O bound다#

LLM API 호출은 HTTP 요청을 보내고 응답을 기다리는 I/O 작업입니다. 스레드가 2-10초 동안 WAITING 상태로 있는 거지 CPU를 쓰는 게 아닙니다. Thread.sleep(5000)이랑 같은 겁니다.

400개 스레드가 동시에 LLM 응답을 기다려도 CPU 사용률은 거의 0%입니다. 스레드가 WAITING 상태면 OS 스케줄러에서 빠져있어서 CPU 사이클을 소모하지 않습니다.

일반 API 서비스 (CPU bound):
  요청 처리 → CPU 사용 → 트래픽 증가 → CPU 상승 → HPA 트리거 ✅

LLM 서비스 (I/O bound):
  요청 수신 → LLM API 호출 → 응답 대기 (CPU 사용 0%) → 응답 반환
  → 트래픽 증가해도 CPU 안 올라감 → HPA 트리거 안 됨 ❌

그래서 CPU 기반 HPA는 LLM 서비스에서 의미가 없습니다. 요청이 아무리 쌓여도 CPU가 안 올라가니까 pod을 늘릴 이유가 없는 거죠.

"그러면 JSON 직렬화나 SSL 처리 같은 건?" — LLM 응답 대기 시간(2-10초) 대비 이런 작업은 밀리초 단위라서 CPU 사용률에 의미 있는 영향을 주지 못합니다.

그러면 원래 리스크 분석은 틀린 건가#

핵심 전제인 **"HPA가 pod을 빠르게 늘려서 rate limit이 터진다"**는 틀렸습니다. LLM 트래픽만으로는 HPA가 트리거되지 않으니까요.

하지만 리스크 분석에서 짚었던 문제들이 전부 틀린 건 아닙니다. 다만 원인이 다릅니다.

틀린 부분#

원래 분석현실
HPA가 빠르게 pod 늘림 → 위험LLM은 I/O bound라 CPU 기반 HPA 트리거 안 됨
k8s 전환이 핵심 위험 요인인프라 전환과 무관한 문제
스케일링 속도가 빠를수록 위험스케일링 자체가 안 일어남

여전히 유효한 부분#

HPA 전제가 무너졌다고 rate limit 자체가 안전해진 건 아닙니다. 목 프로젝트를 덮어두고, 운영 로그의 실제 요청량을 베이스라인으로 삼아 다시 계산해봤습니다. MapReduce의 호출 증폭(1요청 → 12호출)과 성수기 지속 부하를 곱해서 처리량을 올려보면, RPM(분당 요청 수)보다 TPM(분당 토큰)에 먼저 닿습니다. 요청 한 건이 프롬프트+컨텍스트로 수천 토큰을 쓰다 보니, 요청 수로는 여유가 있어도 토큰 총량이 먼저 천장을 치는 거죠. 이건 실제로 429가 터진 장애 기록이 아니라 로그 기반 투영이라는 점을 분명히 해둡니다 — "이 정도 트래픽이 이만큼 증폭되면 여기서 걸린다"는 계산이지, 관측된 사고가 아닙니다. 다만 이 투영이 현실이 되는 날, 지금 구조로는 429가 그대로 유실됩니다. 재시도 큐도, DLQ도, Circuit Breaker도 없으니 rate limit에 걸린 요청은 사용자 에러로 직행합니다.

스레드 고갈 — HPA와 무관하게, LLM 호출이 스레드를 오래 잡아먹는 건 사실입니다. 스레드풀이 500개인 서비스에서 동시에 50명이 MapReduce를 요청하면 스레드가 전부 소진됩니다. 새로운 요청은 LLM과 무관한 일반 API 요청이어도 처리가 안 됩니다. 이건 기존 환경에서도 동일한 위험입니다.

호출 증폭 — MapReduce 체인에서 1요청이 12회 LLM 호출로 뻥튀기되는 구조적 문제. 동시 사용자가 늘어나면 LLM 호출이 12배로 증폭됩니다. 이것도 인프라와 무관합니다.

Organization 레벨 rate limit 공유 — 프로젝트별로 API 키를 분리해도 Organization 레벨 limit은 합산됩니다. 한 서비스가 limit을 잡아먹으면 다른 서비스가 429를 받습니다. 이것도 인프라와 무관합니다.

429 핸들링 부재 — 어떤 서비스도 429 에러를 제대로 처리하지 않고 있었습니다. Retry-After 헤더를 파싱하지도 않고, Circuit Breaker도 없고, 실패한 요청을 재시도할 큐도 없습니다. 이것도 인프라와 무관합니다.

Provider Fallback의 한계 — OpenAI가 429를 반환하면 Anthropic으로 넘기는 Fallback 구조를 만들어뒀는데, rate limit 상황에서는 사실상 무력합니다. OpenAI에서 429가 날 정도의 트래픽이면 Anthropic이 수용할 수 있는 수준을 이미 넘어서니까요. 특히 우리 계정 티어를 기준으로 보면, Anthropic의 TPM(분당 토큰) 한도가 OpenAI의 8% 수준밖에 안 됩니다. 티어에 따라 다른 값이라 일반화할 수는 없지만, 우리 환경에서는 OpenAI가 429를 뱉을 만큼 몰린 트래픽을 Anthropic으로 넘기는 순간 훨씬 낮은 천장에 곧바로 부딪히는 구조라는 뜻입니다. Fallback을 "용량이 부족하면 다른 provider로"라고 이해했는데, rate limit 상황에서는 이게 성립하지 않습니다. Fallback이 유효한 건 서버 에러나 네트워크 장애 같은 비-rate limit 상황뿐입니다.

진짜 위험은 인프라가 아니라 구조에 있었다#

정리하면 이렇습니다:

Loading diagram...

이 문제들은 기존 환경에서도 이미 존재하고 있었습니다. 다만 트래픽이 적어서 표면화되지 않았을 뿐. k8s로 옮긴다고 더 나빠지는 것도, 안 옮긴다고 안전한 것도 아닙니다.

앞으로: LLM Gateway 통합#

4개 서비스가 각각 독립적으로 LLM을 호출하는 현재 구조는, 인프라와 무관하게 취약합니다. 서비스 간 호출량 조율이 안 되고, rate limit을 공유하는데 서로 모르고, 방어 체계가 각자 다르거나 아예 없습니다.

장기적으로는 LLM 호출을 중앙화하는 Gateway 서비스를 고려하고 있습니다.

현재 (분산 호출):
  서비스 A ──→ OpenAI API
  서비스 B ──→ OpenAI API
  서비스 C ──→ OpenAI API
  서비스 D ──→ OpenAI API

향후 (Gateway 경유):
  서비스 A ──┐
  서비스 B ───┤
  서비스 C ────┼──→ LLM Gateway ──→ OpenAI / Anthropic
  서비스 D ──────┘

Gateway가 해결할 수 있는 것들을, 지금 그려둔 설계 수준으로 적으면 이렇습니다:

  • Token Bucket 기반 글로벌 rate limit — 전체 호출량을 한 곳에서 관리. 고정 윈도우가 아니라 Token Bucket을 쓰려는 건, 순간 버스트는 버킷에 쌓인 토큰으로 흡수하면서 평균 유입 속도는 provider 한도 아래로 눌러두기 위해서입니다. 앞서 목 프로젝트의 Mock LLM 서버에서 provider 쪽 스로틀을 흉내 낼 때 같은 알고리즘을 써봤는데, 적용 위치는 정반대입니다. 그땐 provider가 우리를 조이는 서버측 흉내였고 이번엔 우리 호출을 눌러 내보내는 클라이언트측 리미터라, 코드가 재사용되는 건 아니고 알고리즘 감만 잡혀 있는 정도입니다.
  • 우선순위 큐 (P0~P3) — 실시간 사용자 요청(P0)과 배치 분석(P3)을 같은 rate limit에 밀어 넣으면 안 되니까, 우선순위를 매겨서 포화 시 낮은 등급부터 셰딩하거나 지연시킵니다. 배치가 사용자 응답을 굶기는 상황을 막는 게 목적입니다.
  • Circuit Breaker — provider가 429나 장애를 반환하면 회로를 열어 그쪽으로 가는 호출을 일정 시간 차단하고, 복구되면 반개방으로 조금씩 흘려보내며 회복을 확인합니다. 지금은 아무 서비스에도 없어서 429가 나도 계속 두드리는 구조라, 여기부터가 실질적인 방어선입니다.
  • 429 + Retry-After 핸들링 — 지금은 각 서비스가 제각각(대부분 무시)인 이 처리를 중앙에서 일괄로. Retry-After를 파싱해 재시도 시점을 조율하고, 즉시 처리 못 하는 요청은 큐로 뺍니다.
  • 비용 추적 — 어느 서비스의 어느 기능이 토큰을 얼마나 쓰는지 중앙에서 집계. rate limit 튜닝과 우선순위 정책 모두 이 데이터가 있어야 근거를 갖습니다.

처음에 한 서비스에만 LLM을 붙일 때 공통 모듈로 분리하지 않은 게 지금의 분산 구조를 만들었는데, Gateway를 만들면 각 서비스의 LLM 모듈을 걷어내고 하나로 통합할 수 있습니다.

다만 여기까지는 설계 문서와 로드맵 상의 그림이지 아직 구현 전입니다. 체크박스는 아직 다 비어 있고, 이 글도 "만들었다"가 아니라 "이렇게 만들 계획이다"까지입니다.

교훈#

이론적 분석과 실제 검증은 다릅니다. "HPA가 pod을 빠르게 늘리면 동시 호출이 폭증한다"는 논리적으로 맞아 보였습니다. 시나리오도 그럴듯했고, 시뮬레이션 수치도 있었고, 리스크 매트릭스도 만들었습니다. 하지만 실제로 k6를 돌려보니 CPU가 1%밖에 안 올라가서 HPA가 반응조차 하지 않았습니다. 목 프로젝트를 만들지 않았으면 이 사실을 몰랐을 겁니다.

LLM 서비스는 일반 API 서비스와 다릅니다. CPU 기반 오토스케일링이 안 먹히고, 스레드가 I/O 대기로 장시간 블로킹되고, 하나의 사용자 요청이 여러 번의 API 호출로 증폭될 수 있습니다. 일반 서비스의 운영 경험을 그대로 적용하면 잘못된 판단을 할 수 있습니다.

인프라보다 구조가 먼저입니다. k8s 전환이 문제가 아니라, 4개 서비스가 조율 없이 같은 rate limit을 공유하는 구조 자체가 문제였습니다. 인프라를 바꿔도 구조가 그대로면 위험도 그대로입니다.


참고 자료

Connected Notes