Rust + simd-json을 사용한 2 GiB/s AI 토큰 로그 파싱
Source: Dev.to
위에 제공된 소스 링크 외에 번역할 텍스트를 알려주시면 한국어로 번역해 드리겠습니다.
문제
저는 매일 Claude Code, Codex CLI, 그리고 Gemini CLI를 사용합니다. 어느 날 API 청구서를 확인했는데 예상보다 훨씬 높았고, 토큰이 어디로 가는지 전혀 알 수 없었습니다.
기존 추적 도구들은 너무 느렸습니다. 세 개의 CLI에 걸쳐 3 GB의 세션 파일(9,000개 이상의 파일)을 스캔하는 데 40 초가 넘게 걸렸습니다. 저는 즉시 확인할 수 있는 방법이 필요했습니다.
그래서 저는 toktrack를 만들었습니다 — 로컬에서 모든 것을 2 GiB/s 속도로 파싱하는 터미널 기반 토큰 사용량 추적기입니다.
데이터
각 AI CLI는 세션 데이터를 다르게 저장합니다:
| CLI | 위치 | 형식 |
|---|---|---|
| Claude Code | ~/.claude/projects/**/*.jsonl | JSONL, 메시지당 사용량 |
| Codex CLI | ~/.codex/sessions/**/*.jsonl | JSONL, 누적 카운터 |
| Gemini CLI | ~/.gemini/tmp/*/chats/*.json | JSON, thinking_tokens 포함 |
단일 Claude Code 세션 파일은 다음과 같이 보일 수 있습니다:
{
"timestamp":"2026-01-15T10:00:00Z",
"message":{
"model":"claude-sonnet-4-20250514",
"usage":{
"input_tokens":12000,
"output_tokens":3500,
"cache_read_input_tokens":8000,
"cache_creation_input_tokens":2000
}
},
"costUSD":0.042
}
이를 수천 개의 세션에 걸쳐 몇 달 동안 곱하면, 파싱해야 할 JSONL이 기가바이트 단위가 됩니다.
Why simd-json
표준 serde_json도 훌륭하지만, 3 GB 규모의 라인‑구분 JSON을 파싱할 때 라인당 마이크로초 단위의 차이가 누적됩니다.
simd-json은 simdjson을 Rust로 포팅한 것으로, SIMD 명령어(AVX2, SSE4.2, NEON)를 사용해 JSON을 훨씬 빠르게 파싱합니다. 핵심 트릭은 가변 버퍼를 이용한 제자리 파싱입니다.
#[derive(Deserialize)]
struct ClaudeJsonLine<'a> {
timestamp: &'a str, // borrowed, zero‑copy
#[serde(rename = "requestId")]
request_id: Option<&'a str>, // borrowed, zero‑copy
message: Option<&'a str>,
#[serde(rename = "costUSD")]
cost_usd: Option<f64>,
}
String 대신 &'a str을 사용함으로써 각 필드에 대한 힙 할당을 피할 수 있습니다. simd-json은 가변 바이트 버퍼에서 제자리로 JSON을 파싱하고, 우리의 구조체는 그 버퍼에서 슬라이스를 빌려 사용합니다.
주의할 점 하나: simd-json의 from_slice는 &mut [u8]를 요구하므로, 각 라인의 가변 복사본을 소유하고 있어야 합니다.
let reader = BufReader::new(File::open(path)?);
for line in reader.lines() {
let line = line?;
let mut bytes = line.into_bytes(); // owned, mutable
if let Ok(parsed) = simd_json::from_slice(&mut bytes) {
// extract what we need, bytes are consumed
}
}
이 방법을 사용하면 내 데이터셋에서 표준 serde_json에 비해 17–25 % 정도의 처리량 향상을 얻을 수 있었습니다.
rayon을 이용한 병렬 처리 추가
단일 스레드 파서는 약 ~1 GiB/s의 속도를 기록했습니다. 9,000개 이상의 파일을 다룰 때, 파일 수준에서 rayon을 사용하면 쉽게 병렬화할 수 있습니다:
use rayon::prelude::*;
let entries: Vec<_> = files
.par_iter()
.flat_map(|f| parser.parse_file(f).unwrap_or_default())
.collect();
Rayon의 par_iter()는 파일들을 자동으로 스레드에 분배합니다. simd-json과 결합하면 처리량이 ~2 GiB/s까지 상승했으며, 순차 파싱에 비해 3.2배 향상되었습니다.
| 단계 | 처리량 |
|---|---|
serde_json (baseline) | ~800 MiB/s |
simd-json (zero‑copy) | ~1.0 GiB/s |
simd-json + rayon | ~2.0 GiB/s |
어려운 부분: 각 CLI가 다름
실제 복잡성은 파싱 속도가 아니라, 단일 트레이트 뒤에서 세 가지 완전히 다른 데이터 형식을 처리하는 것이었습니다:
pub trait CLIParser: Send + Sync {
fn name(&self) -> &str;
fn data_dir(&self) -> PathBuf;
fn file_pattern(&self) -> &str;
fn parse_file(&self, path: &Path) -> Result<Vec<TokenRecord>, Box<dyn Error>>;
}
Claude 코드
간단합니다 — message.usage 필드가 있는 각 JSONL 라인은 하나의 API 호출입니다.
Codex CLI
복잡합니다. 토큰 수는 누적이며 — 각 token_count 이벤트는 증분이 아니라 현재 총합을 보고합니다. 모델 이름은 별도의 turn_context 라인에 있어 파싱이 상태를 유지해야 합니다:
line 1: session_meta → extract session_id
line 2: turn_context → extract model name
line 3: event_msg → token_count (cumulative total)
line 4: event_msg → token_count (larger cumulative total)
세션당 마지막 token_count만 유지해야 합니다.
Gemini CLI
표준 JSON( JSONL이 아님)을 사용하며, 다른 CLI에서는 추적하지 않는 고유한 thinking_tokens 필드를 포함합니다.
ratatui를 사용한 TUI
- Overview — GitHub 스타일의 52주 히트맵을 사용한 총 토큰/비용
- Models — 모델별 세부 내역과 퍼센트 바
- Daily — 스파크라인 차트가 포함된 스크롤 가능한 테이블
- Stats — 카드 그리드에 표시된 주요 지표
히트맵은 2×2 유니코드 블록 문자를 사용하여 52주 데이터를 컴팩트한 공간에 맞추며, 백분위 기반 색상 강도를 적용합니다.
결과
| 측정항목 | 시간 |
|---|---|
| 콜드 스타트 (캐시 없음) | ~1.2 s |
| 웜 스타트 (캐시 사용) | ~0.05 s |
캐싱 레이어는 일일 요약을 ~/.toktrack/cache/에 저장합니다. 과거 날짜는 변경할 수 없으며—오늘만 다시 계산됩니다. 이는 Claude Code가 30 일 후에 세션 파일을 삭제하더라도 비용 기록이 유지된다는 의미입니다.
사용해 보기
npx toktrack
# or
cargo install toktrack
GitHub:
Claude Code, Codex CLI, 또는 Gemini CLI를 사용하고 토큰이 어디로 가는지 알고 싶다면 — 한 번 사용해 보세요.