Claude Code 멀티 세션의 토큰 비용 — 일이 아니라 세션의 수명이 먹고 있었습니다

정기창·

Claude Code 세션 넷이 하루 쓴 토큰을 대화 기록으로 세어 보니, 비용의 83%는 새로 한 일이 아니라 호출할 때마다 다시 읽는 컨텍스트였습니다. 일의 양보다 세션이 얼마나 오래 살았는지가 비용을 정하고 있었습니다.

한 프로젝트에 Claude Code 세션 넷을 며칠째 띄워 두고 일을 나눠 맡기고 있었습니다. 어느 저녁 «피어 세션들이 토큰을 너무 많이 먹는 것 같다»는 느낌이 들어, 총괄 세션에게 그날치 기록을 세어 보게 했습니다. 이 글은 그 측정과, 측정 뒤에 작업 환경을 어떻게 바꿨는지에 대한 기록입니다.

먼저 밝혀 둘 것이 둘 있습니다. 아래의 달러 금액은 청구서가 아니라, 토큰 수에 API 정가를 곱한 추정치입니다. 그리고 바꾼 구조의 효과는 아직 하루를 다 재지 못했습니다. 재시작 직후의 수치 몇 개만 있고, 나머지는 같은 스크립트로 다시 재서 덧붙이려고 합니다.

세션 넷이 일하던 방식

구조는 사람 팀을 그대로 흉내 낸 모양이었습니다. 총괄 세션 하나가 저와 대화하면서 상황판 파일 한 장을 관리했고, 피어 세션 셋이 각자 git 워크트리를 하나씩 맡아 구현과 리뷰, PR 을 진행했습니다. 세션끼리는 Claude Code 의 세션 간 메시지로 착수와 진행, 완료를 주고받았고, 피어는 다시 구현과 리뷰를 서브에이전트에게 맡겼습니다.

모델은 총괄이 Fable 5.1, 피어가 Opus 5.5 였고, Claude Code 는 2.1.310 이었습니다. 피어 세션은 짧게는 사흘, 길게는 닷새 동안 한 번도 새로 열지 않았습니다. 돌이켜 생각해보면, 세션을 오래 살려 두어야 맥락을 잃지 않는다고 막연히 믿고 있었습니다.

Claude Code 대화 기록으로 하루 토큰을 세는 법

Claude Code 는 세션마다 대화 기록을 ~/.claude/projects/<프로젝트>/<세션 id>.jsonl 에 남깁니다. 모델 응답 레코드의 message.usage 에 입력 · 출력 · 캐시 쓰기 · 캐시 읽기 토큰이 들어 있어서, 이것만 더해도 하루 사용량을 다시 셀 수 있습니다. 다만 그대로 세면 두 군데에서 틀립니다.

첫째, 스트리밍 응답 하나가 여러 줄로 기록되고, 줄마다 같은 message.id 와 같은 usage 가 붙습니다. 처음에 줄 단위로 셌더니 한 피어의 호출이 2,027번으로 나왔는데, message.id 로 중복을 걸러 내니 1,109번이었습니다. 거의 두 배로 부풀려 세고 있었던 것입니다.

둘째, 서브에이전트의 기록은 <세션 id>/subagents/ 아래에 따로 남습니다. 본체 파일만 세면 이날 피어 비용의 39~58%를 놓쳤습니다.

아래는 이 두 가지를 반영한 최소한의 스크립트입니다. 날짜(한국 시간)와 jsonl 파일들을 받아 호출 수, 호출당 컨텍스트 중앙값, 정가 환산을 찍습니다.

#!/usr/bin/env python3
"""Claude Code 대화 기록(jsonl)에서 하루치 호출 수 · 컨텍스트 · 정가 환산을 센다.

사용: python3 token_day.py 2026-10-24 <jsonl 파일> [<jsonl 파일> ...]
"""
import collections
import datetime
import json
import sys

# 백만 토큰당 달러: 입력, 출력, 캐시 쓰기(5분 캐시), 캐시 읽기
PRICE = {
    "fable": (10, 50, 12.5, 1.0),
    "opus": (4, 20, 5, 0.4),
    "sonnet": (2, 10, 2.5, 0.2),
    "haiku": (0.1, 0.5, 0.125, 0.01),
}
KST = datetime.timezone(datetime.timedelta(hours=9))


def price_of(model: str) -> tuple:
    for key, p in PRICE.items():
        if key in model:
            return p
    return PRICE["opus"]


def count(path: str, start: datetime.datetime, end: datetime.datetime) -> None:
    seen = set()
    tokens = collections.Counter()
    contexts = []
    cost = 0.0
    with open(path, encoding="utf-8", errors="replace") as f:
        for line in f:
            try:
                rec = json.loads(line)
            except json.JSONDecodeError:
                continue
            if rec.get("type") != "assistant" or not rec.get("timestamp"):
                continue
            ts = datetime.datetime.fromisoformat(rec["timestamp"].replace("Z", "+00:00"))
            if not (start <= ts < end):
                continue
            msg = rec.get("message") or {}
            usage = msg.get("usage")
            mid = msg.get("id") or rec.get("uuid")
            # 스트리밍 응답 하나가 같은 message.id 로 여러 줄 남는다 — 한 번만 센다
            if not usage or mid in seen:
                continue
            seen.add(mid)
            i = usage.get("input_tokens", 0)
            o = usage.get("output_tokens", 0)
            cw = usage.get("cache_creation_input_tokens", 0)
            cr = usage.get("cache_read_input_tokens", 0)
            tokens.update(input=i, output=o, cache_write=cw, cache_read=cr)
            p = price_of(msg.get("model") or "")
            cost += (i * p[0] + o * p[1] + cw * p[2] + cr * p[3]) / 1e6
            contexts.append(i + cw + cr)
    if not contexts:
        print(f"{path}: 그날 호출 없음")
        return
    contexts.sort()
    median = contexts[len(contexts) // 2]
    print(
        f"{path}: 호출 {len(contexts):,} · 호출당 컨텍스트 중앙값 {median:,}"
        f" · 캐시 읽기 {tokens['cache_read']:,} · 출력 {tokens['output']:,}"
        f" · 정가 환산 ${cost:,.0f}"
    )


def main() -> None:
    day = datetime.date.fromisoformat(sys.argv[1])
    start = datetime.datetime.combine(day, datetime.time(), KST)
    end = start + datetime.timedelta(days=1)
    for path in sys.argv[2:]:
        count(path, start, end)


if __name__ == "__main__":
    main()
# 본체 하나
python3 token_day.py 2026-10-24 ~/.claude/projects/<프로젝트>/<세션 id>.jsonl
# 그 세션의 서브에이전트 전부
find ~/.claude/projects/<프로젝트>/<세션 id>/subagents -name '*.jsonl' -print0 | xargs -0 python3 token_day.py 2026-10-24

캐시 쓰기 단가는 5분 캐시 기준(입력의 1.25배)으로 넣었습니다. 1시간 캐시로 도는 세션이라면 쓰기 단가가 입력의 2배라 쓰기 몫이 조금 더 커집니다.

여기에 한 가지를 더 셌습니다. 무엇이 그 턴을 깨웠는지입니다. 사용자 레코드가 도구 결과인지, 다른 세션의 메시지인지, 백그라운드 작업의 완료 알림인지, 사람의 입력인지로 나누면, 세션이 일을 하려고 부른 호출과 불려서 깨어난 호출을 가를 수 있습니다.

결과 — 83%는 다시 읽는 컨텍스트였습니다

10월 24일 0시부터 21시 20분까지, 본체 세션 넷의 기록입니다. 호출당 컨텍스트는 그 호출이 읽은 입력 · 캐시 쓰기 · 캐시 읽기 토큰의 합이고, 피어 이름은 알파벳으로 바꿨습니다.

세션 본체 호출 호출당 컨텍스트(중앙값) 서브에이전트 호출 정가 환산(본체 + 서브)
피어 A 1,109 46만 토큰 2,067 $231 + $322
피어 B 1,251 44만 토큰 1,346 $253 + $162
피어 C 1,395 45만 토큰 1,879 $291 + $245
총괄 1,206 50만 토큰 0 $745

넷을 합치면 하루 약 2,250달러어치였습니다. 본체 비용을 나눠 보니 캐시 읽기가 피어 셋에서 85~87%, 총괄에서 80%였고, 넷을 합치면 83%였습니다. 출력 토큰은 7~11%에 그쳤습니다. 캐시 읽기는 입력 단가의 10분의 1이지만, 45만 토큰을 하루 1,100~1,400번 다시 읽으면 세션 하나가 5억~6억 토큰이 됩니다.

모델은 대화를 기억하지 않습니다. 도구를 한 번 부를 때마다 지금까지의 대화 전체를 입력으로 다시 보냅니다(컨텍스트 윈도우의 정체에서 정리한 적이 있습니다). 그래서 비용은 결국 «한 번에 보내는 양 × 보내는 횟수»였습니다. 일을 얼마나 했는지는 그 곱에 거의 들어 있지 않았습니다.

솔직히 말하면, 가장 비싼 세션은 제가 «관리»를 맡긴 총괄이었습니다. 상황판 파일을 고치는 명령만 하루 437번이었고, 하나하나가 50만 토큰짜리 Fable 턴이었습니다. 그것만으로 정가 200달러를 넘습니다. 세션 넷이 서로 보낸 메시지는 885건이었고, 받는 쪽에서는 메시지 한 통이 전체 컨텍스트를 다시 읽는 턴 하나를 깨웠습니다. 다른 세션의 메시지가 깨운 턴만 피어마다 100~147번, 총괄은 193번이었습니다. «받았습니다» 같은 확인 답장까지 그 값을 치르고 있었습니다.

왜 45만까지 쌓였나 — autocompact 가 풀어 주지 못한 것

컨텍스트가 컸던 직접적인 이유는 세션의 수명이었습니다. 며칠 동안 끝난 PR 의 diff, 지난 게이트 출력, 오래된 메시지가 계속 쌓였습니다. 새로 연 세션이 첫 호출에서 읽는 양(시스템 프롬프트, 지침 파일 사슬, 넘김 파일)이 14만 토큰이었으니, 45만 가운데 나머지 30만 남짓은 대부분 지나간 일의 흔적이었습니다.

자동 압축(autocompact)이 있으니 괜찮을 줄 알았습니다. 그런데 네 세션 모두 호출당 컨텍스트의 최댓값이 66.7만~66.9만 토큰으로 거의 같았고, 하위 10% 지점도 26만~35만이었습니다. 그 근처에서 압축이 걸리고, 압축 뒤에도 요약과 최근 대화가 남아 바닥까지 내려가지 않았다는 뜻으로 읽었습니다. 다만 압축이 걸리는 기준값을 문서나 코드로 확인하지는 않았으니, 이 부분은 추정입니다.

정확도 쪽 비용도 있었습니다. 피어들은 세션을 연 뒤 18~36번, 총괄은 74번 압축됐습니다. 압축은 지난 대화를 요약으로 바꾸는 손실 과정이어서, 횟수가 쌓일수록 세션은 파일에 있는 사실이 아니라 요약이 기억하는 사실로 일하게 됩니다. 총괄의 지침에는 «압축 요약과 상황판 파일이 어긋나면 갱신 시각이 더 최근인 쪽을 따른다»는 규칙이 있었는데, 그 규칙 자체가 압축 손실을 여러 번 겪은 흔적이라는 생각이 들었습니다.

바꾼 것 — 갱신 간격, 메시지, 세션 수명, 구조, 모델

측정을 놓고 바꾼 것은 다섯 가지입니다.

무엇 전 후
상황판 갱신 사건마다(하루 437번) 15분에 한 번 또는 작업 단위가 끝날 때
세션 간 메시지 진행 보고와 확인 답장까지(하루 885건) 착수 · 막힘 · 끝 세 지점만, 확인 답장 없음(답이 없으면 동의)
피어 세션 수명 사흘~닷새 넘김 파일을 쓰고 /clear 로 새로 시작
구조 총괄 하나 + 상시 피어 셋 총괄 하나 + 작업 단위 서브에이전트 + 긴 기다림은 백그라운드 감시
총괄 모델 Fable 5.1 Opus 5.5(어려운 판단만 Fable 서브에이전트로 올림)

넘김 파일은 6천 자 이내로 정했습니다. 정체(워크트리 · 브랜치 · 잠금 사유), 열린 것, 기다리는 것, 다음 첫 동작 하나, 함정(정본 문서를 가리키는 포인터만), 스크래치 경로의 여섯 절입니다. 새 세션에는 «이 파일을 읽고 다음 첫 동작부터 이어가라»는 한 줄만 줍니다.

재시작 직후 피어 셋의 첫 호출은 14만 토큰이었습니다. 다만 그중 하나는 21시 39분부터 32분 동안 76번 호출하는 사이에 다시 35만이 됐습니다. 리뷰 결과와 게이트 로그가 그대로 쌓였기 때문입니다. 재시작은 바닥을 되돌려 줄 뿐이고, 얼마나 빨리 다시 차는지는 세션이 무엇을 읽느냐에 달려 있었습니다.

피어 세션인가, 서브에이전트인가 — 형태보다 수명

구조를 바꾸기 전에 판단이 한 번 뒤집혔습니다. 총괄 세션의 첫 답은 «서브에이전트로 바꿔도 크게 줄지 않는다»였습니다. 같은 날 서브에이전트도 호출당 23만~26만 토큰을 읽었기 때문입니다. 새로 뜨는 순간은 지침 사슬뿐이지만, 일을 하는 동안 diff 와 테스트 출력이 쌓여 금방 20만~30만이 됩니다. 숫자만 보면 맞는 말이었습니다.

제가 되물은 것은 수명이었습니다. 피어는 /clear 하지 않는 한 끝나지 않고, autocompact 를 끝없이 반복하며 일합니다. 서브에이전트는 백만 토큰 창 안에서 작업 하나를 끝내고 꺼집니다. 그렇다면 정확도로 보나 토큰으로 보나 끝나는 쪽이 낫지 않으냐는 질문이었습니다. 다시 따져 보니 그쪽이 맞았습니다. 호출당 단가는 비슷해도 꺼지는 구조에서는 압축이 거의 일어나지 않고, 시작할 때마다 원본(코드와 문서)을 다시 읽습니다. 차이를 만드는 것은 세션의 형태가 아니라 수명이었습니다.

그래도 오래 사는 세션이 하나는 필요했습니다. 그 자리는 셋이었습니다.

  • 10분을 넘는 기다림 — 자동 리뷰 결과, 협업자의 승인, 수십 분짜리 push 훅 같은 것입니다. 서브에이전트 안에서 붙들고 있으면 멈춤 감시(약 600초)에 걸려 끊깁니다. 이 기다림은 총괄의 백그라운드 감시로 올렸습니다.
  • 사람과 이야기하는 창구입니다.
  • 작업과 작업 사이의 판단 맥락입니다. 이것은 세션의 기억이 아니라 상황판, 넘김 파일, 이슈 본문으로 밖에 두었습니다.

그래서 남은 구조는 «긴 세션 하나 + 작업 단위 서브에이전트 + 긴 기다림은 백그라운드 감시»입니다. 대가도 있습니다. 서브에이전트는 중간에 묻지 않으므로, 맡길 때 워크트리와 브랜치, 시험용 DB, 이미 정한 결정, 완료 기준을 모두 적어야 합니다. 빠뜨리면 «막힘»으로 돌아와 다시 맡기는 왕복이 생깁니다. 결과가 모두 총괄로 모이니 총괄 자신도 하루 한 번은 새로 열어야 합니다. 총괄 모델을 Fable 에서 Opus 로 내린 것도 같은 이유였습니다. 하루 일의 대부분이 판단이 아니라 조율이었기 때문입니다.

돌이켜 생각해보면, 피어를 둔 것은 사람 팀의 모양을 그대로 옮긴 것이었습니다. 사람은 오래 일할수록 맥락이 쌓여 빨라지지만, 모델은 오래 살수록 매번 다시 읽어야 할 것이 늘어납니다. 그 차이를 숫자로 보고서야 알았습니다.

effort 는 상속만 되는 줄 알았습니다 — 상향만 막는 가드

모델을 고르는 김에 추론 강도(effort)도 정리했습니다. 저는 서브에이전트의 effort 는 세션에서 상속될 뿐, 위임할 때 따로 정할 수 없다고 이해하고 있었습니다. 확인해 보니 반만 맞았습니다. 위임 도구에는 모델과 별도로 effort 인자(low · medium · high · xhigh · max)가 있고, 에이전트 정의 머리말에도 effort: 를 둘 수 있습니다. 둘 다 비워 둘 때만 세션 값을 물려받습니다. 다만 도구 설명에 «사용자나 지침 파일이 명시적으로 요구할 때만 지정하라»는 조건이 있어서, 지침에 적어 둔 몇 경우 말고는 상속으로 가고 있었습니다.

제가 제한을 걸어 두었던 이유는 하나였습니다. Opus 세션이 필요 없이 Fable 을 부르는 일이 있을까 봐서였습니다. 그날 피어 셋이 띄운 서브에이전트 호출을 모델별로 세어 보니 opus 4,753번, sonnet 539번, fable 0번이었습니다. 걱정한 일은 일어나지 않고 있었습니다. 다만 앞으로도 그렇다는 보장은 아니었습니다.

그래서 «내려가는 것은 자유, 올라가는 것은 사유를 적고 기록»으로 정했습니다. 위임 호출의 모델이 fable 이거나 effort 가 xhigh · max 이면, prompt 첫 줄에 [tier-up: 사유] 가 있어야 통과합니다. PreToolUse 훅이 이것을 검사하고, 통과한 상향은 로그 파일에 한 줄씩 남깁니다.

#!/bin/bash
# PreToolUse:Agent — 모델 · effort 를 «올리는» 위임만 사유를 요구한다
set -u
command -v jq >/dev/null 2>&1 || exit 0
INPUT=$(cat) || exit 0
[ "$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')" = "Agent" ] || exit 0

MODEL=$(printf '%s' "$INPUT" | jq -r '.tool_input.model // empty')
EFFORT=$(printf '%s' "$INPUT" | jq -r '.tool_input.effort // empty')
UP=""
[ "$MODEL" = "fable" ] && UP="model=fable"
case "$EFFORT" in xhigh|max) UP="${UP:+$UP }effort=$EFFORT" ;; esac
[ -n "$UP" ] || exit 0   # 내려가거나 그대로면 통과

HEAD=$(printf '%s' "$INPUT" | jq -r '.tool_input.prompt // empty' | head -c 300 | tr '\n' ' ')
REASON=$(printf '%s' "$HEAD" | sed -nE 's/.*\[tier-up:[[:space:]]*([^]]+)\].*/\1/p')
if [ -z "$REASON" ]; then
  jq -cn --arg r "상향($UP)은 prompt 첫 줄에 [tier-up: 사유] 가 있어야 합니다" \
    '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
  exit 0
fi
mkdir -p "$HOME/.claude/state"
printf '%s\t%s\t%s\n' "$(date '+%F %T')" "$UP" "$REASON" >> "$HOME/.claude/state/agent-tier-log.tsv"
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Agent",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/agent-tier-escalation-guard.sh"
          }
        ]
      }
    ]
  }
}

한계가 하나 있습니다. 훅은 위임을 부르는 세션이 어떤 모델인지 알 수 없습니다. 그래서 «세션보다 높은가»가 아니라 «fable · xhigh · max 인가»라는 절대 기준으로 걸었습니다. Opus 세션이 opus 를 부르는 것은 자유이고 fable 을 부를 때만 사유가 필요하니, 처음 걱정한 경우는 정확히 잡힙니다. 등록한 뒤에는 다섯 가지 입력(사유 없는 fable, 사유 있는 fable, 사유 없는 effort max, opus · high, Agent 가 아닌 호출)을 넣어 보고, 기대한 대로 막히고 통과하며 사유 있는 상향만 로그에 남는 것을 확인했습니다.

정적 기본값은 에이전트 정의에 두었습니다. 구현 에이전트는 effort: high, 수집 · 측정 · 리포트를 하는 sonnet 에이전트 여섯은 effort: medium 이고, 리뷰어와 게이트는 비워 두어 부르는 세션을 따르게 했습니다. 어떤 모델을 줄지는 서브에이전트 모델 티어링에서 정리한 질문, «이 출력의 오류가 하류 검증 없이 최종 결과에 들어가는가» 그대로입니다.

---
name: backend-impl
description: 백엔드 구현 — 하류에 리뷰 루프가 있다
model: opus
effort: high
---

아직 모르는 것

효과는 아직 재지 못했습니다. 상황판은 15분에 한 번이면 하루 많아야 80번 안팎이라 437번의 5분의 1쯤이 되고, 재시작 직후의 피어는 호출당 컨텍스트가 3분의 1로 내려갔습니다. 다만 한 세션이 32분 만에 35만으로 다시 찬 것을 보면, 하루 평균은 그만큼 줄지 않을 것 같습니다. 하루를 다 돈 뒤 같은 스크립트로 다시 재서 이 글에 덧붙이겠습니다.

총괄을 Opus 로 내리면서 미묘한 규칙 예외를 한 번쯤 더 틀릴 수 있다는 걱정도 남아 있습니다. 그런 자리는 tier-up 으로 올리기로 했지만, 올려야 할 자리를 알아보는 것 역시 총괄의 판단입니다. 서브에이전트에게 주는 지시문이 길어지는 만큼 왕복이 얼마나 늘지도 지켜봐야 합니다.

마치며 — 맥락은 세션이 아니라 파일에 있었습니다

세션을 오래 살려 두는 것이 맥락을 지키는 방법이라고 믿었습니다. 실제로 맥락을 지켜 준 것은 세션의 기억이 아니라 파일이었습니다. 상황판과 넘김 파일, 이슈 본문이 있었기 때문에 세션을 새로 열어도 일이 끊기지 않았고, 오히려 압축 요약보다 정확했습니다.

결국 토큰은 일의 양이 아니라 세션의 수명에 붙어 있었다는 생각이 들었습니다. 그래서 이제는 세션을 얼마나 오래 살릴지보다, 언제든 끝낼 수 있도록 맥락을 어디에 적어 둘지를 먼저 묻기로 했습니다.

Claude Code토큰 비용컨텍스트 관리서브에이전트autocompact멀티 세션AI 에이전트 운영