세 종류의 코딩 에이전트(Claude Code·Codex·Antigravity)를 함께 쓰다 보니 어떤 작업에 얼마가 들어갔는지 알 수 없다는 문제가 생겼습니다. 구독 청구서는 월 정액이라 프로젝트별 비중을 알려주지 않습니다. 도구마다 제공하는 사용량 화면은 서로 기준이 달라 합산할 수 없습니다. 남은 근거는 각 도구가 로컬에 남기는 대화 기록뿐이었습니다. 이 글은 그 기록만으로 일별·모델별·프로젝트별 사용량을 집계하는 파이프라인을 만들면서 정한 전제와, 그 전제가 깨졌을 때 실제로 무슨 일이 있었는지에 대한 기록입니다.
집계가 성립하는 네 가지 전제
먼저 이 방식이 언제 성립하는지 적어 둡니다. 네 가지 중 하나라도 충족되지 않으면 아래 내용은 그대로 적용되지 않습니다.
- 도구가 대화 기록을 로컬 파일로 남깁니다. 세 도구 모두 사용자 홈 디렉터리 아래에 세션별 JSONL 파일을 씁니다. 이 파일이 없으면 집계할 원본 자체가 없습니다.
- 그 기록에 토큰 사용량 필드가 들어 있습니다. Claude Code 와 Codex 는 응답 한 건마다 입력·출력·캐시 토큰 수를 적고, 모델 이름도 같은 줄이나 세션 메타데이터에 남깁니다. 이 필드가 없는 도구는 집계가 아니라 추정이 됩니다.
- 기록은 영구 보존되지 않습니다. 도구의 보존 기간이 지나면 파일이 사라집니다. 따라서 집계값을 따로 누적해 두지 않으면 과거가 조용히 지워집니다.
- 산출되는 비용은 정가 환산 추정값이지 청구액이 아닙니다. 토큰 수에 공개 API 단가를 곱한 값이며, 구독 요금제나 할인, 실제 과금 단위와는 무관합니다.
네 번째 전제가 가장 자주 오해를 부릅니다. 이 파이프라인이 내놓는 금액은 같은 작업을 종량제 API 로 했다면 얼마였을지에 대한 추정입니다. 구독료 대비 실제 사용량이 어느 정도인지, 어느 프로젝트가 비싼지를 비교하는 용도이지 회계 자료가 아닙니다.
도구마다 다른 기록 형식
세 도구가 모두 JSONL 을 쓰지만 한 줄의 의미가 다릅니다. 이 차이를 무시하고 단순 합산하면 결과가 몇 배로 커집니다.
| 항목 | Claude Code | Codex | Antigravity |
|---|---|---|---|
| 기록 위치 | ~/.claude/projects | ~/.codex/sessions 와 ~/.codex/archived_sessions | ~/.gemini/antigravity/brain |
| 한 줄의 의미 | 응답 한 건의 사용량 | 세션 누적 스냅샷 | 응답 한 건. 사용량 필드 없음 |
| 더하는 방법 | 그대로 합산 | 이전 스냅샷과의 차이만 합산 | 글자 수로 추정해 합산 |
| 중복 제거 키 | message.id | 세션 id·타임스탬프·누적값 | 대화 id 와 단계 번호 |
| 모델 이름 위치 | message.model | turn_context.model 또는 세션 메타데이터 | 기록에 없음. 고정값 사용 |
Claude Code 는 응답 한 건이 한 줄이고 그 줄에 사용량이 들어 있습니다. 파서는 줄마다 type 이 assistant 인 것만 골라 토큰 네 종류를 읽습니다.
inp = u.get("input_tokens", 0) or 0
out = u.get("output_tokens", 0) or 0
cr = u.get("cache_read_input_tokens", 0) or 0
cc = u.get("cache_creation", {}) or {}
c5 = cc.get("ephemeral_5m_input_tokens", 0) or 0
c1 = cc.get("ephemeral_1h_input_tokens", 0) or 0
if not cc:
c5 = u.get("cache_creation_input_tokens", 0) or 0
tot = inp + out + cr + c5 + c1Codex 는 한 줄이 그 세션의 누적 합계입니다. 그대로 더하면 같은 토큰이 이벤트 수만큼 반복해서 계산됩니다. 세션별로 직전 스냅샷을 기억해 두고 증가분만 취해야 합니다. 총계가 이전과 같으면 중복된 상태 보고이고, 줄었으면 과거 이력이 다시 기록된 것입니다. 둘 다 새로 쓴 토큰이 아니므로 제외합니다.
current = event["totals"]
prior = previous.get(sid)
if prior is None:
delta = current
elif current[-1] > prior[-1]:
delta = tuple(max(0, now - then) for now, then in zip(current, prior))
else:
# Equal totals are duplicated status snapshots; lower totals are
# stale/replayed history. Neither represents fresh token usage.
continue토큰이 두 번 계산되는 두 곳
필드 이름만 보고 합산하면 잘못 계산되는 곳이 두 군데 있습니다. 둘 다 Codex 기록에서 확인했습니다.
input_tokens가 캐시 읽기와 캐시 쓰기 입력을 이미 포함합니다. 세 값을 나란히 더하면 입력이 두 번 계산됩니다.reasoning_output_tokens는output_tokens에 이미 포함됩니다. 별도 항목처럼 더하면 출력이 부풀어 오릅니다.
그래서 이 파이프라인은 기록에 적힌 total_tokens 를 상한으로 두고, 출력·캐시 읽기·캐시 쓰기·입력 순으로 배분해 네 항목의 합이 항상 총계와 같아지도록 맞춥니다. 필드가 일부만 있는 오래된 기록은 남은 토큰이 전부 일반 입력으로 배정되어 총계가 보존됩니다.
raw_input, raw_cached, raw_write, raw_output, _reasoning, _ = delta
output = min(raw_output, total)
prompt_total = max(0, total - output)
cache_read = min(raw_cached, prompt_total)
cache_write = min(raw_write, max(0, prompt_total - cache_read))
input_tokens = max(0, prompt_total - cache_read - cache_write)토큰 수를 남기지 않는 도구
Antigravity 는 앞의 두 도구와 달리 대화 기록에 토큰 수를 적지 않습니다. 응답 본문과 도구 호출 내용은 남지만 사용량 필드가 없습니다. 선택지는 둘입니다. 집계에서 제외하거나, 남은 정보로 추정하는 것입니다. 이 파이프라인은 추정을 택했습니다.
text_len = len(str(d.get("thinking", ""))) + len(str(d.get("content", ""))) + len(str(d.get("tool_calls", "")))
out_tokens = max(200, int(text_len * 0.3))
inp_tokens = 25000 + (step_idx * 1500)
tot = inp_tokens + out_tokens
model = "gemini-3.5-flash"출력은 생성된 글자 수의 0.3배로, 입력은 기본 25,000 토큰에 대화 단계마다 1,500 토큰을 더한 값으로 잡습니다. 캐시 토큰은 0 으로 둡니다. 모델 이름도 기록에 없어 고정값을 씁니다. 이 세 가지는 모두 관측이 아니라 가정입니다.
추정값과 실측값이 한 표에 함께 올라갑니다. 98일치에서 이 추정 몫은 전체 토큰의 2.1%, 정가 환산 금액의 1.2%입니다. 비중이 작아 총계 판단을 뒤집지는 않지만, 이 모델의 행만 따로 볼 때는 실측과 같은 정확도로 읽으면 안 됩니다.
같은 응답이 여러 파일에 복사되는 문제
세션을 재개하거나 하위 에이전트를 실행하면 같은 응답이 여러 파일에 다시 나타납니다. 파일 단위로 합계를 내고 그 합계들을 더하면 중복이 그대로 남습니다. 해결은 파일이 아니라 응답 단위로 전역 중복 제거를 하는 것입니다. Claude Code 는 응답마다 붙는 메시지 id 가 안정적인 키라 그대로 씁니다. 같은 id 가 서로 다른 총계로 두 번 나오면 큰 쪽을 남깁니다. Antigravity 는 그런 id 가 없어 대화 id 와 단계 번호를 합쳐 키로 만듭니다.
for mid, tup in c.get("assist", ()):
prev = recs.get(mid)
if prev is None or tup[0] > prev[0]:
recs[mid] = tupCodex 에는 그런 id 가 없고 부모 세션 기록이 여러 파일에 복사되므로, 경로가 아니라 이벤트 내용(세션 id·타임스탬프·누적값)으로 동일성을 판정합니다. 같은 내용이 두 곳에서 보이면 프로젝트 경로가 붙어 있는 쪽을 남깁니다.
훅 전송에서 주기 수집으로
처음에는 작업용 PC 에서 세션이 끝날 때마다 훅이 집계해 서버로 보내는 방식이었습니다. 구현은 단순하지만 작업 PC 가 켜져 있고 훅이 정상 동작할 때만 갱신된다는 제약이 있습니다. 훅이 실패해도 오류가 드러나지 않고, 나중에 누락분을 보정할 방법도 없습니다.
지금은 방향이 반대입니다. 서버에 있는 수집 프로세스가 작업 PC 의 읽기 전용 공유 폴더를 60초마다 읽어 스스로 갱신합니다. 원본을 직접 읽으므로 한 사이클을 건너뛰어도 다음 사이클에서 같은 결과가 나옵니다.
- 증분 파싱: 파일의 수정 시각과 크기가 그대로면 다시 읽지 않습니다. 수천 개 파일 중 바뀐 것만 파싱합니다.
- 사이클 시간 제한: 네트워크 공유 접근이 응답하지 않고 멈추는 경우가 있어, 한 사이클이 180초를 넘기면 포기하고 그 대상을 300초 동안 건너뜁니다.
- 도달 실패는 비우기가 아닙니다: 공유가 잠시 안 보일 때 캐시를 버리면 그 사이클의 집계가 원본 절반으로 계산되어 전송됩니다. 읽지 못한 경로는 건너뛰기만 합니다.
날짜 키에 큰 값만 남기기
세 번째 전제(기록은 사라진다) 때문에 저장 방식이 중요해집니다. 매 사이클 계산 결과로 표 전체를 교체하면, 원본이 만료된 날짜는 재계산에서 0 이 되어 이미 저장돼 있던 값까지 지워집니다. 실제로 작업 시간대 통계에서 이 문제가 발생해 과거 몇 주치가 한 번에 사라졌습니다.
지금은 네 테이블 모두 날짜를 기본 키로 두고 큰 값만 남기는 병합을 씁니다. 부분 집계만 전송돼도 기존 값이 내려가지 않습니다.
ON CONFLICT(day) DO UPDATE SET
tokens = MAX(usage_daily.tokens, excluded.tokens),
input = MAX(usage_daily.input, excluded.input),
output = MAX(usage_daily.output, excluded.output),
cache_read = MAX(usage_daily.cache_read, excluded.cache_read),
cache_write = MAX(usage_daily.cache_write, excluded.cache_write),
usd = MAX(usage_daily.usd, excluded.usd)큰 값만 남기는 병합은 과거 수정을 포기하는 선택입니다. 분류 규칙을 고친 코드를 배포해도 원본이 이미 사라진 날짜의 행은 옛 값 그대로 남습니다. 잘못된 라벨을 먼저 지우면 그 사용량도 함께 사라집니다. 정리 순서는 원본을 다시 읽을 수 있게 만들고, 새 라벨로 다시 기록됐는지 확인한 다음, 그때 옛 행을 지우는 것입니다.
비용 환산 규칙
토큰을 금액으로 바꾸는 기준은 모델별 백만 토큰당 단가표와 캐시 배수 세 가지입니다. 표에 없는 모델은 이름에 포함된 계열(opus·sonnet·gpt 등)로 대체 단가를 찾고, 그것도 없으면 보수적인 기본값을 씁니다.
| 토큰 종류 | 적용 단가 |
|---|---|
| 입력 | 모델의 입력 단가 |
| 출력 | 모델의 출력 단가 |
| 캐시 읽기 | 입력 단가의 0.1배 |
| 캐시 쓰기(5분) | 입력 단가의 1.25배 |
| 캐시 쓰기(1시간) | 입력 단가의 2배 |
return (
input_tokens * input_price
+ output_tokens * output_price
+ cache_read_tokens * input_price * CACHE_READ_MULT
+ cache_5m_tokens * input_price * CACHE_5M_MULT
+ cache_1h_tokens * input_price * CACHE_1H_MULT
) / 1_000_000.0원화 표기에 쓰는 환율은 네 단계로 확보합니다. 환경 변수로 고정된 값이 있으면 그것을 씁니다. 없으면 공개 환율 API 세 곳을 차례로 시도하고, 그다음은 디스크 캐시, 마지막은 상수 1,500원입니다. 받은 값이 500원에서 5,000원 범위를 벗어나면 비정상 응답으로 보고 버립니다.
98일치 실측
2026년 6월 6일부터 9월 11일까지 98일 동안 모인 값입니다. 총 762억 4,538만 토큰, 정가 환산 $47,094.58 입니다. 같은 시점 환율(1,345.06원)로는 약 6,334만 원입니다.
토큰 수와 비용은 비례하지 않습니다. 전체 토큰의 95.21%가 캐시 읽기이고 그 단가는 입력의 0.1배입니다. 반대로 출력은 토큰 수로는 0.34%에 불과하지만, 주로 쓴 모델에서 출력 단가는 입력의 5배입니다. 이 구성을 모르고 토큰 총량만으로 비용을 추정하면 자릿수 단위로 어긋납니다.
같은 결론이 모델별 비교에서도 나타납니다. 토큰 수가 비슷한 두 모델의 금액이 2배 넘게 차이 납니다.
| 모델 | 토큰 | 정가 환산 | 백만 토큰당 입력·출력 단가 |
|---|---|---|---|
| claude-opus-5 | 277.0억 | $19,146.79 | $5 / $25 |
| claude-sonnet-5 | 271.5억 | $8,717.38 | $2 / $10 |
| claude-opus-4-8 | 128.1억 | $11,490.71 | $5 / $25 |
| codex 계열 합계 | 27.4억 | $1,582.80 | 모델별 상이 |
| gemini-3.5-flash | 16.3억 | $572.88 | $0.35 / $1.05 |
하루 단위로 보면 구성이 더 분명합니다. 2026년 8월 14일 하루는 24억 5,820만 토큰에 $860.44 였습니다. 그중 출력은 829만 토큰(0.34%)이고 캐시 읽기가 24억 148만 토큰(97.7%)입니다.
프로젝트별 귀속과 그 한계
각 응답 기록에는 그 세션의 작업 디렉터리가 함께 적힙니다. 이 경로가 프로젝트 루트 아래에 있으면 최상위 폴더 이름을 프로젝트로 삼습니다. 98일치에서 22개 프로젝트에 귀속된 토큰이 594억으로 전체의 77.9%였습니다.
나머지 22.1%는 프로젝트 트리 밖에서 한 작업입니다. 홈 디렉터리에서 시작한 세션 · 다른 드라이브의 실험 폴더 · 설정 파일 수정 같은 것들입니다. 이 몫은 총계에는 들어가지만 프로젝트별 표에는 나타나지 않습니다. 작업 디렉터리를 기준으로 삼는 이상 피할 수 없는 구조적 한계입니다.
남은 한계
- 정가 환산은 청구액이 아닙니다. 구독 요금제에서 실제로 나가는 돈과 무관하며, 작업 사이의 상대 비교용입니다.
- 한 도구의 값은 추정입니다. Antigravity 는 토큰 수를 기록하지 않아 글자 수 기반 가정으로 채웁니다. 도구가 사용량 필드를 제공하기 시작하면 그때 실측으로 바꿔야 하고, 그 전까지는 이 몫이 총계에 섞여 있습니다.
- 단가표는 직접 관리합니다. 모델이 새로 나오거나 가격이 바뀌면 표를 손으로 고쳐야 합니다. 표에 없는 모델은 계열 대체값으로 계산되므로 그만큼 오차가 있습니다.
- 과거는 고칠 수 없습니다. 큰 값만 남기는 병합이라, 집계 규칙을 고쳐도 원본이 사라진 날짜의 행은 그대로 남습니다. 수집 초기 이틀은 모델별 분해가 없어 일별 총계와 모델별 합계가 11억 토큰만큼 어긋나 있고, 이 차이는 보정하지 않고 두었습니다.
- 도구가 기록 형식을 바꾸면 조용히 틀립니다. 필드 이름이 바뀌면 해당 값이 0 으로 읽히고 오류는 나지 않습니다. 모델 이름을 세션 메타데이터에서도 읽도록 고치기 전에는 전체의 19%가 알 수 없음으로 분류되고 있었는데, 화면을 보기 전까지 몰랐습니다.
그럼에도 이 구조를 유지하는 이유는 단순합니다. 세 도구의 사용량을 같은 기준으로 나란히 놓고 볼 수 있는 방법이 달리 없고, 누적된 기간이 길어질수록 판단에 쓸 수 있는 값이 되기 때문입니다. 필요한 것은 도구가 이미 남기고 있는 파일을 읽는 프로세스 하나입니다.