LLM 앱에 관측성(Observability)이 없어 하룻밤 사이에 2,000달러를 날린 경험
요약
LLM 애플리케이션 운영 중 발생한 갑작스러운 API 비용 폭증 사례와 그 원인을 분석합니다. 단순 로깅의 한계를 지적하며, OpenTelemetry를 활용한 구조화된 트레이싱(Tracing) 도입을 통해 비용과 성능을 관리하는 방법을 제시합니다.
핵심 포인트
- LLM 앱 운영 시 관측성(Observability) 확보는 필수적임
- 단순 문자 수 기반 로깅은 토큰 사용량 추적에 부적합함
- OpenTelemetry 기반의 분산 트레이싱을 통해 호출 흐름 파악 가능
- 구조화된 데이터로 요청별 토큰 사용량 및 비용 모니터링 필요
지난달, 저는 스타트업의 OpenAI 청구 금액이 단 하룻밤 사이에 2,000달러나 급증하는 것을 지켜보며 잠 못 이루는 밤을 보냈습니다. 가장 최악이었던 점은 무엇이었을까요? 바로 왜 그런 일이 발생했는지 전혀 알 수 없었다는 것입니다. 추적(Trace)도 없었고, 가공되지 않은 텍스트 외에는 로그(Log)도 없었으며, 어떤 사용자 쿼리가 100,000개의 토큰을 잡아먹는 괴물을 유발했는지 알 방법도 없었습니다. 그날 밤, 저는 관측성(Observability) 없이 LLM 기반 앱을 구축하는 것이 계기판 없이 비행기를 조종하는 것과 같다는 사실을 뼈아프게 배웠습니다.
여러분은 저와 같은 실수를 반복하지 않도록 이 사후 분석(Post-mortem) 내용을 공유하고자 합니다. 무엇이 잘못되었는지, 효과가 없었던 시도들은 무엇이었는지, 그리고 마지막으로 우리를 구원한 방법인 모든 LLM 호출에 구조화된 추적(Structured tracing)을 추가하는 것에 대해 설명하겠습니다. 오늘 바로 복사해서 사용할 수 있는 실제 Python 코드를 보여드리겠습니다.
사건 발생: 통제 불능이 된 API 호출
저희는 GPT-4를 사용하여 문서 요약 서비스를 운영하고 있습니다. 각 사용자가 PDF를 업로드하면, 저희는 이를 청크(Chunk)로 나누고 chat completions API를 통해 요약본을 보냅니다. 일반적인 요청은 입력 토큰 약 4,000개, 출력 토큰 약 1,000개 정도입니다. 요청당 비용은 약 0.03달러입니다.
어느 날 저녁, 저희의 모니터링(HTTP 200 응답 확인을 위한 Datadog 사용)은 정상적인 트래픽을 보여주었습니다. 하지만 다음 날 아침, AWS 청구서와 OpenAI 사용량 대시보드는 전혀 다른 이야기를 하고 있었습니다. 새벽 2시에 단 한 명의 사용자 세션이 47개의 요청을 보냈습니다. 각 요청은 80,000개 이상의 입력 토큰을 사용했습니다. 그날 밤 총 지출액은 2,100달러였습니다.
이유는 무엇이었을까요? 저희의 청킹(Chunking) 로직에 버그가 있었습니다. 형식이 잘못된 표가 포함된 PDF가 무한 루프를 유발하여 프롬프트에 동일한 텍스트를 계속 추가하게 만들었습니다. 사용자는 타임아웃(Timeout)을 겪었고, 재시도(Retry)를 했으며, 루프는 계속해서 토큰을 잡아먹었습니다. 요청별 토큰 수(Per-request token counts)가 없었기에, 청구서가 도착하기 전까지는 이를 전혀 알 수 없었습니다.
효과가 없었던 시도들
처음에는 단순한 로깅(Naive logging)을 추가했습니다: print(f"Input tokens: {len(prompt)}"). 하지만 이는 토큰 수가 아닌 단순 문자 수(Character counts)만을 제공할 뿐이었습니다. 설상가상으로 로그가 범람하게 되었고, 요청 ID(Request IDs)와 연관 지을 수도 없었습니다.
다음으로는 OpenAI의 API 응답 JSON을 파싱하여 SQLite 테이블에 저장하는 방법을 시도했습니다. 몇 시간 동안은 작동했지만, 느리거나 비용이 많이 드는 호출을 찾으려면 여러 테이블을 가로질러 쿼리해야 했습니다. 집계(Aggregation)도 안 되고, 그래프(Graphing)도 그릴 수 없었습니다. 저는 다시 수동적인 SELECT * 쿼리를 날리는 상태로 돌아갔습니다.
심지어 모든 API 호출을 캡처하기 위해 커스텀 미들웨어(Custom Middleware)를 추가하는 것까지 고려해 보았지만, 이는 임시방편(Hacky)처럼 느껴졌고 구조화되고 상관관계가 있는 이벤트(Structured, Correlated Events)라는 더 깊은 요구사항을 해결해주지는 못했습니다.
마침내 해결책이 된 것: OpenTelemetry 기반 트레이싱 (Tracing)
돌파구는 모든 LLM 호출을 분산 트레이스(Distributed Trace) 내의 스팬(Span)으로 취급하는 것이었습니다. 저는 OpenTelemetry를 사용하여 OpenAI로 보내는 HTTP 요청과 제 애플리케이션 로직(청킹(Chunking), 요약(Summarization) 등) 모두에 인스트루멘테이션(Instrumentation)을 적용했습니다. 이를 통해 다음과 같은 정보를 얻을 수 있었습니다:
- 요청당 토큰 사용량 (입력/출력)
- 단계별 지연 시간 (Latency) (청킹 vs API 호출 vs 후처리)
- 사용자 세션, 요청 ID(Request ID), 그리고 에러 컨텍스트(Error Context) 간의 상관관계
- 비용 추정 (비용 = 입력_토큰 * 요율 + 출력_토큰 * 요율로 계산할 수 있기 때문)
다음은 제가 현재 사용하고 있는 핵심 인스트루멘테이션 패턴입니다 (Python, openai 및 opentelemetry-api 사용):
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
...
스팬(Span)을 Jaeger, Grafana Tempo, 또는 Observe (간단한 OTLP 수신기를 찾다가 발견한 서비스)와 같은 백엔드로 내보냄으로써, 이제 저는 비용 > $1 또는 지연 시간 > 30s와 같은 조건으로 필터링하여 즉시 근본 원인을 파악할 수 있게 되었습니다. 그 무한 루프는 8만 개의 입력 토큰을 가진 단일 스팬으로 나타났으며, 일단 확인하고 나니 명확했습니다.
대시보드 구축하기
트레이스(Trace)가 흐르기 시작한 후, 저는 다음과 같은 항목을 포함한 간단한 Grafana 대시보드를 설정했습니다:
- 사용자별 비용 (막대 그래프, 상위 10명 지출자)
- 시간 경과에 따른 평균 지연 시간 (시계열, 모델별 분류)
- 토큰 사용량 분포 (요청당 입력 토큰의 히스토그램)
- 에러율 (
finish_reason이 'stop'이 아닌 요청 — 종종 환각(Hallucination)이나 잘림(Truncation)을 나타냄)
환각(Hallucination) 패턴을 탐지하는 것은 까다롭지만, 저는 하나의 휴리스틱(Heuristic)을 발견했습니다. finish_reason이 length(잘림)인 요청은 환각 위험이 더 높다는 것입니다. 저희는 5분 이내에 5번 이상의 잘린 응답(truncated responses)을 생성하는 사용자가 발생할 경우 알림을 보내는 기능을 추가했습니다.
교훈 및 트레이드오프 (Lessons Learned & Trade-offs)
- OpenTelemetry는 강력하지만 학습 곡선이 있습니다. 샘플링(Sampling)을 적절하게 설정하는 것이 매우 중요합니다. 수백만 건의 요청을 처리할 때 모든 요청을 추적(Trace)하고 싶지는 않을 것입니다. 저희는 헤드 기반 샘플링(Head-based sampling)을 사용했습니다 (100개 요청 중 1개는 항상 추적하되, 에러는 항상 추적함).
- 비용 추적은 API 호출 수준에서 토큰 수(Token counts)를 추적하지 않는 한 결코 완전히 정확할 수 없습니다. OpenAI의 가격은 모델과 지역에 따라 다르지만, 대략적인 추정치만으로도 예상치 못한 상황을 방지할 수 있었습니다.
- 추적(Tracing)은 지연 시간(Latency)을 추가합니다. 계측(Instrumentation) 자체는 저렴하지만(마이크로초 단위), 비동기(Asynchronously)로 구성되지 않으면 Span을 익스포터(Exporter)로 전송하는 과정에서 블로킹(Blocking)이 발생할 수 있습니다.
BatchSpanProcessor를 사용하고 백그라운드 스레드로 오프로드(Offload)하세요. - 사용하지 말아야 할 경우: 처리량이 매우 높은 앱(>10k req/s)의 경우, 샘플링을 적용하거나 전체 추적(Full traces) 대신 메트릭 기반 모니터링(예: Prometheus counters)으로 전환해야 합니다. 또한, 프로토타이핑 단계라면 그냥
print()를 사용하되, 첫날부터 구조화된 로깅(Structured logging)을 추가하세요.
다음에 다시 한다면 다르게 할 점
저는 이를 첫 번째 프로덕션 배포 전에 구현하겠습니다. 진심입니다. 관측성(Observability)을 사후에 보완하는 비용은 2주간의 리팩터링과 매우 비싼 하룻밤의 대가였습니다. 또한 OpenAI 사용량 대시보드에 예산 알림(Budget alerts)을 설정하겠습니다 (기능은 있지만 이메일로만 발송됩니다. 저희는 스팸함으로 들어가는 바람에 놓쳤습니다).
여러분의 설정은 어떤가요? LLM 호출을 추적하고 계신가요, 아니면 그냥 로그로 대충 때우고 계신가요? 보이지 않는 비용 괴물에게 데인 다른 분들의 경험담을 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기