(4/6) LLM API 비용 추적 — 키가 난립할 때: 키 인벤토리와 키별 비용 귀속
- 과금 구조와 usage 필드 — 4사 가격표 한눈에
- 파이썬으로 4사 통합 비용 트래커 만들기
- 공식 비용 API 대사와 예산 알림 자동화
- 키가 난립할 때 — 키 인벤토리와 키별 비용 귀속 ← 지금 글
- Grafana 대시보드로 비용 한눈에 보기
- 모델 라우팅 자동화 — 쉬운 작업은 싼 모델로
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_id 나 status=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_report 는 group_by 에 api_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_id와description까지입니다. 그래서 키별 달러는 위처럼 usage × 가격표로 추정하는 구조가 되고, 달러를 공식으로 귀속하고 싶으면 팀별로 워크스페이스를 분리한 뒤group_by[]=workspace_id를 쓰는 게 정석이에요. 키 몇 개 수준의 팀이면 추정으로 충분하고, 부서 단위 정산(차지백)이 필요해지면 워크스페이스를 나누세요.
💡 콘솔 Workbench 에서 날린 호출은 API 키와 연결이 안 돼서
api_key_id가null로 옵니다. 위 스크립트에서(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 는 콘솔 합계뿐이라 외부 집계 수단이 없어요. 그래서 두 가지를 겹칩니다.
- 용도별로 키를 분리 발급 — 어차피 콘솔에서 키별 구분이 안 되니, 최소한 키 이름이라도 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 대시보드로 비용 한눈에 보기