8 분 소요

💸 LLM API 비용 추적 6부작 — Claude · OpenAI · Gemini · Upstage, 네 곳에 흩어진 API 비용을 "과금 구조 이해 → 통합 트래커 → 공식 API 대사·알림 → 키별 귀속 → 대시보드 → 모델 라우팅"으로 끝까지 잡았습니다. 전체 6편.
  1. 과금 구조와 usage 필드 — 4사 가격표 한눈에
  2. 파이썬으로 4사 통합 비용 트래커 만들기
  3. 공식 비용 API 대사와 예산 알림 자동화
  4. 키가 난립할 때 — 키 인벤토리와 키별 비용 귀속지금 글
  5. Grafana 대시보드로 비용 한눈에 보기
  6. 모델 라우팅 자동화 — 쉬운 작업은 싼 모델로

Summary

3편까지로 “이번 달 총 얼마 썼는지”는 잡혔어요. 그런데 팀 단위로 가면 다음 질문이 바로 날아옵니다 — “그래서 누가 쓴 건데?”

흔한 시나리오예요. 팀원들이 Claude·OpenAI·Gemini·Upstage 를 골고루 쓰면서 각자 콘솔에서 API 키를 만듭니다. 실험용으로 하나, 데모용으로 하나, 프로덕션용으로 하나… 어느 날 청구서가 평소의 세 배로 뛰었는데, 조직 합계 금액만 보이고 어느 키가, 누구의 어떤 작업이 태운 돈인지 아무도 답을 못 해요. 이번 편은 이 상태를 정리하는 이야기입니다.

💡 이 글에서 다루는 것

  • 키 난립이 만드는 세 가지 문제 — 귀속 불가 · 회수 불가 · 통제 불가
  • 0단계: 키 인벤토리 — Anthropic Admin API 로 키 전수 조사 + 네이밍 규칙
  • Anthropic 키별 비용 귀속 — usage_report 의 api_key_id 그룹핑 + 가격표 달러화
  • 달러를 공식으로 귀속하려면 — 워크스페이스 · 프로젝트 분리
  • 키 단위 집계 API 가 없는 Gemini · Upstage 대응
  • 난립 자체를 끝내는 구조 — 진짜 키는 금고에, 사람에게는 가상 키
  • 월 1회 키 감사 루틴 (유령 키 비활성화)



1. 키 난립이 만드는 세 가지 문제

키가 늘어나는 과정 자체는 자연스러워요. 콘솔에서 키 하나 만드는 데 10초면 되고, 막는 사람도 없거든요. 문제는 그렇게 쌓인 키들이 세 가지를 동시에 무너뜨린다는 점입니다.

문제 증상
귀속 불가 청구서는 조직 합계뿐. “누구의 어떤 작업”인지 답할 수 없음
회수 불가 실험이 끝나도, 담당자가 바뀌어도 키는 살아있음. 어떤 키를 꺼도 되는지 아무도 모름
통제 불가 키별 한도가 없어서, 폭주하는 루프 하나가 조직 전체 예산을 태움

여기에 보안 문제도 따라옵니다. 키가 많을수록 노트북·CI 설정·공유 문서 어딘가에 흘러 있을 확률이 올라가고, 유출됐을 때 “어느 키인지 몰라서” 전부 로테이션하는 대참사가 벌어져요.

해법은 순서가 있습니다. 목록을 만들고(인벤토리) → 키별로 비용을 붙이고(귀속) → 구조를 바꿔서(가상 키) 다시는 난립하지 않게 만드는 순서예요.



2. 0단계 — 키 인벤토리 만들기

정리는 목록에서 시작합니다. “우리 조직에 키가 몇 개고, 각각 이름이 뭔가”부터 답할 수 있어야 해요.

Anthropic 은 3편에서 쓴 Admin API 키(sk-ant-admin01-...)로 키 목록을 바로 뽑을 수 있습니다.

curl "https://api.anthropic.com/v1/organizations/api_keys?limit=100&status=active" \
  --header "anthropic-version: 2023-06-01" \
  --header "x-api-key: $ANTHROPIC_ADMIN_KEY"

응답에는 키마다 이런 정보가 들어있어요.

{
  "id": "apikey_01Rj2N8SVvo6BePZj99NhmiT",
  "type": "api_key",
  "name": "rag-experiment",
  "workspace_id": "wrkspc_01JwQvzr7rXLA5AGx3HKfFUJ",
  "created_at": "2026-05-02T09:14:03Z",
  "partial_key_hint": "sk-ant-api03-R2D...igAA",
  "status": "active"
}

workspace_idstatus=inactive 필터도 되고, 여기서 받은 id(apikey_...)가 다음 장에서 비용을 붙일 열쇠가 됩니다. 참고로 키 생성은 Admin API 로 안 되고 콘솔에서만 가능해요 — 관리 API 는 조회·이름 변경·비활성화까지만 담당합니다. 상세 스펙은 Admin API 문서에 있어요.

나머지 세 곳은 인벤토리의 형태가 조금씩 다릅니다.

프로바이더 키 목록을 보는 곳 특징
Anthropic Admin API /v1/organizations/api_keys API 로 전수 조사 가능
OpenAI 콘솔 또는 조직 관리 API (레퍼런스) 키가 프로젝트 소속
Gemini Google Cloud 콘솔 키가 GCP 프로젝트 소속
Upstage 콘솔 API Keys 화면 콘솔 조회만

목록을 만들었으면 바로 네이밍 규칙을 정하세요. 이번에 정리하면서 저희가 정착시킨 형식은 <팀>-<용도>-<환경> 입니다.

좋은 이름 나쁜 이름
mlops-doc-summary-prod test
data-rag-experiment-dev my-key-2
chatbot-prod (이름 없음)

이름만 규칙대로 붙어 있어도 인벤토리의 절반은 끝나요. 그리고 규칙에 안 맞는 키·이름 없는 키는 그 자체로 “소유자를 찾거나 끌 후보”라는 신호가 됩니다.



3. Anthropic — 키별 비용 귀속

이제 본론이에요. 3편에서 쓴 usage_reportgroup_byapi_key_id 를 받습니다. 즉 키별 토큰 사용량을 조직 레벨에서 공식으로 뽑을 수 있어요.

curl "https://api.anthropic.com/v1/organizations/usage_report/messages?\
starting_at=2026-06-30T00:00:00Z&\
group_by[]=api_key_id&\
group_by[]=model&\
bucket_width=1d" \
  --header "anthropic-version: 2023-06-01" \
  --header "x-api-key: $ANTHROPIC_ADMIN_KEY"

model 을 같이 그룹핑한 이유가 있어요 — usage_report 는 토큰 수만 주기 때문에, 달러로 바꾸려면 모델별 단가를 곱해야 하거든요. 2편의 PRICING 딕셔너리를 그대로 재사용해서 “지난 7일 키별 리더보드”를 만들어 봅니다.

# key_leaderboard.py — 지난 7일, Anthropic 키별 추정 비용
import os
from collections import defaultdict
from datetime import date, timedelta

import requests

from llm_cost import MILLION, PRICING   # 2편에서 만든 가격표 재사용

BASE = "https://api.anthropic.com/v1/organizations"
HEADERS = {
    "anthropic-version": "2023-06-01",
    "x-api-key": os.environ["ANTHROPIC_ADMIN_KEY"],
}

def key_names():
    """api_key_id → 키 이름 매핑을 만든다."""
    names, after = {}, None
    while True:
        params = {"limit": 100} | ({"after_id": after} if after else {})
        body = requests.get(f"{BASE}/api_keys", headers=HEADERS, params=params).json()
        names |= {k["id"]: k["name"] for k in body["data"]}
        if not body.get("has_more"):
            return names
        after = body["last_id"]

def key_usage(days=7):
    """usage_report 를 (키, 모델)별 토큰 합계로 집계한다."""
    start = date.today() - timedelta(days=days)
    totals = defaultdict(lambda: defaultdict(int))
    params = {
        "starting_at": f"{start}T00:00:00Z",
        "bucket_width": "1d",
        "limit": days,
        "group_by[]": ["api_key_id", "model"],
    }
    while True:
        body = requests.get(
            f"{BASE}/usage_report/messages", headers=HEADERS, params=params
        ).json()
        for bucket in body["data"]:
            for row in bucket["results"]:
                t = totals[(row["api_key_id"], row["model"])]
                t["in"] += row["uncached_input_tokens"]
                t["cached"] += row["cache_read_input_tokens"]
                t["out"] += row["output_tokens"]
        if not body.get("has_more"):
            return totals
        params["page"] = body["next_page"]

names = key_names()
spend = defaultdict(float)
for (key_id, model), t in key_usage().items():
    price = PRICING.get(("anthropic", model))
    if price is None:
        continue    # 가격표에 없는 모델 — 나오면 PRICING 에 한 줄 추가
    price_in, price_out, price_cached = price
    usd = (t["in"] * price_in + t["out"] * price_out + t["cached"] * price_cached) / MILLION
    spend[key_id or "(Workbench)"] += usd

print("지난 7일 Anthropic 키별 추정 비용")
for i, (key_id, usd) in enumerate(sorted(spend.items(), key=lambda x: -x[1]), 1):
    print(f"{i}. {names.get(key_id, key_id):<28} ${usd:7.2f}")
지난 7일 Anthropic 키별 추정 비용
1. data-rag-experiment-dev      $  41.27
2. chatbot-prod                 $  18.55
3. apikey_01Rj2N8SVvo6BePZ...   $  12.03
4. (Workbench)                  $   3.90

이 네 줄이 이번 편의 핵심 산출물이에요. 비용의 절반이 프로덕션이 아니라 실험용 dev 키에서 나오고 있다는 것, 그리고 3위의 정체불명 키(인벤토리에 이름이 없어서 id 가 그대로 찍힌)가 소유자 찾기 1순위라는 것이 바로 보입니다.

두 가지 함정을 짚고 갈게요.

⚠️ cost_report 는 키 단위를 지원하지 않아요. 달러를 직접 주는 cost_report 의 그룹핑은 workspace_iddescription 까지입니다. 그래서 키별 달러는 위처럼 usage × 가격표로 추정하는 구조가 되고, 달러를 공식으로 귀속하고 싶으면 팀별로 워크스페이스를 분리한 뒤 group_by[]=workspace_id 를 쓰는 게 정석이에요. 키 몇 개 수준의 팀이면 추정으로 충분하고, 부서 단위 정산(차지백)이 필요해지면 워크스페이스를 나누세요.

💡 콘솔 Workbench 에서 날린 호출은 API 키와 연결이 안 돼서 api_key_idnull 로 옵니다. 위 스크립트에서 (Workbench) 로 묶은 게 그 몫이에요. “키 합계가 조직 합계보다 작은” 미스터리의 정체가 보통 이겁니다.



4. OpenAI — 프로젝트가 귀속 단위

OpenAI 는 애초에 키가 프로젝트 소속이라, 귀속의 정답이 정해져 있어요 — 서비스·팀마다 프로젝트를 나누고, 키는 그 안에서 발급합니다. 그러면 3편에서 본 Costs API 의 group_by=project_id 가 곧 팀별 공식 달러 귀속이 돼요.

curl "https://api.openai.com/v1/organization/costs?\
start_time=1751068800&bucket_width=1d&group_by=project_id" \
  --header "Authorization: Bearer $OPENAI_ADMIN_KEY"

프로젝트 안에서 키 단위까지 내려가고 싶으면 Usage API 쪽이 api_key_id 그룹핑과 api_key_ids 필터를 지원합니다. Anthropic 과 같은 방식으로 usage × 가격표 추정을 하면 돼요. 상세 파라미터는 OpenAI Usage API 레퍼런스에서 확인하세요 (3편에서 적었듯 이 문서 페이지는 봇 차단이 있어서, 브라우저로 여는 걸 추천드립니다).

한 줄 요약 — OpenAI 에서 “팀원마다 default project 에 키 발급”은 안티패턴이에요. 프로젝트를 나누는 것만으로 귀속 문제의 대부분이 사라집니다.



5. Gemini · Upstage — 집계 API 가 없으면 구조로 푼다

3편에서 본 것처럼 이 두 곳은 조직 집계 API 자체가 없어서, 키별 귀속도 API 로는 못 합니다. 대신 구조를 그렇게 만들면 돼요.

Gemini 는 AI Studio 키가 GCP 프로젝트에 묶여 있다는 점을 씁니다. 팀·용도별로 GCP 프로젝트를 분리하면 Cloud Billing 청구서가 프로젝트 단위로 나뉘니까, 프로젝트가 곧 귀속 단위가 돼요. 3편에서 말한 BigQuery 결제 내보내기 + 프로젝트 라벨 조합이 여기서도 그대로 정답입니다.

Upstage 는 콘솔 합계뿐이라 외부 집계 수단이 없어요. 그래서 두 가지를 겹칩니다.

  1. 용도별로 키를 분리 발급 — 어차피 콘솔에서 키별 구분이 안 되니, 최소한 키 이름이라도 2장의 네이밍 규칙으로.
  2. 2편 트래커의 app 태그에 소유자를 인코딩track(...)app 파라미터를 "팀/용도" 형식으로 통일하면, SQLite 쿼리에서 팀별 집계가 바로 나옵니다.
# app 태그 규칙: "<팀>/<용도>" — substr 로 팀 축 집계가 가능해진다
response = track("upstage", "solar-pro3", upstage_client.chat.completions.create(
    model="solar-pro3",
    messages=[{"role": "user", "content": "..."}],
), app="mlops/doc-summary")
# 팀별 지난 7일 합계
rows = conn.execute("""
    SELECT substr(app, 1, instr(app, '/') - 1) AS team,
           ROUND(SUM(cost_usd), 2)
    FROM llm_usage
    WHERE ts >= datetime('now', '-7 days')
    GROUP BY team ORDER BY 2 DESC
""").fetchall()
print(rows)
[('mlops', 12.41), ('data', 7.08), ('platform', 0.66)]

공식 API 가 없는 프로바이더일수록 이 응답 시점 태깅이 유일한 귀속 수단이라는 것, 3편의 결론이 여기서도 반복됩니다.



6. 난립 자체를 끝내기 — 진짜 키는 금고에, 사람에게는 가상 키

여기까지는 이미 벌어진 난립을 수습하는 이야기였어요. 재발을 막으려면 키 발급의 방향을 뒤집어야 합니다. 팀원이 프로바이더 콘솔에서 키를 만드는 게 아니라 —

  • 진짜 키는 프로바이더당 1개만. 시크릿 매니저에 넣고 게이트웨이(3편의 LiteLLM 프록시)에만 등록합니다.
  • 사람·서비스에게는 가상 키만. 프록시가 발급하는 키라서, 프로바이더 콘솔에는 아무것도 늘어나지 않아요.

3편 6장에서 예산 상한용으로 봤던 /key/generate 를 이번엔 발급 대장 관점으로 씁니다. key_alias 에 2장의 네이밍 규칙을 그대로 태우는 게 포인트예요.

# 팀원 온보딩 = 가상 키 한 장 발급
curl -s http://localhost:4000/key/generate \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key_alias": "mlops-rag-experiment-dev",
    "max_budget": 30.0,
    "budget_duration": "30d"
  }'

이 구조가 좋은 이유는 1~5장의 문제를 한 번에 닫기 때문입니다.

문제 가상 키 체계에서는
귀속 모든 호출이 프록시를 지나니 키별 지출이 자동 집계 (/key/info 로 조회)
회수 퇴사·실험 종료 시 프록시에서 그 키만 즉시 폐기. 진짜 키는 무사
통제 키마다 max_budget — 폭주해도 그 키만 멈춤
유출 유출돼도 프로바이더 키가 아니라 가상 키. 폐기 한 번으로 끝

⚠️ 물론 게이트웨이 운영 부담은 3편에서 말한 그대로예요. 혼자 쓰는 프로젝트라면 여기까지 필요 없고, “팀원이 5명을 넘고 키가 10개를 넘는” 시점이 도입 타이밍이라고 생각합니다.



7. 월 1회 키 감사 루틴

마지막 부품은 사람의 루틴입니다. 달력에 월 1회로 박아두고 아래를 돌리세요.

  • 인벤토리 diff — 지난달 목록과 비교해서 새 키가 있으면 네이밍 규칙·소유자 확인
  • 유령 키 색출 — 3장의 리더보드에서 30일 사용량이 0 인 키를 찾아 비활성화
  • 정체불명 키 처리 — 이름 없는 키는 소유자를 찾고, 2주간 손들 사람이 없으면 끔
  • 회수 이벤트 반영 — 퇴사자·종료된 프로젝트의 키 폐기
  • 로테이션 — 프로덕션 키는 분기 1회 교체

Anthropic 은 비활성화도 Admin API 로 됩니다. 삭제가 아니라 상태 변경이라, “혹시 뭐가 죽으려나” 걱정 없이 일단 꺼보고 문제가 생기면 되살릴 수 있어요.

curl --request POST "https://api.anthropic.com/v1/organizations/api_keys/apikey_01Rj2N8SVvo6BePZj99NhmiT" \
  --header "anthropic-version: 2023-06-01" \
  --header "content-type: application/json" \
  --header "x-api-key: $ANTHROPIC_ADMIN_KEY" \
  --data '{"status": "inactive"}'

실제로 이 루틴을 처음 돌리면 체감상 키의 3분의 1이 “누구 건지 모르는데 살아있는” 키로 나옵니다. 그걸 끄는 것만으로도 유출 표면이 확 줄어요.



8. 시리즈 정리

4편까지의 시스템을 한 그림으로 요약하면 이렇습니다.

계층 도구 답하는 질문
구조 이해 (1편) 가격표 · usage 필드 뭐가 얼마인가
상시 관측 (2편) track() + SQLite 지금 어디에 쓰고 있나
정산 대사 (3편) 공식 Usage/Cost API 청구서와 맞나
통제 (3편) 게이트웨이 예산 초과를 막을 수 있나
귀속 (4편) 키 인벤토리 · api_key_id 그룹핑 · 가상 키 누가 쓰고 있나

“비용이 폭발했는데 추적할 방법이 없다”는 처음의 문제는, 이제 세 개의 답으로 쪼개졌어요. 얼마 쓰는지는 트래커가, 청구서와 맞는지는 대사 스크립트가, 누가 쓰는지는 키별 리더보드가 답합니다. 🎉

일단 오늘은 여기까지….. 다음 글에서는 이 비용 데이터를 Grafana 대시보드에 올려서, 추세가 한눈에 보이는 상태를 만들어볼게요.


← 이전 글: (3/6) LLM API 비용 추적 — 공식 비용 API 대사와 예산 알림 자동화다음 글 →: (5/6) LLM API 비용 추적 — Grafana 대시보드로 비용 한눈에 보기