목차
- 45분짜리 401 에러
- 첫 번째 Claude API 호출
- 모델 선택 — 비용이 5배 차이 난다
- system 프롬프트와 메시지 구조
- 스트리밍 — 체감 속도가 달라지는 이유
- 대화 히스토리를 유지하는 챗봇
- Claude API 에러 핸들링과 재시도
- 비동기 호출 — FastAPI 연동
45분짜리 401 에러
Claude API 키 하나 넣는 데 45분이 걸렸다.
화요일 저녁, 팀 슬랙에 "고객 문의 자동 분류해주는 봇 하나 만들 수 있어?"라는 메시지가 올라왔다. 프론트엔드에서 백엔드로 전환한 지 2년, Python은 어느 정도 손에 붙었지만 LLM API는 처음이었다. Claude API 문서가 잘 되어 있다는 말을 들어서 금방 될 줄 알았다.
pip install anthropic — 설치는 10초면 끝난다. 문제는 그다음이었다.
Anthropic 콘솔에서 API 키를 발급받고 .env 파일에 넣었다. React 프로젝트 하던 습관 그대로 따옴표로 감쌌다.
ANTHROPIC_API_KEY="sk-ant-api03-xxxxxxxxxxxx"
실행하니 이런 에러가 떴다.
anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}
키를 세 번 재발급받았다. 콘솔에서 복사해서 직접 넣어도 같은 에러. 워크스페이스 권한 설정까지 뒤졌다. 45분이 지나서야 원인을 찾았는데, python-dotenv는 .env 파일의 따옴표를 값의 일부로 읽는다. Next.js나 CRA에서는 빌드 타임에 따옴표를 알아서 벗겨주지만 Python 쪽은 그렇지 않다.
ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxx
따옴표 두 개를 지우니까 바로 동작했다. 프론트엔드 습관이 백엔드에서 발목을 잡은 첫 번째 사례였다.
첫 번째 Claude API 호출
Anthropic이 공식 제공하는 anthropic 패키지를 설치하면 된다. 2026년 4월 기준 PyPI의 최신 안정 버전은 0.49.x대다 (출처: PyPI — anthropic).
# 가상환경 안에서 설치
pip install anthropic
의존성으로 httpx, pydantic, tokenizers 등이 같이 들어온다. 설치가 끝나면 가장 단순한 호출부터 해보자.
import anthropic
# 환경변수 ANTHROPIC_API_KEY에서 자동으로 읽는다
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-sonnet-4-20250514", # Claude Sonnet 4
max_tokens=1024,
messages=[
{"role": "user", "content": "Python의 GIL이 뭔지 한 문장으로 설명해줘"}
]
)
print(message.content[0].text)
Anthropic() 생성자에 api_key 파라미터를 직접 넘길 수도 있지만, 환경변수를 쓰는 게 코드에 키가 노출되지 않아서 낫다. 위 코드를 실행하면 이런 구조의 응답 객체가 온다.
Message(
id='msg_01XYZ...',
type='message',
role='assistant',
content=[ContentBlock(type='text', text='GIL은...')],
model='claude-sonnet-4-20250514',
stop_reason='end_turn',
usage=Usage(input_tokens=24, output_tokens=38)
)
텍스트를 꺼내려면 message.content[0].text다. content가 리스트인 이유는 이미지나 tool use 같은 여러 블록이 올 수 있기 때문이다. 텍스트만 다루는 상황에서는 [0].text로 충분하다. usage 필드에 입력·출력 토큰 수가 찍히니까 비용 계산할 때 유용하다.
모델 선택 — 비용이 5배 차이 난다
처음에는 "제일 좋은 모델 쓰면 되는 거 아냐?"라고 생각했다. 틀린 말은 아닌데, 비용 차이가 생각보다 크다.
| 모델 | 모델 ID | 입력 ($/MTok) | 출력 ($/MTok) |
|---|---|---|---|
| Opus 4 | claude-opus-4-20250514 |
$15 | $75 |
| Sonnet 4 | claude-sonnet-4-20250514 |
$3 | $15 |
| Haiku 3.5 | claude-3-5-haiku-20241022 |
$0.80 | $4 |
(2026년 4월 기준, 출처: Anthropic 모델 문서)
고객 문의 분류 봇을 예로 들어보면, 하루 1,000건 처리에 건당 평균 500토큰 입력 + 200토큰 출력이라고 가정했을 때:
- Opus 4: 하루 약 $22.50
- Sonnet 4: 하루 약 $4.50
- Haiku 3.5: 하루 약 $1.20
한 달이면 Opus와 Haiku 사이에 18배 차이가 벌어진다. Haiku가 분류 작업에서 체감상 Sonnet과 큰 차이가 없었던 게 의외였다. "배송 관련 문의입니다" 수준의 판단은 Haiku도 잘 해낸다. 결국 분류에는 Haiku, 고객 응대에는 Sonnet, 복잡한 분석이 필요할 때만 Opus를 붙이는 구조로 갔다.
# 작업별 모델 분리
MODELS = {
"classify": "claude-3-5-haiku-20241022", # 분류 — 빠르고 저렴
"respond": "claude-sonnet-4-20250514", # 응대 — 균형
"analyze": "claude-opus-4-20250514", # 분석 — 정밀
}
def call_claude(task_type, prompt):
return client.messages.create(
model=MODELS[task_type],
max_tokens=1024,
messages=[{"role": "user", "content": prompt}]
)
모델을 하나로 통일하는 것보다 이렇게 나누는 게 현실적이다.
system 프롬프트와 메시지 구조
OpenAI API에서 넘어온 사람이라면 하나 주의할 게 있다. Claude API에서 system 프롬프트는 messages 배열 밖에 별도 파라미터로 분리되어 있다.
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system="너는 Python 코드 리뷰어야. 문제점을 짧고 명확하게 지적해.",
messages=[
{"role": "user", "content": "이 코드 리뷰해줘:\ndef add(a, b): return a+b"}
]
)
{"role": "system", ...} 형태로 messages 배열에 넣으면 바로 에러가 난다.
anthropic.BadRequestError: messages: roles must alternate between "user" and "assistant"
messages 배열에는 user와 assistant만 들어갈 수 있고, 반드시 번갈아가며 나와야 한다. user → assistant → user → assistant 순서. 이걸 어기면 위 에러다.
system 프롬프트 작성 팁
"친절하게 답해줘" 같은 모호한 지시는 효과가 거의 없다. 역할, 제약조건, 출력 형식을 구체적으로 지정하는 게 응답 품질에 직접적으로 영향을 준다.
system_prompt = """너는 고객 문의 분류 봇이다.
아래 카테고리 중 하나로 분류하고 JSON으로 응답해라.
카테고리: 배송, 환불, 상품문의, 기술지원, 기타
출력 형식:
{"category": "카테고리명", "confidence": 0.0~1.0, "reason": "분류 근거"}
규칙:
- 카테고리를 정확히 하나만 선택해라
- confidence가 0.7 미만이면 "기타"로 분류해라
- reason은 한 문장으로 써라
"""
출력 형식까지 지정해두면 후처리 파싱이 편해진다. JSON 출력을 안정적으로 강제하고 싶으면 "반드시 JSON만 출력해라. 다른 텍스트를 포함하지 마라" 같은 제약을 추가하면 된다. 그래도 가끔 “`json 마크다운 블록으로 감싸서 주는 경우가 있어서, 파서 쪽에서 코드 블록을 벗기는 로직을 넣어두는 게 안전하다.
스트리밍 — 체감 속도가 달라지는 이유
Sonnet 4 기준, 일반 호출로 긴 응답을 요청하면 체감상 5~10초는 기본이다. 사용자 입장에서 빈 화면을 그 시간 동안 보고 있는 건 답답하다. 스트리밍을 쓰면 토큰이 생성되는 대로 바로 출력되니까 첫 글자가 나타나기까지 보통 0.5초 이내로 줄어든다.
# 스트리밍 — 토큰 생성 즉시 출력
with client.messages.stream(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": "FastAPI와 Flask의 차이를 설명해줘"}]
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
여기서 한 번 헤맨 적이 있다. 처음에 client.messages.create(stream=True)로 호출하고 반환된 이벤트를 순회하면서 모든 이벤트에 .text 속성이 있을 줄 알고 접근했더니 이런 에러를 만났다.
AttributeError: 'MessageStartEvent' object has no attribute 'text'
스트리밍 이벤트에는 message_start, content_block_start, content_block_delta, message_stop 등 여러 타입이 섞여 있다. 텍스트 데이터는 content_block_delta에만 들어있다. client.messages.stream() 메서드의 .text_stream 이터레이터가 이 필터링을 알아서 해주니까, 특별한 이유가 없으면 이쪽을 쓰는 게 낫다.
stream=True를 직접 써야 하는 경우도 있긴 하다. message_start 이벤트에서 입력 토큰 수를 즉시 확인하고 싶다거나, content_block_start에서 tool use 블록의 시작을 감지해야 할 때다. 단순 텍스트 출력이 목적이면 .text_stream으로 충분하다.
대화 히스토리를 유지하는 챗봇
API 호출 한 번은 쉽다. 진짜 문제는 여러 턴에 걸쳐 대화 맥락을 유지하는 것이다. Claude API는 상태를 서버에 저장하지 않는다. 매 요청마다 이전 대화 전체를 함께 보내야 한다.
import anthropic
client = anthropic.Anthropic()
def chat():
conversation = [] # 대화 히스토리
system = "너는 Python 튜터야. 초보자 눈높이에 맞춰 설명해."
print("챗봇 시작 (종료: quit)")
while True:
user_input = input("\n사용자: ")
if user_input.strip().lower() == "quit":
break
# 사용자 메시지 추가
conversation.append({"role": "user", "content": user_input})
# 전체 히스토리를 매번 전송
with client.messages.stream(
model="claude-sonnet-4-20250514",
max_tokens=2048,
system=system,
messages=conversation
) as stream:
print("\nClaude: ", end="")
full_response = ""
for text in stream.text_stream:
print(text, end="", flush=True)
full_response += text
# 응답도 히스토리에 추가
conversation.append({"role": "assistant", "content": full_response})
print()
if __name__ == "__main__":
chat()
이 코드를 실행하면 터미널에서 Claude와 대화할 수 있다. conversation 리스트에 매 턴의 입력과 응답이 쌓이고, API가 이 전체 리스트를 보고 맥락을 파악한다. "아까 얘기한 그거"라고 해도 문맥을 이해하는 이유다.
히스토리가 길어지면 생기는 문제
대화가 길어질수록 토큰이 누적된다. Sonnet 4의 컨텍스트 윈도우가 200K 토큰이라 금방 차지는 않지만, 10턴째 요청에는 앞선 9턴의 전체 내용이 입력 토큰으로 과금된다. 20턴쯤 되면 한 번의 요청에 입력 토큰만 수천 개가 된다.
가장 단순한 해결법은 오래된 메시지를 잘라내는 것이다.
MAX_MESSAGES = 20 # 최근 20개 메시지만 유지
def trim_conversation(messages, limit=MAX_MESSAGES):
"""오래된 메시지를 제거하되, user로 시작하도록 보정"""
if len(messages) <= limit:
return messages
trimmed = messages[-limit:]
# assistant로 시작하면 하나 더 제거
if trimmed[0]["role"] != "user":
trimmed = trimmed[1:]
return trimmed
오래된 대화를 요약해서 system 프롬프트에 넣는 방법도 있다고 들었는데, 직접 해보지는 않아서 효과가 어느 정도인지는 모르겠다. 단순한 용도에서는 최근 N개만 유지하는 것으로 충분했다.
Claude API 에러 핸들링과 재시도
프로덕션에 올리기 전에 반드시 챙겨야 할 게 에러 처리다. 자주 만나는 에러는 네 가지다.
429 rate_limit_error — 분당 요청 수나 토큰 수 제한에 걸렸을 때 발생한다. 유료 플랜 기준 분당 요청 제한은 티어에 따라 다르고, 무료 티어는 분당 5 요청이라 테스트할 때 자주 마주친다.
529 overloaded_error — 서버 과부하 상태. 잠시 후 재시도하면 대부분 풀린다.
400 invalid_request_error — 메시지 형식 오류나 max_tokens 초과. 재시도해봤자 같은 에러니까 요청 자체를 고쳐야 한다.
401 authentication_error — 위에서 45분 동안 만났던 그 에러다.
anthropic 패키지는 429와 529에 대해 자동 재시도를 기본 지원한다 (출처: anthropic-sdk-python GitHub).
# 자동 재시도 설정 — 기본값은 max_retries=2
client = anthropic.Anthropic(
max_retries=3,
timeout=30.0 # 타임아웃 30초
)
자동 재시도로 부족한 경우, 지수 백오프를 직접 구현할 수도 있다.
import anthropic
import time
def call_with_retry(client, max_retries=3, **kwargs):
for attempt in range(max_retries):
try:
return client.messages.create(**kwargs)
except anthropic.RateLimitError:
if attempt < max_retries - 1:
wait = 2 ** attempt # 1초, 2초, 4초
print(f"Rate limit 도달. {wait}초 대기 후 재시도")
time.sleep(wait)
else:
raise
except anthropic.APIStatusError as e:
if e.status_code == 529 and attempt < max_retries - 1:
time.sleep(5) # 서버 과부하는 5초 대기
else:
raise
다만 max_retries를 너무 크게 잡으면 429가 반복되면서 응답 지연이 심해질 수 있다. 3이면 적당하다. 429가 지속적으로 뜬다면 재시도 횟수를 늘리는 것보다 요청 간격을 조절하거나 Anthropic 콘솔에서 사용량 티어를 올리는 게 맞다.
비동기 호출 — FastAPI 연동
anthropic.AsyncAnthropic을 쓰면 된다.
import anthropic
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
client = anthropic.AsyncAnthropic() # 비동기 클라이언트
@app.post("/chat")
async def chat_endpoint(user_message: str):
async def generate():
async with client.messages.stream(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": user_message}]
) as stream:
async for text in stream.text_stream:
yield text
return StreamingResponse(generate(), media_type="text/plain")
동기 클라이언트를 async 핸들러 안에서 쓰면 이벤트 루프가 블로킹되니까 반드시 AsyncAnthropic을 써야 한다. 인터페이스는 동기 버전과 거의 동일해서, 기존 동기 코드를 비동기로 전환하는 건 Claude API 클라이언트 클래스명만 바꾸면 되기 때문에 체감상 10분이면 끝난다.
관련 글
- Python으로 AI 에이전트 자동화 구축하기 — 작업 분해와 오류 복구 실전 기록 – 주간 리포트를 자동화하려고 Python으로 AI 에이전트 파이프라인을 처음 만들었다. 한 덩어리 스크립트에서 시작해 작업 분해 → 툴 연동…
- GitHub Copilot vs Cursor vs Cline — AI 코딩 어시스턴트 비교 실전 기록 – GitHub Copilot, Cursor, Cline에 동일 프롬프트를 던지면 결과가 전부 다르다. 5인 백엔드 팀에서 3주간 FastAP…
- 로컬 LLM 보안 운영 — Ollama로 사내 데이터 유출 없이 AI 쓰는 법 – 사내 외부 LLM API가 차단된 뒤 Ollama로 로컬 LLM 보안 운영 환경을 구축했다. 기본 설정의 보안 구멍부터 네트워크 격리, 접…