모델을 쓰는 코드도 결국 서비스의 일부입니다. 이 수업에서는 REST와 JSON, 비동기 처리, 버전 관리와 리팩터링 같은 기초가 Claude 연동에서 어떤 모습으로 나타나는지를 다룹니다.
REST 호출로서의 모델 요청
SDK를 쓰면 함수 호출처럼 보이지만 그 아래는 HTTP 요청입니다. 이 사실이 중요한 이유는 일반 HTTP에서 하던 대비를 그대로 해야 하기 때문입니다.
- 네트워크 오류와 타임아웃이 납니다.
- 상태 코드로 재시도할지 말지 갈립니다.
- 요청 본문이 크면 그만큼 전송 시간이 듭니다.
특히 갈라 봐야 할 것은 재시도해도 되는 실패와 그렇지 않은 실패입니다. 요청 한도를 넘어 거절된 경우나 일시적인 서버 오류는 잠시 뒤 다시 보내면 됩니다. 반대로 요청 형식이 잘못되었거나 인증이 실패한 경우는 같은 요청을 백 번 보내도 같은 결과입니다.
import time, random
RETRYABLE = {429, 500, 502, 503, 529}
def call_with_retry(fn, attempts=4):
for i in range(attempts):
try:
return fn()
except APIStatusError as e:
if e.status_code not in RETRYABLE or i == attempts - 1:
raise # 형식·인증 오류는 즉시 포기
# 지수 백오프 + 지터: 동시 재시도가 겹치지 않게
time.sleep((2 ** i) + random.random())
지터가 없으면 여러 요청이 동시에 실패했을 때 같은 시각에 다시 몰려 같은 실패를 반복합니다.
비동기와 동시성
모델 호출은 대기 시간이 깁니다. 여러 건을 순차로 처리하면 대부분의 시간을 기다리며 보냅니다. 서로 의존하지 않는 요청은 동시에 보내는 것이 맞습니다.
import asyncio
async def summarize_all(docs: list[str], limit: int = 5) -> list[str]:
sem = asyncio.Semaphore(limit) # 동시 실행 수 상한
async def one(doc: str) -> str:
async with sem:
resp = await aclient.messages.create(
model=MODEL, max_tokens=512,
messages=[{"role": "user", "content": doc}],
)
return text_of(resp)
return await asyncio.gather(*(one(d) for d in docs))
상한이 반드시 있어야 합니다. 문서 500건을 한꺼번에 보내면 요청 한도에 걸려 대부분 실패하고, 실패한 것을 재시도하느라 더 몰립니다. 세마포어로 동시 실행 수를 묶는 것이 기본입니다.
동시 처리에서 또 하나 챙길 것은 부분 실패입니다. 500건 중 3건이 실패했을 때 전체를 버릴 것인지, 성공분을 살리고 실패분만 다시 돌릴 것인지 정해야 합니다. 대개 후자가 맞고, 그러려면 어떤 입력이 실패했는지 추적할 수 있어야 합니다.
프롬프트도 버전 관리 대상
프롬프트는 코드가 아닌 것처럼 취급되기 쉽지만, 동작을 결정한다는 점에서 코드와 같습니다. 프롬프트만 바꿔도 결과가 달라지므로 변경 이력이 남아야 합니다.
실무에서 쓰는 방식은 대체로 이렇습니다.
- 프롬프트를 소스 저장소 안 파일로 둡니다. 데이터베이스나 관리 화면에만 두면 어떤 배포에 어떤 프롬프트가 나갔는지 추적하기 어렵습니다.
- 변경할 때 무엇을 왜 바꿨는지 커밋 메시지에 남깁니다. "정확도 개선"이 아니라 "출처 표기 누락 사례 12건 대응"처럼 적습니다.
- 평가 세트 점수를 함께 기록합니다. 나중에 되돌릴 근거가 됩니다.
코드 리뷰 대상에도 포함해야 합니다. 프롬프트 한 줄 변경이 출력 형식을 바꿔 소비자 쪽을 깨뜨리는 일이 실제로 생깁니다. 리뷰에서 볼 것은 문장의 매끄러움이 아니라 출력 계약이 바뀌는가입니다.
리팩터링과 호환
모델을 쓰는 코드에서 가장 자주 깨지는 지점은 출력 형식의 계약입니다. 프롬프트를 고쳐 필드 이름을 바꾸면 그 출력을 읽는 쪽이 전부 깨집니다.
그래서 형식을 바꿀 때는 순서가 있습니다.
- 소비자 쪽을 양쪽 다 받을 수 있게 먼저 고칩니다.
- 배포합니다.
- 그다음 생산자(프롬프트·스키마) 쪽을 바꿉니다.
- 옛 형식이 더 이상 오지 않는 것을 확인한 뒤 소비자에서 옛 경로를 지웁니다.
def read_summary(payload: dict) -> str:
# 전환 기간에는 옛 이름과 새 이름을 모두 받는다
return payload.get("summary") or payload.get("summary_text") or ""
반대 순서로 하면 배포 사이에 깨진 구간이 생깁니다. 이것은 데이터베이스 스키마 변경에서 쓰는 방식과 같고, 모델 출력에도 그대로 적용됩니다.
큰 리팩터링을 할 때는 평가 세트가 안전망입니다. 구조를 바꾸면서 프롬프트를 건드리지 않았는지, 건드렸다면 점수가 유지되는지 확인할 수 있습니다.
시험 함정
- 모든 실패를 같은 방식으로 재시도하는 구현 — 형식·인증 오류는 재시도해도 결과가 같습니다.
- 백오프에 지터를 넣지 않는 선택지 — 동시에 실패한 요청이 같은 시각에 다시 몰립니다.
- 동시 요청에 상한을 두지 않는 구현 — 요청 한도에 걸려 대부분이 실패합니다.
- 프롬프트는 코드가 아니므로 버전 관리 대상이 아니라는 서술 — 동작을 결정하므로 이력이 필요합니다.
- 출력 형식을 바꿀 때 생산자를 먼저 바꾸는 순서 — 배포 사이에 깨진 구간이 생깁니다.
- 부분 실패를 전체 실패로 처리하는 설계 — 성공분을 버리게 되고 재처리 비용이 커집니다.
실습 시나리오
실제 시험과 같은 형식의 시나리오 문제입니다.
문서 300건을 요약하는 배치 작업을 asyncio.gather로 한꺼번에 실행했더니 대부분이 요청 한도 초과로 실패했습니다. 가장 적절한 수정은 무엇입니까?
빌드 연습 · 동시성 상한과 재시도를 갖춘 배치 처리기 만들기
약 40분1.순차 기준선
문서 20건을 순차로 처리하고 총 소요 시간을 기록합니다.
기대 결과 · 건당 소요와 총 시간이 기록됩니다.
2.동시 처리
세마포어로 동시 5건 제한을 두고 다시 실행합니다.
기대 결과 · 총 시간이 줄고 실패가 없습니다.
3.재시도 래퍼
재시도 가능한 상태 코드만 골라 지수 백오프와 지터로 재시도합니다.
기대 결과 · 형식 오류는 즉시 실패하고 일시 오류만 재시도됩니다.
4.부분 실패 처리
실패한 입력의 식별자를 모아 따로 반환합니다.
기대 결과 · 성공분이 보존되고 실패 목록만 재처리할 수 있습니다.
5.형식 전환 연습
출력 필드 이름을 바꾸되 소비자가 양쪽을 모두 받도록 먼저 고칩니다.
기대 결과 · 전환 중에도 깨지는 구간이 없습니다.