Claude Code 이용 한도 소진 원인과 캐시 만료 추적 사례

시작하며
Claude Code를 정액제 플랜으로 사용하다 보면, “별로 큰 작업도 안 했는데 이번 주 이용 한도가 벌써 얼마 안 남았다”, “체감보다 더 빨리 줄어든다”라고 느끼는 경우가 있습니다. 화면에 보이는 것은 전체 한도의 잔량이나 경고뿐이고, 어떤 작업이 얼마나 소비했는지의 내역은 어디에도 표시되지 않습니다.
무엇이 원인인지 알아내기 위해, 손에 남는 대화 기록(.jsonl)과 로컬에 띄운 Langfuse v4를 대조하여 턴 단위로 토큰 소비와 비용을 볼 수 있는 구조를 만들었습니다.
그 결과 알아낸 것은 “시간을 두고 같은 세션을 재개했을 때, 프롬프트 캐시 만료로 인한 재기록 비용”이었습니다. 이 글에서는 손에 있는 로그에서 트레이스를 역산해 캐시의 내역을 추적하는 방법과, 거기서 파악한 운영상의 요령을 정리합니다.
개인용 머신 1대에서의 운영 실적이며, 팀 단위 측정은 아닙니다. 또한 시각화한 뒤의 절감률까지는 측정하지 않았기 때문에, 구체적인 절감 수치는 나오지 않습니다.
왜 한도가 빨리 소진되는가: 1턴에 2달러가 사라진 실측값
실제 내역은 이런 형태입니다. 15턴 세션을 집계한 결과, 전체 비용은 3.93달러였습니다.
| 번호 | 시작 시각 | 발화 요약 | Input | Output | Cache-Read | Cache-Write | 비용 (USD) |
|---|---|---|---|---|---|---|---|
| 01 | 09-23 21:00 | 작업 시작 지시 | 1,240 | 850 | 0 | 45,200 | $0.21 |
| 02 | 09-23 21:05 | 파일 수정 지시 | 820 | 620 | 46,100 | 1,200 | $0.04 |
| ... | ... | (중략) | ... | ... | ... | ... | ... |
| 11 | 09-24 07:30 | 하룻밤 지난 뒤 재개 지시 | 650 | 410 | 0 | 213,688 | $2.15 |
| 12 | 09-24 07:35 | 이어서 테스트 실행 | 580 | 320 | 214,200 | 800 | $0.03 |
세션 전체 비용의 절반 이상인 2.15달러가, 전날 밤에서 하룻밤 지나 다음 날 아침에 재개한 첫 번째 1턴에서만 발생했습니다.
내역을 보면 Output이나 일반적인 Input이 아니라, Cache-Write(캐시 신규 생성)에 213,688 토큰이 계상되어 있습니다.
Anthropic의 Prompt Caching은 일정 시간 요청이 없으면 캐시가 만료됩니다. 시간을 두고 같은 세션에서 “어제 작업 이어서 부탁해”라고 짧게 말했을 뿐이어도, 그때까지의 대화 이력이 전부 캐시 만료로 처리되어, 일반 입력보다 단가가 높은 캐시 재기록(Cache Write)으로 처음부터 다시 처리됩니다.
이 숫자가 보이기 전까지는, “간격을 둔 첫 응답이 왠지 무겁다”는 실감조차 없었습니다. 숫자로 추적할 수 있게 된 뒤에야 “잠들기 전에 세션을 한 번 종료하고 다음 날 새로 시작할지”, “재기록 비용을 치르더라도 이전 문맥을 이어갈지”를 비용을 보면서 고를 수 있게 됩니다.
대화 기록에서 트레이스 ID를 역산하기
이 내역을 뽑아내기 위해 특별한 로그 출력 도구를 붙일 필요는 없습니다. 손에 있는 대화 기록에서 트레이스 ID를 역산할 수 있습니다.
Langfuse 공식 관측 플러그인(langfuse-observability 1.2.0)을 Claude Code에 넣으면, 턴마다 1개의 트레이스가 Langfuse로 전송됩니다. 이 트레이스 ID는 난수가 아니라, 대화 기록에서 유일하게 결정되는 규칙입니다.
구체적으로는, 세션 ID와 그 턴을 시작한 user 행의 uuid를 콜론으로 이어 붙인 문자열의 SHA-256을 구하고, 그 앞의 16바이트(32글자의 hex)가 그대로 트레이스 ID가 됩니다(Langfuse SDK에서 seed로 트레이스 ID를 만드는 규칙과 같습니다).
즉, 전송된 쪽을 검색하지 않아도 로컬 .jsonl만 읽으면 “이 턴의 트레이스는 이 ID가 된다”는 것을 알 수 있습니다. 그다음 그 ID로 Langfuse에 조회하면 캐시 내역과 비용이 돌아옵니다.
턴을 특정하고 트레이스 ID를 계산하는 코드
트레이스 ID의 역산은 Node.js 표준 모듈(node:crypto, node:fs)만으로 작성할 수 있습니다.
// 使い方: node trace-id.mjs ~/.claude/projects/<プロジェクト>/<セッションID>.jsonl
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const file = process.argv[2];
const sessionId = path.basename(file, '.jsonl');
// seed からトレース ID を計算(先頭 32 文字の 16 進数)
const traceIdFor = (sessionId, uuid) =>
crypto.createHash('sha256').update(`${sessionId}:${uuid}`).digest('hex').slice(0, 32).toLowerCase();
let index = 0;
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim()) continue;
let row;
try { row = JSON.parse(line); } catch { continue; }
// ターンの起点を判定
if (row.type !== 'user' || row.isSidechain || row.isMeta) continue;
const content = row.message?.content;
const isTurnStart =
typeof content === 'string' ||
(Array.isArray(content) && content.some((b) => b.type === 'text' || b.type === 'image'));
if (!isTurnStart) continue;
index += 1;
console.log(`${String(index).padStart(2)} ${row.timestamp} ${traceIdFor(sessionId, row.uuid)}`);
}턴의 시작 지점을 잡으려면 조건이 있습니다. type: "user"인 행 중에서 서브에이전트 쪽(isSidechain: true)이나 시스템이 주입한 행(isMeta: true)은 제외하고, 텍스트 또는 이미지를 포함한 행만을 1턴의 시작으로 취급합니다. 도구 실행 결과만 들어 있는 user 행은 턴 도중이므로 제외합니다.
Langfuse v4의 MCP에서 캐시 내역을 가져오기
보내는 쪽과 읽는 쪽의 설정
보내는 쪽은 Claude Code 설정에 공식 플러그인을 추가하기만 하면 됩니다. 읽는 쪽은 로컬에 띄운 Langfuse v4에서 데이터를 가져옵니다.
Langfuse v4에서는 이전 REST API(/api/public/traces 등)를 사용할 수 없기 때문에, MCP 엔드포인트(/api/public/mcp)를 통해 관측 데이터를 가져옵니다.
// ~/.claude.json の MCP 設定(抜粋)
{
"mcpServers": {
"langfuse": {
"url": "http://localhost:3000/api/public/mcp",
"headers": {
"Authorization": "Basic <公開キー:秘密キー を base64 化したもの>"
}
}
}
}캐시 내역 집계 로직
MCP 클라이언트에서 tools/call로 listObservations를 호출하면 관측 레코드 배열이 반환됩니다.
그중 type === 'GENERATION'이면서 usageDetails를 가진 요소에서, 캐시 내역을 다음과 같이 꺼냅니다.
// 観測データからトークンとキャッシュの内訳を集計
function sumUsage(observations) {
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
let cost = 0;
for (const obs of observations) {
if (obs.type === 'GENERATION' && obs.usageDetails) {
const u = obs.usageDetails;
usage.input += (u.input || 0);
usage.output += (u.output || 0);
// キャッシュ読み取り(Cache Read)
usage.cacheRead +=
(u.cache_read_input_tokens || 0) +
(u.input_cache_read || 0) +
(u.input_cached_tokens || 0);
// キャッシュ書き直し・作成(Cache Write)
let durationBasedSum = 0;
let hasDurationBased = false;
for (const [key, val] of Object.entries(u)) {
if (key.startsWith('input_cache_creation_') && typeof val === 'number') {
hasDurationBased = true;
durationBasedSum += val;
}
}
if (hasDurationBased) {
usage.cacheWrite += durationBasedSum;
} else {
usage.cacheWrite +=
(u.input_cache_creation || 0) +
(u.cache_creation_input_tokens || 0);
}
// 費用
if (obs.costDetails?.total) {
cost += obs.costDetails.total;
} else if (obs.totalCost) {
cost += obs.totalCost;
}
}
}
return { usage, cost };
}MCP 호출 시 주의점
initialize의 세션 재사용 : MCP 엔드포인트를 호출할 때는 먼저initialize를 호출해 응답 헤더에서mcp-session-id를 가져옵니다. 턴마다 매번initialize를 다시 하면 요청 수가 두 배가 되므로, 얻은mcp-session-id를 이후의listObservations호출에서 계속 사용합니다.
- SSE 형식 파싱 : 응답이 Server-Sent Events(SSE) 형식으로 돌아오는 경우가 있으며, 첫 줄이 빈 줄이거나
id:로 시작할 수 있습니다. 맨 앞만 보고 JSON인지 판단하지 말고,data:행을 추출해 JSON 파싱해야 합니다.
관측으로 보이지 않는 것, 닿지 않는 것
운영할 때는 아래 점들을 유의해야 합니다.
- 이용 한도 잔량은 나오지 않음 : Claude Code는 정액제 플랜의 “남은 한도”를 출력하지 않으므로, 관측 기반에서도 잔량은 알 수 없습니다. 비용 표시는 Langfuse 쪽 API 단가표를 바탕으로 환산한 값이며, “API로 동일하게 동작시켰을 때의 기준치”로 사용합니다.
- 최신 1턴은 아직 도착하지 않음 : 공식 플러그인의 전송 훅은 최신 턴의 데이터를 보류해 두고, 다음 Stop 시점에 전송하는 구조입니다. 따라서 세션 실행 중인 최신 턴은 “미도착”으로 표시됩니다.
- 중단이나 클라우드 실행 기록 : 사용자에 의한 중단이나 작업 알림 등은 독립된 턴이 아니므로 트레이스가 존재하지 않아 “기록 없음”이 됩니다. 또한 클라우드 환경에서 실행한 분량은 로컬 Langfuse에는 도착하지 않습니다.
마치며
“왜인지 이용 한도가 빨리 줄어든다”는 답답함을 체감에만 맡기지 말고, 턴 단위 숫자로 추적한다.
그것만으로도 “시간을 두고 재개할 때 발생하는 캐시 만료 비용”이 드러나고, 세션을 어디서 끊어야 할지 판단할 수 있게 됩니다. 같은 방식으로 Langfuse를 운영하는 환경이라면, 손에 있는 대화 기록과 대조하는 것만으로 내역이 보입니다. 손에 있는 .jsonl로 확인해 보세요.
출처
- 원문: https://zenn.dev/keisato848/articles/turn-cost-langfuse (2026-09-27 · keisato)