토큰 비용을 기능 단위로 관측하기

토큰 비용을 기능 단위로 관측하기

한눈에 보기

전체 토큰 사용량과 월 청구액만으로는 어떤 기능이 비용을 만들었고 그 비용이 가치로 이어졌는지 알 수 없다. 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]

이 구분 없이 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

이 예에서는 여전히 조금 저렴하지만, 상담원 전환 비용이나 재시도 비용을 합치면 결과가 뒤집힐 수 있다. 그래서 비용 대시보드에는 다음을 함께 둔다.

출력 길이 제한의 함정

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;

첫 화면에는 다음 네 질문에 답하는 그래프를 둔다.

  1. 총비용은 예산 안에 있는가
  2. 어느 기능과 단계가 비용을 만들었는가
  3. 요청당·성공당·해결당 비용은 어떻게 변했는가
  4. 비용 변화와 동시에 품질이나 지연이 나빠졌는가

테넌트별 비용이 필요해도 고객 이름을 메트릭 라벨에 직접 넣기보다는 제한된 분석 테이블에서 집계한다. 무료 체험 사용자의 남용 탐지와 고객별 원가 분석은 접근 권한도 다를 수 있다.

비용 이상을 탐지하는 방법

고정 월 예산 경보만으로는 늦다. 다음 이상 신호를 함께 본다.

단순 임계값부터 시작할 수 있다.

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와 분포도 확인한다. 긴 문서 몇 건 때문에 비용이 오른 것인지 모든 요청이 조금씩 비싸진 것인지 대응이 다르다.

최적화 순서와 실험 방법

비용이 높다고 바로 모델부터 바꾸면 품질 원인을 섞기 쉽다. 보통 다음 순서가 위험이 낮다.

  1. 중복 시스템 프롬프트와 대화 기록을 제거한다.
  2. 검색 결과의 개수와 청크 크기를 실제 평가셋으로 조정한다.
  3. 안정적인 접두 프롬프트에 캐시를 적용한다.
  4. 실패를 만드는 출력 형식과 불필요한 repair 호출을 줄인다.
  5. 간단한 분류·재작성 단계만 작은 모델로 라우팅한다.
  6. 품질 게이트를 유지한 채 주 답변 모델을 비교한다.
  7. 자주 반복되는 결과 중 최신성 위험이 낮은 것만 애플리케이션 캐시한다.

각 변경은 같은 트래픽 구간의 전후 비교보다 실험 그룹을 두는 편이 좋다. 요일, 고객 구성, 문서 길이가 달라지면 비용 차이를 최적화 효과로 오해할 수 있다.

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"

운영 점검표

결론

월간 토큰 합계는 얼마를 썼는지는 알려 주지만 왜 썼는지와 가치가 있었는지는 알려 주지 않는다. 비용을 개선하려면 각 모델 호출을 기능, 단계, 논리 요청, 재시도, 최종 결과와 연결해 사용자 작업 한 건의 원가를 재구성할 수 있어야 한다.

원시 토큰과 가격표 버전을 함께 저장하고, 캐시·실패·취소 비용을 숨기지 않으며, 비용을 품질과 성공률 옆에서 봐야 한다. 그래야 단순히 출력 길이를 자르거나 모델을 낮추는 대신 중복 컨텍스트, 잘못된 재시도, 과도한 검증 단계처럼 실제 원인을 고칠 수 있다.

좋은 비용 관측은 청구서를 설명하는 데서 끝나지 않는다. 다음 기능을 계속 운영할 가치가 있는지, 어느 최적화가 사용자 경험을 해치지 않고 효과를 냈는지 판단할 수 있게 한다.

관련 노트