목차
- 월 청구서 보고 멈칫한 순간
- 비용 구조부터 뜯어보기
- 프롬프트 캐싱으로 Claude API 토큰 비용 절감 첫 삽
- 배치 API: 실시간 아니면 반값
- 컨텍스트 압축: 보내는 양 자체를 줄이기
- Claude API 토큰 비용 절감 효과 정리
- 잘한 점과 아쉬운 점
- 실전 적용 시 주의사항
- 다음 스텝: 더 줄일 수 있는 것들
월 청구서 보고 멈칫한 순간
3월 말, 금요일 오후 6시쯤이었다. 퇴근하려고 슬랙을 닫는데 Anthropic 콘솔에서 메일이 왔다. Claude API 토큰 비용 절감 같은 건 생각도 안 하고 있었는데, 3월 청구 예정액이 $2,100을 찍고 있었다. 한화로 약 300만 원. 전 직장에서는 인프라팀 예산에 묻혀서 API 비용을 체감한 적이 없었다. 근데 지금은 다르다. 20명짜리 팀에서 내가 쓰는 API 비용이 눈에 보이니까.
Claude API 토큰 비용 절감이 갑자기 내 OKR이 됐다. 그날 저녁부터 Anthropic 문서를 뒤지기 시작했고, 2주 동안 프롬프트 캐싱, 배치 API, 컨텍스트 압축 세 가지를 순서대로 적용했다. 결과적으로 4월 예상 비용은 $650 정도. 체감 70% 절감이다. 이건 그 2주간의 기록이다.
비용 구조부터 뜯어보기
비용을 줄이려면 일단 뭐가 비싼지 알아야 한다. Anthropic의 과금 구조는 단순하다. 인풋 토큰과 아웃풋 토큰에 각각 단가가 붙는다.
| 모델 | 인풋 (1M 토큰) | 아웃풋 (1M 토큰) | 비고 |
|---|---|---|---|
| Claude Sonnet 4.6 | $3 | $15 | 가성비 최강 |
| Claude Haiku 4.5 | $1 | $5 | 단순 작업용 |
| Claude Opus 4.6 | $5 | $25 | 최신 모델 |
아웃풋이 인풋 대비 5배 비싸다. 이게 핵심이다. 내 서비스는 고객 문의를 분석해서 카테고리 분류 + 요약 + 답변 초안을 생성하는 파이프라인인데, 시스템 프롬프트만 2,000토큰이었다. 매 요청마다 같은 시스템 프롬프트를 통째로 보내고 있었으니 돈이 새는 게 당연했다.
토큰 사용량 모니터링부터
줄이기 전에 먼저 현황 파악이 필요했다. Anthropic 콘솔에서 일별 사용량은 보이는데, 요청별 세부 내역은 직접 로깅해야 한다.
import anthropic
import json
from datetime import datetime
client = anthropic.Anthropic()
def call_with_logging(messages, system_prompt, model="claude-sonnet-4-20250514"):
response = client.messages.create(
model=model,
max_tokens=1024,
system=system_prompt,
messages=messages
)
usage = response.usage
log_entry = {
"timestamp": datetime.now().isoformat(),
"model": model,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_read": getattr(usage, "cache_read_input_tokens", 0),
"cache_creation": getattr(usage, "cache_creation_input_tokens", 0),
}
# 비용 계산 (Claude Sonnet 4 기준)
cost = (usage.input_tokens * 3 + usage.output_tokens * 15) / 1_000_000
log_entry["cost_usd"] = round(cost, 6)
print(json.dumps(log_entry, indent=2))
return response
이걸 3일 돌려보니 패턴이 보였다. 전체 인풋 토큰의 60%가 시스템 프롬프트 반복이었다. 바로 이거다. 프롬프트 캐싱을 쓰면 이 60%를 90% 할인받을 수 있다.
프롬프트 캐싱으로 Claude API 토큰 비용 절감 첫 삽질
프롬프트 캐싱은 Anthropic이 2024년에 내놓은 기능이다. 같은 프롬프트 프리픽스를 반복 전송할 때, 서버 측에서 캐싱해두고 이후 요청에서는 캐시 읽기 비용만 받는다. 캐시 읽기는 기본 인풋 대비 90% 할인. Anthropic 프롬프트 캐싱 문서에 자세한 스펙이 있다.
캐싱 적용 코드
적용 자체는 어렵지 않다. system 파라미터를 리스트로 바꾸고 cache_control을 붙이면 된다.
import anthropic
client = anthropic.Anthropic()
# 시스템 프롬프트가 길수록 효과가 크다
system_prompt = [
{
"type": "text",
"text": """당신은 고객 문의 분석 전문가입니다.
다음 규칙을 따르세요:
1. 문의를 [결제, 배송, 반품, 기술지원, 기타] 중 하나로 분류
2. 핵심 내용을 2문장으로 요약
3. 고객 감정을 [긍정, 중립, 부정, 격앙]으로 판단
4. 답변 초안을 작성
... (이하 1,500토큰 분량의 상세 규칙과 예시)
""",
"cache_control": {"type": "ephemeral"}
}
]
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system=system_prompt,
messages=[
{"role": "user", "content": "주문한 지 3일인데 배송 조회가 안 됩니다. 빨리 확인해주세요."}
]
)
print(f"인풋: {response.usage.input_tokens}")
print(f"캐시 생성: {response.usage.cache_creation_input_tokens}")
print(f"캐시 읽기: {response.usage.cache_read_input_tokens}")
첫 번째 요청에서 cache_creation_input_tokens가 찍히고, 두 번째 요청부터 cache_read_input_tokens가 찍히면 성공이다. 캐시 생성 시에는 기본 요금의 25%가 추가되지만, 이후 읽기에서 90% 할인이니 2번째 요청부터 이득이다.
삽질한 부분
근데 처음에 캐싱이 안 먹었다. 30분 동안 로그만 쳐다봤는데 계속 cache_read_input_tokens: 0이 찍히는 거다. 원인은 두 가지였다.
첫째, 캐싱 가능한 최소 토큰 수가 있다. Claude Sonnet 기준 1,024토큰 이상이어야 캐싱이 된다. 처음에 테스트한다고 짧은 시스템 프롬프트로 했더니 캐싱 자체가 안 걸렸다. Haiku는 2,048토큰이 최소다.
둘째, cache_control 위치 문제. 시스템 프롬프트 블록 여러 개를 쓸 때 마지막 블록에만 cache_control을 붙여야 한다. 중간 블록에 붙이면 그 뒤 블록이 캐시에서 빠진다. 사실 문서에 써 있는 건데 대충 읽어서 놓쳤다.
# 이렇게 하면 두 번째 블록은 캐시 안 됨 (잘못된 예)
system_prompt = [
{
"type": "text",
"text": "규칙 파트...",
"cache_control": {"type": "ephemeral"} # 여기에 붙이면 안 됨
},
{
"type": "text",
"text": "예시 파트..." # 이건 매번 새로 처리됨
}
]
# 마지막 블록에 붙여야 전체가 캐싱됨 (올바른 예)
system_prompt = [
{
"type": "text",
"text": "규칙 파트..."
},
{
"type": "text",
"text": "예시 파트...",
"cache_control": {"type": "ephemeral"} # 마지막에!
}
]
캐시 TTL은 5분이다. 5분 안에 같은 프리픽스로 요청이 안 들어오면 캐시가 날아간다. 내 서비스는 분당 10~20건 요청이 들어오니까 문제없었는데, 트래픽이 적은 서비스라면 캐싱 효과가 제한적일 수 있다. 이건 좀 아쉬운 점.
배치 API: 실시간 아니면 반값
프롬프트 캐싱만으로 인풋 비용을 꽤 줄였는데, 아웃풋 비용은 그대로였다. 그러다 배치 API를 발견했다. 실시간 응답이 필요 없는 작업은 배치로 보내면 50% 할인이다. 인풋도, 아웃풋도 전부.
내 파이프라인에서 실시간이 꼭 필요한 건 고객 응대 답변 초안뿐이었다. 문의 분류와 일일 리포트 생성은 30분 뒤에 나와도 상관없었다. 그래서 파이프라인을 쪼갰다.
배치 요청 보내기
import anthropic
import json
client = anthropic.Anthropic()
# 배치로 보낼 요청들을 JSONL로 준비
requests = []
for i, inquiry in enumerate(today_inquiries):
requests.append({
"custom_id": f"classify-{i}",
"params": {
"model": "claude-sonnet-4-20250514",
"max_tokens": 256,
"messages": [
{"role": "user", "content": f"다음 고객 문의를 분류하세요: {inquiry}"}
]
}
})
# JSONL 파일로 저장
with open("batch_requests.jsonl", "w") as f:
for req in requests:
f.write(json.dumps(req) + "\n")
# 배치 생성
batch = client.batches.create(
requests=requests # 또는 파일 업로드 방식
)
print(f"배치 ID: {batch.id}")
print(f"상태: {batch.processing_status}")
배치 결과 폴링
배치는 비동기다. 결과가 준비될 때까지 폴링하거나 웹훅을 쓴다.
import time
def wait_for_batch(batch_id, poll_interval=30):
while True:
batch = client.batches.retrieve(batch_id)
status = batch.processing_status
if status == "ended":
print(f"완료! 성공: {batch.request_counts.succeeded}")
print(f"실패: {batch.request_counts.errored}")
return batch
print(f"진행 중... 상태: {status}")
time.sleep(poll_interval)
# 결과 가져오기
completed_batch = wait_for_batch(batch.id)
# 결과 순회
for result in client.batches.results(completed_batch.id):
if result.result.type == "succeeded":
content = result.result.message.content[0].text
print(f"{result.custom_id}: {content[:100]}")
배치 API의 SLA는 24시간 이내 완료인데, 실제로는 대부분 1시간 안에 끝났다. 새벽에 보낸 건 15분 만에 돌아온 적도 있다. 다만 피크 시간대에는 2~3시간 걸린 적도 있어서, 정말 급한 작업에는 안 쓰는 게 맞다.
솔직히 이 부분이 제일 효과가 컸다. 실시간이 아닌 작업을 분리하는 것 자체가 아키텍처적으로도 깔끔해졌다. 전 직장에서는 모든 걸 실시간으로 처리했는데, 돌이켜보면 절반은 배치로 돌려도 됐을 거다.
컨텍스트 압축: 보내는 양 자체를 줄이기
캐싱과 배치로 단가를 줄였으니, 이번엔 보내는 토큰 양 자체를 줄일 차례다. 이건 Anthropic 기능이 아니라 프롬프트 엔지니어링 영역이다.
시스템 프롬프트 다이어트
기존 시스템 프롬프트가 2,000토큰이었는데, 분석해보니 중복 지시와 과도한 예시가 많았다. 예시를 3개에서 1개로 줄이고, 지시 문구를 압축했다.
# AS-IS (2,000토큰)
당신은 고객 문의를 분석하는 전문가입니다.
고객이 보낸 문의 내용을 꼼꼼히 읽고 다음 항목들을 분석해주세요.
첫 번째로 문의 카테고리를 분류해주세요. 카테고리는 결제, 배송, 반품,
기술지원, 기타 중 하나를 선택해야 합니다.
두 번째로 핵심 내용을 요약해주세요...
(이하 장황한 설명과 예시 3개)
# TO-BE (800토큰)
고객 문의 분석. 출력 포맷:
- category: 결제|배송|반품|기술지원|기타
- summary: 2문장 요약
- sentiment: 긍정|중립|부정|격앙
- draft_reply: 답변 초안 (3문장 이내)
예시:
입력: "카드 결제가 두 번 됐어요"
출력: {"category":"결제","summary":"이중 결제 발생 문의. 환불 요청.",
"sentiment":"부정","draft_reply":"..."}
800토큰으로 줄여도 출력 품질은 거의 차이가 없었다. Claude가 지시를 잘 따르는 모델이라 장황하게 설명할 필요가 없더라. 개인적으로 이게 제일 깔끔한 방법이다. 돈도 안 들고.
멀티턴 대화 압축
진짜 토큰 먹는 괴물은 멀티턴이다. 대화가 길어지면 이전 메시지가 전부 인풋으로 들어간다. 10번 왔다 갔다 하면 인풋이 만 토큰을 넘기기도 한다.
내가 쓴 방법은 대화 요약 삽입이다. 대화가 일정 길이를 넘으면 이전 대화를 요약해서 넣는다.
def compress_conversation(messages, max_turns=6):
"""대화가 max_turns를 넘으면 앞부분을 요약으로 교체"""
if len(messages) <= max_turns:
return messages
# 앞부분 대화를 요약 요청
old_messages = messages[:-max_turns]
summary_response = client.messages.create(
model="claude-haiku-3-5-20241022", # 요약은 Haiku로 충분
max_tokens=300,
messages=[
{"role": "user", "content": f"다음 대화를 3문장으로 요약해:\n{format_messages(old_messages)}"}
]
)
summary = summary_response.content[0].text
# 요약 + 최근 대화로 재구성
compressed = [
{"role": "user", "content": f"[이전 대화 요약] {summary}"},
{"role": "assistant", "content": "네, 이전 대화 내용을 이해했습니다."},
] + messages[-max_turns:]
return compressed
아 그리고 이건 주의해야 하는데, 요약용 API 호출이 추가되니까 요약 비용 자체가 들긴 한다. Haiku로 돌리면 싸지만 0은 아니다. 대화가 짧으면 오히려 손해다. 나는 8턴 이상일 때만 압축하도록 했다.
모델 라우팅: 작업별로 다른 모델
이건 컨텍스트 압축은 아닌데 비용 절감에 엄청 효과가 있어서 같이 쓴다. 모든 작업에 Sonnet을 쓸 필요가 없다. 단순 분류는 Haiku로 충분하다.
def route_model(task_type):
"""작업 난이도에 따라 모델 선택"""
routing = {
"classify": "claude-haiku-3-5-20241022", # 분류: Haiku
"summarize": "claude-haiku-3-5-20241022", # 요약: Haiku
"draft_reply": "claude-sonnet-4-20250514", # 답변 생성: Sonnet
"complex_analysis": "claude-sonnet-4-20250514" # 복잡한 분석: Sonnet
}
return routing.get(task_type, "claude-sonnet-4-20250514")
분류 정확도를 비교해봤는데, 내 데이터셋 기준으로 Haiku가 Sonnet 대비 정확도 2~3% 낮은 정도였다. 이 정도면 분류 작업에는 충분하다. 비용은 4분의 1이니까.
Claude API 토큰 비용 절감 효과 정리
2주간 적용한 결과를 정리하면 이렇다.
| 최적화 기법 | 적용 대상 | 절감 효과 | 난이도 |
|---|---|---|---|
| 프롬프트 캐싱 | 시스템 프롬프트 반복 | 인풋 비용 ~60% 감소 | 쉬움 |
| 배치 API | 비실시간 작업 | 전체 비용 50% 감소 | 보통 |
| 시스템 프롬프트 압축 | 모든 요청 | 인풋 토큰 40~60% 감소 | 쉬움 |
| 대화 압축 | 멀티턴 대화 | 인풋 토큰 30~50% 감소 | 보통 |
| 모델 라우팅 | 단순 작업 | 작업당 비용 70~75% 감소 | 쉬움 |
이 기법들은 곱으로 적용된다. 프롬프트 캐싱 + 배치 API를 같이 쓰면 이론상 인풋 비용이 95% 줄어든다. 실제로는 그 정도까지는 안 나오지만, 전체 합산 70% 절감은 현실적인 수치다.
월 $2,100에서 $650 정도로 내려왔으니까. 아직 더 줄일 여지가 있는데, 일단 여기서 멈췄다.
잘한 점과 아쉬운 점
잘한 것
가장 잘한 건 모니터링을 먼저 붙인 거다. 요청별 토큰 사용량 로깅 없이 “아 비싸다” 하고 감으로 최적화했으면 효과 측정이 불가능했을 거다. 3일간의 로그 데이터로 “시스템 프롬프트 반복이 60%”라는 팩트를 잡은 게 전부의 시작이었다.
배치 API 도입도 결과적으로 좋았다. 비용뿐 아니라 아키텍처가 깔끔해졌다. 실시간 파이프라인과 배치 파이프라인을 분리하니까 각각 독립적으로 스케일링할 수 있게 됐다. 에러 핸들링도 쉬워졌고.
프롬프트 캐싱은 적용이 너무 쉬워서 안 할 이유가 없다. cache_control 한 줄 추가하면 끝이니까.
아쉬운 것
컨텍스트 압축에서 대화 요약을 Haiku로 돌리는 부분이 좀 찝찝하다. 요약 과정에서 정보가 빠지면 후속 응답 품질이 떨어질 수 있는데, 아직 체계적으로 검증은 못 했다. “요약했더니 중요한 맥락이 날아갔다”는 케이스가 1~2건 있었다. 이 부분은 더 고민해야 한다.
그리고 모델 라우팅 기준이 지금은 하드코딩이다. task_type을 수동으로 지정하니까 새로운 작업이 추가될 때마다 어떤 모델을 쓸지 결정해야 한다. 이걸 자동화하려면 별도의 라우터를 만들어야 하는데 — 그 라우터도 API 호출이 필요하니까 치킨-에그 문제가 생긴다. 솔직히 이건 좀 불편하다.
캐시 TTL 5분도 아쉽다. 트래픽이 적은 야간 시간대에는 캐시가 만료되어서 캐싱 효과가 반감된다. Anthropic에서 TTL을 늘려주거나 커스텀할 수 있게 해주면 좋겠는데, 현재는 불가능하다.
실전 적용 시 주의사항
캐싱과 배치는 동시에 못 쓴다
이거 처음에 몰랐다. 배치 API에서는 프롬프트 캐싱이 적용되지 않는다. 둘 다 할인이니까 합쳐서 95% 할인? 그런 건 없다. 배치는 배치 할인만, 실시간은 캐싱 할인만 받을 수 있다.
그래서 작업 분류가 중요하다.
flowchart TD
A[API 요청] --> B{실시간 필요?}
B -->|Yes| C{시스템 프롬프트 반복?}
B -->|No| D[배치 API - 50% 할인]
C -->|Yes| E[프롬프트 캐싱 적용]
C -->|No| F[일반 요청]
E --> G{모델 선택}
F --> G
D --> G
G -->|단순 작업| H[Haiku]
G -->|복잡한 작업| I[Sonnet]
토큰 카운팅 함수
비용 최적화 중에 “이 프롬프트가 몇 토큰인지”를 자주 확인해야 했다. Anthropic에서 제공하는 토큰 카운팅 API를 쓰면 실제 전송 전에 확인할 수 있다.
# 메시지 토큰 카운팅
count = client.messages.count_tokens(
model="claude-sonnet-4-20250514",
system="시스템 프롬프트 내용",
messages=[
{"role": "user", "content": "사용자 메시지"}
]
)
print(f"예상 인풋 토큰: {count.input_tokens}")
이걸 프롬프트 압축할 때 썼다. 기존 프롬프트 토큰 수 → 압축 후 토큰 수를 비교해서 실제 절감량을 확인했다. 감이 아니라 수치로 확인하니까 “이 문장은 빼도 되겠다” 판단이 쉬워졌다.
비용 알림 설정
Anthropic 콘솔에서 Usage Alerts를 설정할 수 있다. 나는 일일 $50, 월간 $800에 알림을 걸어뒀다. 이건 꼭 하자. 실수로 무한 루프 돌리면 하루 만에 $500 나올 수 있다. 새벽 3시에 슬랙 알림 울려서 확인해보니 테스트 스크립트가 루프 돌고 있었던 적 있다. 진짜 식은땀 났다.
다음 스텝: 더 줄일 수 있는 것들
비용을 70% 줄였지만 여기서 끝은 아니다. 몇 가지 더 시도해볼 만한 게 있다.
Anthropic의 Extended Thinking 기능을 쓰면 복잡한 분석 시 thinking 토큰에 대한 비용이 추가로 든다. 지금은 complex_analysis에서 이걸 쓰고 있는데, 정말 thinking이 필요한 요청만 골라서 보내는 라우팅을 만들면 또 줄일 수 있겠다.
MCP(Model Context Protocol) 서버를 만들어서 Claude에 외부 데이터소스를 직접 연결하는 것도 다음 과제다. 지금은 사용자 정보를 프롬프트에 통째로 넣고 있는데, MCP로 필요한 데이터만 동적으로 가져오면 컨텍스트 크기를 더 줄일 수 있다. 그리고 Anthropic SDK의 anthropic-bedrock이나 anthropic-vertex 같은 클라우드 프로바이더 경유도 가격 협상 여지가 있으니, 볼륨이 더 커지면 검토해볼 만하다. Claude API 토큰 비용 절감은 한 번에 끝나는 게 아니라, 트래픽 패턴이 바뀔 때마다 계속 튜닝해야 하는 작업이다.