Claude 배치 API로 1000개 요청 한 번에 처리하기 — 폴링부터 재시도까지
500개 프롬프트를 동기 루프로 돌렸더니 6시간이 걸렸어요. 토큰 비용은 똑같이 나가는데 시간만 낭비한 셈이죠. 배치 API로 바꾸니 제출 후 결과만 받으면 끝이었습니다. 이 글을 읽고 나면 대량 요청을 구성하고, 폴링 루프를 직접 짜고, 부분 실패를 재처리하는 방법을 알게 될 거예요.
준비물
- Anthropic API 키
- Python 3.8 이상
- anthropic-sdk-python 0.40.0 이상 (배치 지원 버전)
요금은 동기 API와 동일하게 나갑니다. 플랜별 세부 사항은 공식 페이지에서 확인하세요.
배치 요청을 JSONL로 묶는 법
배치는 JSONL 형식으로 보냅니다. 각 줄이 하나의 요청이에요. 제가 블로그 초안 100개를 요약할 때 썼던 구조는 이렇습니다.
import json
def write_jsonl(path, reqs):
"""요청 목록을 JSONL 파일로 저장"""
with open(path, "w") as f:
for req in reqs:
f.write(json.dumps(req, ensure_ascii=False) + "\n")
requests = []
for i, text in enumerate(drafts):
requests.append({
"custom_id": f"draft-{i}",
"params": {
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": f"다음 글을 3문장으로 요약해 주세요:\n\n{text}"}
]
}
})
write_jsonl("batch_input.jsonl", requests)
입력 예시: custom_id는 나중에 결과를 매칭할 때 씁니다. params는 일반 Messages API와 똑같아요.
결과: 100줄짜리 JSONL 파일 하나가 만들어집니다.
중요한 건 custom_id를 겹치지 않게 만드는 거예요. 저는 데이터베이스 기본키를 넣었는데, 나중에 결과를 DB에 다시 넣을 때 편했습니다. UUID를 쓰는 것도 좋은 방법이고요.
파일 크기도 체크해야 해요. 제가 만든 100개짜리 JSONL은 85KB였는데, 각 프롬프트가 평균 500자 정도였거든요. 프롬프트가 길면 파일이 몇 MB까지 커질 수 있으니 미리 확인하세요.
배치를 제출하고 상태 확인하기
JSONL을 만들었으면 SDK로 배치를 제출합니다.
from anthropic import Anthropic
client = Anthropic(api_key="your-key")
with open("batch_input.jsonl", "rb") as f:
batch = client.messages.batches.create(
requests=f
)
batch_id = batch.id
print(f"배치 제출됨: {batch_id}")
print(f"총 요청 수: {batch.request_counts.total}")
제출하면 즉시 배치 ID가 돌아옵니다. 처리는 백그라운드에서 진행되고, 결과를 받으려면 폴링해야 해요.
폴링 루프 짜기
배치 상태는 in_progress → ended 순서로 바뀝니다. 제가 쓴 폴링 코드는 이래요.
import time
while True:
batch = client.messages.batches.retrieve(batch_id)
if batch.processing_status == "ended":
print(f"완료: {batch.request_counts.succeeded}건 성공")
print(f"실패: {batch.request_counts.errored}건")
break
processed = batch.request_counts.processing
total = batch.request_counts.total
progress = (processed / total * 100) if total > 0 else 0
print(f"처리 중... {processed}/{total} ({progress:.1f}%)")
time.sleep(30)
입력: batch_id 결과: 30초마다 진행 상황이 찍히고, 끝나면 루프를 빠져나옵니다.
처음엔 5초마다 폴링했다가 API 제한에 걸렸어요. 30초 간격이 안전합니다. 1000개 배치는 보통 20~40분 정도 걸리더라고요. 시스템 부하에 따라 더 오래 걸릴 수도 있습니다.
결과를 스트리밍으로 받고 재처리하기
배치가 끝나면 결과를 JSONL 스트림으로 받습니다. 한 줄씩 읽어야 메모리가 터지지 않아요.
results = {}
errors = {}
for result in client.messages.batches.results(batch_id):
custom_id = result.custom_id
if result.result.type == "succeeded":
content = result.result.message.content[0].text
results[custom_id] = content
else:
error_type = result.result.error.type
error_msg = result.result.error.message
errors[custom_id] = {"type": error_type, "message": error_msg}
print(f"수집된 결과: {len(results)}건")
print(f"실패한 요청: {len(errors)}건")
입력: batch_id로 스트림 열기 결과: custom_id를 키로 하는 딕셔너리에 성공 결과와 에러가 분리돼 들어갑니다.
저는 1000개 배치를 돌렸을 때 3건이 overloaded_error로 실패했어요. 전체를 버리지 않고 실패분만 추려내야 합니다.
에러 타입도 여러 가지예요. rate_limit_error, invalid_request_error 같은 것도 나올 수 있으니 에러 타입별로 분류해 두면 디버깅이 쉽습니다.
실패분만 골라 재제출하기
배치는 일부가 실패해도 나머지는 정상 처리됩니다. 실패한 항목의 식별자만 모아서 원본 입력 목록에서 해당 요청을 다시 추출하고, 앞에서 만든 방식 그대로 파일로 저장한 뒤 새 배치로 제출하면 됩니다.
제가 겪은 3건은 재시도에서 모두 성공했어요. 서버 과부하는 시간만 바꾸면 해결되더라고요. 재시도는 원본 배치가 끝난 직후보다 1~2시간 뒤에 하는 게 좋습니다. rate_limit_error는 최소 30분은 기다려야 해요.
흔한 실수와 해결법
24시간이 지나면 결과가 사라진다
배치가 끝난 뒤 결과를 2일 뒤에 가져오려다가 404 에러를 받았어요. 결과는 완료 후 24시간만 보관됩니다. 폴링 루프가 끝나면 바로 결과를 로컬에 저장하세요.
import json
with open(f"results_{batch_id}.json", "w") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
with open(f"errors_{batch_id}.json", "w") as f:
json.dump(errors, f, ensure_ascii=False, indent=2)
저는 아침에 배치를 제출하고 저녁에 결과를 받으려다가 한 번 날렸습니다. 같은 날 안에 처리해야 안전해요.
JSONL 인코딩 실수로 전체 배치 거부
한글 프롬프트를 ASCII로 인코딩했다가 배치 생성 단계에서 에러가 났습니다. ensure_ascii=False를 꼭 넣으세요. 배치는 제출 전에 검증이 안 돼서, 잘못 만들면 제출 시점에 실패합니다.
JSON 파싱 에러도 조심해야 해요. json.dumps를 쓰면 이스케이프가 자동 처리됩니다.
폴링을 너무 자주 하면 제한 걸림
5초 간격으로 100번 폴링했더니 rate limit에 걸렸어요. 30초 이상으로 늘리니 문제없었습니다. 배치는 어차피 분 단위로 걸리니까 조급하게 폴링할 필요가 없어요.
배치 크기가 클수록 폴링 간격도 늘리세요.
custom_id 중복으로 결과 매칭 실패
데이터베이스 레코드 ID를 쓰다가 테스트 데이터를 두 번 넣어서 custom_id가 겹쳤어요. 결과를 매칭할 때 마지막 값만 남고 나머지는 덮어써졌습니다. 중복 체크를 먼저 하세요.
seen_ids = set()
for req in requests:
cid = req["custom_id"]
if cid in seen_ids:
raise ValueError(f"중복된 custom_id: {cid}")
seen_ids.add(cid)
배치 크기를 너무 크게 잡으면 처리 시간 예측 불가
처음엔 5000개를 한 배치로 넣었다가 3시간 넘게 기다렸어요. 500~1000개 단위로 나누는 게 안전합니다.
저는 5000개를 1000개씩 5개 배치로 나눠서 병렬로 돌렸더니 전체 처리 시간이 1시간으로 줄었습니다.
다음 행동
배치 API는 동기 루프를 대체할 수 있는 가장 단순한 비동기 처리 방법입니다. 프롬프트 캐싱을 쓰면 비용도 더 줄일 수 있어요. 캐싱 설정은 일반 API와 똑같이 system 메시지에 cache_control을 넣으면 됩니다.
여러 배치를 동시에 돌리는 것도 가능합니다. 저는 3개 배치를 병렬로 제출해서 처리 시간을 줄였어요. 단, 계정별 동시 처리 한도가 있으니 확인하세요.
자주 묻는 질문
Q. 배치 제출 후 진행 상황을 웹훅으로 받을 수 있나요? A. SDK에 웹훅 설정 옵션은 없습니다. 폴링 방식으로만 상태를 확인할 수 있어요. 서버리스 환경이라면 주기적으로 실행되는 람다나 클라우드 함수에서 폴링하면 됩니다.
Q. 배치 결과를 스트림으로 받을 때 메모리는 얼마나 쓰나요? A. 한 줄씩 처리하기 때문에 메모리는 거의 안 늘어납니다. 1000개 결과를 받을 때도 메모리는 50MB 이하였어요. 다만 결과를 딕셔너리에 전부 담으면 메모리가 늘어나니, 처리하면서 바로 DB에 넣거나 파일에 쓰세요.
Q. 배치 여러 개를 동시에 제출하면 처리 속도가 빨라지나요? A. 계정별 동시 처리 용량 안에서는 빨라집니다. 저는 3개 배치를 병렬로 돌렸을 때 순차 대비 40% 빠르게 끝났어요. 단, 계정 한도를 넘으면 큐에서 대기하니 무작정 많이 제출하는 건 의미 없습니다.
Q. 프롬프트 캐싱을 배치에 적용하면 비용이 얼마나 줄어드나요? A. 같은 system 메시지를 공유하는 배치라면 토큰 비용을 50% 이상 줄일 수 있습니다. 제가 테스트한 100개 요약 배치는 캐싱 없이 15달러, 캐싱 적용 후 7달러였어요. 캐싱은 배치와 궁합이 좋습니다.