토큰 비용을 기능 단위로 관측하기
토큰 비용을 기능 단위로 관측하기
전체 토큰 사용량과 월 청구액만으로는 어떤 기능이 비용을 만들었고 그 비용이 가치로 이어졌는지 알 수 없다. LLM 비용은 기능, 사용자 흐름, 호출 단계, 모델, 캐시, 재시도, 최종 결과와 연결해야 한다. 이 글에서는 요청 한 건의 원가를 재구성할 수 있는 이벤트 모델과 비용 배분 방법, 품질·지연 시간을 함께 보는 운영 지표를 정리한다.
목차
- #월 청구액만 보면 답을 찾을 수 없다
- #토큰 수와 실제 비용을 구분하기
- #관측 단위를 먼저 정의하기
- #기능과 호출 단계를 안정적인 이름으로 분류하기
- #한 번의 사용자 요청에 발생한 비용 합치기
- #캐시와 재시도와 실패 비용 다루기
- #스트리밍 응답의 미완료 비용 계산하기
- #비용만 줄이다가 품질을 잃지 않기
- #예시 코드로 사용량 수집기 만들기
- #집계 테이블과 대시보드 설계
- #비용 이상을 탐지하는 방법
- #최적화 순서와 실험 방법
- #운영 점검표
- #결론
- #관련 노트
월 청구액만 보면 답을 찾을 수 없다
LLM 기능을 처음 붙였을 때는 공급자 대시보드의 월간 토큰과 청구액만 봐도 충분해 보인다. 사용자가 늘고 기능이 복잡해지면 이 숫자만으로는 다음 질문에 답할 수 없다.
- 고객 상담과 문서 요약 중 어느 기능이 비용을 더 쓰는가
- 유료 고객 한 명을 유지하는 데 평균 얼마의 모델 비용이 드는가
- 답변 생성보다 검색 쿼리 재작성이나 평가 단계가 더 비싼가
- 실패한 도구 호출과 재시도가 비용의 몇 퍼센트를 차지하는가
- 긴 컨텍스트가 실제 품질을 높였는가
- 캐시를 적용한 뒤 비용은 줄었지만 오래된 답변이 늘지는 않았는가
- 비용이 두 배가 된 이유가 트래픽 증가인지 요청당 원가 증가인지
월 청구액은 회계에는 필요하지만 원인 분석에는 너무 늦고 거칠다. 비용을 줄이려면 청구서를 모델 호출과 사용자 결과까지 거슬러 올라갈 수 있어야 한다.
월 비용 증가
-> support-answer 기능
-> escalation-summary 흐름
-> evidence-verification 단계
-> 평균 입력 토큰 2.4배 증가
-> 중복 대화 기록 포함
이 정도로 분해되어야 “저렴한 모델로 바꾸자”가 아니라 “검증 프롬프트에 중복으로 들어가는 대화 기록을 제거하자”처럼 정확한 결정을 내릴 수 있다.
가장 적은 토큰을 쓰는 것이 아니라 사용자에게 유효한 결과 한 건을 만드는 데 든 비용을 알고 조절하는 것이다.
토큰 수와 실제 비용을 구분하기
토큰 수는 사용량이고 비용은 가격 정책을 적용한 결과다. 같은 1,000토큰이라도 입력과 출력, 캐시 적중 여부, 모델, 배치 처리 여부에 따라 단가가 다를 수 있다.
estimated cost =
input_tokens × input_rate
+ cached_input_tokens × cached_input_rate
+ output_tokens × output_rate
+ tool_or_hosting_cost
여기서 단가는 예시일 뿐 코드에 상수로 박아 두면 안 된다. 가격은 바뀔 수 있고 공급자가 최종 청구 시 반올림이나 별도 정책을 적용할 수 있다. 관측 시점에는 예상 비용을 계산하되, 실제 청구 데이터와 주기적으로 대조한다.
| 값 | 의미 | 사용 목적 |
|---|---|---|
| input tokens | 모델에 전달된 입력 | 컨텍스트 비대화 분석 |
| cached input tokens | 할인 적용된 재사용 입력 | 캐시 효과 분석 |
| output tokens | 모델이 생성한 출력 | 응답 길이와 조기 중단 분석 |
| reasoning tokens | 제공되는 경우 내부 추론 사용량 | 모델·노력 수준 비교 |
| estimated cost | 당시 가격표로 계산한 예상액 | 실시간 기능 원가 |
| billed cost | 공급자 청구 기준 실제액 | 회계 대조와 보정 |
금액만 저장하면 나중에 가격이 바뀌었을 때 원인을 분리하기 어렵다. 원시 사용량, 적용한 가격표 버전, 계산된 금액을 함께 저장한다.
{
"inputTokens": 1820,
"cachedInputTokens": 900,
"outputTokens": 340,
"priceBookVersion": "provider-a:2026-05-01",
"estimatedCostMicros": 2840
}
부동소수점 금액 대신 마이크로 달러나 최소 통화 단위의 정수를 쓰면 집계 오차를 줄일 수 있다. 다중 통화를 쓴다면 원 통화와 환산 기준일도 남긴다.
관측 단위를 먼저 정의하기
LLM 시스템에는 서로 다른 수명의 단위가 존재한다.
flowchart LR
S[Session] --> T[User Turn]
T --> R[Application Request]
R --> C1[Model Call: plan]
R --> C2[Model Call: answer]
R --> C3[Model Call: verify]
C2 --> A1[Attempt 1]
C2 --> A2[Retry]- Session: 사용자의 대화나 작업 세션
- Turn: 사용자 메시지 하나와 그에 대한 결과
- Application request: 애플리케이션이 추적하는 논리적 작업
- Model call: 공급자 API를 호출한 한 번
- Attempt: 네트워크 오류나 정책 재시도로 발생한 실제 시도
이 구분 없이 request_count만 집계하면 재시도가 트래픽으로 보이거나, 여러 모델 호출로 구성된 에이전트 작업이 한 호출처럼 보인다.
각 이벤트에는 연결 ID를 둔다.
interface TraceIdentity {
sessionId: string;
turnId: string;
requestId: string;
callId: string;
attempt: number;
traceId: string;
}
사용자 메시지 원문을 ID 대신 사용해서는 안 된다. 개인정보가 섞일 뿐 아니라 같은 질문도 서로 다른 사용자 흐름에서 다른 의미를 갖는다.
기능과 호출 단계를 안정적인 이름으로 분류하기
URL이나 함수명을 기능명으로 그대로 쓰면 리팩터링할 때 시계열이 끊긴다. 제품 관점의 낮은 카디널리티 이름을 따로 정의한다.
type AiFeature =
| "support-answer"
| "ticket-summary"
| "document-search"
| "meeting-action-items";
type AiStage =
| "query-rewrite"
| "classification"
| "planning"
| "answer-generation"
| "evidence-verification"
| "output-repair";
feature는 사용자가 쓰는 기능이고 stage는 그 기능 내부의 모델 호출 역할이다. 한 기능이 여러 단계를 갖고, 같은 단계가 여러 기능에서 재사용될 수 있다.
| 필드 | 좋은 예 | 좋지 않은 예 |
|---|---|---|
| feature | support-answer |
/api/v3/chat/stream |
| stage | evidence-verification |
callModel2 |
| outcome | completed, aborted |
자유 텍스트 오류 |
| model alias | balanced-answer-v2 |
코드 곳곳의 공급자 모델명 |
| experiment | context-trim-b |
프롬프트 전체 문자열 |
기능명에 사용자 ID, 문서 ID, 쿼리 문자열을 넣으면 메트릭 카디널리티가 폭발한다. 이런 값은 접근 제어된 로그나 트레이스 속성으로 제한하고 시계열 메트릭 라벨에는 넣지 않는다.
제품 코드에는 balanced-answer-v2 같은 내부 별칭을 사용하고 실제 공급자 모델과 설정의 매핑을 버전 관리하면, 교체 전후 비용과 품질을 같은 기능 축에서 비교하기 쉽다.
한 번의 사용자 요청에 발생한 비용 합치기
에이전트 한 번이 계획, 검색어 재작성, 답변 생성, 검증, 출력 복구를 거칠 수 있다. 각각만 보면 저렴해 보여도 전체 비용은 커진다.
request req-204
planning $0.0011
query-rewrite $0.0003
answer-generation $0.0068
evidence-verification $0.0042
output-repair $0.0017
--------------------------------
total $0.0141
요청 단위 집계에는 다음 결과도 연결해야 한다.
- 사용자에게 최종 응답이 전달되었는가
- 목표한 도구 실행이 완료되었는가
- 사람이 다시 수정했는가
- 사용자가 곧바로 재질문했는가
- 안전 정책 때문에 보류되었는가
- 사용자가 스트림을 중간에 취소했는가
비용 이벤트와 결과 이벤트는 생성 시점이 다를 수 있다. 모델 호출 직후 사용량을 기록하고, 요청 완료 시 결과를 별도 기록한 뒤 requestId로 조인하는 편이 안전하다. 결과를 기다렸다가 한 번에 기록하면 프로세스가 죽을 때 비용 데이터까지 잃을 수 있다.
캐시와 재시도와 실패 비용 다루기
캐시는 비용을 줄이지만 집계 방식에 따라 효과가 과장되거나 숨겨진다. 총 입력 토큰과 캐시 적중 토큰을 모두 기록해 “캐시가 없었다면 얼마였는지”도 계산할 수 있다.
gross cost: 캐시 할인이 없다고 가정한 비용
net cost: 실제 캐시 할인을 적용한 비용
savings: gross cost - net cost
재시도는 같은 논리 요청에 속한 별도 attempt로 기록한다.
{
"requestId": "req-204",
"callId": "call-answer",
"attempt": 2,
"retryReason": "provider_timeout",
"outcome": "completed"
}
첫 시도가 공급자에게 도달해 토큰을 썼지만 응답을 받지 못했을 수 있다. 사용량이 누락된 실패는 0으로 단정하지 말고 usageKnown: false로 남긴다. 청구 데이터와 대조해 보정해야 한다.
실패 비용도 유형별로 나눈다.
| 실패 유형 | 비용 해석 |
|---|---|
| 사용자 취소 | 일부 출력은 이미 과금되었을 수 있음 |
| 네트워크 재시도 | 첫 시도 과금 여부가 불명확할 수 있음 |
| JSON 파싱 실패 | 생성 비용과 repair 호출 비용 모두 포함 |
| 안전 정책 차단 | 내부 검증 비용은 들었지만 답변은 미전달 |
| 도구 실행 실패 | 계획·도구 선택·후속 설명 비용까지 발생 |
성공 요청의 평균 비용만 보면 실패 때문에 새는 비용이 보이지 않는다. 전체 비용에서 aborted, failed, repaired가 차지하는 비율을 별도로 본다.
스트리밍 응답의 미완료 비용 계산하기
스트리밍은 사용자가 첫 토큰을 빨리 받게 하지만 완료 여부와 사용량 기록 시점이 복잡하다. 사용자가 브라우저를 닫았다고 공급자 생성이 즉시 멈춘다는 보장은 없다. 취소 신호를 전파하고 실제 종료 이벤트를 기록해야 한다.
sequenceDiagram
participant U as User
participant A as App
participant M as Model API
participant O as Usage Store
U->>A: 답변 요청
A->>M: 스트림 시작
M-->>A: token chunks
A-->>U: 부분 응답
U-xA: 연결 종료
A->>M: cancel signal
M-->>A: final usage 또는 abort
A->>O: outcome=aborted, usage 기록다음 시각을 구분하면 분석하기 쉽다.
- 요청 시작 시각
- 첫 토큰 수신 시각
- 사용자 연결 종료 시각
- 취소 신호 전송 시각
- 공급자 스트림 종료 시각
사용자가 취소한 뒤에도 생성된 토큰이 많다면 취소 전파가 느리거나 동작하지 않는 것이다. 자세한 구현은 스트리밍 응답의 취소와 오류 처리와 연결된다.
비용만 줄이다가 품질을 잃지 않기
요청당 비용이 낮아졌다고 최적화가 성공한 것은 아니다. 답변 품질이 떨어져 재질문과 상담원 전환이 늘면 전체 고객 흐름의 비용은 오를 수 있다.
cost per call
< cost per completed request
< cost per resolved user task
예를 들어 저렴한 모델로 교체해 호출 비용이 30% 감소했지만 해결률이 80%에서 60%로 내려갔다면 해결 건당 비용은 오를 수 있다.
기존: 요청당 $0.010 / 해결률 0.80 = 해결당 $0.0125
변경: 요청당 $0.007 / 해결률 0.60 = 해결당 $0.0117
이 예에서는 여전히 조금 저렴하지만, 상담원 전환 비용이나 재시도 비용을 합치면 결과가 뒤집힐 수 있다. 그래서 비용 대시보드에는 다음을 함께 둔다.
- task success rate
- 근거가 필요한 답변의 지원률
- 사람이 수정한 비율
- 사용자 재질문율
- p95 지연 시간
- 안전 정책 위반률
- 해결된 작업당 총비용
max_output_tokens를 줄이면 즉시 비용이 감소하지만 설명이나 JSON이 중간에 잘려 repair 호출이 늘 수 있다. 잘린 응답률과 재시도 비용까지 함께 봐야 한다.
예시 코드로 사용량 수집기 만들기
다음 코드는 특정 공급자나 실제 프로젝트와 무관한 재구성 예시다.
interface TokenUsage {
input: number;
cachedInput: number;
output: number;
reasoning?: number;
}
interface UsageEvent {
occurredAt: string;
traceId: string;
requestId: string;
callId: string;
attempt: number;
feature: AiFeature;
stage: AiStage;
modelAlias: string;
providerModel: string;
outcome: "completed" | "failed" | "aborted";
usageKnown: boolean;
usage?: TokenUsage;
estimatedCostMicros?: number;
priceBookVersion?: string;
latencyMs: number;
}
가격 계산은 버전이 있는 가격표 객체에 맡긴다.
interface ModelRate {
inputPerMillion: number;
cachedInputPerMillion: number;
outputPerMillion: number;
reasoningPerMillion?: number;
}
function calculateCostMicros(
usage: TokenUsage,
rate: ModelRate,
): number {
const dollars =
usage.input / 1_000_000 * rate.inputPerMillion +
usage.cachedInput / 1_000_000 * rate.cachedInputPerMillion +
usage.output / 1_000_000 * rate.outputPerMillion +
(usage.reasoning ?? 0) / 1_000_000 *
(rate.reasoningPerMillion ?? 0);
return Math.round(dollars * 1_000_000);
}
입력 토큰 정의를 주의해야 한다. 공급자 응답에서 input이 캐시 토큰을 이미 포함한다면 위 식에서 중복 계산하지 않도록 비캐시 입력을 분리해야 한다. SDK 필드 이름만 보고 추측하지 말고 공급자 사용량 정의를 어댑터에 캡슐화한다.
async function callMeasuredModel<T>(
context: CallContext,
run: () => Promise<ModelResult<T>>,
): Promise<T> {
const startedAt = performance.now();
try {
const result = await run();
const normalized = usageAdapter.normalize(result.usage);
const priceBook = await priceBookStore.get(context.priceBookVersion);
await usageStore.append({
...context,
occurredAt: new Date().toISOString(),
outcome: "completed",
usageKnown: true,
usage: normalized,
estimatedCostMicros: calculateCostMicros(
normalized,
priceBook.rateFor(context.providerModel),
),
latencyMs: performance.now() - startedAt,
});
return result.value;
} catch (error) {
await usageStore.append({
...context,
occurredAt: new Date().toISOString(),
outcome: isAbortError(error) ? "aborted" : "failed",
usageKnown: false,
latencyMs: performance.now() - startedAt,
});
throw error;
}
}
관측 코드가 본 요청을 실패시키지 않게 비동기 버퍼나 내구성 있는 이벤트 스트림을 사용할 수 있다. 다만 무조건 오류를 삼키면 비용 데이터가 조용히 사라진다. 수집 실패율과 버퍼 적체량 자체도 경보 대상으로 둔다.
집계 테이블과 대시보드 설계
원시 이벤트는 조사에 유용하지만 대시보드는 일·기능·단계 단위로 미리 집계하면 효율적이다.
SELECT
DATE(occurred_at) AS usage_date,
feature,
stage,
model_alias,
COUNT(DISTINCT request_id) AS requests,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
SUM(estimated_cost_micros) AS cost_micros,
SUM(CASE WHEN outcome <> 'completed' THEN estimated_cost_micros ELSE 0 END)
AS non_success_cost_micros
FROM ai_usage_events
WHERE occurred_at >= :from
AND occurred_at < :to
GROUP BY usage_date, feature, stage, model_alias;
첫 화면에는 다음 네 질문에 답하는 그래프를 둔다.
- 총비용은 예산 안에 있는가
- 어느 기능과 단계가 비용을 만들었는가
- 요청당·성공당·해결당 비용은 어떻게 변했는가
- 비용 변화와 동시에 품질이나 지연이 나빠졌는가
테넌트별 비용이 필요해도 고객 이름을 메트릭 라벨에 직접 넣기보다는 제한된 분석 테이블에서 집계한다. 무료 체험 사용자의 남용 탐지와 고객별 원가 분석은 접근 권한도 다를 수 있다.
비용 이상을 탐지하는 방법
고정 월 예산 경보만으로는 늦다. 다음 이상 신호를 함께 본다.
- 기능별 요청당 토큰이 평소보다 급증
- output/input 비율이 갑자기 변경
- 캐시 적중률 급락
- retry attempt 비율 증가
- 동일 request 안의 model call 수 증가
- 성공률은 그대로인데 해결당 비용 증가
- 특정 프롬프트 버전 배포 직후 비용 변화
단순 임계값부터 시작할 수 있다.
function shouldAlert(current: CostWindow, baseline: CostWindow) {
const costRatio =
current.costPerCompletedRequest /
baseline.costPerCompletedRequest;
return (
current.requestCount >= 100 &&
costRatio >= 1.5 &&
current.successRate <= baseline.successRate + 0.02
);
}
트래픽이 적을 때 한두 요청이 평균을 왜곡하지 않도록 최소 표본을 둔다. 평균뿐 아니라 p50, p95와 분포도 확인한다. 긴 문서 몇 건 때문에 비용이 오른 것인지 모든 요청이 조금씩 비싸진 것인지 대응이 다르다.
최적화 순서와 실험 방법
비용이 높다고 바로 모델부터 바꾸면 품질 원인을 섞기 쉽다. 보통 다음 순서가 위험이 낮다.
- 중복 시스템 프롬프트와 대화 기록을 제거한다.
- 검색 결과의 개수와 청크 크기를 실제 평가셋으로 조정한다.
- 안정적인 접두 프롬프트에 캐시를 적용한다.
- 실패를 만드는 출력 형식과 불필요한 repair 호출을 줄인다.
- 간단한 분류·재작성 단계만 작은 모델로 라우팅한다.
- 품질 게이트를 유지한 채 주 답변 모델을 비교한다.
- 자주 반복되는 결과 중 최신성 위험이 낮은 것만 애플리케이션 캐시한다.
각 변경은 같은 트래픽 구간의 전후 비교보다 실험 그룹을 두는 편이 좋다. 요일, 고객 구성, 문서 길이가 달라지면 비용 차이를 최적화 효과로 오해할 수 있다.
experiment: context-trim-b
primary_metric: cost_per_resolved_task
quality_guards:
grounded_answer_rate: "decrease <= 1%"
escalation_rate: "increase <= 2%"
p95_latency: "increase <= 5%"
rollback:
high_risk_false_answer: "any confirmed case"
운영 점검표
결론
월간 토큰 합계는 얼마를 썼는지는 알려 주지만 왜 썼는지와 가치가 있었는지는 알려 주지 않는다. 비용을 개선하려면 각 모델 호출을 기능, 단계, 논리 요청, 재시도, 최종 결과와 연결해 사용자 작업 한 건의 원가를 재구성할 수 있어야 한다.
원시 토큰과 가격표 버전을 함께 저장하고, 캐시·실패·취소 비용을 숨기지 않으며, 비용을 품질과 성공률 옆에서 봐야 한다. 그래야 단순히 출력 길이를 자르거나 모델을 낮추는 대신 중복 컨텍스트, 잘못된 재시도, 과도한 검증 단계처럼 실제 원인을 고칠 수 있다.
좋은 비용 관측은 청구서를 설명하는 데서 끝나지 않는다. 다음 기능을 계속 운영할 가치가 있는지, 어느 최적화가 사용자 경험을 해치지 않고 효과를 냈는지 판단할 수 있게 한다.