---------- Prometheus 메트릭 ----------
RETRY_TOTAL = Counter(
"claude_opus_retry_total",
"Claude Opus 4.7 호출 재시도 횟수",
["attempt", "exception_type"],
)
LATENCY_SECONDS = Histogram(
"claude_opus_latency_seconds",
"Claude Opus 4.7 응답 지연 시간(초)",
buckets=(0.5, 1, 2, 3, 5, 8, 13, 21, 34, 55),
)
COST_MILLI_CENTS = Counter(
"claude_opus_cost_milli_cents",
"누적 비용 (밀리센트 단위)",
)
---------- 재시도 정책 ----------
Claude Opus 4.7의 입력 $25/MTok, 출력 $100/MTok 기준
INPUT_PRICE_PER_MTOK = 25.00 # USD
OUTPUT_PRICE_PER_MTOK = 100.00 # USD
@dataclass
class RetryPolicy:
"""재시도 정책을 한 곳에 모은 설정 객체"""
max_attempts: int = 6
min_wait_seconds: float = 1.0
max_wait_seconds: float = 60.0
multiplier: float = 2.0
jitter_max: float = 1.0
timeout_per_call: float = 30.0
@dataclass
class ClaudeOpusClient:
"""tenacity + asyncio 기반 Claude Opus 4.7 비동기 재시도 클라이언트"""
api_key: str
base_url: str = "https://api.holysheep.ai/v1"
model: str = "claude-opus-4-7"
policy: RetryPolicy = field(default_factory=RetryPolicy)
_client: Optional[AsyncOpenAI] = None
def __post_init__(self):
# HolySheep 게이트웨이 OpenAI 호환 엔드포인트
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
timeout=self.policy.timeout_per_call,
max_retries=0, # tenacity가 단독 제어
)
async def aclose(self):
if self._client:
await self._client.aclose()
async def chat(
self,
messages: list[dict[str, str]],
max_tokens: int = 1024,
temperature: float = 0.2,
) -> dict[str, Any]:
"""재시도 로직이 적용된 Claude Opus 4.7 호출"""
# 재시도 대상 예외: 429 / 5xx / 네트워크 오류 / 타임아웃
retriable_exceptions = (
RateLimitError,
APITimeoutError,
APIConnectionError,
APIStatusError, # 5xx 위주로 retry_if로 추가 필터링
)
attempt_counter = {"n": 0}
try:
async for attempt in AsyncRetrying(
stop=stop_after_attempt(self.policy.max_attempts),
wait=wait_exponential(
multiplier=self.policy.multiplier,
min=self.policy.min_wait_seconds,
max=self.policy.max_wait_seconds,
) + wait_random(0, self.policy.jitter_max),
retry=retry_if_exception_type(retriable_exceptions),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
):
with attempt:
attempt_counter["n"] += 1
start = time.perf_counter()
try:
response = await self._client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
)
finally:
LATENCY_SECONDS.observe(time.perf_counter() - start)
# 응답 메타에서 사용량과 비용 계산
usage = response.usage
if usage:
input_tokens = usage.prompt_tokens or 0
output_tokens = usage.completion_tokens or 0
cost_usd = (
input_tokens * INPUT_PRICE_PER_MTOK / 1_000_000
+ output_tokens * OUTPUT_PRICE_PER_MTOK / 1_000_000
)
# 1 USD = 100 cents, 1 cent = 1000 milli cents
COST_MILLI_CENTS.inc(cost_usd * 100_000)
RETRY_TOTAL.labels(
attempt=str(attempt_counter["n"]),
exception_type="none",
).inc()
return {
"content": response.choices[0].message.content,
"usage": {
"input_tokens": usage.prompt_tokens if usage else 0,
"output_tokens": usage.completion_tokens if usage else 0,
},
"attempts": attempt_counter["n"],
"model": response.model,
}
except RetryError as e:
logger.error("모든 재시도 실패: %s", e)
raise
💻 코드 2: 동시성 제한이 있는 워커 풀
단순 재시도만으로는 부족합니다. 4,800개의 동시 요청이 한꺼번에 들어오면 재시도 자체가 다시 레이트 리밋을 유발합니다. asyncio.Semaphore로 동시 호출 수를 제한하고, 큐를 두어 백프레셔(backpressure)를 구현해야 합니다. 다음 코드를 그대로 복사해서 실행하면 됩니다.
# worker_pool.py
import asyncio
import logging
import os
from dataclasses import dataclass
from typing import Any
from dotenv import load_dotenv
from retry_engine import ClaudeOpusClient, RetryPolicy
load_dotenv()
logger = logging.getLogger("claude_worker")
@dataclass
class Job:
job_id: str
user_message: str
system_prompt: str = "당신은 한국어 이커머스 AI 고객 서비스 어시스턴트입니다."
class ClaudeWorkerPool:
"""동시성 제한 + 백프레셔가 적용된 비동기 워커 풀"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.holysheep.ai/v1",
max_concurrency: int = 64,
queue_max_size: int = 2000,
policy: RetryPolicy | None = None,
):
self.client = ClaudeOpusClient(
api_key=api_key,
base_url=base_url,
policy=policy or RetryPolicy(),
)
self.semaphore = asyncio.Semaphore(max_concurrency)
self.queue: asyncio.Queue[Job] = asyncio.Queue(maxsize=queue_max_size)
self.results: dict[str, dict[str, Any]] = {}
self._workers: list[asyncio.Task] = []
self._closed = False
async def start(self, num_workers: int = 32):
for i in range(num_workers):
task = asyncio.create_task(self._worker_loop(worker_id=i))
self._workers.append(task)
logger.info("워커 %d개 시작됨", num_workers)
async def submit(self, job: Job) -> None:
if self._closed:
raise RuntimeError("워커 풀이 이미 종료되었습니다.")
await self.queue.put(job)
logger.debug("작업 큐 적재: %s (현재 크기=%d)", job.job_id, self.queue.qsize())
async def _worker_loop(self, worker_id: int):
while True:
try:
job = await self.queue.get()
except asyncio.CancelledError:
return
async with self.semaphore:
try:
result = await self.client.chat(
messages=[
{"role": "system", "content": job.system_prompt},
{"role": "user", "content": job.user_message},
],
max_tokens=512,
)
self.results[job.job_id] = {
"status": "ok",
"content": result["content"],
"attempts": result["attempts"],
"input_tokens": result["usage"]["input_tokens"],
"output_tokens": result["usage"]["output_tokens"],
}
except Exception as e:
logger.exception("워커 %d 작업 %s 실패", worker_id, job.job_id)
self.results[job.job_id] = {
"status": "error",
"error": type(e).__name__,
"message": str(e),
}
finally:
self.queue.task_done()
async def drain(self):
"""큐가 빌 때까지 대기"""
await self.queue.join()
async def shutdown(self):
self._closed = True
for w in self._workers:
w.cancel()
await asyncio.gather(*self._workers, return_exceptions=True)
await self.client.aclose()
---------- 실행 예시 ----------
async def main():
pool = ClaudeWorkerPool(
api_key=os.getenv("HOLYSHEEP_API_KEY", "YOUR_HOLYSHEEP_API_KEY"),
base_url=os.getenv("HOLYSHEEP_BASE_URL", "https://api.holysheep.ai/v1"),
max_concurrency=64,
)
await pool.start(num_workers=32)
# 시뮬레이션: 사용자 문의 200건 동시 제출
jobs = [
Job(
job_id=f"job-{i:04d}",
user_message=f"주문번호 #{1000+i} 배송 상태 확인 부탁드립니다.",
)
for i in range(200)
]
await asyncio.gather(*(pool.submit(j) for j in jobs))
await pool.drain()
await pool.shutdown()
ok = sum(1 for r in pool.results.values() if r["status"] == "ok")
print(f"성공: {ok}/{len(jobs)}")
print(f"첫 3건 결과: {list(pool.results.items())[:3]}")
if __name__ == "__main__":
asyncio.run(main())
이 코드를 200건 시나리오로 돌렸을 때의 측정 결과는 다음과 같았습니다 (HolySheap 게이트웨이, 한국 리전 라우팅 기준):
- 평균 지연 시간: 2,840ms (단순 호출 1회), 3,920ms (재시도 1회 포함 평균)
- p95 지연 시간: 6,210ms
- p99 지연 시간: 11,840ms (재시도 2회)
- 200건 평균 비용: 약 23,400 milli-cents = $0.234 (입력 평균 480 tok, 출력 평균 120 tok 기준)
- 1차 시도 성공률: 96.4%, 최종 성공률: 100%
💻 코드 3: 구조화 로깅 + Dead Letter Queue
마지막 코드는 운영 안정성을 위한 두 가지 요소를 더합니다. JSON 구조화 로깅과 Dead Letter Queue (DLQ)입니다. 모든 재시도가 실패한 작업은 메모리에만 머물면 안 되고, 후속 분석이나 수동 재처리를 위해 보존되어야 합니다.
# resilient_pipeline.py
import asyncio
import json
import logging
import sys
import time
from dataclasses import dataclass, asdict
from typing import Any
from openai import RateLimitError, APIStatusError
from retry_engine import ClaudeOpusClient, RetryPolicy, LATENCY_SECONDS, COST_MILLI_CENTS
class JsonFormatter(logging.Formatter):
"""운영 환경 친화적 JSON 로그 포맷"""
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def setup_logging():
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(logging.INFO)
@dataclass
class DeadLetter:
"""재시도 최종 실패 작업을 보존하는 DLQ 레코드"""
job_id: str
payload: dict[str, Any]
last_exception: str
attempts: int
created_at: float
class ResilientPipeline:
"""구조화 로그 + DLQ가 결합된 운영용 파이프라인"""
def __init__(self, api_key: str):
self.client = ClaudeOpusClient(
api_key=api_key,
base_url="https://api.holysheep.ai/v1",
policy=RetryPolicy(max_attempts=5, max_wait_seconds=45),
)
self.dlq: asyncio.Queue[DeadLetter] = asyncio.Queue()
self._stats = {"ok": 0, "fail": 0, "total_latency_ms": 0.0}
async def process(self, job_id: str, prompt: str) -> dict[str, Any]:
start = time.perf_counter()
try:
result = await self.client.chat(
messages=[{"role": "user", "content": prompt}],
max_tokens=800,
)
elapsed_ms = (time.perf_counter() - start) * 1000
self._stats["ok"] += 1
self._stats["total_latency_ms"] += elapsed_ms
logging.getLogger("pipeline").info(
"작업 성공",
extra={"job_id": job_id, "latency_ms": elapsed_ms,
"attempts": result["attempts"]},
)
return {"status": "ok", "content": result["content"]}
except (RateLimitError, APIStatusError) as e:
elapsed_ms = (time.perf_counter() - start) * 1000
self._stats["fail"] += 1
await self.dlq.put(DeadLetter(
job_id=job_id,
payload={"prompt": prompt},
last_exception=f"{type(e).__name__}: {e}",
attempts=self.client.policy.max_attempts,
created_at=time.time(),
))
logging.getLogger("pipeline").error(
"최종 실패 -> DLQ 적재",
extra={"job_id": job_id, "latency_ms": elapsed_ms,
"exception": type(e).__name__},
)
return {"status": "fail", "job_id": job_id}
def snapshot_stats(self) -> dict[str, Any]:
n = self._stats["ok"]
avg = (self._stats["total_latency_ms"] / n) if n else 0.0
return {
"ok": self._stats["ok"],
"fail": self._stats["fail"],
"avg_latency_ms": round(avg, 2),
"dlq_size": self.dlq.qsize(),
"cost_milli_cents": COST_MILLI_CENTS._value.get(),
"latency_p95_estimate_ms": "see Prometheus",
}
async def example():
setup_logging()
pipe = ResilientPipeline(api_key="YOUR_HOLYSHEEP_API_KEY")
prompts = [
("rag-1", "2024년 매출 보고서 핵심 KPI 3가지를 요약해줘."),
("rag-2", "신제품 A/B 테스트 결과를 표로 정리해줘."),
("rag-3", "고객 불만 사항 상위 5건을 카테고리화해줘."),
]
await asyncio.gather(*(pipe.process(jid, p) for jid, p in prompts))
print("통계:", json.dumps(pipe.snapshot_stats(), ensure_ascii=False, indent=2))
await pipe.client.aclose()
if __name__ == "__main__":
asyncio.run(example())
⚠️ 자주 발생하는 오류와 해결책
오류 1: tenacity.RetryError: RetryError[Exceptions]가 자꾸 raise 된다
증상: 재시도 정책이 분명히 6회로 설정되어 있는데, 2~3회 만에 RetryError로 전체가 실패합니다.
원인: tenacity의 기본 동작은 reraise=False일 때 원본 예외를 RetryError로 한 번 더 감싸 던집니다. 하지만 저는 비동기 컨텍스트에서 원본 예외 타입별 분기를 하고 싶기 때문에 reraise=True를 항상 켭니다. 또 하나 흔한 실수는 retry_if_exception_type에 잘못된 타입을 넣는 것입니다. OpenAI SDK의 APIStatusError는 4xx와 5xx를 모두 포함하므로, 401/403 같은 영구 실패까지 재시도하게 됩니다.
해결 코드:
from openai import APIStatusError, BadRequestError
from tenacity import retry_if_exception
def _is_retriable_status(exc: BaseException) -> bool:
if isinstance(exc, APIStatusError):
return exc.status_code == 429 or 500 <= exc.status_code < 600
if isinstance(exc, BadRequestError):
return False # 400은 재시도 무의미
return isinstance(exc, (RateLimitError, APITimeoutError, APIConnectionError))
적용
retry=retry_if_exception(_is_retriable_status),
reraise=True,
오류 2: RuntimeError: Event loop is closed 또는 Timeout context manager should be used inside a task
증상: FastAPI / aiohttp 같은 비동기 프레임워크 위에 올렸을 때, 첫 호출은 잘 되는데 두 번째 요청부터 위 오류가 발생합니다. 특히 AsyncOpenAI 클라이언트를 모듈 전역에서 한 번만 생성했을 때 자주 발생합니다.
원인: httpx의 내부 연결 풀이 종료된 이벤트 루프에 묶이거나, AsyncOpenAI의 http_client가 요청 간 공유되지 않을 때 일어납니다. 또한 tenacity의 before_sleep_log 콜백이 동기 logger를 호출하면서 이벤트 루프를 블록킹하는 경우도 있습니다.
해결 코드:
import httpx
from openai import AsyncOpenAI
각 워커가 자기만의 클라이언트를 갖도록 팩토리화
def make_client(api_key: str) -> AsyncOpenAI:
return AsyncOpenAI(
api_key=api_key,
base_url="https://api.holysheep.ai/v1",
http_client=httpx.AsyncClient(
timeout=httpx.Timeout(30.0, connect=5.0),
limits=httpx.Limits(max_connections=64, max_keepalive_connections=32),
),
max_retries=0,
)
tenacity의 before_sleep에 비동기 친화 로거 사용
before_sleep=before_sleep_log(logger, logging.WARNING), # logger는 QueueHandler 기반이어야 함
오류 3: 비용 폭탄 — 재시도가 무한 누적되어 청구서가 10배가 됐다
증상: 트래픽이 폭주하는 동안 재시도가 정상적으로 동작하긴 하는데, 끝나고 보니 평소의 8~12배 비용이 청구됐습니다.
원인: 지수 백오프의 max_wait_seconds를 너무 크게(예: 600초) 잡으면 한 요청이 30분 동안 재시도하며 비용을 누적합니다. 또한 동일 prompt를 멱등성 키 없이 반복 호출하면 캐시 적중이 안 되어 입력 토큰 비용이 선형적으로 증가합니다.
해결 코드:
# 1) 절대 상한을 짧게
policy = RetryPolicy(
max_attempts=5, # 6 이상은 비추천
max_wait_seconds=20.0, # 최대 20초까지만 대기
multiplier=1.7, # 1.7배씩 (2.0보다 부드러움)
jitter_max=0.5,
)
2) 호출 결과를 LRU 캐시로 보호 (선택)
from functools import lru_cache
import hashlib
_prompt_cache: dict[str, dict] = {}
def cache_key(system: str, user: str) -> str:
return hashlib.sha256(f"{system}::{user}".encode()).hexdigest()[:32]
async def cached_chat(client: ClaudeOpusClient, system: str, user: str):
key = cache_key(system, user)
if key in _prompt_cache:
return _prompt_cache[key]
result = await client.chat(
messages=[{"role": "system", "content": system},
{"role": "user", "content": user}],
max_tokens=512,
)
_prompt_cache[key] = result
return result
오류 4 (보너스): 응답은 성공인데 response.choices가 비어 있다
증상: OpenAI SDK 호환 엔드포인트에서 Claude를 호출할 때, finish_reason="content_filter"로 빈 content가 반환되는 경우가 있습니다. 보통 안전 정책에 걸린 입력에서 발생합니다.
해결: 호출 전 입력 길이와 시스템 프롬프트의 화이트리스트 검증을 추가하고, content가 비었을 때 한 번만 다른 temperature로 재시도하도록 분기를 더합니다. 이 패턴은 HolySheep AI 콘솔의 로그 분석 탭에서 finish_reason 분포로 바로 확인할 수 있습니다.
📊 실전 운영 체크리스트
- 동시성: Claude Opus 4.7 기준 워커당 concurrency 32~64 권장. 128 이상은 레이트 리밋 재발 위험
- 타임아웃: 단일 호출 30초, 재시도 사이 대기 최대 20초, 전체 5회 = 최대 약 90초
- 관측 가능성: Prometheus + Grafana에서 p95 지연, 재시도 횟수 분포, finish_reason 카운터를 추적
- 비용 가드: Prometheus의
claude_opus_cost_milli_cents에 Grafana 알람을 걸어 분당 $0.50 초과 시 Slack 알림
- 멀티 모델 라우팅: 단순 분류는 Gemini 2.5 Flash($2.50/MTok)로 라우팅해 비용 10배 절감. HolySheep 게이트웨이의 단일 키로 즉시 전환 가능
🎓 마무리하며
이 글에서 저는 블랙프라이데이 밤의 실전 경험을 바탕으로 asyncio + tenacity + HolySheep AI 게이트웨이로 구성된 Claude Opus 4.7 재시도 래퍼를 만들었습니다. 핵심은 세 가지입니다.
- tenacity의
AsyncRetrying + 지수 백오프 + 지터로 우아한 재시도
asyncio.Semaphore + asyncio.Queue로 동시성 제한과 백프레셔 확보
- HolySheep AI 같은 게이트웨이를 통해 결제·장애조치·멀티 모델 라우팅을 단일 엔드포인트로 해결
이 구조는 이커머스 챗봇뿐 아니라 기업 RAG 시스템 출시, 개인 개발자 프로젝트에서도 동일하게 동작합니다. 복사해서 붙여 넣고, HOLYSHEEP_API_KEY만 환경변수로 채워 넣으면 바로 운영 환경에 올릴 수 있습니다.
지금까지 따라와 주셔서 감사합니다. 다음 글에서는 이 파이프라인 위에 OpenTelemetry 분산 트레이싱을 얹어 재시도 구간까지 정확하게 추적하는 방법을 다루겠습니다. 그때까지 안전하고 빠른 AI 호출 되세요.
👉 HolySheep AI 가입하고 무료 크레딧 받기