본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 28. 14:20

Rails를 위한 LLM 비용 추적 (LLM Cost Tracking for Rails)

요약

Rails 애플리케이션에서 OpenAI 및 Anthropic 사용 비용을 효율적으로 추적하기 위한 Rails Engine인 'llm_cost_tracker'를 소개합니다. 새로운 인프라 도입, 프롬프트 저장, 트래픽 리다이렉션(프록시)을 배제하고 기존 Rails 환경을 재사용하는 설계 원칙을 다룹니다.

핵심 포인트

  • 새로운 인프라 추가 없이 기존 Rails DB와 패턴 재사용
  • 개인정보 보호를 위해 프롬프트 내용 저장 금지
  • 프록시 방식 대신 SDK 패치/래핑을 통한 직접 호출 유지
  • TSDB 대신 ActiveRecord를 활용한 데이터 관리

Rails 앱이 OpenAI 또는 Anthropic을 호출하기 시작합니다. 몇 달 후 재무팀 누군가가 "누가 매달 $X를 쓰고 있으며, 무엇에 쓰고 있는가?"라고 묻습니다. 이에 답하려면 사용자별, 기능별, 테넌트(tenant)별 귀속(attribution)이 필요합니다. 그리고 뻔한 해결책들은 모두 제가 포기하고 싶지 않았던 무언가를 포기하라고 요구합니다.

이것이 제가 구축해 온 Rails Engine인 llm_cost_tracker의 설계 근거입니다. 이것이 이 문제를 해결하는 유일한 방법은 아닙니다. 다만 제가 중요하게 생각한 제약 조건에 부합하는 방식일 뿐입니다.

제약 조건 세트

세 가지 타협할 수 없는 원칙이 다른 모든 선택을 결정했습니다.

  1. 새로운 인프라(infra) 금지. Rails 앱은 이미 데이터베이스, 요청 생명주기(request lifecycle), 인증 계층(authentication layer), 대시보드 패턴을 가지고 있습니다. 제가 추가하는 모든 것은 이를 복제하는 것이 아니라 재사용해야 합니다.
  2. 프롬프트(prompt) 저장 금지. 프롬프트 내용은 많은 맥락에서 규제 대상 데이터입니다 — 개인정보(PII), 고객 상담 기록, 의료, 법률 등. 추적기(tracker)가 이를 보유할 이유는 없습니다.
  3. 트래픽 리다이렉션(traffic redirection) 금지. OpenAI / Anthropic / Gemini로의 직접 호출이 가장 단순한 경로이며 실패 모드(failure modes)가 가장 적습니다. 프록시(proxy)는 홉(hop)을 추가하고, 키 순환(key rotation) 표면을 늘리며, 벤더(vendor) 관계를 형성합니다.

이 세 가지 규칙이 기존의 대부분의 솔루션들을 제외시켰습니다.

왜 프록시가 아닌가

"LLM 지출을 추적한다"라고 할 때 가장 먼저 떠오르는 본능은 앞에 프록시를 두는 것입니다. Helicone, Portkey, LiteLLM Proxy, OpenRouter — 이들은 모두 문제를 다음과 같은 방식으로 모델링합니다: OpenAI 트래픽을 proxy.example.com을 통해 라우팅하고, 프록시가 요청과 응답을 확인하여 비용을 기록한 뒤 OpenAI로 전달합니다.

이는 깔끔한 분리입니다. 하지만 이는 또한 당신의 API 키가 당신의 것이 아닌 그들의 설정에 존재함을 의미하며, 그들의 다운타임이 곧 당신의 다운타임이 되고, 그들의 TLS 및 데이터 거주성(data-residency) 태세가 기본적으로 당신의 것이 되며, 그들의 속도 제한(rate-limiting)이 당신의 코드와 제공자 사이에 위치하게 되고, 새로운 SDK 기능이 그들의 프록시에서 지원될 때까지 기다려야 함을 의미합니다.

일부 팀에게는 그러한 트레이드오프(trade-off)가 괜찮을 수 있습니다. 하지만 저에게는 그렇지 않았습니다. LLM 호출은 이미 요청(request)에서 가장 비용이 많이 들고 신뢰성에 민감한 부분인데, 그 앞에 또 다른 홉(hop)을 추가하는 것은 잘못된 선택처럼 느껴졌습니다.

대안은 이것입니다: Ruby 프로세스 내부에서, 즉 요청이 나가는 길목에서 필요한 정보를 캡처하는 것입니다. 부팅 시 공식 SDK 메서드를 패치(patch)하거나, 기반이 되는 Faraday 클라이언트를 래핑(wrap)합니다. 호출은 여전히 OpenAI / Anthropic / Gemini로 직접 전달되며, 우리는 단지 요청과 응답이 통과할 때 이를 관찰할 뿐입니다.

왜 TSDB가 아닌 ActiveRecord인가

두 번째 갈림길은 데이터가 어디로 가느냐 하는 문제입니다. 비용 추적은 시계열(time series) 형태를 띱니다. 즉, 주로 추가(append-mostly)되는 행들과 시간 창(time windows)에 따른 집계(aggregation)가 필요합니다. TSDB(Timescale, ClickHouse, Influx)가 교과서적인 정답입니다.

그럼에도 불구하고 저는 한 가지 이유로 ActiveRecord를 통해 Postgres / MySQL을 선택했습니다. 데이터가 분석용(analytical)이 아니라 운영용(operational)이기 때문입니다. 데이터는 당신의 users 테이블, subscriptions 테이블, tenants 테이블과 조인(join)되어야 합니다. 또한 앱의 나머지 데이터와 동일한 RLS(Row Level Security) 및 백업 체계 뒤에 존재해야 합니다. "지난달 테넌트 42의 LLM 비용을 보여줘"라는 쿼리를 실행하기 위해 별도의 TSDB를 구축하는 것은 조인을 더 쉽게 만드는 것이 아니라 더 어렵게 만듭니다.

설치 생성기(install generator)에는 세 개의 테이블이 포함되어 제공됩니다: llm_cost_tracker_calls (LLM 호출당 하나의 행, 토큰 수 및 총 비용 포함), llm_cost_tracker_call_line_items (구성 요소별 상세 내역 — 입력, 출력, 캐시 읽기, 호스팅된 도구 비용), 그리고 llm_cost_tracker_call_tags (귀속(attribution) 행)입니다. 오늘날 대부분의 Rails 앱이 처리하는 LLM 볼륨 규모라면, 단일 Postgres로도 충분히 처리 가능합니다.

왜 블록 범위 태그(block-scoped tags)인가

귀속(Attribution)이 핵심입니다. 토큰(Tokens) × 요율(rate) × 모델(model)은 총액을 산출하지만, 태그는 "그 총액이 누구의 것인가?"라는 질문에 답합니다.

메커니즘은 블록(block) 형태입니다:

LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat") do
  client.chat.completions.create(model: "gpt-4o-mini", messages: ...)
end

해당 블록 내부에서 추적 대상인 SDK 또는 Faraday 클라이언트를 호출하는 모든 것은 태그(tags)를 가져오게 됩니다. 컨트롤러의 around_action 주위에, Job의 perform 주위에, 또는 기능 모듈(feature module)의 진입점 주위에 이를 감싸면 됩니다. SDK 호출 자체는 변경되지 않습니다.

SDK 호출 시 키워드 인자(kwarg)로 처리하지 않는 이유는 다음과 같습니다: 저는 SDK 호출을 제어할 수 없습니다. OpenAI gem의 client.chat.completions.create는 자체적인 시그니처(signature)를 가지고 있습니다. 여기에 태그를 전달하려면 호출 형태를 몽키 패치(monkey-patching)하거나 모든 호출자에게 래퍼(wrapper)를 사용하도록 요청해야 합니다. 블록 범위 컨텍스트(Block-scoped context)는 Ruby의 특성에 부합합니다. 이는 ActiveSupport::CurrentAttributes나 Rails의 request-store 패턴과 동일한 형태입니다.

태그는 중첩된 블록을 가로질러 병합되며(내부 블록이 우선함), 고카디널리티(high-cardinality) 값이나 비밀값 형태의 데이터는 정제(sanitized) 과정을 거칩니다. 최종적으로 데이터베이스에는 (호출, 키, 값)당 하나의 행(row)으로 저장됩니다. 이를 통해 그룹화(Group by), 필터링(filter), 상세 분석(breakdown)이 가능합니다.

호출당 고정된 가격 스냅샷(frozen pricing snapshot)을 사용하는 이유

가격은 변합니다. OpenAI는 지난 1년 동안 프롬프트 캐싱(prompt caching) 요율을 두 번 인하했습니다. Anthropic은 자체 요율과 함께 1시간 캐시 TTL을 도입했고, Gemini는 컨텍스트 길이 계층형 가격제(context-length-tiered pricing)를 출시했습니다. 만약 비용을 지연 계산(lazily compute)한다면 — 즉, "요율은 현재 가격표에 적힌 대로"라고 처리한다면 — 과거 보고서의 기반이 되는 수치가 계속 변하게 됩니다.

따라서 모든 호출은 기록 시점에 가격 스냅샷을 고정합니다. 즉, 비용을 발생시킨 정확한 구성 요소별 요율을 해당 행에 찍어둡니다. 오늘로부터 3개월 전의 보고서를 실행하면, 당시 발생했던 정확한 비용을 얻을 수 있습니다. 내일 가격표를 업데이트하더라도 과거의 수치는 변하지 않습니다.

트레이드오프(trade-off)는 저장 공간입니다. 스냅샷을 위해 호출당 수백 바이트가 소요됩니다. 우리가 논의하고 있는 LLM 호출 규모에서는, (저장하지 않는) 메시지 본체에 비하면 무시할 수 있는 수준입니다.

현재 구현된 내용

Version 0.11.0은 세 가지 공식 SDK (OpenAI, Anthropic, RubyLLM)를 계측(instrument)하며, 그 외의 모든 것(Groq, DeepSeek, OpenRouter와 같은 OpenAI 호환 API, 두 가지 엔드포인트 스타일의 Azure OpenAI, Gemini, 커스텀 게이트웨이 등)을 위해 Faraday 미들웨어(middleware)를 제공합니다. /llm-costs에 마운트된 대시보드에는 비용 개요, 상위 모델, 호출 원장(call ledger), 태그별 상세 내역, 데이터 품질 신호(data-quality signals), 그리고 가격 참조 페이지가 포함되어 있습니다. 예산 가드레일(Budget guardrails)은 추정 비용이 설정된 월간, 일간 또는 호출당 한도를 초과할 경우 호출을 전송하기 전에 차단합니다.

의도적으로 포함하지 않은 기능은 다음과 같습니다: 프롬프트(prompt) 또는 완료(completion) 저장, 트레이스 재생(trace replay), 평가 프레임워크(eval framework), 모델 라우팅(model-routing) 로직, 사이드카 서비스(sidecar service), OpenTelemetry 익스포터(exporter). 이 기능들은 각각 별도의 gem을 만들 명분이 충분합니다.

만약 여러분의 상황이 "Rails 앱을 사용 중이고, 한두 개의 제공업체에 직접 API 호출을 하며, 재무팀에서 지출 내역을 묻고 있는 상황"이라면 — 이것이 바로 제가 존재하기를 바랐던 레이어입니다.

Repo: github.com/sergey-homenko/llm_cost_tracker

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0