AI API 비용과 Rate Limit 설계: 사용자별 토큰 예산, retry-after 재시도, 지출 한도 429 구분

모의 서버로 재현한 rate limit·월 지출 한도·예산 초과 상황과, 실행하며 찾은 예산 추정 버그

AI API 비용·Rate Limit 설계 썸네일

핵심 요약

  1. Claude API의 rate limit은 모델별 RPM·ITPM·OTPM이며 토큰 버킷 방식이라 짧은 몰림에도 429가 날 수 있다.
  2. rate limit 429는 retry-after만큼 기다려 재시도하고, retry-after가 없는 월 지출 한도 429는 재시도하지 않는다.
  3. 사용자 메시지 길이만으로 토큰을 추정하면 시스템 프롬프트 등이 빠져 예산을 넘긴다.
  4. 대부분의 모델에서 캐시 읽기 토큰은 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_tokensOTPM 계산에 들어가지 않음출력 상한은 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를 호출하기 전에 막혔다. 이 방식도 예산을 정확히 지키지는 못한다. 실제 사용량이 직전보다 크면 여전히 넘칠 수 있으므로, 예산은 "넘으면 다음 요청부터 막는 상한"으로 보고 여유를 두어 정한다.

운영에 넣는 순서

  1. 모든 AI API 호출을 래퍼 하나로 모으고, 요청마다 사용자, 기능, 입력·출력 토큰, 캐시 토큰을 기록한다. 기록 스키마는 LLMOps 모니터링 구현을 참고한다.
  2. 429는 retry-after가 있을 때만 그만큼 기다려 재시도하고, 최대 횟수를 둔다.
  3. enforced_spend_limit_reached는 재시도하지 않고 알림을 보낸다. SDK의 자동 재시도도 이 경우에는 실패만 반복한다.
  4. 사용자별·기능별 일일 예산을 두고, 예산 추정에는 실제 사용량을 반영한다.
  5. 반복되는 시스템 프롬프트와 문서는 프롬프트 캐싱으로 처리해 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입니다. 이 경우는 재시도하지 말고 알림을 보냅니다. 그 밖의 경우에는 짧은 지수 백오프로 몇 번만 재시도하고 멈추는 것이 안전합니다.

사용자별 예산은 어떻게 정하나요?

먼저 기능별 실제 사용량 분포를 기록한 뒤, 상위 사용자 몇 명이 전체 비용의 얼마를 쓰는지 보고 정합니다. 처음에는 넉넉하게 두고 알림만 보내다가, 분포를 확인한 뒤 차단 기준으로 바꾸는 방식이 사용자 불편을 줄입니다.

참고 자료 · 검증 기준

위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.

이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.