Claude API 사용법: Anthropic SDK, 스트리밍, Tool Use, 프롬프트 캐싱과 요금
이 글의 핵심
Claude API는 Anthropic의 Messages API를 Python·TypeScript SDK로 호출하는 방식입니다. 기본 호출과 응답 구조, 스트리밍, 멀티턴 대화, Tool Use 루프, 이미지 입력, 프롬프트 캐싱, 재시도 처리를 예제로 보고, 각 기능에서 실제로 자주 막히는 지점을 함께 설명합니다.
Claude API란?
Claude API는 Anthropic이 제공하는 LLM API입니다. 모든 기능이 POST /v1/messages 하나의 엔드포인트로 모여 있어서, 일반 대화·스트리밍·도구 호출·이미지 입력·캐싱이 모두 같은 요청에 파라미터를 더하는 방식으로 동작합니다. 공식 SDK는 이 HTTP API를 감싸 타입, 스트리밍 파싱, 자동 재시도를 제공합니다.
이 글에서는 API 키 발급부터 Tool Use와 프롬프트 캐싱까지 Python과 TypeScript 예제로 설명하고, 처음 연동할 때 흔히 막히는 부분(응답 블록 구조, Tool Use 루프 종료 조건, 캐시가 적용되지 않는 이유)을 함께 짚습니다.
# 30초 만에 시작하는 Claude API
import anthropic
client = anthropic.Anthropic(api_key="your-api-key")
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Python으로 피보나치 수열 구현해줘"}]
)
print(message.content[0].text)
api_key를 코드에 직접 쓰는 것은 시작 예제에서만 허용되는 방식입니다. 키가 저장소에 커밋되면 누구나 그 키로 요금을 발생시킬 수 있으므로, 실제 코드에서는 anthropic.Anthropic()처럼 인자 없이 만들고 ANTHROPIC_API_KEY 환경 변수에서 읽게 합니다. max_tokens는 필수 파라미터로, 응답이 이 길이에 도달하면 문장 중간이라도 잘립니다.
모델 비교 및 요금
| 모델 | 컨텍스트 | 입력 ($/MTok) | 출력 ($/MTok) | 특징 |
|---|---|---|---|---|
| claude-haiku-4-5 | 200K | $1.00 | $5.00 | 가장 빠름, 저비용 |
| claude-sonnet-4-5 | 200K | $3.00 | $15.00 | 균형잡힌 성능 |
| claude-opus-4-5 | 200K | $5.00 | $25.00 | 복잡한 추론·에이전트 작업 |
| claude-sonnet-4-6 | 1M | $3.00 | $15.00 | 긴 컨텍스트, 적응형 사고 지원 |
실무 팁: 개발/테스트는 Haiku, 프로덕션은 Sonnet을 사용하면 비용을 크게 절감할 수 있습니다.
모델 라인업과 가격은 몇 달 단위로 바뀌므로 표는 작성 시점 기준으로 보고, 새 모델이 나오면 공식 문서의 모델 목록에서 정확한 ID를 확인하는 것이 좋습니다. 모델 ID는 문자열이라 오타가 있으면 not_found_error(404)가 납니다. 비용을 계산할 때는 입력보다 출력 토큰이 5배 비싸다는 점이 중요합니다. 긴 문서를 요약하는 작업은 입력 비용이, 코드를 길게 생성하는 작업은 출력 비용이 대부분을 차지합니다. 개발 단계에서 Haiku를 쓰다가 프로덕션에서 Sonnet으로 바꾸는 방식은 비용 면에서는 합리적이지만, 모델마다 응답 스타일과 지시 따르는 정도가 달라서 프롬프트를 다시 조정해야 하는 경우가 많습니다. 저는 처음부터 운영에 쓸 모델로 프롬프트를 다듬고, 대량 처리처럼 단순한 작업만 작은 모델로 내리는 편이 전체 작업량이 적었습니다.
API 키 발급
- console.anthropic.com 접속
- 계정 생성 (Google/GitHub 로그인 가능)
- API Keys → Create Key
- 키 복사 후 안전하게 보관 (재확인 불가)
# .env 파일에 저장
ANTHROPIC_API_KEY=sk-ant-api03-...
SDK 설치
Python
pip install anthropic
TypeScript / Node.js
npm install @anthropic-ai/sdk
# 또는
pnpm add @anthropic-ai/sdk
기본 메시지 API
Python
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_API_KEY 환경변수 자동 사용
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[
{"role": "user", "content": "대한민국의 수도는 어디인가요?"}
]
)
# 응답 구조
print(message.content[0].text) # 서울입니다.
print(message.model) # claude-sonnet-4-5
print(message.usage) # 토큰 사용량
TypeScript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const message = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages: [{ role: "user", content: "대한민국의 수도는 어디인가요?" }],
});
console.log(message.content[0].type === "text" ? message.content[0].text : "");
TypeScript 예제에서 content[0].type === "text"를 확인하는 이유는 content가 여러 종류의 블록이 섞일 수 있는 배열이기 때문입니다. 일반 대화에서는 대부분 텍스트 블록 하나지만, 도구를 쓰면 tool_use 블록이, 확장 사고를 켜면 thinking 블록이 텍스트보다 앞에 올 수 있습니다. 그래서 content[0].text를 바로 읽는 코드는 기능을 추가하는 순간 깨집니다. 안전한 방법은 배열을 돌면서 type == "text"인 블록만 모으는 것입니다. TypeScript는 이를 타입 단계에서 강제하므로 확인 없이 .text에 접근하면 컴파일 에러가 납니다.
응답 객체 구조
API가 돌려주는 응답을 JSON으로 보면 다음과 같습니다.
{
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "서울입니다."
}
],
"model": "claude-sonnet-4-5",
"stop_reason": "end_turn",
"usage": {
"input_tokens": 14,
"output_tokens": 5
}
}
stop_reason은 응답이 왜 끝났는지를 알려 주는 필드이고, 운영 코드에서 반드시 확인해야 합니다. end_turn은 모델이 스스로 답을 마친 경우, max_tokens는 길이 제한에 걸려 잘린 경우, tool_use는 도구 호출을 요청한 경우, stop_sequence는 지정한 중단 문자열을 만난 경우입니다. max_tokens로 끝난 응답을 정상 응답처럼 저장하면 JSON이 중간에 끊겨 파싱에 실패하거나 코드가 반쯤 잘린 채 사용자에게 보이게 됩니다. usage의 토큰 수는 비용 계산의 기준이므로 요청마다 로그로 남겨 두면 나중에 비용이 어디서 나가는지 추적하기 쉽습니다.
스트리밍
대화형 앱에서 실시간 응답을 표시할 때 필수입니다. 스트리밍 없이 긴 응답을 요청하면 사용자는 생성이 끝날 때까지 빈 화면을 보게 되고, 응답이 아주 길면 HTTP 요청 자체가 타임아웃에 걸릴 수 있습니다. 스트리밍은 서버가 SSE(Server-Sent Events)로 토큰이 생성되는 대로 조각을 보내는 방식이며, SDK가 이 이벤트를 파싱해 텍스트 조각만 꺼내는 헬퍼를 제공합니다.
Python (동기)
import anthropic
client = anthropic.Anthropic()
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Python의 GIL에 대해 설명해줘"}]
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
# 완료 후 최종 메시지 객체 접근
final_message = stream.get_final_message()
print(f"\n\n총 토큰: {final_message.usage.input_tokens + final_message.usage.output_tokens}")
with 블록을 쓰는 이유는 스트림이 HTTP 연결을 붙잡고 있기 때문입니다. 블록을 벗어나면 연결이 정리되고, 중간에 break로 빠져나와도 연결이 남지 않습니다. text_stream은 텍스트 조각만 돌려주는 편의 이터레이터이고, get_final_message()는 스트림이 끝난 뒤 조각을 합친 완성 메시지와 usage, stop_reason을 돌려줍니다. 스트리밍 중 오류는 HTTP 상태 코드가 아니라 이벤트 스트림 안의 error 이벤트로 올 수 있으므로, 이미 일부 텍스트를 화면에 보여 준 뒤에 실패하는 경우를 UI에서 처리해 두어야 합니다.
Python (비동기)
웹 서버(FastAPI 등)처럼 이벤트 루프 위에서 동작하는 코드에서는 AsyncAnthropic을 써야 요청을 기다리는 동안 다른 요청을 처리할 수 있습니다. 동기 클라이언트를 비동기 핸들러 안에서 호출하면 응답이 올 때까지 이벤트 루프 전체가 멈춥니다.
import asyncio
import anthropic
client = anthropic.AsyncAnthropic()
async def stream_response():
async with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "비동기 프로그래밍이란?"}]
) as stream:
async for text in stream.text_stream:
print(text, end="", flush=True)
asyncio.run(stream_response())
TypeScript (스트리밍)
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const stream = await client.messages.stream({
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages: [{ role: "user", content: "JavaScript의 이벤트 루프를 설명해줘" }],
});
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
process.stdout.write(chunk.delta.text);
}
}
TypeScript 예제는 원시 이벤트를 직접 다루는 방식입니다. 스트림은 message_start → content_block_start → 여러 개의 content_block_delta → content_block_stop → message_delta(여기에 stop_reason과 최종 usage) → message_stop 순서로 이벤트를 보냅니다. 텍스트만 필요하면 text_delta만 골라 쓰면 되고, 완성된 메시지 객체가 필요하면 루프가 끝난 뒤 await stream.finalMessage()를 호출하면 됩니다. 이벤트를 직접 모아 메시지를 재구성하는 코드를 따로 짤 필요는 없습니다.
시스템 프롬프트
모델의 역할과 행동 방식을 정의합니다. OpenAI API와 달리 Claude API에서는 시스템 프롬프트가 messages 배열 안의 한 항목이 아니라 별도의 system 파라미터입니다. messages에 {"role": "system", ...}을 넣던 습관대로 코드를 옮기면 모델에 따라 에러가 나거나 의도와 다르게 처리되니 주의해야 합니다.
import anthropic
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system="""당신은 10년 경력의 시니어 백엔드 개발자입니다.
- Python, Go, Rust에 능숙합니다
- 성능 최적화와 시스템 설계를 중시합니다
- 코드 리뷰 시 실용적이고 구체적인 피드백을 제공합니다
- 한국어로 답변합니다""",
messages=[
{"role": "user", "content": "이 Django 코드의 N+1 쿼리 문제를 해결해줘:\n\n```python\ndef get_posts():\n posts = Post.objects.all()\n return [(p.title, p.author.name) for p in posts]\n```"}
]
)
print(message.content[0].text)
시스템 프롬프트에는 역할보다 구체적인 작업 조건을 적는 편이 효과가 큽니다. “10년 경력의 시니어 개발자”라는 설정보다 “답변은 한국어로”, “수정한 코드 전체를 보여 줄 것”, “Django ORM의 select_related/prefetch_related 중 무엇을 썼는지 이유를 설명할 것”처럼 출력 형태와 판단 기준을 적어 주는 쪽이 결과를 더 일관되게 만듭니다. 위 예제의 질문처럼 코드를 사용자 메시지에 넣을 때는 코드 블록이나 XML 태그로 감싸 지시와 데이터를 구분해 주면 모델이 코드 안의 주석을 지시로 오해하는 일이 줄어듭니다.
멀티턴 대화
대화 히스토리를 관리하여 맥락 있는 대화를 구현합니다. API는 상태를 저장하지 않으므로(stateless) 이전 대화를 기억하게 하려면 매 요청마다 지금까지의 사용자·어시스턴트 메시지를 전부 다시 보내야 합니다.
import anthropic
client = anthropic.Anthropic()
conversation_history = []
def chat(user_message: str) -> str:
conversation_history.append({
"role": "user",
"content": user_message
})
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=2048,
system="당신은 친절한 프로그래밍 튜터입니다.",
messages=conversation_history
)
assistant_message = response.content[0].text
conversation_history.append({
"role": "assistant",
"content": assistant_message
})
return assistant_message
# 대화 예시
print(chat("Python이란 무엇인가요?"))
print(chat("방금 설명한 Python으로 Hello World를 출력해줘"))
print(chat("거기서 변수를 추가해줘"))
히스토리를 매번 다시 보낸다는 것은 대화가 길어질수록 요청마다 입력 토큰이 늘어난다는 뜻입니다. 20턴째 요청은 앞의 19턴을 모두 입력으로 다시 과금하므로, 긴 대화에서는 비용이 턴 수에 비례하는 것이 아니라 그보다 빠르게 늘어납니다. 이 문제를 줄이는 방법이 뒤에서 다룰 프롬프트 캐싱이고, 컨텍스트 창을 넘을 만큼 길어지면 오래된 턴을 요약하거나 잘라 내야 합니다. 또 위 코드는 전역 리스트에 히스토리를 쌓기 때문에 웹 서버에서 그대로 쓰면 모든 사용자의 대화가 한 리스트에 섞입니다. 실제 서비스에서는 세션이나 대화 ID별로 히스토리를 분리해 저장해야 합니다. API 호출이 실패했을 때 사용자 메시지만 추가된 채로 남으면 다음 요청에서 user 메시지가 연속되므로, 실패 시 방금 추가한 메시지를 되돌리는 처리도 필요합니다.
Tool Use (함수 호출)
Claude가 외부 함수를 호출하여 실시간 정보를 처리할 수 있게 합니다. 여기서 “호출”은 모델이 직접 함수를 실행한다는 뜻이 아닙니다. 모델은 “이 도구를 이런 인자로 호출하고 싶다”는 tool_use 블록을 돌려줄 뿐이고, 실제 실행은 여러분의 코드가 하고, 그 결과를 tool_result로 다시 보내면 모델이 결과를 보고 답을 이어 갑니다. 그래서 Tool Use는 한 번의 API 호출이 아니라 루프가 됩니다.
import anthropic
import json
from datetime import datetime
client = anthropic.Anthropic()
# 도구 정의
tools = [
{
"name": "get_weather",
"description": "특정 도시의 현재 날씨 정보를 가져옵니다",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "날씨를 조회할 도시 이름 (예: Seoul, Tokyo)"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "온도 단위"
}
},
"required": ["city"]
}
},
{
"name": "get_stock_price",
"description": "주식 가격을 조회합니다",
"input_schema": {
"type": "object",
"properties": {
"ticker": {
"type": "string",
"description": "주식 티커 심볼 (예: AAPL, TSLA)"
}
},
"required": ["ticker"]
}
}
]
# 실제 함수 구현
def get_weather(city: str, unit: str = "celsius") -> dict:
# 실제로는 날씨 API 호출
return {"city": city, "temperature": 22, "unit": unit, "condition": "맑음"}
def get_stock_price(ticker: str) -> dict:
# 실제로는 주식 API 호출
return {"ticker": ticker, "price": 180.50, "currency": "USD"}
def process_tool_call(tool_name: str, tool_input: dict) -> str:
if tool_name == "get_weather":
result = get_weather(**tool_input)
elif tool_name == "get_stock_price":
result = get_stock_price(**tool_input)
else:
result = {"error": f"Unknown tool: {tool_name}"}
return json.dumps(result, ensure_ascii=False)
def chat_with_tools(user_message: str) -> str:
messages = [{"role": "user", "content": user_message}]
while True:
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=tools,
messages=messages
)
# 도구 호출이 없으면 최종 응답
if response.stop_reason == "end_turn":
return response.content[0].text
# 도구 호출 처리
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
messages.append({"role": "user", "content": tool_results})
# 실행
print(chat_with_tools("서울 날씨와 애플 주가를 알려줘"))
코드의 흐름을 정리하면 다음과 같습니다. 모델이 stop_reason: "tool_use"로 응답하면, 그 응답 전체(response.content)를 어시스턴트 메시지로 히스토리에 추가하고, 각 tool_use 블록의 id를 tool_use_id에 넣어 결과를 만든 뒤, 모든 결과를 하나의 user 메시지로 묶어 보냅니다. 이 질문처럼 날씨와 주가를 함께 물으면 모델은 두 도구를 한 응답에서 동시에 요청하는 경우가 많은데, 결과를 메시지 두 개로 나눠 보내면 형식 에러가 나거나 이후 병렬 호출이 줄어듭니다.
도구의 description은 모델이 언제 이 도구를 쓸지 판단하는 유일한 근거입니다. “주식 가격을 조회합니다”보다 “미국 주식 티커로 현재 주가를 조회합니다. 회사 이름이 주어지면 티커로 바꿔서 호출하세요”처럼 쓰임과 입력 형식을 구체적으로 적으면 잘못된 호출이 크게 줄어듭니다.
이 예제 루프에는 운영 코드에서 반드시 고쳐야 할 함정이 두 가지 있습니다. 첫째, while True 안에서 end_turn과 tool_use만 처리하므로 stop_reason이 max_tokens나 다른 값이면 아무것도 추가하지 않고 같은 요청을 무한히 반복합니다. 요금이 계속 나가는 무한 루프가 되므로 그 외의 경우는 예외를 던지거나 반환하고, 반복 횟수 상한도 두어야 합니다. 둘째, end_turn 응답에서 content[0].text를 읽는데, 모델이 텍스트 없이 끝나거나 첫 블록이 텍스트가 아닐 수 있으므로 텍스트 블록을 찾아 읽는 편이 안전합니다. 도구 실행이 실패했을 때는 예외로 루프를 끊지 말고 "is_error": True를 넣은 tool_result로 에러 메시지를 돌려주면, 모델이 인자를 바꿔 다시 시도하거나 사용자에게 상황을 설명합니다. 루프를 직접 짜는 것이 번거롭다면 SDK의 Tool Runner(client.beta.messages.tool_runner)가 이 반복을 대신 처리해 줍니다.
Vision (이미지 입력)
Claude는 이미지를 이해하고 분석할 수 있습니다.
import anthropic
import base64
from pathlib import Path
client = anthropic.Anthropic()
# 방법 1: Base64 인코딩
def analyze_image_file(image_path: str, question: str) -> str:
image_data = Path(image_path).read_bytes()
base64_image = base64.standard_b64encode(image_data).decode("utf-8")
# 확장자로 media type 결정
ext = Path(image_path).suffix.lower()
media_types = {".jpg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp"}
media_type = media_types.get(ext, "image/jpeg")
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": base64_image,
},
},
{"type": "text", "text": question}
],
}
],
)
return message.content[0].text
# 방법 2: URL (공개 이미지)
def analyze_image_url(image_url: str, question: str) -> str:
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "url", "url": image_url},
},
{"type": "text", "text": question}
],
}
],
)
return message.content[0].text
# 사용 예
result = analyze_image_file("screenshot.png", "이 에러 메시지가 무엇을 의미하나요?")
print(result)
이미지는 content 배열에 텍스트 블록과 함께 넣으며, 공식 문서도 이미지를 질문 텍스트보다 앞에 두는 것을 권장합니다. Base64 방식은 로컬 파일이나 비공개 이미지에 쓰고, URL 방식은 API 서버가 직접 이미지를 내려받을 수 있는 공개 URL에만 쓸 수 있습니다. 위 코드는 확장자로 media_type을 추정하는데, 확장자가 .png인데 실제로는 JPEG인 파일처럼 내용과 선언이 다르면 요청이 거부됩니다. 파일 형식이 확실하지 않다면 Pillow 같은 라이브러리로 실제 형식을 확인하는 편이 안전합니다. 이미지도 토큰으로 환산되어 과금되며 해상도가 클수록 토큰이 늘어나므로, 스크린샷 분석처럼 세부가 필요 없는 작업은 전송 전에 긴 변을 1,000~1,500픽셀 정도로 줄이면 비용과 지연이 함께 줄어듭니다. 요청 전체 크기에도 제한이 있어 고해상도 사진 여러 장을 Base64로 넣으면 request_too_large 에러가 날 수 있습니다.
프롬프트 캐싱
반복적으로 사용되는 긴 컨텍스트의 비용을 최대 90% 절감합니다. 원리는 요청의 앞부분(prefix)을 서버에 저장해 두고, 다음 요청의 앞부분이 바이트 단위로 똑같으면 저장된 계산 결과를 재사용하는 것입니다. 캐시에 처음 쓸 때는 기본 입력 가격보다 약 25% 비싸고(5분 TTL 기준), 캐시에서 읽을 때는 약 10% 가격만 냅니다.
import anthropic
client = anthropic.Anthropic()
# 긴 시스템 프롬프트나 문서를 캐싱
LARGE_DOCUMENT = """
[매우 긴 기술 문서 내용 - 5000+ 토큰...]
""" # 실제로는 수천 토큰의 문서
def ask_about_document(question: str) -> str:
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system=[
{
"type": "text",
"text": "당신은 기술 문서 전문가입니다. 다음 문서를 바탕으로 질문에 답변하세요.",
},
{
"type": "text",
"text": LARGE_DOCUMENT,
"cache_control": {"type": "ephemeral"} # 캐싱 활성화 (5분)
}
],
messages=[{"role": "user", "content": question}]
)
# 캐시 히트 여부 확인
usage = message.usage
print(f"입력 토큰: {usage.input_tokens}")
print(f"캐시 생성: {getattr(usage, 'cache_creation_input_tokens', 0)}")
print(f"캐시 읽기: {getattr(usage, 'cache_read_input_tokens', 0)}")
return message.content[0].text
# 첫 번째 호출: 캐시 생성
print(ask_about_document("이 문서의 주요 내용을 요약해줘"))
# 두 번째 호출: 캐시 히트 (비용 90% 절감)
print(ask_about_document("이 문서에서 성능 최적화 방법은?"))
cache_control을 붙인 블록까지가 캐시되는 범위입니다. 요청은 tools → system → messages 순서로 이어 붙여지므로, 매번 바뀌는 질문은 캐시 표시 뒤(messages)에 두고 변하지 않는 문서는 앞(system)에 두는 구조가 핵심입니다. 위 예제가 그 형태입니다.
캐싱을 처음 적용할 때 가장 흔한 문제는 “설정했는데 cache_read_input_tokens가 계속 0”인 경우입니다. 제가 겪은 원인은 대부분 다음 셋 중 하나였습니다. 첫째, 캐시할 부분이 모델별 최소 길이(Sonnet 4.5는 1024토큰, Opus 4.5·Haiku 4.5는 4096토큰)보다 짧은 경우로, 에러 없이 조용히 캐시되지 않습니다. 둘째, 시스템 프롬프트 앞부분에 현재 시각이나 요청 ID처럼 매번 바뀌는 값을 넣은 경우로, 앞부분이 한 글자만 달라도 뒤는 전부 캐시 미스가 됩니다. 셋째, 도구 목록의 순서가 요청마다 달라지는 경우입니다. 딕셔너리에서 도구 목록을 만들면서 정렬하지 않으면 이런 일이 생깁니다. 또 캐시는 마지막 사용 후 기본 5분이 지나면 만료되므로, 요청 간격이 긴 배치 작업이라면 "ttl": "1h"로 1시간 캐시(쓰기 비용은 기본 입력의 약 2배)를 쓰는 것을 검토할 만합니다. getattr로 필드를 읽는 코드는 구버전 SDK와의 호환을 위한 것이고, 현재 SDK에서는 usage.cache_read_input_tokens로 바로 읽을 수 있습니다.
실전 패턴: 오류 처리 & 재시도
import anthropic
import time
from typing import Optional
client = anthropic.Anthropic()
def create_message_with_retry(
messages: list,
model: str = "claude-sonnet-4-5",
max_tokens: int = 1024,
system: Optional[str] = None,
max_retries: int = 3,
base_delay: float = 1.0,
) -> str:
"""재시도 로직이 포함된 안정적인 API 호출"""
for attempt in range(max_retries):
try:
kwargs = {
"model": model,
"max_tokens": max_tokens,
"messages": messages,
}
if system:
kwargs["system"] = system
response = client.messages.create(**kwargs)
return response.content[0].text
except anthropic.RateLimitError as e:
# 요청 한도 초과 - 지수 백오프
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
print(f"요청 한도 초과. {delay}초 후 재시도 ({attempt + 1}/{max_retries})")
time.sleep(delay)
else:
raise
except anthropic.APIStatusError as e:
if e.status_code == 529: # 과부하
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
print(f"서버 과부하. {delay}초 후 재시도")
time.sleep(delay)
else:
raise
else:
raise # 다른 오류는 즉시 전파
except anthropic.APIConnectionError:
if attempt < max_retries - 1:
time.sleep(base_delay)
else:
raise
# 사용
result = create_message_with_retry(
messages=[{"role": "user", "content": "안녕하세요!"}],
system="You are a helpful assistant.",
)
print(result)
한 가지 알아 둘 점은 공식 SDK가 이미 자동 재시도를 한다는 것입니다. Python·TypeScript SDK는 기본적으로 연결 오류, 408, 409, 429, 5xx(529 과부하 포함) 응답을 짧은 지수 백오프로 2번까지 다시 시도합니다(max_retries 옵션으로 조정). 그래서 위 함수를 그대로 쓰면 SDK 재시도 2번 × 직접 재시도 3번이 겹쳐 최악의 경우 한 요청이 아홉 번 가까이 전송되고, 사용자가 기다리는 시간도 그만큼 길어집니다. 직접 재시도 로직을 두려면 anthropic.Anthropic(max_retries=0)으로 SDK 재시도를 끄거나, 반대로 직접 재시도를 없애고 max_retries만 늘리는 쪽 중 하나를 고르는 것이 맞습니다. 429 응답에는 retry-after 헤더가 붙어 있어 SDK는 이 값을 존중해 기다립니다. 요청 한도는 분당 요청 수뿐 아니라 분당 입력·출력 토큰 수로도 걸리므로, 긴 문서를 병렬로 많이 보내는 배치 작업은 동시 요청 수를 제한하거나 50% 할인되는 Message Batches API를 쓰는 편이 낫습니다.
ChatGPT API vs Claude API 비교
| 항목 | Claude API | ChatGPT API |
|---|---|---|
| 시스템 프롬프트 | 별도 system 파라미터 | messages 안의 system/developer 역할 |
| 응답 형태 | content 블록 배열 (text, tool_use 등) | choices[].message |
| 프롬프트 캐싱 | cache_control로 명시 지정 | 긴 프롬프트에 자동 적용 |
| 도구 호출 | tools + tool_use/tool_result 블록 | tools + tool_calls |
| 구조화 출력 | output_config.format(JSON 스키마), strict 도구 | Structured Outputs(JSON 스키마) |
| Vision | ✅ | ✅ |
| SDK | Python, TypeScript, Java, Go 등 | Python, TypeScript 등 |
두 API는 개념이 거의 같아서 한쪽 코드를 다른 쪽으로 옮기는 데 큰 어려움은 없습니다. 옮길 때 실제로 고쳐야 하는 부분은 시스템 프롬프트 위치, 응답에서 텍스트를 꺼내는 경로, 도구 호출 결과를 돌려주는 메시지 형식 세 가지입니다. 성능과 가격은 모델 세대가 바뀔 때마다 순위가 달라지므로 별점식 비교보다는 자신의 실제 작업 몇 개로 두 API를 비교해 보는 것이 가장 정확합니다.
연동 초기에 자주 나는 버그 네 가지
Claude API의 모든 기능은 Messages API 하나에 파라미터를 더하는 방식이라, 기본 호출 구조만 이해하면 스트리밍·도구·이미지·캐싱으로 자연스럽게 확장됩니다. 실무에서 문제가 되는 곳은 대부분 정해져 있습니다. stop_reason을 확인하지 않는 것, content[0].text를 가정하는 것, Tool Use 루프의 종료 조건을 빠뜨리는 것, 캐시 앞부분에 바뀌는 값을 넣는 것입니다. 이 네 가지만 챙겨도 연동 초기의 버그 대부분을 피할 수 있습니다.
다음 단계:
- LangChain 실전 가이드 | LLM 애플리케이션 개발 프레임워크
- RAG로 사내 데이터를 LLM에 연결하기: 임베딩과 벡터 DB, 청크 전략, Python 구현
- ChatGPT API 실전 가이드 | OpenAI API로 AI 애플리케이션 만들기
자주 묻는 질문 (FAQ)
Q. Claude API 호출이 429나 529 에러로 실패하면 어떻게 처리해야 하나요?
A. 429는 요청 한도 초과(RateLimitError), 529는 서버 과부하를 뜻하므로 둘 다 잠시 기다렸다가 다시 시도하면 성공할 가능성이 있는 오류입니다. 재시도 간격을 1초, 2초, 4초처럼 지수적으로 늘리는 백오프를 적용하고 최대 횟수를 정해 두어야 요청이 몰릴 때 부하를 더 키우지 않습니다. 인증 실패나 잘못된 요청 같은 다른 상태 코드는 재시도해도 결과가 바뀌지 않으므로 바로 오류로 전파하는 것이 맞습니다.
같이 보면 좋은 글
- ChatGPT API 실전 가이드 | OpenAI API로 AI 애플리케이션 만들기
- Cursor AI 에디터 실전: Tab 자동완성, Chat, Composer와 .cursorrules 설정
- RAG로 사내 데이터를 LLM에 연결하기