AI API 비용과 Rate Limit 설계: 사용자별 토큰 예산, retry-after 재시도, 지출 한도 429 구분
모의 서버로 재현한 rate limit·월 지출 한도·예산 초과 상황과, 실행하며 찾은 예산 추정 버그

핵심 요약
- Claude API의 rate limit은 모델별 RPM·ITPM·OTPM이며 토큰 버킷 방식이라 짧은 몰림에도 429가 날 수 있다.
- rate limit 429는 retry-after만큼 기다려 재시도하고, retry-after가 없는 월 지출 한도 429는 재시도하지 않는다.
- 사용자 메시지 길이만으로 토큰을 추정하면 시스템 프롬프트 등이 빠져 예산을 넘긴다.
- 대부분의 모델에서 캐시 읽기 토큰은 ITPM에 포함되지 않아 캐싱이 처리량과 비용을 함께 줄인다.
AI API 비용은 사용자가 늘거나 긴 문서를 처리하는 기능이 붙는 순간 예상보다 빨리 늘어난다. 같은 시기에 rate limit 오류도 함께 나타난다. 이 글은 호출 래퍼 하나에 사용자별 일일 토큰 예산, rate limit 재시도, 재시도하면 안 되는 오류 구분을 넣는 방법을 Claude API의 rate limit 문서(2026-10-07 확인) 기준으로 정리하고, 로컬 모의 서버로 각 상황을 재현한 결과를 싣는다.
문서에서 확인한 제한 방식
| 항목 | 내용 | 설계에 주는 의미 |
|---|---|---|
| 제한 단위 | 모델별 RPM(분당 요청), ITPM(분당 입력 토큰), OTPM(분당 출력 토큰) | 요청 수와 토큰을 따로 관리해야 한다 |
| 알고리즘 | 토큰 버킷: 고정 시점 초기화가 아니라 최대치까지 계속 채워짐 | 짧은 순간의 몰림만으로도 429가 날 수 있다 |
| rate limit 초과 | 429와 함께 기다릴 초를 알려 주는 retry-after 헤더 | retry-after만큼 기다린 뒤 재시도한다 |
| 월 지출 한도 도달 | 같은 429·rate_limit_error지만 retry-after가 없고 error.details.error_code가 enforced_spend_limit_reached | 재시도해도 다음 달까지 실패하므로 즉시 중단한다 |
| 캐시된 입력 | 대부분의 모델에서 cache_read_input_tokens는 ITPM에 포함되지 않음 | 반복되는 시스템 프롬프트·문서는 캐싱으로 처리량을 늘린다 |
| max_tokens | OTPM 계산에 들어가지 않음 | 출력 상한은 rate limit이 아니라 비용 관점에서 정한다 |
문서에 따르면 응답에는 anthropic-ratelimit-requests-remaining, anthropic-ratelimit-input-tokens-remaining 같은 헤더가 함께 오므로, 남은 양이 적을 때 미리 속도를 늦추는 데 쓸 수 있다.
호출 래퍼 구현
// LLM API 호출 래퍼: 사용자별 일일 토큰 예산, retry-after를 따르는 재시도, 월 지출 한도 429는 재시도하지 않음.
export function createClient({ baseUrl, dailyTokenBudget, maxRetries = 3, log = console.log }) {
const used = new Map(); // userId → 오늘 사용한 토큰(입력+출력)
const last = new Map(); // userId → 직전 호출의 실제 토큰. 시스템 프롬프트·도구 정의까지 포함된 값이다
let spendCapReached = false;
async function call(userId, body) {
if (spendCapReached) return { ok: false, reason: 'spend_cap' };
// 메시지 길이만으로 추정하면 시스템 프롬프트 등이 빠져 실제보다 작게 나온다. 직전 실제 사용량과 비교해 큰 값을 쓴다.
const rough = Math.ceil(JSON.stringify(body.messages).length / 2) + body.max_tokens;
const estimate = Math.max(rough, last.get(userId) || 0);
if ((used.get(userId) || 0) + estimate > dailyTokenBudget) {
log(` [budget] ${userId} 예산 초과 예상 (사용 ${used.get(userId) || 0} + 추정 ${estimate} > ${dailyTokenBudget}) → 호출 안 함`);
return { ok: false, reason: 'user_budget' };
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(`${baseUrl}/v1/messages`, { method: 'POST', body: JSON.stringify(body) });
if (res.ok) {
const data = await res.json();
const tokens = data.usage.input_tokens + data.usage.output_tokens;
used.set(userId, (used.get(userId) || 0) + tokens);
last.set(userId, tokens);
log(` [200] ${userId} 토큰 ${tokens} (오늘 누적 ${used.get(userId)})`);
return { ok: true, data };
}
const err = await res.json().catch(() => ({}));
const retryAfter = res.headers.get('retry-after');
if (res.status === 429 && err.error?.details?.error_code === 'enforced_spend_limit_reached') {
spendCapReached = true; // retry-after가 없고, 다음 달까지 재시도해도 실패한다
log(' [429] 월 지출 한도 도달 → 재시도하지 않고 모든 호출 중단');
return { ok: false, reason: 'spend_cap' };
}
if (res.status === 429 && retryAfter && attempt < maxRetries) {
log(` [429] rate limit → retry-after ${retryAfter}초 대기 후 재시도 (${attempt + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, Number(retryAfter) * 1000));
continue;
}
log(` [${res.status}] 재시도하지 않음`);
return { ok: false, reason: `http_${res.status}` };
}
return { ok: false, reason: 'retries_exhausted' };
}
return { call, used };
}
모의 서버로 재현하기
실제 API를 호출하지 않고 상황을 재현하기 위해, 두 번째 요청에는 retry-after: 1이 붙은 429를, 여섯 번째 요청에는 retry-after가 없는 월 지출 한도 429를 돌려주는 로컬 서버를 만들었다. 정상 응답은 입력 800, 출력 400 토큰을 쓴 것으로 돌려준다. 사용자별 일일 예산은 3,000 토큰이다.
// 로컬 모의 서버로 rate limit 429, 월 지출 한도 429, 사용자 예산 초과를 재현한다.
import http from 'node:http';
import { createClient } from './llm-client.mjs';
let n = 0;
const server = http.createServer((req, res) => {
n++;
if (n === 2) { // 일시적 rate limit
res.writeHead(429, { 'content-type': 'application/json', 'retry-after': '1' });
return res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
}
if (n === 6) { // 월 지출 한도: retry-after 없음
res.writeHead(429, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'usage limits', details: { error_code: 'enforced_spend_limit_reached' } } }));
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ usage: { input_tokens: 800, output_tokens: 400 } }));
}).listen(0, async () => {
const client = createClient({ baseUrl: `http://127.0.0.1:${server.address().port}`, dailyTokenBudget: 3000 });
const body = { max_tokens: 500, messages: [{ role: 'user', content: '주문 상태를 요약해 줘' }] };
const t0 = Date.now();
for (const user of ['alice', 'alice', 'alice', 'bob', 'bob', 'carol']) {
console.log(`${((Date.now() - t0) / 1000).toFixed(1)}s ${user} 요청`);
const r = await client.call(user, body);
if (!r.ok) console.log(` → 실패 사유: ${r.reason}`);
}
console.log(`서버가 받은 요청 수: ${n}`);
server.close();
});
첫 실행: 예산을 넘긴 요청이 통과했다
처음 작성한 래퍼는 호출 전 토큰을 메시지 길이와 max_tokens로만 추정했다.
$ node simulate.mjs # 첫 버전
0.0s alice 요청
[200] alice 토큰 1200 (오늘 누적 1200)
0.0s alice 요청
[429] rate limit → retry-after 1초 대기 후 재시도 (1/3)
[200] alice 토큰 1200 (오늘 누적 2400)
1.0s alice 요청
[200] alice 토큰 1200 (오늘 누적 3600)
1.0s bob 요청
[200] bob 토큰 1200 (오늘 누적 1200)
1.0s bob 요청
[429] 월 지출 한도 도달 → 재시도하지 않고 모든 호출 중단
→ 실패 사유: spend_cap
1.0s carol 요청
→ 실패 사유: spend_cap
서버가 받은 요청 수: 6
retry-after 재시도와 지출 한도 중단은 의도대로 동작했지만, alice의 세 번째 요청이 예산 3,000을 넘겨 누적 3,600이 됐다. 메시지 길이로 추정한 값은 약 530 토큰인데 실제 사용량은 1,200이었다. 실제 서비스에서는 시스템 프롬프트, 도구 정의, 이전 대화가 함께 들어가므로 사용자 메시지만으로 추정하면 항상 작게 나온다.
수정 후: 직전 실제 사용량을 추정에 반영
추정값을 "메시지 기반 추정"과 "같은 사용자의 직전 실제 사용량" 중 큰 값으로 바꿨다. 위 래퍼 코드가 수정된 버전이다.
$ node simulate.mjs # 수정 후
0.0s alice 요청
[200] alice 토큰 1200 (오늘 누적 1200)
0.0s alice 요청
[429] rate limit → retry-after 1초 대기 후 재시도 (1/3)
[200] alice 토큰 1200 (오늘 누적 2400)
1.0s alice 요청
[budget] alice 예산 초과 예상 (사용 2400 + 추정 1200 > 3000) → 호출 안 함
→ 실패 사유: user_budget
1.0s bob 요청
[200] bob 토큰 1200 (오늘 누적 1200)
1.0s bob 요청
[200] bob 토큰 1200 (오늘 누적 2400)
1.0s carol 요청
[429] 월 지출 한도 도달 → 재시도하지 않고 모든 호출 중단
→ 실패 사유: spend_cap
서버가 받은 요청 수: 6
alice의 세 번째 요청은 API를 호출하기 전에 막혔다. 이 방식도 예산을 정확히 지키지는 못한다. 실제 사용량이 직전보다 크면 여전히 넘칠 수 있으므로, 예산은 "넘으면 다음 요청부터 막는 상한"으로 보고 여유를 두어 정한다.
운영에 넣는 순서
- 모든 AI API 호출을 래퍼 하나로 모으고, 요청마다 사용자, 기능, 입력·출력 토큰, 캐시 토큰을 기록한다. 기록 스키마는 LLMOps 모니터링 구현을 참고한다.
- 429는 retry-after가 있을 때만 그만큼 기다려 재시도하고, 최대 횟수를 둔다.
enforced_spend_limit_reached는 재시도하지 않고 알림을 보낸다. SDK의 자동 재시도도 이 경우에는 실패만 반복한다.- 사용자별·기능별 일일 예산을 두고, 예산 추정에는 실제 사용량을 반영한다.
- 반복되는 시스템 프롬프트와 문서는 프롬프트 캐싱으로 처리해 ITPM과 비용을 함께 줄인다.
재시도할 오류와 즉시 멈출 오류를 나누는 일반 원칙은 서킷 브레이커와 재시도 전략에서 이어서 다룬다.
초보자가 자주 실수하는 포인트
- 모든 429를 같은 오류로 보고 고정 간격으로 재시도한다.
- 월 지출 한도 429를 계속 재시도해 로그와 알림만 늘린다.
- 사용자 메시지 길이로만 토큰을 추정해 예산을 넘긴다.
- max_tokens를 rate limit 관리 수단으로 착각한다.
체크리스트
- 모든 호출이 래퍼 하나를 거친다.
- retry-after를 읽고 최대 재시도 횟수가 있다.
- enforced_spend_limit_reached를 별도로 처리한다.
- 사용자별 예산 추정에 실제 사용량을 반영한다.
- 반복 컨텍스트에 프롬프트 캐싱을 적용했다.
자주 묻는 질문
retry-after 헤더가 없는 429는 어떻게 처리하나요?
문서상 월 지출 한도에 도달한 경우 retry-after가 없고 error.details.error_code가 enforced_spend_limit_reached입니다. 이 경우는 재시도하지 말고 알림을 보냅니다. 그 밖의 경우에는 짧은 지수 백오프로 몇 번만 재시도하고 멈추는 것이 안전합니다.
사용자별 예산은 어떻게 정하나요?
먼저 기능별 실제 사용량 분포를 기록한 뒤, 상위 사용자 몇 명이 전체 비용의 얼마를 쓰는지 보고 정합니다. 처음에는 넉넉하게 두고 알림만 보내다가, 분포를 확인한 뒤 차단 기준으로 바꾸는 방식이 사용자 불편을 줄입니다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.