6 분 소요

💸 LLM API 비용 추적 3부작 — Claude · OpenAI · Gemini · Upstage, 네 곳에 흩어진 API 비용을 "과금 구조 이해 → 통합 트래커 → 공식 API 대사·알림"으로 끝까지 잡았습니다. 전체 3편.
  1. 과금 구조와 usage 필드 — 4사 가격표 한눈에
  2. 파이썬으로 4사 통합 비용 트래커 만들기지금 글
  3. 공식 비용 API 대사와 예산 알림 자동화

Summary

1편에서 4사의 과금 구조와 usage 필드가 어떻게 다른지 봤어요. 이번 편은 그걸 코드로 합칩니다. 목표는 단순해요 — 어떤 프로바이더를 호출하든 한 줄로 감싸면, 비용이 자동으로 SQLite 에 쌓이고, 아침에 일별 리포트를 뽑아볼 수 있는 상태.

외부 서비스 없이 표준 라이브러리 + 각 사 SDK 만으로 만들어요. 파일 하나(llm_cost.py)에 다 들어가는 크기입니다.

💡 이 글에서 다루는 것

  • 가격표를 코드로 — PRICING 딕셔너리 설계
  • 4사 usage 응답 → 공통 스키마 정규화 함수
  • 호출 1건의 비용을 계산하는 estimate_cost()
  • SQLite 스키마와 기록 함수 log_usage()
  • 기존 코드를 안 고치고 감싸는 track() 래퍼
  • 일별 · 모델별 리포트 쿼리



1. 가격표를 코드로 — PRICING 딕셔너리

1편의 가격표를 그대로 옮깁니다. 단위는 1M 토큰당 USD 로 통일하고, 캐시 읽기 단가를 별도 컬럼으로 둬요.

# llm_cost.py
# 2026-07 기준 $/1M tokens. 가격 개정 시 이 딕셔너리만 갱신하면 됩니다.
MILLION = 1_000_000

PRICING = {
    # provider, model : (input, output, cached_input)
    ("anthropic", "claude-fable-5"):    (10.00, 50.00, 1.00),
    ("anthropic", "claude-opus-4-8"):   (5.00,  25.00, 0.50),
    ("anthropic", "claude-sonnet-4-6"): (3.00,  15.00, 0.30),
    ("anthropic", "claude-haiku-4-5"):  (1.00,   5.00, 0.10),
    ("openai",    "gpt-5.5"):           (5.00,  30.00, 0.50),
    ("openai",    "gpt-5.4"):           (2.50,  15.00, 0.25),
    ("openai",    "gpt-5.4-mini"):      (0.75,   4.50, 0.075),
    ("openai",    "gpt-5.4-nano"):      (0.20,   1.25, 0.02),
    ("google",    "gemini-3.5-flash"):  (1.50,   9.00, 0.375),
    ("google",    "gemini-2.5-flash"):  (0.30,   2.50, 0.075),
    ("upstage",   "solar-pro3"):        (0.15,   0.60, 0.015),
    ("upstage",   "solar-pro2"):        (0.15,   0.60, 0.015),
}

몇 가지 설계 판단을 적어두면 이렇습니다.

  • 키를 (provider, model) 튜플로 — 같은 모델명이 두 회사에 있을 일은 없지만, 나중에 Bedrock/Vertex 경유 같은 “같은 모델, 다른 단가” 케이스가 생기면 provider 축이 필요해져요.
  • 캐시 쓰기 할증(Anthropic 1.25배)은 뺐어요 — 응답의 cache_creation_input_tokens 에 1.25배를 적용하면 반영할 수 있는데, 추정치 단계에선 과대추정보다 단순함을 택했습니다. 필요하면 컬럼 하나 더 추가하면 돼요.
  • Gemini 3.1 Pro 같은 이단 요금제 모델은 이 단순 딕셔너리로 못 담아요. 쓰게 되면 함수형 가격(토큰 수를 받아 단가를 돌려주는)으로 확장합니다.



2. usage 정규화 — 4사 응답을 공통 스키마로

트래커의 심장이에요. 어떤 프로바이더의 응답이든 아래 다섯 필드로 바꿉니다.

def normalize_usage(provider, response):
    """프로바이더별 usage 응답 → 공통 스키마 dict."""
    if provider == "anthropic":
        u = response.usage
        return {
            "input_tokens":  u.input_tokens + (u.cache_creation_input_tokens or 0),
            "output_tokens": u.output_tokens,
            "cached_tokens": u.cache_read_input_tokens or 0,
        }
    if provider == "openai":
        u = response.usage
        if hasattr(u, "input_tokens"):          # Responses API
            cached = u.input_tokens_details.cached_tokens if u.input_tokens_details else 0
            return {
                "input_tokens":  u.input_tokens - cached,
                "output_tokens": u.output_tokens,
                "cached_tokens": cached,
            }
        cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0
        return {                                 # Chat Completions API
            "input_tokens":  u.prompt_tokens - cached,
            "output_tokens": u.completion_tokens,
            "cached_tokens": cached,
        }
    if provider == "google":
        m = response.usage_metadata
        cached = m.cached_content_token_count or 0
        return {
            "input_tokens":  m.prompt_token_count - cached,
            "output_tokens": m.candidates_token_count + (m.thoughts_token_count or 0),
            "cached_tokens": cached,
        }
    if provider == "upstage":                    # OpenAI Chat Completions 호환
        u = response.usage
        return {
            "input_tokens":  u.prompt_tokens,
            "output_tokens": u.completion_tokens,
            "cached_tokens": 0,
        }
    raise ValueError(f"unknown provider: {provider}")

1편에서 짚었던 함정들이 여기서 처리돼요.

  • Gemini 의 thinking 토큰thoughts_token_count 를 출력에 더했습니다. 이거 하나로 Gemini 비용 추정이 반토막 나는 걸 막아요.
  • 캐시 토큰 이중가산 방지 — OpenAI 와 Gemini 는 캐시 토큰이 입력 합계에 포함돼서 오기 때문에 빼주고, Anthropic 은 처음부터 분리돼서 오기 때문에 그대로 씁니다. 방향이 반대라는 게 함정이에요.
  • Upstage — OpenAI 구형 스키마와 동일해서 로직을 공유해도 되지만, 분기 하나 값이라 명시적으로 뒀습니다.

실제로 돌려보면 이렇게 나와요.

import anthropic

client = anthropic.Anthropic()
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[{"role": "user", "content": "SQLite 를 세 문장으로 소개해줘."}],
)
print(normalize_usage("anthropic", response))
{'input_tokens': 24, 'output_tokens': 118, 'cached_tokens': 0}



3. 비용 계산 — estimate_cost()

정규화된 usage 와 가격표를 곱하면 끝이에요.

def estimate_cost(provider, model, usage):
    """공통 스키마 usage → 추정 비용(USD)."""
    price_in, price_out, price_cached = PRICING[(provider, model)]
    return (
        usage["input_tokens"]  * price_in
        + usage["output_tokens"] * price_out
        + usage["cached_tokens"] * price_cached
    ) / MILLION
usage = {"input_tokens": 24, "output_tokens": 118, "cached_tokens": 0}
cost = estimate_cost("anthropic", "claude-opus-4-8", usage)
print(f"${cost:.6f}")
$0.003070

호출 한 번에 0.3센트. 이런 값들이 하루 수천 번 쌓이면 의미 있는 돈이 되고, 그래서 다음 단계가 저장입니다.



4. SQLite 에 쌓기 — 스키마와 log_usage()

관측 데이터는 호출 시점에 바로 기록하는 게 원칙이에요. 서버 DB 까지 갈 것 없이 SQLite 파일 하나면 충분합니다.

import sqlite3
from datetime import datetime, timezone

DB_PATH = "llm_usage.db"

SCHEMA = """
CREATE TABLE IF NOT EXISTS llm_usage (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    ts            TEXT NOT NULL,       -- UTC ISO8601
    provider      TEXT NOT NULL,
    model         TEXT NOT NULL,
    app           TEXT NOT NULL,       -- 어느 기능/서비스에서 썼는지
    input_tokens  INTEGER NOT NULL,
    output_tokens INTEGER NOT NULL,
    cached_tokens INTEGER NOT NULL,
    cost_usd      REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_usage_ts ON llm_usage (ts);
"""

def log_usage(provider, model, usage, cost, app="default"):
    with sqlite3.connect(DB_PATH) as conn:
        conn.executescript(SCHEMA)
        conn.execute(
            "INSERT INTO llm_usage "
            "(ts, provider, model, app, input_tokens, output_tokens, cached_tokens, cost_usd) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            (
                datetime.now(timezone.utc).isoformat(timespec="seconds"),
                provider, model, app,
                usage["input_tokens"], usage["output_tokens"],
                usage["cached_tokens"], cost,
            ),
        )

포인트는 app 컬럼이에요. “회사 챗봇”, “문서 요약 배치”, “실험 노트북”처럼 용도별로 태그를 달아두면, 나중에 “비용의 80%가 어디서 나오지?”라는 질문에 쿼리 한 줄로 답할 수 있습니다. 이 컬럼이 없으면 프로바이더별 총액밖에 못 봐요.



5. 기존 코드를 안 고치는 track() 래퍼

이제 부품 세 개(정규화 → 계산 → 기록)를 하나로 묶습니다. 기존 호출 코드를 최대한 안 건드리는 게 목표라, 응답을 받은 직후 한 줄만 끼워 넣는 형태로 만들어요.

def track(provider, model, response, app="default"):
    """API 응답을 받아 비용을 기록하고, 응답을 그대로 돌려준다."""
    usage = normalize_usage(provider, response)
    cost = estimate_cost(provider, model, usage)
    log_usage(provider, model, usage, cost, app=app)
    return response

쓰는 쪽은 이렇게 바뀝니다. 원래 코드에서 track(...) 으로 감싼 것만 차이예요.

# Claude
response = track("anthropic", "claude-opus-4-8", client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[{"role": "user", "content": "..."}],
), app="doc-summary")

# Upstage (OpenAI 호환 클라이언트)
response = track("upstage", "solar-pro3", upstage_client.chat.completions.create(
    model="solar-pro3",
    messages=[{"role": "user", "content": "..."}],
), app="doc-summary")

⚠️ 스트리밍 호출은 이 패턴이 바로 안 먹어요. 스트림은 응답 객체에 usage 가 마지막에야 채워지니까, Anthropic 은 stream.get_final_message(), OpenAI 는 stream_options={"include_usage": True} 로 최종 usage 를 받은 뒤 track() 에 넘기세요.



6. 일별 · 모델별 리포트

쌓였으면 뽑아야죠. 쿼리 두 개면 아침 점검 루틴이 됩니다.

def daily_report(days=7):
    with sqlite3.connect(DB_PATH) as conn:
        rows = conn.execute("""
            SELECT substr(ts, 1, 10) AS day,
                   provider, model,
                   SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens),
                   ROUND(SUM(cost_usd), 4), COUNT(*)
            FROM llm_usage
            WHERE ts >= datetime('now', ?)
            GROUP BY day, provider, model
            ORDER BY day DESC, SUM(cost_usd) DESC
        """, (f"-{days} days",)).fetchall()
    print(f"{'day':10} {'provider':10} {'model':18} {'in':>9} {'out':>8} {'cached':>8} {'USD':>9} {'calls':>6}")
    for day, prov, model, tin, tout, tcache, usd, calls in rows:
        print(f"{day:10} {prov:10} {model:18} {tin:>9,} {tout:>8,} {tcache:>8,} {usd:>9.4f} {calls:>6}")

daily_report(days=3)
day        provider   model                     in      out   cached       USD  calls
2026-07-06 anthropic  claude-opus-4-8      812,440  103,215  611,020    6.9464    142
2026-07-06 google     gemini-2.5-flash   1,204,331   88,410        0    0.5824    203
2026-07-06 upstage    solar-pro3           311,208   40,112        0    0.0708     67
2026-07-05 anthropic  claude-opus-4-8      744,102   96,882  548,330    6.4166    131
2026-07-05 openai     gpt-5.4-mini         188,270   21,404        0    0.2375     54

이 출력에서 바로 읽히는 게 있어요. 호출 수는 Gemini 가 가장 많은데 돈은 Claude 가 90% 이상을 가져가죠. “비용 절감 = 호출 수 줄이기”가 아니라 “비싼 모델이 가는 트래픽을 다시 보기”라는 방향이 데이터에서 나옵니다. 그리고 Claude 의 cached 컬럼이 입력의 절반을 넘는 것도 보이는데, 프롬프트 캐싱이 실제로 일하고 있다는 증거예요.

용도별로 보고 싶으면 GROUP BYapp 을 추가하면 됩니다.

SELECT app, ROUND(SUM(cost_usd), 2) AS usd
FROM llm_usage
WHERE ts >= datetime('now', '-30 days')
GROUP BY app ORDER BY usd DESC;

💡 원화로 보고 싶다면 리포트 출력에서만 환산하세요. 환율은 매일 변하니까 DB 에는 USD 로 저장하고, 표시할 때 usd * 환율 로 계산하는 게 뒤탈이 없습니다.



7. 정리

  • 가격표는 PRICING 딕셔너리 하나로 — 개정 시 여기만 수정
  • normalize_usage() 가 4사 4색 스키마를 통일 (Gemini thinking 가산, 캐시 이중가산 방지 포함)
  • estimate_cost() — 1M 단위 상수로 단위 실수 차단
  • SQLite llm_usage 테이블 + app 태그로 용도별 추적
  • track() 래퍼 — 기존 호출 코드에 한 줄만 추가
  • 일별 리포트에서 “호출 수와 비용은 다르게 움직인다”를 눈으로 확인

여기까지 만들면 “이번 달 얼마 썼지?”에 초 단위로 답할 수 있어요. 다만 이건 어디까지나 우리 쪽 관측치입니다. 실제 청구서와는 반올림, 서버 도구 과금, 무료 크레딧 차감 같은 이유로 어긋날 수 있어요.

일단 오늘은 여기까지….. 다음 글에서는 각 사의 공식 비용 API(Anthropic Usage & Cost API, OpenAI Usage/Costs API)로 추정치를 청구서와 대사하고, 게이트웨이 예산 관리와 Slack 알림까지 운영 루틴을 완성해볼게요.


← 이전 글: (1/3) LLM API 비용 추적 — Claude·OpenAI·Gemini·Upstage 과금 구조와 usage 필드다음 글 →: (3/3) LLM API 비용 추적 — 공식 비용 API 대사와 예산 알림 자동화