작년 11월, 새벽 2시에 슬랙 알림이 쏟아졌다. 콘텐츠 분류 파이프라인이 6시간 동안 돌아가면서 OpenAI API 비용으로 $340을 날렸는데, 결과물의 70%가 완전히 엉터리였다. 재시도 로직에 버그가 있어서 실패한 요청들을 계속 반복하고 있었고, 나는 그걸 아침에야 발견했다.
그게 내가 AI 파이프라인을 “그냥 돌아가게” 만드는 것과 “프로덕션에서 실제로 신뢰할 수 있게” 만드는 것의 차이를 제대로 배운 시작점이었다.
지금까지 이 파이프라인으로 1만 5천 번 이상 실행을 처리했다. 문서 분류, 코드 리뷰 자동화, 내부 Q&A 시스템 등 여러 용도로. 그 과정에서 실패도 많이 했고, 비용도 꽤 날렸고, 한 번은 모델 환각 때문에 프로덕션 데이터가 잘못 분류되는 사고도 있었다. 이 글은 그 경험들을 정리한 것이다.
재시도 로직: “지수 백오프면 되잖아요”라고 생각했던 나에게
처음에는 단순했다. API 호출이 실패하면 재시도. tenacity 라이브러리 붙이고, 지수 백오프 설정하고, 끝이라고 생각했다.
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60)
)
def call_llm(prompt: str) -> str:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.content
문제는 이게 틀린 게 아니라 불완전하다는 거다. OpenAI 에러를 전부 같은 방식으로 처리하면 안 된다.
RateLimitError는 재시도해야 한다. 하지만 InvalidRequestError는? 재시도해봤자 소용없다. 프롬프트가 컨텍스트 길이를 초과했거나 잘못된 파라미터를 보낸 거니까. 재시도하면 그냥 돈만 더 쓰는 거다. 그 새벽 2시 사고가 정확히 이거였다 — 잘못 설계된 프롬프트가 context_length_exceeded 에러를 계속 일으키는데, 재시도 로직이 이걸 구분하지 못하고 계속 돌렸다.
OpenAI 에러를 제대로 분류하면 이렇다:
- 재시도 가능:
RateLimitError,APITimeoutError,APIConnectionError,InternalServerError(503) - 재시도 불가:
InvalidRequestError,AuthenticationError,PermissionDeniedError - 조건부:
BadRequestError— 내용을 보고 판단해야 함
실제로 쓰는 코드는 이렇게 생겼다:
import openai
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
RETRYABLE_EXCEPTIONS = (
openai.RateLimitError,
openai.APITimeoutError,
openai.APIConnectionError,
openai.InternalServerError,
)
@retry(
retry=retry_if_exception_type(RETRYABLE_EXCEPTIONS),
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=2, min=5, max=120),
reraise=True
)
def call_llm(prompt: str, model: str = "gpt-4o-mini") -> str:
try:
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
timeout=30.0 # 이거 없으면 무한정 기다린다
)
return response.choices[0].message.content
except openai.BadRequestError as e:
# context_length_exceeded는 재시도 불가 — 즉시 포기
if "context_length_exceeded" in str(e):
raise ValueError(f"프롬프트가 너무 깁니다: {len(prompt)} chars") from e
raise
timeout=30.0이 중요하다. 처음에 이걸 달지 않았는데, OpenAI API가 가끔 수십 초씩 응답을 안 하는 경우가 있다. 특히 새벽 시간대에. 타임아웃 없으면 워커가 거기 묶여서 아무것도 못 하게 된다. 실제로 이 때문에 배치 작업이 몇 시간씩 멈춰있던 적이 있다.
실용적인 기준: 재시도 최대 횟수는 4번이면 충분하다. 그 이상은 대부분 상황에서 효과가 없고, 레이턴시만 늘린다.
비용이 폭발하는 지점 세 가지
솔직히 말하면, AI 파이프라인 비용 최적화는 처음엔 너무 과소평가했다. “토큰 좀 쓰겠지, 뭐” 하다가 월말 청구서 보고 정신 차렸다.
첫 번째 — 시스템 프롬프트 중복
배치 처리할 때 같은 시스템 프롬프트를 매번 API에 보내는 실수를 한다. 100개 문서를 분류한다면, 시스템 프롬프트가 500 토큰이라고 할 때 그게 100번 반복된다. OpenAI의 Prompt Caching을 쓰면 캐시된 토큰은 90% 할인된다 (gpt-4o 기준, 2024년 8월부터 지원). 아직 이걸 안 쓰는 팀이 생각보다 많다.
주의할 점은 캐싱이 자동으로 되긴 하는데, 프롬프트의 첫 번째 부분이 1024 토큰 이상이어야 캐시가 트리거된다. 시스템 프롬프트가 짧으면 효과가 없다.
두 번째 — 모델 선택
처음에는 습관적으로 gpt-4o를 모든 작업에 썼다. 나중에 A/B 테스트를 해보니 단순 분류 작업에서는 gpt-4o-mini가 정확도가 거의 비슷하면서 비용은 15-20배 저렴했다. 지금은 작업 복잡도에 따라 모델을 나눠 쓴다:
- 단순 분류, 키워드 추출, 감정 분석 → gpt-4o-mini
- 복잡한 추론, 코드 리뷰, 긴 문서 요약 → gpt-4o
이 접근법으로 월 비용을 약 60% 줄였다. 단, “단순하다”는 기준은 직접 테스트해봐야 한다. 나는 같은 입력 50개에 대해 두 모델 결과를 비교해서 정확도 차이가 5% 미만이면 mini로 넘긴다.
세 번째 — 출력 토큰 제한 안 함
JSON 형식으로 결과를 받을 때 max_tokens를 설정하지 않으면 모델이 필요 이상으로 길게 응답하는 경우가 있다. gpt-4o-mini 기준으로 출력 토큰이 입력의 4배 비싸니까, 이걸 제어하는 게 생각보다 중요하다.
One thing I noticed: response_format={"type": "json_object"}를 쓰면 출력이 훨씬 일관되고 토큰도 절약된다. 근데 만능은 아니다 — 모델이 JSON 안에 쓸데없이 긴 reasoning 필드를 집어넣는 경우가 있어서, 스키마를 최대한 타이트하게 정의해야 한다.
출력 검증: 모델을 너무 오래 믿었다
LLM 출력을 그냥 믿으면 안 된다는 건 머리로는 알았는데, 실제로 검증 레이어를 제대로 만든 건 꽤 나중이었다.
우리 파이프라인에서 있었던 사고: 문서를 5개 카테고리 중 하나로 분류하는 작업인데, 모델이 가끔 존재하지 않는 카테고리명을 반환했다. 처음엔 빈도가 낮아서 로그에서 잘 안 보였다. 나중에 다운스트림 시스템에서 에러가 터지기 시작했고, 추적해보니 잘못된 카테고리가 DB에 저장된 거였다. 데이터를 수동으로 정리하는 데 반나절이 걸렸다.
Pydantic으로 출력 스키마를 정의하고 파싱하는 게 가장 간단한 해결책이었다:
from pydantic import BaseModel, field_validator
from enum import Enum
class Category(str, Enum):
TECHNICAL = "technical"
BUSINESS = "business"
LEGAL = "legal"
MARKETING = "marketing"
OTHER = "other"
class ClassificationResult(BaseModel):
category: Category
confidence: float
reasoning: str
@field_validator("confidence")
@classmethod
def confidence_range(cls, v: float) -> float:
if not 0.0 <= v <= 1.0:
raise ValueError("confidence must be between 0 and 1")
return v
def classify_document(text: str) -> ClassificationResult:
response = call_llm(
f"다음 문서를 분류하세요. JSON 형식으로만 응답하세요.\n\n{text}"
)
try:
data = json.loads(response)
return ClassificationResult(**data)
except (json.JSONDecodeError, ValueError) as e:
# 파싱 실패를 별도 메트릭으로 추적
metrics.increment("llm.output_parse_failure")
logger.error(f"출력 파싱 실패: {e}, 원본: {response[:200]}")
raise OutputValidationError("모델 출력이 예상 형식과 다릅니다") from e
여기서 중요한 건 파싱 실패를 에러로 처리하되, 그 에러를 별도로 추적해야 한다는 거다. 파싱 실패율이 갑자기 올라가면 프롬프트에 문제가 생겼거나 모델 동작이 변한 거다. 이 메트릭을 보고 있다가 gpt-4o-mini의 동작이 조용히 바뀐 걸 두 번 발견했다 — OpenAI가 공지 없이 모델을 업데이트하는 경우가 있다.
confidence 필드도 유용하다. 0.5 미만인 결과는 자동 분류 대신 사람 검토 큐로 보내는 식으로 쓰고 있다. 물론 모델이 말하는 confidence가 실제 정확도와 얼마나 일치하는지는 검증해봐야 한다 — 내 경험상 대략적인 가이드는 되지만 정확한 확률 수치로 믿기엔 무리다.
관찰 가능성: 없으면 아무것도 모른다
이게 가장 과소평가된 부분이다. 일반 API 서비스는 요청/응답 로그만 봐도 어느 정도 파악된다. AI 파이프라인은 다르다.
같은 프롬프트를 10번 보내도 결과가 조금씩 다를 수 있다. 비용이 갑자기 올라갔는데 이유를 모를 수 있다. 모델이 특정 타입의 입력에서만 이상한 결과를 내는데, 쌓인 로그 없이는 패턴을 찾기가 힘들다.
내가 요청 단위로 추적하는 것들:
- 입력 토큰 수, 출력 토큰 수, 레이턴시, 모델명, 성공/실패 여부
- 토큰 기반 비용 계산 (OpenAI API 응답의
usage필드 활용) - 파싱 성공/실패, 재시도 횟수
- 입력 타입이나 소스 (어떤 종류의 문서인지)
그리고 한 가지 더 — 전체 요청의 1-2%를 랜덤 샘플링해서 사람이 직접 검토한다. 자동화된 검증은 예상치 못한 실패 패턴을 놓친다. 주 1회 30분 정도 샘플을 보는 것만으로도 문제를 빨리 잡을 수 있다. 귀찮지만 이 습관이 두 번의 사고를 미리 막아줬다.
LangSmith를 잠깐 써봤는데, 복잡한 체인을 디버깅할 때는 유용하다. 근데 우리 파이프라인이 LangChain에 강하게 의존하지 않아서 오버헤드 대비 효용이 별로 없었다. 지금은 OpenTelemetry로 직접 계측하고 있다. 더 투명하고 벤더 종속성이 없다.
고백하자면 — 처음 3개월 동안 관찰 가능성을 제대로 구축하지 않았다. “일단 빨리 만들고 나중에 붙이자”는 생각이었는데, 나중에 붙이는 게 처음부터 붙이는 것보다 몇 배 더 힘들었다. 특히 레이턴시 분석이나 비용 추적은 초기 설계에 없으면 역으로 넣기가 까다롭다.
실제로 추천하는 것들
한 줄로 요약하면: AI 파이프라인은 일반 소프트웨어보다 훨씬 빨리 예상치 못한 방식으로 실패한다. 그걸 전제로 설계해야 한다.
스택 선택: 간단한 파이프라인이라면 LangChain 같은 프레임워크 없이 OpenAI SDK 직접 쓰는 걸 추천한다. 추상화 레이어가 디버깅을 어렵게 만들고, 버전 업데이트마다 뭔가 깨지는 경우가 많다 (langchain 0.2 → 0.3 마이그레이션이 얼마나 고통스러웠는지). LangChain은 복잡한 에이전트나 RAG 파이프라인 같은 경우에 유용하다. 단순한 LLM 호출 파이프라인에는 과하다.
에러 처리: 재시도 가능한 에러와 불가능한 에러를 반드시 구분해라. 그리고 모든 에러에 대해 재시도 횟수, 총 소요 시간, 최종 결과를 로그에 남겨라. 나중에 디버깅할 때 이 정보가 없으면 굉장히 답답하다.
비용 관리: 처음부터 요청 단위 비용을 계산하는 로직을 넣어라. OpenAI 대시보드에서 일별 예산 알림도 설정해둬라. 나는 일별 $50 초과 시 슬랙 알림 오게 해뒀는데, 이게 두 번 울렸다. 두 번 다 버그 때문이었다.
점진적 롤아웃: 새 프롬프트나 모델로 바꿀 때는 트래픽의 10%만 먼저 보내고 지표를 확인한 다음 전체로 확대해라. 모델이 이전 버전보다 나쁜 결과를 내는 경우가 생각보다 자주 있다. “더 새로운 모델 = 더 좋은 결과”가 항상 성립하지 않는다.
출력 검증: Pydantic으로 스키마를 정의하고 파싱 실패를 별도로 추적해라. 파싱 실패율이 1%를 넘어가면 반드시 원인을 조사해야 한다.
나는 100% 확신하지는 않는다 — 팀 규모나 파이프라인 복잡도에 따라 다를 수 있다. 하지만 소규모 팀에서 LLM을 처음 프로덕션에 올리는 상황이라면, 이 교훈들이 꽤 많은 시행착오를 줄여줄 거라고 생각한다. 새벽 2시 알림은 최대한 안 받는 게 좋으니까.