Claude API 토큰 효율 높이기

zion·2026년 4월 25일

🤖 이 글은 Claude Code을 활용하여 작성되었습니다.

캐싱을 설정했다. 그런데 토큰이 줄지 않았다.

Anthropic 문서를 보고 cache_control: { type: 'ephemeral' }을 추가했다. 코드도 정상 동작했다. 근데 캐시가 전혀 작동하지 않고 있었다는 사실을 한참 뒤에야 알았다. 이유는 단순했다. 위치가 틀렸다.

이 글은 그 버그를 발견하면서 정리한 Claude API 토큰 효율 개선 원칙들이다. Claude 채팅 기능을 붙이는 과정에서 직접 적용한 내용을 바탕으로 썼다.


TL;DR

  • body-level cache_control은 Anthropic API가 에러 없이 무시하는 잘못된 위치다
  • 캐싱은 system block 또는 messages content block에 붙여야 한다
  • 캐시 브레이크포인트는 요청당 최대 4개로 제한된다
  • 캐시 TTL은 5분이다 — "영구 캐시"는 없다
  • 캐시 레이어 설계의 핵심은 "얼마나 자주 바뀌는가"를 기준으로 나누는 것
  • 보내는 데이터 자체를 줄이는 것이 캐싱만큼 중요하다
  • 모델을 작업 복잡도에 맞춰 분기하면 비용을 추가로 줄일 수 있다

1️⃣ 프롬프트 캐싱 — 위치가 전부다

동작 방식

Anthropic의 프롬프트 캐싱은 요청 전체를 캐싱하는 게 아니다. 특정 지점(breakpoint)까지의 컨텍스트를 서버에 저장해두고, 동일한 prefix가 오면 그 지점부터 이어서 처리한다.

캐시 브레이크포인트를 설정할 수 있는 위치는 두 곳이다. 단, 요청당 최대 4개까지만 설정할 수 있다. 4개를 초과하면 API 에러가 발생한다.

[system block]       ← cache_control 가능 ✅ (여러 블록 가능, 4개 한도 내에서)
[messages]
  user: ...
  assistant: ...     ← content block에 cache_control 가능 ✅
  user: 새 질문      ← 캐시 이후 (매번 새로 처리)

캐시는 브레이크포인트를 설정한 지점까지의 prefix를 서버에 보관한다. 보관 시간은 5분(TTL)이다. 5분 안에 동일한 prefix로 요청이 오지 않으면 캐시가 만료되어 다음 요청에서 다시 쓰기 비용을 지불해야 한다. 요청 빈도가 낮은 기능에는 캐싱 효과가 제한적이다.

잘못된 방식 vs 올바른 방식

// ❌ body-level — Anthropic이 에러 없이 무시함
const body = {
  model,
  messages,
  cache_control: { type: 'ephemeral' },  // 무효
}

// ✅ system block에 직접 붙이기
system: [
  {
    type: 'text',
    text: '정적인 지시사항...',
    cache_control: { type: 'ephemeral' },
  }
]

// ✅ messages content block에 붙이기
messages: [
  {
    role: 'assistant',
    content: [
      {
        type: 'text',
        text: '이전 응답...',
        cache_control: { type: 'ephemeral' },
      }
    ]
  },
  { role: 'user', content: '새 질문' }
]

API가 에러를 내지 않는다는 게 더 위험하다. 조용히 실패하기 때문에 한참 동안 캐싱이 작동한다고 착각할 수 있다.

히스토리 캐싱

채팅 히스토리를 캐싱하려면 마지막 assistant turn에 브레이크포인트를 설정한다. 매 요청마다 바뀌는 새 user 메시지 직전까지를 캐시하는 것이다. user 메시지에 걸면 질문이 바뀔 때마다 캐시가 무효화되므로 assistant turn이 적합하다.

const messages = history.map(({ role, content }, i) => {
  const isLastAssistant = role === 'assistant' && i === history.length - 2
  if (isLastAssistant && history.length >= 2) {
    return {
      role,
      content: [{ type: 'text', text: content, cache_control: { type: 'ephemeral' } }],
    }
  }
  return { role, content }
})

대화가 4턴 이상 쌓이면 캐시 히트가 발생하기 시작한다. 대화가 길어질수록 절감 효과가 커진다.


2️⃣ 캐시 레이어 설계 — 변경 빈도가 기준

캐싱을 제대로 설계하려면 프롬프트 구성요소를 변경 빈도에 따라 분리해야 한다.

변경 빈도가 다른 데이터를 하나의 블록에 묶으면, 가장 빠르게 바뀌는 것이 전체 캐시를 무효화한다.

채팅 시스템을 예로 들면 이렇다.

자주 바뀜 (캐싱 의미 없음)
├── 사용자 메시지
└── 처리 결과 요약 (실행마다 업데이트)

가끔 바뀜 (5분 내 반복 요청 시 히트 기대 가능)
└── 사용 가능한 데이터 목록 (추가/삭제 시만)

거의 안 바뀜 (요청 빈도가 높으면 캐시 히트율 높음)
└── 역할 정의 + 액션 형식 지시사항

이 분석을 바탕으로 system block을 3개로 분리했다. system block 2개 + messages 1개를 사용하므로 브레이크포인트 총 3개 — 4개 한도 내에 여유가 있다.

system: [
  // block 1: 역할·지시사항 — 거의 안 바뀜 → 캐시 포인트 1
  { type: 'text', text: staticInstructions, cache_control: { type: 'ephemeral' } },

  // block 2: 데이터 목록 — 추가/삭제 시만 무효화 → 캐시 포인트 2
  { type: 'text', text: dataList, cache_control: { type: 'ephemeral' } },

  // block 3: 처리 결과 — 실행마다 바뀜 → 캐싱 안 함 (여기 걸면 쓰기 비용만 발생)
  { type: 'text', text: resultSummary },
]
// messages 내 마지막 assistant turn → 캐시 포인트 3 (총 3/4 사용)

✅ 어차피 무효화될 데이터를 캐싱하면 캐시 쓰기 비용만 추가로 발생한다.


3️⃣ 보내는 토큰 줄이기 — 컨텍스트 최소화

캐싱이 "이미 보낸 토큰을 재활용"하는 방식이라면, 이건 애초에 보내는 토큰을 줄이는 방식이다. 두 가지를 병행해야 효과가 크다.

조건부 컨텍스트

모든 요청에 모든 정보를 넣을 필요가 없다. 필요한 맥락이 무엇인지 요청마다 판단해서 선택적으로 포함한다.

예를 들어, 아이템별 상세 위치 정보를 매 채팅마다 포함하고 있었다. 그런데 "공간 활용률 높이려면?" 같은 분석 질문에는 위치 정보가 전혀 필요하지 않다.

// 마지막 user 메시지에 이동 관련 키워드가 있을 때만 위치 정보 포함
const MOVE_KEYWORDS = ['이동', '옮겨', '위치', '배치', '모서리', 'move']

const needsPos = MOVE_KEYWORDS.some((k) => lastUserMsg.includes(k))
const summary = buildSummary(result, needsPos)

아이템 10개 기준 요청당 약 150~200 토큰 절약.

히스토리에서 처리된 데이터 제거

Claude 응답에 시뮬레이션 실행을 위한 action JSON이 포함되는 경우가 있다.

분석 결과입니다. 해당 아이템을 다른 위치로 이동하는 것을 추천합니다.
<action>{"type":"move_item","moves":[{"productName":"item-a","fromBoxIndex":1,"toBoxIndex":0,"anchor":"bottom-center"}]}</action>

이미 실행된 action은 다음 요청의 히스토리로 전송할 필요가 없다. assistant 메시지를 히스토리에 추가하기 전에 제거한다.

const cleaned = role === 'assistant'
  ? content.replace(/<action>[\s\S]*?<\/action>/g, '').trimEnd()
  : content

요청당 30~100 토큰 절약. action JSON이 복잡할수록 효과가 커진다.

히스토리 크기 동적 조정

모든 대화 유형에 동일한 히스토리 크기를 유지할 필요가 없다. 맥락이 많이 필요한 요청과 그렇지 않은 요청을 구분한다.

// 맥락이 중요한 요청: 이전 대화 유지 → 8개
// 단순 분석 질문: 최근 내용만으로 충분 → 4개로 절반 축소
const historyLimit = needsContext ? 8 : 4
const trimmed = history.slice(-historyLimit)

4️⃣ 모델 분기 — 작업 복잡도에 맞는 모델 선택

모든 요청이 같은 수준의 모델을 필요로 하지 않는다. 작업 복잡도에 따라 모델을 분리하면 비용을 크게 줄일 수 있다.

작업 유형별 모델 매핑

작업 유형요구사항적합한 모델
구조화된 JSON 생성 (action 반환)형식 정확도가 높아야 함claude-sonnet-4-5
통계 분석 리포트 생성긴 텍스트, 복잡한 추론claude-sonnet-4-5
결과 간단 분석 (3~5줄 요약)단순 텍스트 생성claude-haiku-4-5
히스토리 패턴 분석패턴 인식 + 추천claude-sonnet-4-5

Haiku는 3.5 → 4.5 세대로 올수록 성능이 크게 올랐지만 가격도 상승했다. 단순 텍스트 요약이라면 구버전 Haiku도 충분하다. 모델 ID는 배포 시점에 따라 달라지므로 환경변수로 분리해두는 것이 좋다.

예를 들어 즉석 분석, 통계 리포트, 패턴 분석 요청은 action JSON을 생성할 일이 없다. 순수 텍스트 분석이다. 채팅만 정확한 JSON 생성이 필요하므로 Sonnet을 유지한다.

const modelMain = process.env.ANTHROPIC_MODEL        // Sonnet
const modelFast = process.env.ANTHROPIC_MODEL_FAST   // Haiku (미설정 시 Sonnet으로 fallback)
  ?? modelMain

// 채팅: action 정확도 필요 → main 모델
// 분석 전용: 순수 텍스트 → fast 모델
const model = type === 'chat' ? modelMain : modelFast

ANTHROPIC_MODEL_FAST를 설정하지 않으면 기존처럼 동작하므로 안전하게 점진적으로 적용할 수 있다.


5️⃣ 시스템 프롬프트 압축

캐싱을 가장 효과적으로 만드는 방법 중 하나는 캐싱 대상 자체를 줄이는 것이다. 시스템 프롬프트가 짧을수록 캐시 히트 조건이 단순해지고, 신규 요청에서 처리해야 할 토큰도 줄어든다.

압축 전

4. 아이템 이동 (컨테이너 변경 또는 위치 지정):
   <action>{"type":"move_item","moves":[{"itemName":"아이템명","fromIndex":0,"toIndex":1,"anchor":"bottom-center","rotation":"flat"}]}</action>
   anchor 값(선택): "bottom-front-left","bottom-front-right","bottom-back-left","bottom-back-right","bottom-center","top-front-left","top-front-right","top-back-left","top-back-right","top-center"
   anchor와 rotation은 생략 가능. 생략 시 알고리즘이 최적 위치/방향 선택.
   fromIndex/toIndex는 0-based. 같은 컨테이너 내 이동도 가능.
   복수 아이템 동시 이동 가능.

압축 후

4. 아이템 이동: <action>{"type":"move_item","moves":[{"itemName":"아이템명","fromIndex":0,"toIndex":0,"anchor":"bottom-front-left","rotation":"flat"}]}</action>
   anchor(선택): bottom/top + front/back + left/right/center 조합. 생략 시 알고리즘 자동 선택.
   fromIndex/toIndex는 0-based. 같은 컨테이너 내 이동 가능. 복수 이동 가능.

중요한 건 압축이 의미를 훼손하지 않아야 한다는 점이다. 예시 값을 하나 남기고 패턴을 설명하는 방식으로 토큰을 줄였다.

XML 태그 활용

Claude는 XML 구조를 잘 이해한다. 긴 지시사항을 줄임표로 압축하는 것보다 <rules>, <format>, <examples> 같은 태그로 구조화하면 토큰 효율과 응답 정확도를 동시에 잡을 수 있다.

// 비구조화 (압축해도 모델이 경계를 파악하기 어려움)
규칙: A를 하라. B는 하지 마라. 형식은 C다. 예시: ...

// XML 구조화 (구조가 명확해 모델 이해도 ↑, 불필요한 설명 제거 가능)
<rules>A를 하라. B는 하지 마라.</rules>
<format>C 형식을 따른다.</format>
<examples>...</examples>

적용 결과 요약

개선분류효과
body-level → message content 캐싱버그 수정히스토리 캐시 실제 활성화
system block 3분리캐시 설계캐시 히트율 향상
instant/report 캐시 system block 적용캐시 확장반복 분석 요청 시 효과
위치 정보 조건부 전송토큰 절약~150~200 토큰/요청
action 태그 히스토리 제거토큰 절약~30~100 토큰/요청
히스토리 크기 동적 조정토큰 절약단순 질문 시 히스토리 절반
모델 분기 (Sonnet / Haiku)비용 절감분석 전용 요청 비용 대폭 감소

⚠️ 캐싱 제약사항 요약

  • 브레이크포인트는 요청당 최대 4개. 초과 시 API 에러 발생
  • 캐시 TTL은 5분. 5분 이상 간격으로 발생하는 드문 요청에는 효과 없음
  • 캐싱이 적용되려면 프롬프트 최소 토큰 기준(약 1,024 토큰)을 충족해야 함

토큰 비용은 기능이 추가될수록 선형적으로 증가한다. 채팅에 히스토리가 쌓이고, 컨텍스트가 풍부해질수록 요청당 토큰이 늘어난다. 미리 설계하지 않으면 나중에 고치기 어렵다.

이 과정에서 배운 핵심은 두 가지다.

첫째, 캐싱은 "어디에 붙이는가"보다 "무엇이 얼마나 자주 바뀌는가"를 먼저 분석해야 한다. 변경 빈도가 다른 데이터를 하나의 블록에 묶으면, 가장 빠르게 바뀌는 것이 전체 캐시를 무효화한다.

둘째, "보내지 않는 것"이 "캐시 히트"보다 확실하다. 조건부 컨텍스트, 히스토리 정리, 히스토리 크기 조정은 캐싱과 독립적으로 항상 효과가 있다.

토큰 절약은 부수 효과다. 캐시 히트가 발생하면 대형 컨텍스트의 응답 속도가 눈에 띄게 줄어든다. 진짜 목적은 사용자 경험이다.

profile
be_zion

0개의 댓글