고객별 LLM 비용 보고서 (청구 파이프라인 재설계 없이)

발행: (2026년 5월 24일 PM 06:36 GMT+9)
9 분 소요
원문: Dev.to

출처: Dev.to

도서: LLM Observability Pocket Guide: Picking the Right Tracing & Evals Tools for Your Team

또 다른 저작: Thinking in Go (2권 시리즈) — Go 프로그래밍 완전 정복 + Go 헥사고날 아키텍처

내 프로젝트: Hermes IDE | GitHub — Claude Code와 기타 AI 코딩 도구를 사용하는 개발자를 위한 IDE
내 사이트: xgabriel.com | GitHub

재무팀이 찾아옵니다. “지난달 Acme Corp가 LLM 호출에 얼마를 썼나요?” 추적 UI를 열어봅니다. 모든 스팬에 request_id는 있지만 customer_id는 없습니다. 추적을 연결한 제품 엔지니어는 SDK 예제대로 요청 단위로 태그를 달았을 뿐입니다. 고객 귀속은 미래의 당신에게 맡겨두었습니다. 이제 당신이 그 미래의 당신입니다.

본능적으로는 다시 만들 생각이 듭니다. 모든 스팬에 customer_id 필드를 추가하고, 10개의 서비스를 재배포하고, 스키마를 데이터 팀과 동기화하고, 웨어하우스로 마이그레이션을 배포합니다. 작업 기간은 6주, 재무팀은 내일이라도 숫자를 원합니다.

하지만 재구축이 필요하지 않습니다. 필요한 것은 OpenTelemetry baggage, 버전 관리된 가격표, 그리고 하나의 집계 워커뿐입니다. 임포트를 정리하면 80줄 정도면 충분합니다.

흔히 하는 실수

고객 ID를 LLM 클라이언트를 호출하는 모든 곳에 인자로 전달한다.
코드베이스를 살펴보면 바로 무너집니다.

동기 HTTP 경로에서는 인증 컨텍스트에 고객 정보가 있어 쉽게 전달할 수 있습니다. 하지만 30초 뒤에 요약 작업을 실행하는 백그라운드 워커는 작업 ID를 받아 Redis에서 꺼내 같은 LLM 클라이언트를 호출합니다. 고객 정보는 두 번의 홉을 거쳐야 하므로 작업 페이로드에 직접 넣어야 합니다. 같은 로직을 재시도 핸들러, 캐시를 미리 채우는 프리페처, 스케줄된 임베딩 작업, 웹훅 파이아웃, CI에서 프롬프트를 재생하는 eval 러너 등에도 적용해야 합니다.

각 호출 지점마다 잊어버릴 위험이 있습니다. 잊힌 스팬은 “shared” 버킷에 들어가거나 전혀 태그되지 않으며, 6개월 뒤에 그 버킷이 전체 비용의 40%를 차지하게 되고 재무팀은 이유를 묻습니다.

해결책

명시적으로 전달하지 말고 한 번만 설정하고 프레임워크가 전파하도록 합니다. 바로 OpenTelemetry의 baggage가 그 역할을 합니다.

Baggage는 트레이스와 함께 이동하는 컨텍스트‑바인드 키‑값 저장소입니다. baggage에 넣은 모든 값은 스팬이 생성될 때마다, 스레드 경계, 비동기 경계, 심지어 프로세스 경계까지 자동으로 전달됩니다.

요청 핸들러, 작업 큐 디큐, 크론 티켓 등 경계에서 한 번만 고객 ID를 설정하고, 그 이후에는 잊어버리면 됩니다. 스팬 프로세서를 통해 해당 값이 app.customer_id 같은 속성으로 복사됩니다.

from opentelemetry import baggage, context, trace
from opentelemetry.sdk.trace import SpanProcessor

class BaggageToAttributesProcessor(SpanProcessor):
    # 스팬 속성으로 승격시킬 baggage 키를 화이트리스트합니다.
    # 무분별한 승격은 개인정보 위험을 초래합니다.
    KEYS = ("customer_id", "tenant_id", "billing_account_id")

    def on_start(self, span, parent_context=None):
        ctx = parent_context or context.get_current()
        for key in self.KEYS:
            value = baggage.get_baggage(key, ctx)
            if value is not None:
                span.set_attribute(f"app.{key}", value)

    def on_end(self, span): pass
    def shutdown(self): pass
    def force_flush(self, timeout_millis=30_000): return True

Tracer Provider를 설정할 때 위 프로세서를 한 번 등록하면, 코드 어디서든 다음과 같이 사용할 수 있습니다.

ctx = baggage.set_baggage("customer_id", "cus_8H2k...")
token = context.attach(ctx)
try:
    # 여기서부터 생성되는 모든 LLM 스팬은 자동으로 app.customer_id를 상속받습니다.
    response = llm_client.messages.create(...)
finally:
    context.detach(token)

HTTP와 gRPC에서는 W3C baggage 헤더 전파기가 값을 자동으로 하위 서비스에 전달합니다. 작업 큐의 경우, enqueue 시 baggage를 페이로드에 직렬화하고 dequeue 시 복원하면 됩니다. enqueue 래퍼 8줄, dequeue 쪽 8줄이면 충분합니다.

핵심 원칙: baggage는 경계에서만 설정하고, 비즈니스 로직 안에서는 절대 설정하지 마세요. 함수 중간에 set_baggage를 호출한다면, 다시 호출 지점마다 태그를 달아야 하는 상황으로 되돌아간 것입니다.

비용 계산은 별도 문제

태깅은 누가 호출했는지를 알려줄 뿐, 얼마나 비용이 들었는지는 알려주지 않습니다. 가격은 변동합니다. 공급자가 가격을 인하하거나 모델을 교체하고, 새로운 캐시 할인 정책이 생기면 비용 계산 방식도 달라집니다. 쿼리 시점에 tokens * current_price 로 계산하면, 가격이 바뀔 때마다 지난 분기의 청구서가 바뀌게 됩니다. 이는 청구서가 아니라 추정치에 불과합니다.

따라서 버전 관리된 가격표가 필요합니다. 각 행은 (model, valid_from, valid_to) 로 키가 지정되고, 토큰당 가격을 입력·출력·캐시 쓰기·캐시 읽기 별로 구분합니다.

CREATE TABLE llm_price_book (
    model               TEXT       NOT NULL,
    valid_from          TIMESTAMPTZ NOT NULL,
    valid_to            TIMESTAMPTZ NOT NULL,
    input_per_mtok_usd          NUMERIC(10, 6) NOT NULL,
    output_per_mtok_usd         NUMERIC(10, 6) NOT NULL,
    cache_write_per_mtok_usd    NUMERIC(10, 6) NOT NULL,
    cache_read_per_mtok_usd     NUMERIC(10, 6) NOT NULL,
    PRIMARY KEY (model, valid_from)
);

-- 예시 행: 2026년 1분기 기준 Claude 가격 스냅샷
INSERT INTO llm_price_book VALUES (
    'claude-opus-4-7',
    '2026-01-01 00:00:00+00',
    '2999-12-31 23:59:59+00',
    15.00, 75.00, 18.75, 1.50
);

현재 행의 valid_to는 2999년까지의 sentinel 값으로 두고, 새로운 가격이 나오면 해당 행을 UPDATE하고 새 행을 INSERT합니다. 고유 제약조건 덕분에 겹치는 구간이 생기지 않게 됩니다.

사용량과 가격을 조인할 때는 스팬 시작 시점을 기준으로 구간 조회를 수행합니다.

SELECT
    u.customer_id,
    u.ts,
    u.model,
    u.input_tokens,
    u.output_tokens,
    u.cache_read_tokens,
    u.cache_write_tokens,
    (u.input_tokens       * p.input_per_mtok_usd
   + u.output_tokens      * p.output_per_mtok_usd
   + u.cache_write_tokens * p.cache_write_per_mtok_usd
   + u.cache_read_tokens  * p.cache_read_per_mtok_usd) / 1e6
       AS gross_cost_usd
FROM llm_usage_events u
JOIN llm_price_book p
  ON p.model = u.model
 AND u.ts >= p.valid_from
 AND u.ts <  p.valid_to
WHERE u.ts >= %(day_start)s
  AND u.ts <  %(day_end)s;

아래는 하루 단위 집계를 수행하는 파이썬 워커 예시입니다.

INSERT_SQL = """
INSERT INTO daily_llm_cost (day, customer_id, gross_cost_usd)
SELECT %(day)s, u.customer_id,
       SUM(
           (u.input_tokens       * p.input_per_mtok_usd
          + u.output_tokens      * p.output_per_mtok_usd
          + u.cache_write_tokens * p.cache_write_per_mtok_usd
          + u.cache_read_tokens  * p.cache_read_per_mtok_usd) / 1e6
       ) AS gross_cost_usd
FROM llm_usage_events u
JOIN llm_price_book p
  ON p.model = u.model
 AND u.ts >= p.valid_from
 AND u.ts <  p.valid_to
WHERE u.ts >= %(day_start)s
  AND u.ts <  %(day_end)s
GROUP BY u.customer_id
ON CONFLICT (day, customer_id)
0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.