🤖 이 글은 Claude Code을 활용하여 작성되었습니다.
캐싱을 설정했다. 그런데 토큰이 줄지 않았다.
Anthropic 문서를 보고 cache_control: { type: 'ephemeral' }을 추가했다. 코드도 정상 동작했다. 근데 캐시가 전혀 작동하지 않고 있었다는 사실을 한참 뒤에야 알았다. 이유는 단순했다. 위치가 틀렸다.
이 글은 그 버그를 발견하면서 정리한 Claude API 토큰 효율 개선 원칙들이다. Claude 채팅 기능을 붙이는 과정에서 직접 적용한 내용을 바탕으로 썼다.
body-level cache_control은 Anthropic API가 에러 없이 무시하는 잘못된 위치다Anthropic의 프롬프트 캐싱은 요청 전체를 캐싱하는 게 아니다. 특정 지점(breakpoint)까지의 컨텍스트를 서버에 저장해두고, 동일한 prefix가 오면 그 지점부터 이어서 처리한다.
캐시 브레이크포인트를 설정할 수 있는 위치는 두 곳이다. 단, 요청당 최대 4개까지만 설정할 수 있다. 4개를 초과하면 API 에러가 발생한다.
[system block] ← cache_control 가능 ✅ (여러 블록 가능, 4개 한도 내에서)
[messages]
user: ...
assistant: ... ← content block에 cache_control 가능 ✅
user: 새 질문 ← 캐시 이후 (매번 새로 처리)
캐시는 브레이크포인트를 설정한 지점까지의 prefix를 서버에 보관한다. 보관 시간은 5분(TTL)이다. 5분 안에 동일한 prefix로 요청이 오지 않으면 캐시가 만료되어 다음 요청에서 다시 쓰기 비용을 지불해야 한다. 요청 빈도가 낮은 기능에는 캐싱 효과가 제한적이다.
// ❌ 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턴 이상 쌓이면 캐시 히트가 발생하기 시작한다. 대화가 길어질수록 절감 효과가 커진다.
캐싱을 제대로 설계하려면 프롬프트 구성요소를 변경 빈도에 따라 분리해야 한다.
변경 빈도가 다른 데이터를 하나의 블록에 묶으면, 가장 빠르게 바뀌는 것이 전체 캐시를 무효화한다.
채팅 시스템을 예로 들면 이렇다.
자주 바뀜 (캐싱 의미 없음)
├── 사용자 메시지
└── 처리 결과 요약 (실행마다 업데이트)
가끔 바뀜 (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 사용)
✅ 어차피 무효화될 데이터를 캐싱하면 캐시 쓰기 비용만 추가로 발생한다.
캐싱이 "이미 보낸 토큰을 재활용"하는 방식이라면, 이건 애초에 보내는 토큰을 줄이는 방식이다. 두 가지를 병행해야 효과가 크다.
모든 요청에 모든 정보를 넣을 필요가 없다. 필요한 맥락이 무엇인지 요청마다 판단해서 선택적으로 포함한다.
예를 들어, 아이템별 상세 위치 정보를 매 채팅마다 포함하고 있었다. 그런데 "공간 활용률 높이려면?" 같은 분석 질문에는 위치 정보가 전혀 필요하지 않다.
// 마지막 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)
모든 요청이 같은 수준의 모델을 필요로 하지 않는다. 작업 복잡도에 따라 모델을 분리하면 비용을 크게 줄일 수 있다.
작업 유형별 모델 매핑
| 작업 유형 | 요구사항 | 적합한 모델 |
|---|---|---|
| 구조화된 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를 설정하지 않으면 기존처럼 동작하므로 안전하게 점진적으로 적용할 수 있다.
캐싱을 가장 효과적으로 만드는 방법 중 하나는 캐싱 대상 자체를 줄이는 것이다. 시스템 프롬프트가 짧을수록 캐시 히트 조건이 단순해지고, 신규 요청에서 처리해야 할 토큰도 줄어든다.
압축 전
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 토큰)을 충족해야 함
토큰 비용은 기능이 추가될수록 선형적으로 증가한다. 채팅에 히스토리가 쌓이고, 컨텍스트가 풍부해질수록 요청당 토큰이 늘어난다. 미리 설계하지 않으면 나중에 고치기 어렵다.
이 과정에서 배운 핵심은 두 가지다.
첫째, 캐싱은 "어디에 붙이는가"보다 "무엇이 얼마나 자주 바뀌는가"를 먼저 분석해야 한다. 변경 빈도가 다른 데이터를 하나의 블록에 묶으면, 가장 빠르게 바뀌는 것이 전체 캐시를 무효화한다.
둘째, "보내지 않는 것"이 "캐시 히트"보다 확실하다. 조건부 컨텍스트, 히스토리 정리, 히스토리 크기 조정은 캐싱과 독립적으로 항상 효과가 있다.
토큰 절약은 부수 효과다. 캐시 히트가 발생하면 대형 컨텍스트의 응답 속도가 눈에 띄게 줄어든다. 진짜 목적은 사용자 경험이다.