AEGLOSLAB ChatGPT 같은 AI를 만들고 서비스하기

제7부 제품·서비스(Product/Service)

7.2 API·플랫폼 설계 — 게이트웨이·인증·rate limit

프런트에 게이트웨이 하나를 세우고 그 뒤에 모델을 두는 일은 평범한 백엔드 작업처럼 보인다. 인증하고, 한도를 걸고, 라우팅하고, 로깅한다 — 어느 REST API나 하는 일이다. 그런데 LLM을 API로 내놓는 순간 그 평범함이 세 곳에서 동시에 붕괴한다. 이 세 가지가 이 장의 거의 모든 설계 결정을 낳으므로 먼저 명시하고 시작한다.

첫째, 요청 비용이 크고, 불균일하고, 무엇보다 미리 알 수 없다. 입력 50토큰·출력 20토큰 길이의 요청과 입력 100K·출력 4K 길이의 요청은 같은 엔드포인트를 때리지만 연산과 비용은 수백 배 차이 난다. 게다가 출력이 몇 토큰 나올지는 생성이 끝나야 안다. 일반 API에서 "요청 하나"는 대략 균일한 단위지만, LLM에서 요청 하나의 무게는 받아 보기 전에도, 심지어 처리하는 도중에도 확정되지 않는다. 이 한 문장이 rate limit·과금·용량 계획을 전부 비튼다.

둘째, 연결이 길고 스트리밍이 기본이다. 응답이 수십 초에 걸쳐 SSE로 흐르는 동안 게이트웨이는 그 연결을 붙들고 있어야 하고, 그 중간에 사용자가 떠나거나(취소) 뒤의 복제본이 종료되는(부분 실패) 일이 벌어진다. 요청을 받아 한 번에 응답하고 끝나는 웹 API의 가정이 여기서 깨진다.

셋째, 단위가 토큰이다. 초당 요청 수(RPM)만으로는 부족하고 분당 토큰 수(TPM)를 함께 세야 하며, 과금도 토큰으로 하고, 그 토큰을 세려면 모델의 토크나이저(1.2)와 서빙 엔진이 보고하는 실제 사용량이 필요하다. "요청 개수"가 아니라 "토큰 총량"이 자원의 실제 단위다.

이 장에서 나오는 rate limit 수치·헤더 이름·요청 필드·가격은 전부 원리를 보이기 위한 예시다. 어떤 벤더가 입력·출력 토큰을 어떻게 합산하는지, 429에 무슨 헤더를 실어 주는지, 별칭을 어떻게 운용하는지는 벤더마다 다르고 수시로 바뀐다 — 특정 플랫폼에 붙이려면 그 공식 문서로 확인해야 한다(Anthropic·Claude는 claude-api). 이 장의 무게는 특정 스펙이 아니라 어떤 계층을 왜 두고 무엇과 무엇을 맞바꾸는가에 있다.

7.2.1 게이트웨이라는 관문 파이프라인

5.5에서 프로덕션 서빙 스택을 그렸을 때 맨 앞에 "API 게이트웨이(인증·rate limit)"라는 상자를 두고 "이 장은 그 상자 뒤에 집중한다"며 미뤘다. 이제 그 상자를 연다. 게이트웨이는 요청 경로 위의 한 점이 아니라, 한 요청이 순서대로 통과하는 관문들의 파이프라인이다.

실패 성공 → 테넌트· 스코프 확정 초과 통과 위반 통과 클라이언트 앱·SDK·타사 TLS 종료· 요청 파싱 인증 API키·OAuth·mTLS 401·403 거부 rate limit·쿼터 RPM·TPM 확인·예약 429 + Retry-After 스키마 검증· 정책·입력 모더레이션 (7.3) 400·422 거부 라우팅 모델·버전 핀 → 대상 풀
게이트웨이 파이프라인 1/2 — 진입 관문(인증·rate limit·검증) → 라우팅까지(다음 그림에서 계속)
트레이스·지표· 로그 라우팅 모델·버전 핀 → 대상 풀 서빙 인프라 (5.5) 큐·admission·복제본 응답 스트림 SSE 청크 중계 미터링 정산 실사용 토큰 → 쿼터 반영·과금 (7.5) 클라이언트 앱·SDK·타사 관측성 (7.6)
게이트웨이 파이프라인 2/2 — 라우팅 이후 실행·스트리밍·정산, 클라이언트로 돌아감

각 관문이 무엇을 하는지는 뒤에서 하나씩 다룬다. 지금 붙들어야 할 것은 이 파이프라인의 두 가지 구조적 특징이다. 웹 API의 관문 나열과 이 그림을 가르는 것이 바로 이 둘이다.

첫째, rate limit과 미터링이 요청의 양 끝에 걸쳐 있다. 앞의 rate limit 관문은 요청을 받을 때 "이 요청이 쓸 토큰"을 미리 인출(예약)하고, 뒤의 미터링 관문은 스트림이 끝난 뒤 "실제로 쓴 토큰"으로 정산한다. 출력 토큰을 모르기 때문에 어쩔 수 없이 이렇게 앞뒤로 벌어진다. 이 예약-정산 구조가 이 장의 rate limit 절 전체를 지배한다.

둘째, 스트리밍이 게이트웨이를 오래 붙든다. 인증·검증은 밀리초 단위로 끝나지만 STREAM 관문은 수십 초 동안 열려 있다. 그 긴 시간 동안 연결이 끊기고, 뒤가 종료되고, 사용자가 취소한다. 요청-응답이 한 번에 끝난다는 가정 위에 세워진 표준적 웹 관행 — 상태 코드로 실패를 알리고, 단순히 재시도하면 되는 — 이 여기서 붕괴한다.

이 두 특징을 머리에 넣고 관문을 하나씩 열자. 그러면 각 관문의 LLM 특유의 함정이 어디서 오는지가 매번 이 둘 중 하나로 설명된다.

7.2.2 인증 — 누가 부르는가, 그리고 왜 그것이 과금의 근원인가

인증은 흔히 "신원 확인"으로만 이해되지만 LLM 플랫폼에서는 그 이상이다. 인증 관문이 확정하는 테넌트가 이후 모든 관문의 핵심이 된다 — rate limit이 누구의 쿼터에서 차감할지, 라우팅이 어느 모델을 허용할지, 캐시가 어느 경계 안에서 재사용할지, 미터링이 누구에게 청구할지가 전부 여기서 정해진 테넌트에 매달린다. 신원을 헐겁게 확정하면 그 헐거움이 과금·격리·쿼터 전체로 번진다.

세 가지 방식이 흔하다. API 키는 테넌트나 프로젝트에 발급하는 장기 비밀 문자열로, 서버-투-서버 호출과 개발자 플랫폼의 기본값이다. 구현이 단순하고 회전·폐기가 쉬운 대신, 유출되면 그대로 도용된다. OAuth 2.0·OIDC는 최종 사용자를 대신해 타사 앱이 호출할 때 쓴다 — 단기 액세스 토큰과 스코프·동의 흐름으로 위임 접근을 표현하지만 구현이 복잡하고 토큰 갱신을 관리해야 한다. mTLS(상호 TLS)는 클라이언트 인증서로 양쪽이 서로를 인증하는 방식으로, 엔터프라이즈 전용 연동처럼 높은 보증이 필요한 곳에 쓰되 인증서 배포·회전의 운영 부담이 크다.

가장 흔한 API 키를 정직하게 다루자. 키를 평문으로 저장하는 것은 사고를 예약해 두는 것과 같다. 키는 발급 시 한 번만 사용자에게 보여 주고, 서버에는 해시(예: SHA-256)로만 저장해 대조한다. 그래야 데이터베이스 전체가 유출돼도 키 원본이 새지 않는다. 키 앞부분에 sk_live_...처럼 환경·종류를 식별하는 접두어를 삽입해 두면, 실수로 소스 코드나 로그에 노출된 키를 시크릿 스캐너가 그 패턴으로 검출해 자동 회수할 수 있다. 무중단 회전을 하려면 옛 키와 새 키를 잠깐 동시에 유효하게 두고 전환하되, 유출이 확인되면 즉시 폐기할 수 있는 경로를 갖춰야 한다. 그리고 키마다 접근 가능한 모델·기능·한도를 스코프로 좁혀 최소 권한을 준다.

여기서 초기 제품이 거의 반드시 저지르는 실수 하나를 명시해야 한다. 브라우저나 모바일 앱에서 LLM API를 직접 부르라고 클라이언트에 API 키를 심는 것이다. 클라이언트에 나간 키는 곧 유출된다 — 앱을 뜯거나 네트워크를 들여다보면 그대로 보인다 — 그리고 유출된 키는 비용 폭탄(7.2.16)으로 돌아온다. 원칙은 단순하다. 클라이언트는 당신의 백엔드를 부르고, 백엔드가 서버에 보관한 키로 LLM을 부른다. 사용자별 접근 제어가 필요하면 그 경계는 당신 백엔드에서 OAuth로 처리하지, LLM 키를 사용자에게 배포해서 처리하지 않는다.

7.2.3 인가·라우팅과 모델 버전 핀 — 조용한 회귀의 문제

인증이 "누구"라면 인가는 "무엇을 부를 수 있는가"다. 스코프(허용 모델·기능·한도)와 쿼터를 신원에 묶는 일인데, 이것이 rate limit·라우팅과 사실상 한 몸이다. 무료 티어 키는 소형 모델과 낮은 TPM만, 엔터프라이즈 키는 전 모델·높은 한도·우선순위 큐(7.2.17)를 갖는다. 즉 인가 결정은 신원에서 티어로, 티어에서 쿼터·우선순위·허용 모델로 곧장 이어지는 매핑이다.

라우팅은 인가를 통과한 요청을 어느 모델·어느 버전·어느 복제본으로 보낼지 정한다. 요청의 model 필드가 대상 모델 풀을 고르고(쉬운 요청을 값싼 모델로 보내는 모델 라우팅의 정책은 5.6·7.5의 몫이고, 여기서는 그 선택을 API 계약으로 노출하는 문제다), 고른 풀 안에서 어느 복제본으로 보낼지는 부하 인지·프리픽스 인지(5.5)로 정한다. 라운드로빈은 LLM에서 나쁜 기본값이다 — 프리픽스 캐시와 KV 지역성을 무시하기 때문이다.

라우팅에서 실제로 문제가 되는 지점은 복제본 선택이 아니라 모델 버전을 어떻게 이름 붙이느냐다. 별칭(alias)은 chat-latest처럼 "현재 권장 버전"을 가리키는 움직이는 이름이고, 핀(pinned version)은 chat-2025-06-01처럼 특정 스냅샷을 고정한 이름이다. 이 구분은 사소해 보이지만 프로덕션에서 정확히 취약한 곳을 공략한다.

장점: 자동 최신· 개선 장점: 재현성·안정 요청: model = 'chat-latest' 별칭 해석 실제로는 chat-2025-03 → ... → chat-2025-09 (플랫폼이 언제든 갱신) 요청: model = 'chat-2025-06-01' 항상 같은 스냅샷 (폐기 전까지 동작 고정) 단점: 조용한 동작 변화 프롬프트·평가 회귀 (4.1·8.2) 단점: 수동 업그레이드·폐기 대응 필요

별칭이 가리키는 실제 모델이 바뀌면 당신 코드는 한 글자도 안 바뀌었는데 출력의 형식·품질·장황함이 조용히 달라진다. 애써 맞춰 둔 프롬프트(6.1), 정규식으로 작성한 출력 파서(6.9), 통과 기준을 정해 둔 평가(4.1)가 예고 없이 회귀할 수 있다 — 그리고 코드가 안 바뀌었으니 원인을 찾기까지 한참을 헤맨다. 그래서 프로덕션은 핀 버전으로 고정하고 별칭은 개발·실험에서만 쓰는 것이 내 견해다. 업그레이드는 새 핀 버전을 A/B로 평가(8.2)해 회귀가 없음을 확인한 뒤 의도적으로 전환한다. 핀 버전도 영원하지 않으므로 — 플랫폼은 언젠가 옛 버전을 폐기한다 — 플랫폼이라면 폐기 예고와 유예 기간·마이그레이션 안내를 계약으로 제공하고(관행적으로 Deprecation·Sunset 헤더를 쓰지만 표준화 정도는 스택마다 다르다), 클라이언트라면 자기가 핀해 둔 버전의 폐기 일정을 추적해야 한다.

마지막으로 자주 혼동되는 것 하나를 분리해 두자. 모델 버전과 API 버전은 다른 축이다. API 스키마(/v1/...)가 그대로여도 그 뒤의 모델은 chat-2025-06에서 -09로 갈 수 있고, 반대로 모델은 그대로인데 API 스키마만 바뀔 수도 있다. 이 둘을 한 번호에 묶으면 나중에 어느 쪽이 회귀를 냈는지 가려낼 수 없다.

7.2.4 rate limit이 지키는 네 가지와 RPM·TPM 두 축

rate limit은 사용자를 괴롭히려는 심술이 아니라 네 가지를 동시에 지키는 장치다. 하나, 용량 보호 — 뒤의 서빙 인프라(5.5)는 유한하고, 유입을 통제하지 않으면 포화 순간 모두가 함께 느려져 아무도 SLO를 못 지키는 전면 붕괴가 온다. 둘, 공정성 — 한 테넌트의 폭주가 다른 테넌트의 몫을 잠식하지 않게 한다(소음 이웃, 7.2.11). 셋, 비용 통제 — 토큰이 곧 돈이라, 한도가 없으면 버그나 남용 하나가 비용 폭탄을 낸다(7.2.16). 넷, 남용 억제 — 스크래핑·스팸·재판매를 속도로 막는다.

일반 API라면 RPM(분당 요청 수) 하나면 충분하다. 요청들이 대략 균일한 무게를 가지니 개수를 세면 부하를 짐작할 수 있기 때문이다. LLM은 그렇지 않다. 요청 비용이 토큰에 비례하고 극단적으로 불균일하므로, RPM만 걸면 요청 하나에 100K 토큰을 밀어 넣어 얼마든지 우회할 수 있다. 그래서 TPM(분당 토큰 수)을 함께 적용한다. 거꾸로 TPM만 걸면 아주 짧은 요청이 폭주할 때 개수 자체로 게이트웨이·큐가 붕괴하므로 RPM도 여전히 필요하다. 실무에서 함께 쓰는 축은 대략 넷이다.

축무엇을 막나단위
RPM(요청/분)요청 개수 폭주·큐 압박requests / min
TPM(토큰/분)토큰(=연산·비용) 총량tokens / min (입력+출력)
동시성(concurrency)동시에 진행 중인 스트림 수in-flight requests
RPD·TPD(일 단위)하루 총량·비용 상한requests·tokens / day

두 축을 함께 적용하면 자연스럽게 이런 질문이 나온다. 실제로 사용자를 막는 것은 RPM인가 TPM인가? 답은 요청의 평균 크기에 달렸고, 교차점을 계산할 수 있다. RPM 한도가 500, TPM 한도가 60,000이라면, 두 한도를 동시에 꽉 채우는 요청 크기는 $60{,}000 / 500 = 120$ 토큰이다. 요청이 평균 120토큰(입력+출력)보다 크면 TPM이 먼저 걸리고(사용자는 요청 개수를 다 못 쓴다), 120토큰보다 작으면 RPM이 먼저 걸린다(사용자는 토큰 예산을 다 못 쓴다). 챗 요청은 보통 수백~수천 토큰이라 TPM이 binding constraint가 되는 경우가 대부분이고, 그래서 TPM을 어떻게 세느냐가 rate limit 설계의 실제 어려움이 된다. 어떤 벤더가 입력과 출력을 어떻게 합산해 TPM에 넣는지 — 입력만 세는 곳, 입력과 출력을 합치는 곳, 둘에 별도 한도를 두는 곳 — 는 제각각이므로 붙일 때는 그 문서로 확인해야 한다.

7.2.5 토큰 버킷과 리키 버킷 — 버스트를 허용할 것인가 평활할 것인가

한도를 거는 알고리즘은 여럿이고, 그중 두 개 — 토큰 버킷과 리키 버킷 — 가 정확히 반대되는 성질을 가져 대비하기 좋다. 이름부터 정리하자. 여기서 "토큰"은 알고리즘의 허가권 토큰이지 LLM의 언어 토큰이 아니다. 다만 LLM rate limit에서는 허가권 하나를 언어 토큰 하나에 대응시키면 TPM을 그대로 표현할 수 있어, 우연히도 이름이 겹쳐도 개념이 맞물린다.

토큰 버킷 — 여유분을 모아 버스트 허용 리필 r tok/s (= 지속 허용률·TPM) 용량 B (버스트 한도) 현재 잔량 b 쌓아 둔 허가권 요청 cost 인출 b ≥ cost? 예 → 수락, b −= cost 아니오 → 429 가득 차면(리필이 넘치면) 버림 → 최대 B까지만 쌓임 → 평소 아낀 만큼 순간 버스트 가능, 지속률은 r로 수렴 리키 버킷 — 일정률로 유출, 출력 평활화 불규칙 유입(버스트) 대기 큐(버킷) 넘치면 429 드롭 일정률 유출 = 고정 처리율 → 출력이 매끈하게 일정. 버스트를 흡수 못 하고 지연·드롭으로 흘림
토큰 버킷(버스트 허용) vs 리키 버킷(평활화) — 두 rate limit 알고리즘.

토큰 버킷은 용량 $B$ 의 통에 허가권이 초당 $r$ 개씩 채워지는 그림이다. 요청이 오면 그 비용 $c$ 만큼 인출하는데, 잔량 $b \ge c$ 면 수락하고 $b \leftarrow b - c$, 아니면 429다. 통이 가득 차면 넘치는 리필은 버린다. 시각 $t_0$ 의 잔량이 $b(t_0)$ 였다면 이후 잔량은

$$b(t) = \min\!\big(B,\ b(t_0) + r\,(t - t_0)\big), \qquad \text{수락 조건 } b \ge c,\ \text{수락 시 } b \leftarrow b - c$$

이 한 줄에서 두 성질이 곧장 읽힌다. 장기적으로는 초당 $r$ 개 이상 쓸 수 없다 — 아무리 통에 쌓아 둬도 리필이 $r$ 이라, 지속 사용률의 상한은 $r$ 이다. 이것이 TPM 그 자체다. 반면 한동안 요청이 없어 통이 $B$ 까지 찼다면, 그 순간 최대 $B$ 만큼을 한꺼번에 투입할 수 있다 — 이 "아껴 둔 여유"가 버스트다. 요청이 거절될 때 클라이언트에 줄 대기 힌트도 이 식에서 나온다. 잔량 $b$ 가 $c$ 에 못 미쳐 거절됐다면, 필요한 만큼 리필되는 데 걸리는 시간이 $(c-b)/r$ 이므로

$$\text{Retry-After} \approx \left\lceil \frac{c - b}{r} \right\rceil$$

이 정확한 값을 429에 실어 주면 클라이언트가 아무렇게나 재시도하지 않고 정확히 그만큼 기다렸다 다시 온다 — 429가 실패 통보가 아니라 협조 요청이 되는 것이다.

리키 버킷은 정반대로 출력을 평활화한다. 들어오는 요청을 큐(버킷)에 담아 두고 일정률로만 빼낸다. 버스트가 아무리 몰려와도 처리는 매끈한 고정률이고, 대신 버스트를 흡수하지 못해 큐가 차면 지연시키거나 드롭한다. 네 알고리즘을 한자리에 놓으면 성격이 뚜렷하다.

알고리즘버스트출력 형태LLM에서
토큰 버킷허용(모아 둔 만큼)버스티가장 흔함 — 사용자 버스트를 관대히, 지속률만 제한. RPM·TPM·동시성을 각자의 버킷으로
리키 버킷억제매끈·일정하류를 엄격히 평활해야 할 때. 버스트 관대함이 없어 UX가 뻣뻣
고정 윈도(분당 카운터)경계에서 2배 급증계단식구현은 쉬우나 윈도 경계 직전·직후에 몰리면 순간 2배
슬라이딩 윈도완화부드러움고정 윈도의 경계 문제를 매끈하게. 상태·계산이 약간 더

고정 윈도의 경계 문제는 함정이라 정리해 둘 값어치가 있다. "분당 60,000"을 매 분 0초에 카운터를 리셋하는 방식으로 구현하면, 어떤 분의 59초에 60,000을 쓰고 다음 분의 0초에 다시 60,000을 쓸 수 있다 — 2초 사이에 120,000이 지나가, 명목 한도의 두 배가 경계 시점에 몰린다. 슬라이딩 윈도나 토큰 버킷은 이 경계 급증이 없다. 실무의 흔한 답은 토큰 버킷을 여러 개 두고 — RPM용·TPM용·동시성용 — 모두를 통과해야 수락하는 조합이다.

7.2.6 출력 토큰을 모른 채 TPM을 걸기 — 예약과 정산

여기가 LLM rate limit의 실제 어려움이고, 7.2.1에서 예고한 "예약-정산이 요청의 양 끝에 걸쳐 있다"의 정체다. TPM 버킷에서 한 요청의 비용은 입력 토큰 더하기 출력 토큰인데, 요청을 받는 순간 출력 토큰은 아직 존재하지 않는다. 생성이 끝나야 안다. 그래서 언제 비용을 인출할지를 두고 두 갈래로 갈린다.

낙관적으로 가면, 입력 토큰만 보고 일단 통과시킨 뒤 생성이 끝나면 실제 토큰을 세어 사후에 차감한다. 정확하기는 하지만 한도를 넘겨 받을 수 있다 — 그리고 그때는 이미 고비용 생성이 다 실행된 뒤라 손을 쓸 수 없다. 용량과 비용이 이미 초과된 상태에서 장부만 맞추는 셈이다. 비관적으로 가면, 요청을 받을 때 최악을 가정해 입력에 max_tokens를 더한 만큼을 미리 인출(예약)하고, 끝난 뒤 안 쓴 만큼을 환불한다.

$$c_{\text{reserve}} = n_{\text{in}} + \texttt{max\_tokens}, \qquad c_{\text{actual}} = n_{\text{in}} + n_{\text{out}}, \qquad \text{refund} = \texttt{max\_tokens} - n_{\text{out}}$$

예약 방식은 용량을 확실히 지킨다 — 최악을 미리 산정해 두니 실제로 그 이상 나올 수 없다. 대신 사용자가 max_tokens를 크게 설정하고 실제로는 짧게 생성하면, 그동안 예약해 둔 큰 몫 때문에 실제 사용량보다 훨씬 빨리 한도에 걸린다. 그래서 정산(환불)이 반드시 짝을 이뤄야 한다. 환불이 없는 순수 예약은 과도하게 조이고, 정산만 하는 순수 사후 집계는 초과를 못 막는다. 출력을 모른다는 LLM의 근본 성질 때문에, 앞에서 예약하고 뒤에서 정산하는 이 절차가 자연스러운 답이 된다. 성숙한 플랫폼들이 대체로 이 방식을 쓰는 것으로 보이지만 상세는 벤더마다 다르다. 이 예약이 얼마나 조이고 환불이 얼마나 되살리는지는 숫자로 따라가는 것이 가장 빠르다 — 다음 절에서 한다.

7.2.7 워크스루 — 토큰 버킷 TPM 타임라인

숫자는 전부 절차를 몸에 익히기 위한 예시다. 한 테넌트의 TPM 버킷 하나만 본다(실제로는 RPM·동시성 버킷도 함께 통과해야 한다). 설정은 이렇다. TPM 한도 60,000 tokens/min이므로 리필 $r = 1{,}000$ tok/s. 버킷 용량 $B = 90{,}000$ 이라 최대 1.5분치를 모아 버스트할 수 있다. 각 요청은 예약 $c_{\text{reserve}} = n_{\text{in}} + \texttt{max\_tokens}$ 로 인출하고 완료 시 $\texttt{max\_tokens} - n_{\text{out}}$ 을 환불한다. 시작 시 버킷은 가득 차 $b = 90{,}000$.

버킷 로직을 개념적으로 적으면 다음과 같다.

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate, self.capacity = rate, capacity      # r tok/s, B
        self.tokens = capacity                          # 시작은 가득
        self.ts = now()

    def _refill(self):
        t = now()
        self.tokens = min(self.capacity, self.tokens + self.rate * (t - self.ts))
        self.ts = t

    def try_reserve(self, cost):                        # 입장 시: n_in + max_tokens
        self._refill()
        if self.tokens >= cost:
            self.tokens -= cost
            return True, 0
        wait = (cost - self.tokens) / self.rate         # Retry-After 힌트(초)
        return False, ceil(wait)

    def refund(self, unused):                            # 완료 시: max_tokens - n_out
        self._refill()
        self.tokens = min(self.capacity, self.tokens + unused)

이제 빠르게 도착하는 요청들을 따라간다. 각 요청은 입력 2,000에 max_tokens 6,000이라 예약이 8,000이고, 리필은 초당 1,000이다.

시각(s)이벤트리필(+)예약/환불잔량 $b$판정
0.0시작——90,000가득
0.0R1~R10 도착(각 예약 8,000)0−80,00010,00010개 모두 수락(버스트)
0.1R11 도착(예약 8,000)+100−8,0002,100수락
0.2R12 도착(예약 8,000)+10002,200429 (b<8,000)
0.2R12에 Retry-After ≈ ⌈(8000−2200)/1000⌉ = 6s——2,200대기 안내
2.0R1 완료: 출력 실제 1,500 → 환불 6,000−1,500=4,500+1,800(0.2→2.0)+4,5008,500환불 반영
2.0R12 재시도(예약 8,000)0−8,000500수락(이제 통과)
6.0유휴 구간(신규 없음)+4,000+환불들상승버킷 회복

세 가지가 이 표에서 보인다. 첫째, 버스트 흡수. t=0에 열 개가 한꺼번에 들이닥쳤는데도 전부 통과했다 — 평소 아껴 둔 $B = 90{,}000$ 덕이다. 리키 버킷이었다면 열 개를 일정률로 흘려보내며 늦췄을 것이다. 둘째, 지속률로 수렴. 버킷이 마르자 이후는 리필 속도 $r$ 에 묶인다. R12는 잔량 2,200이 예약 8,000에 못 미쳐 429를 받고, Retry-After로 6초를 안내받는다 — $(8000-2200)/1000 = 5.8$ 을 올림한 값이고, 실제로 그만큼 지나면 리필로 통과할 만큼 회복된다. 셋째, 정산이 활용률을 되살린다. R1은 6,000을 예약했지만 실제로는 1,500만 생성해 4,500을 환불받았고, 그 덕에 잔량이 8,500까지 올라 R12 재시도가 통과했다. 환불이 없었다면 이 테넌트는 실제 쓴 것보다 훨씬 빨리 장벽에 부딪혔을 것이다 — max_tokens를 습관적으로 크게 설정하는 클라이언트일수록 손실이 커진다. 그래서 max_tokens에 합리적 기본값과 상한을 두고 이 동작을 문서화하는 것이 예약 방식의 필수 짝이며, 이것은 비용 폭탄 방어(7.2.16)와도 곧장 이어진다. 적정한 $B$·$r$·max_tokens 기본값은 워크로드와 용량(5.5)·가격(7.5)에서 나오지, 위 숫자에서 나오지 않는다.

7.2.8 테넌트별 쿼터와 공정성 대 활용률

한도를 어디에 거느냐 — 전역인가 테넌트별인가 — 는 순수 기술 문제가 아니라 공정성과 활용률을 맞바꾸는 비즈니스 결정이다. 세 가지 접근을 펼쳐 보면 그 긴장이 드러난다.

테넌트별 고정 쿼터는 각 테넌트에 TPM 상한을 고정한다. 공정하고 예측 가능하다 — 내 몫은 남이 무엇을 하든 보장된다. 대신 다른 테넌트가 놀 때 남는 용량을 아무도 못 쓴다. GPU가 비어 있는데 내 쿼터가 다 찼다고 거절당하는 상황이 벌어지고, 그 비어 있는 GPU는 그대로 낭비되는 비용이다(5.5·7.5). 공유 풀과 소프트 쿼터는 반대다. 여유가 있으면 상한을 넘겨 버스트를 허용하고, 혼잡해지면 공정한 몫으로 되돌린다. 활용률은 높지만 예측성과 공정성이 약해지고 소음 이웃이 고개를 든다 — 어제 잘 실행되던 워크로드가 오늘 옆 테넌트의 폭주 때문에 느려진다. 가중 공정 분배는 혼잡할 때 활성 테넌트들이 가중치 비율로 나눈다.

$$r_i = \frac{w_i}{\sum_{j \in \text{active}} w_j}\, R \qquad (\text{혼잡 시 테넌트 } i \text{ 의 공정 몫})$$

이 식의 미묘함은 분모가 활성 테넌트만 센다는 데 있다. 유휴 테넌트는 분모에서 빠지므로, 활성인 소수가 전체 용량 $R$ 을 가중치대로 나눠 갖는다 — 즉 아무도 안 쓸 때는 쓰는 사람이 다 갖고(높은 활용률), 다 같이 몰릴 때는 가중치대로 공정하게 분할한다. 고정 쿼터와 순수 공유 풀의 절충인 셈이다.

여기서 정면으로 인정할 것은, 공정성과 활용률이 근본적으로 서로를 당긴다는 사실이다. 엄격한 테넌트별 상한은 공정하지만 비는 GPU를 유휴 상태로 두어 비용을 낭비하고, 관대한 공유 풀은 활용률을 극대화하지만 한 테넌트의 폭주가 이웃을 교란한다. 보편 정답은 없다. 흔한 절충은 하이브리드다 — 유료 티어에는 보장된 최소 한도(고정)에 여유 시 버스트(소프트)를 추가하고, 무료 티어에는 낮은 고정 상한을 준다. 어느 쪽으로 조정 변수를 바꿀지는 "우리 제품이 예측 가능성을 파는가, 저렴함을 파는가"라는 상품 결정이지 알고리즘 선택이 아니다. 그리고 이 조정 변수를 얼마나 과감하게 실행할 수 있는지 — 즉 명목 쿼터의 합을 실제 용량보다 얼마나 크게 팔 수 있는지 — 는 숫자로 따져야 답이 나온다.

7.2.9 워크스루 — 과판매는 어디까지 안전한가

플랫폼은 거의 언제나 쿼터를 과판매(oversubscription)한다. 모든 테넌트가 자기 한도를 동시에 꽉 채우는 일은 없기 때문이다 — 대부분 대부분의 시간을 유휴 상태이고, 가끔 버스트한다. 문제는 얼마나 과판매해도 되느냐이고, 이것이 앞 절의 공정성-활용률 조정 변수를 구체적인 테넌트 수로 바꿔 준다.

서빙 클러스터의 지속 처리 용량이 5.5의 사이징에서 $C = 1{,}200{,}000$ tokens/min으로 나왔다고 하자. 우리는 테넌트당 60,000 TPM 플랜을 판다. 과판매를 전혀 안 하면 $1{,}200{,}000 / 60{,}000 = 20$ 개 테넌트밖에 못 판다 — 모두가 항상 한도를 쓴다고 가정한 것이다. 하지만 실측해 보니 테넌트당 평균 이용률이 자기 한도의 8%였다(가끔 버스트하고 대부분 유휴 상태다). 그러면 테넌트당 평균 부하는 $0.08 \times 60{,}000 = 4{,}800$ TPM이고, 평균만 맞추면 $1{,}200{,}000 / 4{,}800 = 250$ 개, 즉 12.5배까지 과판매할 수 있다.

그런데 이 250은 위험하다. 평균은 맞지만 버스트가 겹치는 순간을 무시했기 때문이다. 각 테넌트를 "확률 $p = 0.08$ 로 60,000 TPM을 다 쓰고, 아니면 거의 안 쓴다"는 베르누이 시행으로 거칠게 모형화하자. $M = 60{,}000$ 을 한도라 하면, $N$ 개 테넌트의 합산 부하는 평균과 표준편차가

$$\mu = N p M, \qquad \sigma = \sqrt{N\,p(1-p)}\;M$$

이다. $N = 250$ 을 넣으면 $\mu = 250 \times 0.08 \times 60{,}000 = 1{,}200{,}000$ — 정확히 용량이다. 즉 평균으로 이미 용량을 꽉 채웠다. 표준편차는 $\sigma = \sqrt{250 \times 0.08 \times 0.92}\times 60{,}000 = \sqrt{18.4}\times 60{,}000 \approx 257{,}000$ TPM이다. 부하가 평균보다 $1\sigma$ 만 올라가도 257K만큼 용량을 넘겨 포화한다 — 250개는 상시 벼랑 끝에 서 있는 셈이다.

그래서 헤드룸을 두고 다시 푼다. 합산 부하가 $\mu + 3\sigma$ 여도 용량 안에 들어오도록 $N$ 을 정하자.

$$N p M + 3\sqrt{N\,p(1-p)}\;M \le C$$

$x = \sqrt{N}$ 로 놓고 숫자를 넣으면 $4{,}800\,x^2 + 48{,}834\,x - 1{,}200{,}000 \le 0$ 이고, 근의 공식으로 $x \approx 11.5$, 즉 $N \approx 132$ 가 나온다. 과판매 배수로는 $132 \times 60{,}000 / 1{,}200{,}000 \approx 6.6$ 배다. 정리하면 이렇다. 이용률이 낮으니 과판매는 분명히 가능하지만(20개가 아니라 100개 넘게 판다), 버스트가 겹칠 위험 때문에 무한정은 아니다. 평균만 보면 250개(12.5배), 3σ 헤드룸을 두면 132개(6.6배) — 이 둘 사이의 간격이 바로 공정성-활용률 조정 변수의 폭이다. 132에서 250 쪽으로 다가갈수록 SLA 위반(포화·429 폭주) 확률이 오르고, 132 아래로 물러설수록 GPU를 유휴 상태로 둔다.

이 모형은 거칠다는 것을 정직하게 말해 둔다. 버스트는 서로 독립이 아니고(같은 시간대에 몰린다), 부하는 베르누이가 아니며(연속적이다), 이용률 8%도 시간에 따라 변한다. 그래서 이 계산은 정확한 테넌트 수를 주는 것이 아니라 사고의 틀을 준다 — 과판매는 이용률 낮음이 주는 무비용 이득이되, 그 이득의 크기를 버스트의 분산이 제한하며, 헤드룸 없이 평균만 보고 팔면 반드시 문제가 생긴다. 실제 값은 측정으로 정하고, 평균과 3σ 사이에서 못 채운 부분은 버스트 헤드룸·오토스케일·429로 메운다 — 이것이 7.2.17에서 계약을 물리와 잇는 이야기로 이어진다.

7.2.10 분산 rate limit — 상태를 어디에 두나

지금까지의 버킷은 게이트웨이가 한 대라는 암묵적 가정 위에 있었다. 실제로는 게이트웨이가 여러 대이고, 그러면 버킷 상태를 어디에 두느냐가 새 문제가 된다. 각 인스턴스가 자기 버킷을 따로 세면 실제 한도가 인스턴스 수만큼 늘어난다 — 10대라면 명목 60,000 TPM이 실효 600,000이 될 수 있다.

가장 단순한 대응은 명목 한도를 인스턴스 수로 나눠 정적으로 분할하는 것이다(10대에 각 6,000). 상태 공유가 필요 없어 빠르지만, 트래픽이 인스턴스에 고르게 퍼지지 않으면 활용률을 크게 잃는다. 어떤 테넌트가 스티키 라우팅으로 한 게이트웨이에 몰리면, 그 게이트웨이의 6,000에 걸려 429를 받는데 정작 나머지 아홉 대에는 54,000이 유휴 상태에 있다 — 전체 예산은 남아도는데 개별적으로 제한되는 것이다. 반대편 끝은 중앙 저장이다. Redis 같은 곳에 버킷 상태를 두고 원자적 연산(Lua 스크립트 등)으로 매 요청 갱신한다. 정확하지만 매 요청 왕복 지연이 붙고 인기 테넌트의 버킷 키가 핫스팟이 된다. 그 중간이 로컬 근사에 주기 동기화를 추가하는 방식이다 — 인스턴스가 자기 몫을 로컬로 처리하다 주기적으로 맞춘다. 빠르지만 동기화 간격 사이에는 각 인스턴스가 독립적으로 허용하므로 경계에서 약간 넘칠 수 있다.

동시성이 개입하면 레이스 컨디션도 있다. 두 요청이 같은 버킷을 동시에 읽고-빼면 둘 다 통과해 초과 인출된다 — 그래서 읽기-검사-쓰기를 원자적 CAS나 스크립트로 묶는다. 종합하면 분산 rate limit은 완벽한 정확성과 낮은 지연을 동시에 주지 못한다. 대부분은 약간의 초과를 감수하고 지연을 택한다. 그러니 한도를 "절대 넘지 않는 하드 상한"이 아니라 "이 근처에서 조인다"로 이해하는 편이 현실적이다. 정말로 하드해야 하는 상한 — 누적 비용을 막는 지출 캡(7.2.16) — 은 이 근사적 rate limit과 별개의, 더 엄격한 장치로 따로 세운다.

7.2.11 멀티테넌시와 격리 — 소음 이웃과 캐시라는 누수 벡터

플랫폼은 여러 테넌트를 한 인프라 위에서 운용한다. 격리는 네 축으로 갈리고, 각 축이 실패하면 다른 종류의 사고가 난다. 데이터가 유출되면 한 테넌트가 다른 테넌트의 프롬프트·응답을 보고, 연산이 간섭하면 소음 이웃으로 지연이 변동하고, 쿼터 격리가 실패하면 한 테넌트가 공유 용량을 독식하고, 보안 격리가 실패하면 크로스 테넌트 접근·권한 상승이 일어난다.

테넌트 A 키·프로젝트 A 테넌트 B 키·프로젝트 B 공유 게이트웨이 테넌트 태깅 tenant_id 전파 프리픽스·시맨틱 캐시 (5.6) 누수 위험: 캐시 키에 tenant_id 없으면 B가 A를 회수 로그·트레이스 저장 (7.6) 누수 위험: 크로스 테넌트 조회·PII 복제본 풀 (5.5) 연산 공유 → 소음 이웃(성능) 격리를 지키는 규칙 · 캐시 키에 tenant_id 포함 · 로그·저장 쿼리에 tenant 스코프 · 쿼터·우선순위 테넌트별 · 파인튜닝 모델 테넌트 소유 · 엄격 SLO → MIG·전용(5.5) 한 곳이라도 스코프 누락 = 크로스 테넌트 유출(7.4)
멀티테넌시 — 격리 경계와 데이터 누수 벡터(빨강 = 스코프 안 하면 유출되는 곳).

일반 SaaS의 격리 지식(모든 저장·조회를 tenant_id로 스코프한다)은 여기서도 그대로 필요하지만, LLM에는 일반 SaaS에 없는 누수 벡터가 하나 더 있다. 성능을 위해 도입한 캐시가 격리를 조용히 우회한다는 것이다. 프리픽스·KV 캐시(5.6)는 여러 요청이 앞부분(시스템 프롬프트 등)을 공유할 때 그 KV를 재사용해 prefill을 아낀다. 그런데 캐시 키에 tenant_id가 없으면, 테넌트 B가 A와 같은 프리픽스로 시작하는 요청을 보내 A의 캐시된 컨텍스트에 추가되어 갈 수 있다 — 프리픽스 캐시는 반드시 테넌트 경계 안에서만 재사용해야 한다. 시맨틱 캐시(비슷한 질문에 캐시된 답을 주는)는 더 노골적이다. 테넌트를 안 나누면 B의 질문에 A의 사적 데이터가 담긴 응답이 그대로 나갈 수 있다. 로그·트레이스에는 프롬프트·응답의 PII·기밀이 담기므로 저장·조회·보존 기간을 테넌트별로 스코프하고 지원팀의 크로스 테넌트 조회를 통제해야 하며, 테넌트 데이터로 미세조정한 모델·어댑터(3.8)는 라우팅이 소유권을 확인해 그 테넌트만 닿게 해야 한다.

여기서 두 종류의 누수를 확실히 갈라야 한다. 소음 이웃 — 연산 공유로 인한 성능 변동 — 은 공유 인프라의 본질이라 완전히 없앨 수 없다. API 계층에서 할 수 있는 것은 쿼터·우선순위로 완화하는 것이고, 엄격한 성능 격리가 필요한 테넌트에게는 전용 복제본·MIG·전용 배포를 티어로 팔아 트레이드오프를 명시적으로 만드는 것이다. 반면 크로스 테넌트 데이터 유출은 완화의 대상이 아니라 절대 막아야 할 보안 경계다(7.4). 전자는 정도의 문제고 후자는 예/아니오의 문제다 — 이 둘을 같은 급으로 다루면 어느 쪽도 제대로 못 지킨다.

7.2.12 스트리밍 API — SSE·취소·부분 실패

LLM은 토큰을 하나씩 만든다(5.1). 다 만든 뒤 한 번에 주면 사용자는 수십 초를 빈 화면 앞에서 기다린다 — 그래서 첫 토큰부터 흘려보내는 스트리밍이 API의 기본값이 되고, TTFT가 체감 반응성을 지배한다(7.1). 전송은 여러 방식이 가능하다. SSE(Server-Sent Events)는 서버에서 클라이언트로 가는 단방향 텍스트 스트림으로 HTTP 위에 추가되어 프록시·방화벽 친화적이라 가장 흔한 기본값이고, 토큰 청크를 data: 이벤트로 흘린다. WebSocket은 양방향 지속 연결이라 음성·연속 대화에 맞지만 단방향 텍스트에는 과하고, gRPC 스트리밍은 사내 서비스 간 고성능 연동에 좋지만 브라우저 직접 연결에 제약이 있다. SSE 응답의 골격은 대략 이렇다(정확한 이벤트 포맷·필드는 벤더마다 다르다).

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

data: {"delta":{"text":"안"},"index":0}

data: {"delta":{"text":"녕"},"index":0}

data: {"delta":{"text":"하세요"},"index":0}

data: {"usage":{"input_tokens":12,"output_tokens":4},"finish_reason":"stop"}

data: [DONE]

여기서 두 가지가 이미 눈에 걸린다. 사용량(usage)이 스트림 마지막에 실린다는 것 — 미터링(7.2.15)이 최종 토큰 수를 받는 지점이 바로 여기다 — 과, 스트리밍을 중단시키는 함정이 프록시 버퍼링이라는 것이다. 게이트웨이·로드밸런서·CDN이 응답을 버퍼링하면 청크가 모였다가 한꺼번에 나가, 애써 얻은 TTFT 이득 전체가 사라진다. X-Accel-Buffering: no 같은 힌트와 프록시 버퍼 비활성이 필요한데, 정확한 구성은 스택마다 다르다.

스트리밍이 여는 실제 새 문제는 취소와 부분 실패다. 취소부터 보자. 사용자가 탭을 닫거나 "중지"를 누르면 클라이언트 연결이 끊긴다. 그런데 연결이 끊겨도 뒤의 서빙 엔진은 계속 생성할 수 있다 — 아무도 안 보는 응답을 위해 GPU를 소모하는 것이고, 이것은 곧 돈이다(5.5). 그래서 게이트웨이는 클라이언트 disconnect를 감지하는 즉시 서빙 엔진에 취소를 전달해 생성을 멈추고 KV 캐시를 회수해야 한다. 이 전파 경로가 없으면 취소는 사용자 화면에서만 멈추고 비용은 계속 흐른다. 비동기·백그라운드 요청은 연결이 아예 없어 disconnect로 취소를 알 수 없으니 요청 ID 기반의 명시적 취소 엔드포인트를 따로 둔다. 이미 생성된 부분 토큰을 과금할지는 정책인데, 흔히 생성된 만큼은 과금한다.

클라이언트 게이트웨이 서빙 엔진 (5.5) POST /chat (stream=true) 생성 시작 청크 "안" data: "안" 청크 "녕" data: "녕" 사용자가 중지·연결 종료 연결 끊김(감지) 취소 전파 (생성 중단· KV 회수) 부분 사용량 미터링 (7.2.15)

부분 실패는 더 까다롭다. HTTP 상태 코드는 첫 바이트에 이미 정해진다. 200 OK를 보내고 청크를 흘리기 시작한 뒤 생성 중간에 실패하면(OOM·타임아웃·복제본 크래시) 이제 와서 500으로 바꿀 수 없다. 그래서 실패를 스트림 안의 특별한 이벤트로 보내는 인밴드(in-band) 에러가 필요하다 — 클라이언트의 스트림 파서가 data: {"error":{...}} 같은 이벤트를 처리하도록 계약해야 한다. 정상 종료든 비정상이든 왜 끝났는지는 finish_reason으로 알린다 — stop(정상), length(max_tokens 도달), content_filter(모더레이션 차단, 7.3), error 등 — 이 값이 클라이언트가 부분 응답을 어떻게 다룰지의 근거가 된다. 절반가량 스트리밍된 요청을 재시도하면 이미 만든 비용이 큰 부분을 버리고 처음부터 다시 하며(중간 이어받기는 일반적으로 불가), 그래서 스트리밍의 재시도는 항상 값이 비용이 크다. 가장 나쁜 것은 조용한 스트림 중단이다 — 클라이언트가 정상 종료인지 실패인지 구분하지 못하는 상태로 끝나는 것. 스트리밍은 UX를 크게 개선하지만 상태 코드·재시도·부분 응답의 표준적 웹 관행을 무력화하므로, 인밴드 에러·finish_reason·취소 전파·버퍼링 방지를 함께 설계하지 않으면 조용한 실패와 낭비된 GPU가 쌓인다.

7.2.13 스키마·버전·하위호환

API는 계약이다. 한 번 공개하면 수많은 클라이언트가 그 모양에 기대어 코드를 작성하고, 함부로 바꾸면 그들의 코드가 깨진다. 요청·응답을 명시적 스키마(OpenAPI·JSON Schema)로 정의하고 게이트웨이에서 검증한다. 전형적인 LLM 요청은 대략 이런 모양이다.

{
  "model": "chat-2025-06-01",
  "messages": [
    {"role": "system", "content": "간결하게 답하라."},
    {"role": "user", "content": "SSE가 뭐야?"}
  ],
  "max_tokens": 512,
  "temperature": 0.7,
  "stream": true
}

많은 서빙 프레임워크(5.4)와 플랫폼이 사실상 표준이 된 OpenAI 형식의 엔드포인트(/v1/chat/completions)를 노출한다. 이 호환을 택하면 기존 SDK·툴 생태계를 그대로 쓸 수 있다는 이점이 크지만, "호환"이 어디까지인지 — 스트리밍 포맷·툴콜·에러 형식이 정말 같은지 — 를 명확히 해야 한다. 검증은 반드시 게이트웨이에서 한다. 잘못된 스키마나 범위 밖 파라미터(temperature 범위, max_tokens 상한)를 뒤로 보내기 전에 400·422로 막아야 하고, 이 상한은 비용 폭탄 방어(7.2.16)와도 이어진다.

버전은 URL 경로(/v1, /v2)에 다는 것이 가장 흔하고 명확하며 큰 단절에 적합하다. 헤더(API-Version: 2025-06-01)에 날짜 기반으로 다는 방식은 URL을 안 바꾸고 세밀한 스냅샷 고정을 준다. 어느 쪽이든 핵심 규율은 하나다 — 기존 클라이언트가 안 깨지게 바꿔라. 무엇이 안전하고 무엇이 깨는지는 명확하다.

변경안전한가이유
응답에 새 선택 필드 추가대체로 안전옛 클라이언트는 모르는 필드를 무시
요청에 새 선택 파라미터(기본값 有) 추가안전안 보내면 기존 동작 유지
필드 제거·이름 변경깨짐그 필드를 읽던 클라이언트 실패
필드 타입 변경(문자열→객체 등)깨짐파싱 실패
새 필수 파라미터 추가깨짐안 보내던 클라이언트 400
enum 값 확대(새 finish_reason 등)주의미지의 값에 클라이언트가 대비돼 있어야
기본값 변경주의명시 안 한 클라이언트의 동작이 조용히 바뀜

깨는 변경은 반드시 새 버전으로 내보내고, 옛 버전은 폐기 예고에서 유예 기간, 종료(sunset)로 이어지는 생애주기로 관리한다. 그리고 진화 여지를 넓히는 오래된 원칙 하나 — 받을 때는 모르는 필드를 관대히 무시하고, 보낼 때는 스키마를 엄격히 지킨다. 이 관대한 수신·엄격한 송신이 양쪽 모두에게 나중에 필드를 추가할 여지를 남긴다.

7.2.14 멱등성·재시도·타임아웃

네트워크는 실패하고 클라이언트는 재시도한다. 평범한 이야기지만 LLM은 비용이 크고 비결정적이라 이 평범함이 특별해진다. 클라이언트가 요청을 보냈는데 응답을 못 받았다고 하자(타임아웃·연결 끊김). 요청이 처리됐는지 아닌지 모른다. 단순히 재시도하면? 비결정적 샘플링(1.7) 때문에 같은 요청도 다른 답이 나오고, 둘 다 과금되며, 비용이 큰 생성을 두 번 한다. 그래서 멱등성 키가 필요하다. 클라이언트가 요청마다 고유 키를 헤더로 보내면, 서버는 그 키로 결과를 저장·중복 제거해 같은 키의 재요청에는 저장된 결과를 돌려주고 다시 실행·과금하지 않는다.

없음(신규) 있음·완료 있음·진행중 요청 + Idempotency-Key 키 조회 실행 → 결과·사용량 저장 진행 중 응답 / 대기 저장된 결과 반환 (재실행·재과금 안 함) 응답
멱등성 키로 재시도 안전하게 처리하기

재시도 자체는 조심스럽게 해야 한다. 429·503·일시적 네트워크 오류는 지수 백오프에 지터를 섞어 재시도할 값이 있지만, 400·401·422처럼 클라이언트 잘못이 원인인 것은 재시도해 봐야 소용없다. 더 위험한 것은 재시도 폭풍이다 — 과부하로 실패한 것을 모두가 일제히 재시도하면 과부하를 악화시킨다(5.5). 그래서 재시도 예산(전체의 몇 %만 재시도)을 두고 서버가 준 Retry-After를 존중해야 한다. 멱등성 키가 있으면 절반 생성된 비용이 큰 요청도 중복 걱정 없이 안전하게 재시도할 수 있다.

타임아웃은 스트리밍을 고려하지 않으면 정상 요청을 차단한다. LLM 요청은 원래 길다 — 긴 생성은 수십 초가 정상이다. "전체 30초" 같은 절대 타임아웃을 걸면 멀쩡한 롱 응답이 잘려 나간다. 대신 나눠서 건다. 첫 토큰이 정해진 시간 안에 안 오면 죽었다고 판단하는 TTFT 타임아웃(큐·복제본 문제를 검출한다), 토큰이 흐르는 한 유지하되 일정 시간 청크가 안 오면 끊는 스트림 유휴 타임아웃, 무한 생성·폭주를 막는 서버측 최대 생성 시간(비용 폭탄 방어). 그리고 클라이언트·게이트웨이·서빙 엔진의 타임아웃이 서로 어긋나면 — 예컨대 게이트웨이가 서빙 엔진보다 먼저 끊으면 — 이미 진행 중인 비용이 큰 생성이 버려지고 클라이언트는 혼란스러운 에러를 받는다. 아래 계층에서 위로 갈수록 여유를 두어 정합시킨다.

7.2.15 미터링과 과금 연동 — 토큰을 세어 넘기기

플랫폼은 쓴 만큼 세어 청구한다. 미터링은 매출의 근원이자 감사(audit)의 대상이라 정확하고 재현 가능해야 한다. 상세한 가격·마진·유닛 이코노믹스는 7.5이 본진이고, 여기서는 API 계층이 무엇을 어떻게 세어 넘기는가만 다룬다.

무엇을 세느냐부터. 입력 토큰과 출력 토큰을 따로 센다 — 대개 가격이 다르고 출력이 비용이 크기 때문이다. 세는 단위는 그 모델의 토크나이저(1.2)라, 다른 토크나이저로 세면 값이 어긋난다. 그래서 어디서 세느냐가 중요하다. 게이트웨이가 직접 세려면 모델과 정확히 일치하는 토크나이저를 가져야 하는데, 더 신뢰할 원천은 서빙 엔진이 스트림 끝에 실어 주는 usage다 — 실제로 처리한 토큰의 권위 있는 값이기 때문이다. 프리픽스 캐시(5.6)로 prefill을 건너뛴 토큰은 할인될 수 있으므로, 사용량 레코드에 cached_tokens를 분리해 담아야 정확한 과금이 된다. 한 요청의 사용량 레코드는 대략 이렇게 생겼다.

{
  "request_id": "req_abc",
  "idempotency_key": "idem_123",
  "tenant_id": "t_A",
  "model": "chat-2025-06-01",
  "input_tokens": 1200,
  "output_tokens": 340,
  "cached_input_tokens": 800,
  "finish_reason": "stop",
  "ts": "2026-07-17T09:00:00Z"
}

이 레코드가 스트림 끝에서 확정돼 파이프라인을 탄다.

실시간 요청 완료 usage 확정(스트림 끝) 사용량 이벤트 발행 (request_id 기준 멱등) 집계 테넌트·모델·기간별 합산 요금 적용(rating) 토큰 × 단가 (7.5) 청구·인보이스 / 쿼터 반영 쿼터 정산(7.2.6) 예약→실사용 환불

두 가지를 붙들어야 한다. 하나는 멱등 집계다 — 재시도·재전송으로 같은 이벤트를 두 번 세면 그대로 과다 청구가 되므로 request_id로 중복을 제거한다. 다른 하나는 한 원천 원칙이다 — 같은 usage가 쿼터 정산(예약→실사용 환불, 7.2.6)과 과금 양쪽에 쓰이므로, 한 곳에서 갈라 써야 둘이 일관된다. 정산과 청구가 서로 다른 수를 세기 시작하면 그 어긋남이 곧 고객 분쟁이 된다.

여기서 토큰 과금의 근본적 불편함을 정직하게 인정해야 한다. 토큰 기반 과금은 고객이 비용을 예측하기 어렵다 — 출력 길이가 요청마다 다르고 미리 알 수 없기 때문이다. 같은 질문도 답 길이에 따라 비용이 몇 배 차이 나고, 이것이 판매와 예산의 마찰이 된다. 사용량 대시보드·실시간 사용량 헤더·예산 알림·상한(캡)·선불 크레딧 같은 완화책은 있지만 근본 불확실성은 남는다. "토큰당 얼마"가 단순해 보여도 고객이 체감하는 비용은 결코 단순하지 않다는 것을 인정하고 UX로 메워야 한다. 이 어려움은 7.5에서 가격 설계로 더 깊이 다룬다.

7.2.16 남용과 비용 폭탄 — 속도가 아니라 총량을 막기

공개 API는 공격면이다. 콘텐츠 남용(7.3)과 보안 공격(7.4)은 각자의 장에서 다루고, 이 절은 플랫폼·경제적 남용에 집중한다. 토큰이 곧 돈이라, LLM에서 남용은 곧 당신의 비용이 된다. 위협은 대략 다섯 갈래다.

위협무엇API 계층 방어
키 유출유출된 키로 남이 당신 비용을 태움해시 저장·접두어 스캐닝·이상 사용 탐지·즉시 회수·스코프 최소화
비용 폭탄거대 컨텍스트·큰 max_tokens·에이전트 무한 루프입력 길이 상한·max_tokens 상한·요청당 비용 상한·테넌트별 일 지출 캡·이상 알림
스팸·자동화 남용대량 자동 생성(스팸·가짜 계정)RPM·TPM 제한·봇 탐지·가입 검증·CAPTCHA
스크래핑·재판매API를 되팔거나 데이터 추출쿼터·이상 패턴 탐지·이용약관
DoS·자원 고갈대량 요청·긴 연결로 용량 마비rate limit·admission·백프레셔(5.5)·연결 수 상한

이 중 비용 폭탄이 LLM에 특히 위험하다. 이유는 한 요청이 거대할 수 있고(롱컨텍스트 수십만 토큰, 큰 max_tokens), 에이전트 루프(6.6)가 도구를 부르며 스스로 반복하다 무한 루프에 빠져 토큰을 끝없이 소비할 수 있기 때문이다. 그래서 입력 토큰 상한·max_tokens 상한·요청당 예상 비용 상한을 게이트웨이에서 걸고, 에이전트에는 작업당 총 토큰·스텝·비용 예산을 강제한다.

하지만 여기서 핵심적인 구분 하나가 있다. rate limit은 속도를 막지 누적 비용을 막지 못한다. 유출된 키가 낮은 속도로도 하루 종일 실행되면 큰 금액이 된다. 숫자로 보자. TPM 60,000인 키가 24시간 쉬지 않고 실행되면 $60{,}000 \times 60 \times 24 = 86{,}400{,}000$, 즉 하루 8,640만 토큰이다. 출력 위주라 가령 100만 토큰당 \$10로 치면 하루 약 \$864이고, 주말 사이 아무도 눈치채지 못하면 수천 달러가 쌓인다. 공격자가 동시성 한도를 활용해 병렬로 처리하면 더 커진다. rate limit은 이 흐름의 속도만 정할 뿐 총량을 못 막는다 — 그래서 테넌트별 일·월 하드 지출 캡이 필요하다. 캡을 \$50/일로 걸어 두면 유출 피해가 \$864가 아니라 \$50에서 멈춘다. 이것이 유출된 키의 피해를 유한하게 만드는 마지막 방어선이고, 여기에 이상 지출·요청 급증을 탐지하는 알림(7.6)을 더하면 유출·폭주를 빨리 차단해 피해를 더 줄인다.

무료·저마찰 접근과 남용 방지는 서로 당긴다. 가입을 쉽게 하면 남용도 쉬워지고, 검증을 빡세게 하면 정상 사용자도 막힌다. 완벽한 방어는 없으니 목표는 남용의 경제성을 깨고(이득보다 비용이 크게) 피해를 유한하게 만드는 것이다. 특히 하드 지출 캡은 "설마 그런 일이" 를 대비하는 저렴한 보험이다 — 안 걸어 두면 버그나 유출 하나가 예상 밖 청구서를 만든다.

7.2.17 계약을 물리와 잇기 — rate limit·SLA를 서빙 인프라와 정합

게이트웨이는 계약(SLA)의 접점이고, 5.5의 서빙 인프라는 그 계약을 지탱하는 물리다. 둘을 잇지 않으면 계약이 헛말이 된다 — 게이트웨이에서 약속한 한도와 지연이 뒤의 실제 용량과 어긋나면, 그 괴리가 곧 429 폭주와 SLA 위반으로 이어진다. 정합시켜야 할 지점이 넷 있다.

rate limit과 용량. 테넌트들의 합산 TPM 한도는 실제 용량(5.5의 사이징)과 이어져야 한다. 7.2.9에서 봤듯 이용률이 낮으면 과판매할 수 있지만, 그 과판매분은 버스트 헤드룸·오토스케일로 뒷받침되지 않으면 SLA 위반이 된다. 백프레셔와 429. 뒤의 큐·복제본이 포화하면(5.5) 그 신호가 게이트웨이로 올라와 429·503에 Retry-After를 실은 응답으로 번역돼야 한다. 백프레셔가 API 계층까지 전파돼야 클라이언트가 우아하게 물러선다 — 전파되지 않으면 뒤는 종료되어 가는데 앞은 계속 받아 상황을 악화시킨다. admission·우선순위와 티어. 무료·프로·엔터프라이즈 티어는 결국 쿼터에 우선순위 큐(5.5의 우선순위·선점)를 더한 것으로 구현된다 — 유료·인터랙티브가 먼저, 배치·무료가 뒤이자 선점 대상이다. 오토스케일과 버스트. 토큰 버킷이 허용하는 버스트를 인프라가 감당하려면 헤드룸과 프리워밍이 필요하다. 콜드스타트(5.5)가 느려 순간 버스트를 스케일로 못 막으면, 큐와 rate limit이 그 공백을 흡수해야 한다.

이 정합을 한 장의 매핑으로 그리면 티어에서 쿼터로, 우선순위로, SLO로, 그리고 인프라 대응으로 곧장 이어진다.

티어쿼터(예시)우선순위SLO(예시)인프라 대응
무료낮은 RPM·TPM, 소형 모델만낮음(선점 대상)best-effort골짜기 용량·선점 가능(5.5)
프로중간, 버스트 허용중간TTFT p95 목표헤드룸·오토스케일
엔터프라이즈높음·협상 한도높음(비선점)보장 SLA·전용 옵션전용·MIG·예약 용량(5.5)

SLO 목표값과 rate limit 수치에 보편 정답은 없다 — 제품·용량·비용이 함께 정한다. 핵심은 게이트웨이가 약속한 것과 5.5가 지탱할 수 있는 것을 한 숫자 체계로 묶고, 백프레셔·우선순위·오토스케일로 정합시키는 것이다. 이 장 전체가 사실 이 정합의 각 면이었다. 인증이 테넌트를 확정하고, rate limit이 예약-정산으로 용량을 지키고, 과판매가 이용률을 극대화하되 헤드룸으로 SLA를 보장하고, 스트리밍이 GPU를 낭비 없이 붙들고, 미터링이 그 모든 것을 돈으로 환산한다 — 이 관문들이 한 계약면 위에서 서로 맞물릴 때 비로소 "LLM을 API로 판다"가 말이 된다.

이 장이 API의 계약·경제·격리를 세웠다면, 다음 7.3는 그 관문에 붙는 입출력 안전 필터를 다룬다 — 정책·분류기·가드레일로 유해하거나 정책을 위반하는 콘텐츠를 거르는 층이다. 7.2.1의 파이프라인에서 "검증·정책" 관문으로 미뤄 둔 안전 쪽이 거기서 열리고, 이어 7.4이 프롬프트 인젝션 같은 공격면을, 7.5이 미터링을 돈으로, 7.6이 계측을 깊이 잇는다.