작년 11월, 우리 팀(나 포함 개발자 2명)이 문서 요약 파이프라인을 처음 프로덕션에 올렸을 때, 솔직히 자신 있었다. 로컬에서 테스트도 충분히 했고, 응답 품질도 괜찮았다. 그런데 이틀도 안 돼서 파이프라인이 조용히 망가지기 시작했다. 오류 메시지도 없이. 그냥 요청이 들어가고, 타임아웃이 나고, 사용자는 빈 화면을 보고 있었다.
그때부터 진짜 공부가 시작됐다.
이후 4개월 동안 약 1만 2천 번의 파이프라인 실행을 모니터링하면서 — 성공한 것들, 실패한 것들, 그리고 내가 왜 실패했는지조차 한참 뒤에야 알게 된 것들까지 — 몇 가지 패턴이 보였다. 이 글은 그 경험을 정리한 것이다.
로깅을 나중에 추가하겠다는 생각, 당장 버려라
처음에 나는 파이프라인을 빠르게 돌려보는 데 집중했다. 로깅은 “나중에 정리하면 되는 것”이라고 생각했다. 이게 첫 번째 실수였다.
문제는 LLM 호출이 일반적인 API 호출과 다르다는 점이다. HTTP 상태 코드가 200이어도 내용이 쓸모없는 경우가 있다. 모델이 “죄송합니다, 해당 요청은 처리할 수 없습니다”를 아주 자신 있게 반환하기도 하고, 프롬프트 구조가 조금만 바뀌어도 출력 포맷이 완전히 달라진다. 이걸 감지하려면 응답 내용을 실제로 들여다봐야 한다.
내가 결국 만든 로깅 구조는 이렇다:
import time
import uuid
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class PipelineRun:
run_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
model: str = ""
prompt_tokens: int = 0
completion_tokens: int = 0
latency_ms: float = 0.0
status: str = "pending" # success | failed | invalid_output
error: str | None = None
def run_with_logging(prompt: str, model: str = "gpt-4o") -> dict:
run = PipelineRun(model=model)
start = time.monotonic()
try:
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
timeout=30, # 이게 없으면 나중에 후회한다
)
run.latency_ms = (time.monotonic() - start) * 1000
run.prompt_tokens = response.usage.prompt_tokens
run.completion_tokens = response.usage.completion_tokens
content = response.choices[0].message.content
# 응답이 비어 있거나 예상 포맷이 아닌 경우 별도 상태로 기록
if not content or len(content.strip()) < 10:
run.status = "invalid_output"
else:
run.status = "success"
logger.info("pipeline_run", extra={"run": run.__dict__, "output_len": len(content)})
return {"content": content, "run_id": run.run_id}
except Exception as e:
run.status = "failed"
run.error = str(e)
run.latency_ms = (time.monotonic() - start) * 1000
logger.error("pipeline_run_failed", extra={"run": run.__dict__})
raise
별거 없어 보이지만, invalid_output 상태를 따로 구분한 게 핵심이다. 이걸 추가하고 나서야 내 파이프라인의 “성공” 중 약 3.2%가 실제로는 모델이 요청을 거부한 응답이었다는 걸 알게 됐다. 그 전까지는 그냥 성공률 100%인 줄 알았다.
한 가지 더 — 로그는 처음부터 구조화된 JSON으로 남겨라. print() 디버깅은 로컬에서만 쓰고, 프로덕션에서는 logger.info(..., extra={...}) 패턴으로 통일해야 나중에 쿼리가 가능하다. 이걸 나중에 바꾸려면 생각보다 훨씬 귀찮다.
비용은 예측보다 항상 더 나온다 — 특히 이 경우에
초기에 비용 추정을 꽤 꼼꼼하게 했다고 생각했다. 평균 문서 길이를 재고, 토큰 수를 계산하고, 월간 요청 수를 곱했다. 그런데 실제 청구서를 보고 살짝 멍했다.
이유가 몇 가지 있었다.
첫째, 재시도. 실패한 요청은 다시 보낸다. 당연한 말이지만, 재시도할 때마다 프롬프트 토큰이 다시 카운트된다. 나는 재시도 비용을 계산에 포함하지 않았다.
둘째, 오류 응답도 토큰이 나간다. rate limit에 걸려서 실패한 요청이어도, API를 호출한 시점에 이미 일부 토큰이 소비되는 경우가 있다. OpenAI 문서에서 이 부분을 읽긴 했는데 실감을 못 했다.
셋째 — 이게 나를 가장 놀라게 했는데 — 시스템 프롬프트를 매 요청마다 통째로 보내고 있었다. 우리 시스템 프롬프트가 약 800토큰이었고, 이걸 1만 2천 번 보냈다. 계산해 보면 그냥 시스템 프롬프트만으로 약 960만 토큰이다.
지금은 짧은 작업에는 gpt-4o-mini를, 복잡한 요약이나 다단계 추론이 필요한 경우에만 gpt-4o를 쓴다. 라우팅 로직이 조금 귀찮지만, 비용이 대략 40% 줄었다. 솔직히 처음에는 품질 저하가 걱정됐는데, 내 케이스에서는 대부분의 작업에서 체감 차이가 없었다. 물론 도메인마다 다를 수 있다.
재시도 전략: 단순한 것 같아도 함정이 많다
처음에 재시도 로직은 이랬다: 실패하면 한 번 더 시도. 끝.
이게 얼마나 순진한 생각인지는 rate limit 오류를 처음 맞닥뜨렸을 때 알게 됐다. 금요일 오후에 배포를 밀었는데 — 지금 생각하면 왜 그랬는지 모르겠다 — 트래픽이 몰리면서 429 오류가 쏟아졌다. 재시도 로직이 즉시 재시도를 반복하면서 rate limit을 더 악화시켰다. 전형적인 retry storm이었다.
그 뒤로 세 가지 원칙을 지킨다.
지수 백오프는 선택이 아니다. tenacity 라이브러리를 쓰면 간단하게 구현된다. wait_exponential(multiplier=1, min=2, max=60)으로 설정하면 2초, 4초, 8초… 이런 식으로 대기 시간이 늘어난다.
재시도 가능한 오류와 아닌 오류를 구분하는 것도 중요하다. 429(rate limit)와 503(서버 오류)은 재시도해볼 만하다. 반면 400(잘못된 요청)은 재시도해도 같은 결과가 나온다 — 그냥 빠르게 실패 처리하는 게 낫다. 최대 재시도 횟수는 반드시 명시적으로 설정해라. 나는 3회다.
그런데 재시도보다 더 중요한 게 있다. 실패를 사용자에게 어떻게 전달하느냐다. 우리 파이프라인은 비동기로 동작하기 때문에, 최종 실패 시 작업을 dead letter queue에 넣고 알림을 보낸다. 이 구조를 만들기 전까지는 실패한 요청이 그냥 사라졌다. 사용자도 우리도 몰랐다.
파이프라인 아키텍처: 동기 vs 비동기, 그리고 내 선택
처음에는 동기 방식으로 구현했다. 사용자가 요청하면 → LLM 호출 → 결과 반환. 간단하고 직관적이다.
문제는 LLM 응답 시간이 들쭉날쭉하다는 점이다. 평균 2.3초인데, 가끔 15초짜리 요청이 들어온다. 동기 방식에서 이건 사용자가 15초 동안 로딩 화면을 보는 것과 같다. 그리고 그 사이에 다른 요청은 처리를 못 하거나, 스레드를 낭비한다.
지금 아키텍처는 이렇다:
- 사용자 요청이 들어오면 즉시
job_id를 반환한다 (HTTP 202) - 실제 LLM 호출은 Celery 워커가 비동기로 처리한다
- 결과가 나오면 Redis에 저장하고, 클라이언트는 polling 또는 WebSocket으로 결과를 받는다
이 구조가 처음에는 과하다고 생각했다. 팀이 2명인데 Celery까지 써야 하나. 그런데 트래픽이 늘어나면서 워커 수만 조정하면 스케일링이 됐고, 무엇보다 파이프라인이 죽어도 큐에 쌓인 작업은 사라지지 않는다는 점이 결정적이었다.
솔직히 말하면, 처음부터 이 구조로 갔으면 초기 개발이 2배는 걸렸을 것 같다. 동기 방식으로 빠르게 검증하고, 확신이 생겼을 때 비동기로 전환한 게 맞는 순서였다고 지금도 생각한다.
프롬프트 버전 관리: 코드처럼 다뤄야 한다
프롬프트를 코드에 하드코딩했던 시절이 있었다. 부끄럽지만 꽤 오래됐다. 프롬프트를 수정하면 코드를 배포해야 했고, 어떤 버전의 프롬프트가 어떤 결과를 냈는지 추적이 안 됐다.
지금은 프롬프트를 별도 YAML 파일로 관리하고, 각 파이프라인 실행에 프롬프트 버전 해시를 기록한다. 덕분에 “지난주부터 요약 품질이 왜 떨어졌지?”라는 질문에 로그를 뒤져서 답을 찾을 수 있다.
한 가지 더 — 프롬프트를 바꿀 때는 A/B 테스트를 거쳐라. 나는 처음에 “이 프롬프트가 더 좋아 보이니까”라는 직감으로 바꿨다가, 나중에 보니 특정 문서 유형에서 성능이 오히려 떨어졌다는 걸 알았다. 지금은 최소 500건의 실행 결과를 보고 나서 프롬프트를 전체 배포한다.
평가 지표를 어떻게 설정하느냐는 케이스마다 다르고, 나도 100% 맞는 방법을 알지는 못한다. 우리 경우에는 출력 길이 분포, 특정 패턴의 출현 빈도, 그리고 일부 요청에 대해서는 사람이 직접 품질을 평가하는 방식을 병행한다. 완벽하지는 않지만, 아무것도 안 하는 것보다는 훨씬 낫다.
실제로 추천하는 것들
이 글을 여기까지 읽었다면 아마 비슷한 문제를 겪고 있거나, 겪기 전에 미리 알고 싶은 상황일 것이다. “상황에 따라 다르다”는 식의 마무리 대신, 내가 지금 다시 시작한다면 무조건 할 것들을 말하겠다.
구조화된 로깅부터 시작해라. 파이프라인이 얼마나 작든 상관없이. 나중에 추가하면 기술 부채가 된다. 이건 정말로 나중에 후회한다.
invalid_output을 오류로 취급해라. HTTP 200이 성공을 의미하지 않는다. 응답 내용을 검증하는 로직이 없으면 실제 성공률을 모르는 것이다 — 나처럼 한참 지나서야 깨닫게 된다.
비용 추정에는 재시도와 시스템 프롬프트 오버헤드를 반드시 포함해라. 이 두 가지를 빠뜨리면 실제 청구서가 예상보다 30~50% 높게 나오는 경우가 많다. 경험에서 하는 말이다.
프롬프트는 코드처럼 버전 관리해라. Git이든 DB든 방식은 상관없다. 추적이 안 되면 개선도 안 된다.
마지막으로, 완벽한 아키텍처를 처음부터 설계하려고 하지 마라. 나는 그러다가 두 달을 날린 경험이 있다. 돌아가는 것부터 만들고, 데이터를 보고, 실제로 문제가 생긴 부분을 고쳐라. LLM 파이프라인도 결국 소프트웨어다.