추가 전용 감사 로그(Append-only audit log)가 216 스타 사용량 추적기에서 발견한 두 가지 회계 버그
요약
Claude Code 사용량 추적 과정에서 발생한 회계 버그를 해결하기 위해 추가 전용(append-only) 로그의 중요성을 설명합니다. 세션 파일이 재작성될 때 데이터가 유실되는 문제를 Rust 기반의 splitrail 도구로 검증하고 해결하는 과정을 다룹니다.
핵심 포인트
- 세션 파일 재작성 시 발생하는 데이터 드리프트 문제 해결
- 추가 전용(append-only) 로그를 통한 데이터 무결성 확보
- SQLite 히스토리 저장소를 활용한 사용량 정규화 방식
- 버그 검증을 위한 격리된 환경 및 시뮬레이션 프로토콜
지난달 저는 34일간의 멀티 모델 Claude Code 사용량 감사에 대해 글을 썼고, 단 하나의 누락된 모델 라인이 제 초과 지출의 절반을 차지했다는 사실을 발견했습니다. 그 글의 핵심은 지루한 엔지니어링 결정이었습니다. Claude Code의 세션 파일에서 모든 어시스턴트 메시지를 **message.id를 키로 하는 추가 전용 로그 (append-only log)**로 수집하고, 소스(source)가 절대 기록을 다시 쓰지 못하게 하는 것이었습니다.
이 글은 제가 계획하지 않았던 후속 글입니다. 동일한 로그가 에이전트형 CLI를 위한 216 스타 Rust 사용량 추적기인 splitrail에서 두 가지 회계 버그를 방금 잡아냈습니다. 그리고 첫 보고가 있은 지 12일 만에, 두 버그 모두 공식 릴리스에서 수정되었습니다. 하나는 저의 PR(Pull Request)을 통해, 다른 하나는 보고된 분석 내용을 채택한 유지 관리자(maintainer)의 패치를 통해 수정되었습니다. 수치와 함께 상세 과정을 설명하겠습니다.
버그 1: 히스토리가 조용히 스스로를 다시 씀
Claude Code는 대화를 재개하거나 압축(compact)할 때 세션 JSONL 파일을 제자리에서 다시 씁니다(rewrites in place). _동일한 파일_을 두 번 스캔하는 사이에, 이미 발생한 사용량인 저의 어시스턴트 메시지 5개가 사라져 기록에서 삭제되었습니다. 라이브 파일로부터 합계를 다시 계산하는 모든 추적기는 이러한 드리프트(drift)를 상속받게 됩니다. 즉, 당신이 잠든 사이에 어제의 수치가 변하게 됩니다.
저는 이를 #200으로 등록했습니다. 유지 관리자는 당일에 동일한 메커니즘을 확인했습니다. splitrail이 라이브 파일을 다시 읽기 때문에, 재개/압축에 의해 제거된 메시지가 과거 합계에서 사라진다는 점을 확인했고, 3.6.0 버전에서 수정 사항을 배포했습니다. 수정 방법은 정규화된 사용량을 유지하는 로컬 SQLite 히스토리 저장소를 사용하여, 중복 제거(deduplication) 전에 이를 현재 세션 데이터와 병합하는 방식이었습니다. 빠르고 깔끔한 작업이었습니다.
한 가지 까다로운 점이 있었습니다. 그는 3.5.9 버전과 3.6.0 버전의 총합을 비교하여 검증할 것을 제안하며, "3.6.0이 더 높아야 한다"고 예상했습니다. 이는 미묘하게 틀린 생각이며, 이러한 종류의 수정을 테스트하는 사람에게는 그 차이가 매우 중요합니다. 콜드 스타트 (cold start) 시에는 두 버전이 동일합니다. 히스토리 저장소(history store)에 아직 복구할 데이터가 없기 때문입니다. 차이는 오직 드리프트 (drift) 이벤트가 발생한 '이후'에만 나타납니다. 그래서 저는 테스트를 위해 인위적으로 드리프트 상황을 만들었습니다.
검증 프로토콜 (The validation protocol)
모든 과정은 ~/.claude/projects의 고정된 스냅샷 (APFS 클론)을 대상으로 실행되었습니다. 따라서 두 버전 모두 동일한 바이트를 스캔했으며, 모든 호출은 격리된 $HOME 환경(자체 설정, 자체 히스토리 저장소, 업로드 경로 없음)에서 이루어졌습니다. 그 후 결과는 다음과 같습니다:
| # | assertion (단언) | result (결과) |
|---|---|---|
| A | 콜드 스타트 일치성: 동일한 입력에 대해 신규 3.5.9 == 신규 3.6.0 | ✅ 동일 |
| ... |
시뮬레이션된 재작성(rewrite)은 가장 큰 트랜스크립트(transcript)에서 마지막 5개의 어시스턴트 메시지 그룹을 제거하며, 이는 원래의 드리프트 이벤트와 동일한 형태를 가집니다. 이제 이 전체 과정은 독립적인 회귀 테스트 픽스처 (regression fixture)가 되었습니다 (PR #208, 현재 머지됨). 즉, 3.5.9에서는 실패(red)하고 3.6.0에서는 통과(green)하며, 이는 회귀 테스트가 수행해야 할 정확한 동작입니다.
제가 가장 중요하게 생각하는 부분은 다음과 같습니다. splitrail이 스캔한 트랜스크립트에서 3.6.0은 저의 추가 전용 감사 로그 (append-only log)와 토큰 단위까지 정확히 일치했습니다 — 13.5k개의 메시지에 걸쳐 양측 모두 18,548,947개의 출력 토큰을 기록했습니다. 두 개의 독립적인 구현체, 서로 다른 언어, 서로 다른 중복 제거 (dedup) 전략을 사용했음에도 숫자 하나 틀리지 않고 일치했습니다. (미세한 메시지 수 차이는 splitrail이 의도적으로 건너뛰는 사용량 0인 API 오류 기록 때문이었습니다.) 두 시스템이 정확하게 일치할 때, 남아있는 모든 불일치는 노이즈가 아니라 하나의 '발견 사항 (finding)'이 됩니다.
버그 2: 발견 사항이었던 불일치
로그에 모든 메시지가 어디에서 왔는지 기록되어 있었기 때문에, 저는 라이브 트리(live tree)와 대조하여 모든 메시지를 분류할 수 있었습니다:
| class | files | messages | output tokens |
|---|---|---|---|
| live, main transcript (what splitrail scans) | 76 | 13,704 | 18,548,947 |
| ... | |||
Row two는 두 번째 버그입니다. Claude Code가 서브 에이전트 트랜스크립트(Task tool: Explore, general-purpose, custom agents)를 projects/<slug>/<sessionId>/subagents/ 아래에 디렉터리 깊이가 ≥ 4인 곳에 작성합니다. Splitrail의 발견 기능은 세 군데에서 깊이를 하드캡(hard-caps)으로 제한하고 있습니다 (WalkDir…min_depth(2).max_depth(2), components() == 2 경로 검사, 그리고 glob 패턴). 이 파일들은 구조적으로 보이지 않으며 — 제 라이브 메시지의 54%, 그리고 대략 3분의 1에 해당하는 금액이 총계에 포함된 적이 없습니다. |
모델 혼합(model mix)이 사각지대를 생생하게 보여줍니다: splitrail은 제 5,059개의 Sonnet 메시지 중 22개와 1,566개의 Haiku 메시지 중 0개만 확인했습니다. 이 모델들은 거의 전적으로 서브 에이전트 내부에서 실행됩니다. 만약 저렴한 모델에 크게 의존한다면 — 이것이 비용을 고려하는 에이전트 사용자들이 정확히 하는 일입니다 — 사용자의 추적기는 가장 많이 과소평가하며, 가장 열심히 최적화했던 워크플로우에서 그렇습니다.
#207로 레이아웃과 두 줄의 재현 코드(repro), 그리고 제안된 수정 사항을 포함하여 이슈를 등록했습니다. 이틀 후에는 제가 패치하지 않았는데도 닫혔습니다. 유지 관리자가 직접 수정을 작성했습니다: #209
실시간으로 변경 가능한(Live mutable) 파일은 감사 추적(Audit trail)이 아닙니다. 만약 소스 데이터가 기록(History)을 다시 쓸 수 있다면, 재계산(Recomputation)은 회계(Accounting)가 아닙니다. 안정적인 레코드별 식별자(message.id, 부분 데이터에 대한 last-write-wins 방식)를 갖춘 추가 전용(Append-only) 수집은 저렴한 보험과 같습니다. 제 경우에는 수백 줄의 코드와 SQLite 파일 하나면 충분했습니다.
토큰 단위로 정확하게 대조(Reconcile)하십시오. 그렇지 않으면 아무것도 모르는 것입니다. "대충 비슷하다"는 식의 합계는 특정 유형의 버그들을 은폐합니다. 스캔된 하위 집합(Subset)에 대해 정확한 일치가 이루어졌기에, 남은 차이(Gap)를 단순히 어쩔 수 없는 일로 치부하는 대신 명확히 정의 가능하고 수정 가능한 두 가지 결함으로 찾아낼 수 있었습니다.
누군가를 비난하기 전에 차이(Gap)를 분해하십시오. "당신의 숫자가 내 것보다 낮다"는 것은 비난입니다. 반면, *"차이는 정확히 삭제된 파일 수 + 스캔되지 않은 디렉토리 클래스의 합이며, 여기 표가 있습니다"*는 유지 관리자가 몇 시간 내에 조치할 수 있는 버그 리포트입니다. 여기서 발생한 두 번의 해결 과정 — #200→#204(8일 소요), #207→#209(2일 소요) — 모두 메커니즘이 리포트와 함께 도착했기 때문에 가능했습니다.
트래커 A/B 테스트를 위한 핵심 비결은 고정된 스냅샷(Frozen snapshot)과 격리된 $HOME입니다. 데이터를 복제하고, HOME(그리고 XDG_STATE_HOME/XDG_DATA_HOME — Linux에서 상태 디렉토리는 XDG를 준수하며 샌드박스를 벗어날 수 있습니다. 제 피스처(Fixture)에서 CodeRabbit의 리뷰가 이 부분을 잡아냈는데, 공정하게 인정해야 할 부분입니다)을 고정하면, 동일한 바이트를 스캔하는 두 개의 바이너리가 통제된 실험(Controlled experiment)이 됩니다.
타임라인
| 날짜 (2026) | 이벤트 |
|---|---|
| 7월 11일 | #200 제출 — resume/compact 기능이 session JSONL을 제자리에서 다시 씀 |
| ... |
12일 동안, 두 개의 버그를 발견했으며, 누구의 숫자가 맞는지에 대한 논쟁은 전혀 없었습니다. 로그가 논쟁이 되기 전에 모든 질문을 해결해 주었기 때문입니다.
하위 레이어
이 모든 과정은 TraceGuard의 routing_audit 모듈(v1.1.0, "감사 증거 레이어 (audit evidence layer)") 위에서 실행되었습니다. 이는 Claude Code 트랜스크립트(transcripts)를 message.id를 키로 사용하여 SQLite 트레이스 저장소(trace store)로 입력하는 추가 전용(append-only) 방식이며, 파트 1의 기반이 된 부분입니다. 안정적인 총계는 이 기질(substrate) 위에서 형성됩니다. 그 상위 레이어의 목적은 **의도된 라우팅 대 실제 드러난 라우팅 분석 (stated-vs-revealed routing analysis), 결정당 가격 책정 (priced per decision)**입니다. 즉, 어떤 모델로 라우팅하겠다고 말했는지, 실제로 어떤 모델이 실행되었는지, 그리고 그 차이가 얼마의 비용을 발생시켰는지를 분석하는 것입니다. 이것이 파트 3의 내용이며, 마침내 제 총계 수치들이 이 글을 쓸 수 있을 만큼 안정되었습니다. (보아하니 이것이 소수의 취향만은 아닌 듯합니다. 이 과정 중에 splitrail의 유지 관리자가 TraceGuard에 스타(star)를 눌렀으며, 감사(audit) 부분에 관심이 있다고 말했습니다.)
pip install traceguard — Apache-2.0. 회귀 테스트 피스처(regression fixture)는 약 600줄의 표준 라이브러리 Python 및 bash로 구성되어 있으며, PR #208에 병합되었습니다. 서브에이전트(subagent) 수정 사항은 splitrail 3.6.1에 포함되어 배포되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기