dev notes

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

2026-04-0817 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와 무관하게, 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이 수용할 수 있는 수준을 이미 넘어서니까요. 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가 해결할 수 있는 것들:

  • 글로벌 rate limit 제어 — 전체 호출량을 한 곳에서 관리
  • 서비스 간 우선순위 — 실시간 요청 > 배치 분석
  • Circuit Breaker — 장애 전파 차단
  • 429 + Retry-After 핸들링 — 중앙에서 일괄 처리
  • 비용 추적 — 서비스별/기능별 사용량 집계

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

교훈#

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

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

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


참고 자료

Connected Notes