Claude API 토큰 비용 70% 절감한 3가지 실전 전략: 프롬프트 캐싱·배치 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 토큰 비용 절감은 한 번에 끝나는 게 아니라, 트래픽 패턴이 바뀔 때마다 계속 튜닝해야 하는 작업이다.