한줄 요약
:
ccusage보다 최저 40배, 최대 1000배 빠른 오픈소스를 만들다.

GitHub - mag123c/toktrack: Ultra-fast token & cost tracker for Claude Code, Codex CLI, and Gemini CLI
Ultra-fast token & cost tracker for Claude Code, Codex CLI, and Gemini CLI - mag123c/toktrack
github.com
개발 동기
갑자기 느려진 ccusage
ccusage를 출시 이래로 정말 잘 사용하고 있던 사용자 중 한 명이었습니다. 그런데 최근 체감될 정도로 느려졌어요. Claude Code의 JSONL 파일들을 확인해봤습니다.
# 용량
du -sh ~/.claude/projects
3.4G
# 파일 수
find ~/.claude/projects -name "*.jsonl" | wc -l
2772
Claude Code는 기본적으로 30일 지난 세션 파일을 자동 삭제합니다. 그럼에도 불구하고 최근에 역대급으로 많이 사용해서 그런지, 세션 파일이 생각보다 많이 남아있더라구요. 아마 사용량이 엄청나게 증가해서, ccusage가 수집하는 데 많은 시간을 소요하는 것이라고 생각했습니다. 43초가 걸렸으니까요.
해결되지 않는 이슈
국내/해외 할 것 없이 저보다 많은 사용량을 가진 사람이 더 많을 것으로 생각되는데요. 역시나 ccusage의 깃헙에는 여러 관련 이슈들이 이미 발행되어있는 상태였습니다. (#821, #804, #718)
성능 관련 이슈들이 열려있고, PR들도 머지되지 않은 채 방치되어 있었습니다.
이참에 직접 만들어보기로 했습니다. 성능 좋기로 소문난 Rust로 만들면서, 관심 있던 Rust 학습도 겸할 수 있으니까요.
언어는 Rust로, JSON처리는 Rust의 simd-json을 사용했습니다.
NodeJS의 한계
ccusage는 제 주력 스택인 TS로 작성되어 있습니다.
// ccusage의 파일 처리 방식
import { readFile } from 'node:fs/promises';
import { glob } from 'tinyglobby';
const files = await glob(['**/*.jsonl']);
for (const file of files) {
const content = await readFile(file, 'utf-8');
for (const line of content.split('\n')) {
const parsed = JSON.parse(line);
// 처리...
}
}
glob으로 파일을 탐색하고, fs로 파일을 읽고 JSON.parse로 파싱하게 되어있어요.
JSON.parse
V8은 JSON의 처리 성능을 꾸준히 개선해왔던 것으로 알고 있습니다.
v7.6에서는 JSON.parse의 메모리 최적화를, 최근 v13.8에서는 JSON.stringify의 SIMD 최적화가 대표적이죠.
하지만 이와 관계없이 JSON.parse는 여전히 순차 처리입니다.
JavaScript의 JSON.parse는 바이트 단위로 순차 처리합니다.
반면 simdjson은 CPU의 SIMD 명령어를 활용해 한 번에 32~64바이트를 병렬 처리하죠. simdjson은 초당 기가바이트 단위의 JSON을 처리할 수 있습니다. 문자 하나씩이 아니라 64문자를 한 번의 CPU 명령으로 처리하기 때문입니다.

물론 simdjson은 Node.js 바인딩도 존재하지만 유지 보수가 5년째 되지 않고 있어요.
그리고 JSON 파싱만 빨라져도 아래에서 다룰 싱글스레드, GC 오버헤드 문제는 여전히 남아있습니다.
fs.readFile
const content = await readFile(filePath, 'utf-8');
readFile은 파일 전체를 한 번에 메모리에 로드합니다. 2,772개 파일을 순차적으로 읽으면서 각 파일의 내용이 메모리에 올라가고, UTF-8 디코딩이 수행되고, 이후 split('\n')으로 또다시 새로운 문자열 배열이 생성됩니다.
createReadStream으로 스트리밍 읽기가 가능하지만, 어차피 JSON.parse는 완전한 문자열 단위로 동작하기 때문에 근본적 해결은 아닙니다.
libuv
Node.js의 파일 I/O는 이벤트 루프가 아닌 libuv의 스레드풀에서 처리됩니다. 기본값은 4개입니다.

UV_THREADPOOL_SIZE 환경변수로 늘릴 수 있지만, 실제로 테스트해본 결과 스레드풀을 32배로 늘려도 22% 개선이 한계였습니다.
CPU 사용률이 100%에 도달했는데도 33초. 파일 I/O를 병렬화해도 JSON.parse를 실행하는 메인 스레드가 병목이기 때문입니다.
Worker Threads로 JSON 파싱 자체를 병렬화할 수 있지만, Worker 간 데이터 전달 시 직렬화/역직렬화 오버헤드가 발생합니다. 결국 병렬로 파싱하려면 직렬화가 필요하고, 직렬화 자체가 파싱만큼 비싸다는 딜레마에 빠집니다.
GC
3GB 분량의 JSONL을 파싱하면 수많은 임시 객체가 생성됩니다. 이 정도 규모의 데이터를 처리하면 stop-the-world가 자주 발생할 것으로 추측됩니다. 실제로 ccusage 실행 중 --trace-gc 옵션으로 GC를 트레이싱해봤습니다.
# GC 트레이싱 결과 (3GB / 2,772 파일 처리)
총 GC 이벤트: 504회
힙 메모리 최대: 378MB
Major GC 단일 pause: 최대 135ms
# Major GC (Mark-Compact) 로그 일부
605 ms: Mark-Compact 195.3 → 163.4 MB, 135.79ms pause
1051 ms: Mark-Compact 273.0 → 239.9 MB
1378 ms: Mark-Compact 378.9 → 225.5 MB
1650 ms: Mark-Compact 390.2 → 210.9 MB
1886 ms: Mark-Compact 375.3 → 161.7 MB
V8은 Orinoco GC를 통해 Incremental Marking, Concurrent Sweeping 등으로 pause를 줄이고 있지만, 대용량 데이터를 처리할 때는 여전히 무시할 수 없는 오버헤드입니다. 50초 실행 중 504번의 GC가 발생했다는 것은, 평균 100ms마다 한 번씩 GC가 개입한다는 뜻이니까요.
Rust 선택의 이유
외부 레퍼런스들을 보면 항상 성능 좋다고 언급되는 Rust를 이 기회에 한 번 사용해보고 싶었습니다. 겸사겸사 공부도 하면 좋으니까요.
그 외에 사실 크게 특별한 이유는 없었습니다. (다른 오픈소스 홍보 글에는 사실 적어두었지만, 솔직하게 입장을 밝힙니다 ㅋㅋ)
JSON.parse에는 simd-json을, 파일 탐색에는 glob을, 병렬 처리는 rayon을 사용했습니다.
simd-json
// toktrack의 JSON 파싱 (zero-copy)
let data: ClaudeJsonLine = simd_json::from_slice(&mut line_bytes)?;
Rust의 simd-json simdjson의 Rust 포트입니다. from_slice에 &mut [u8]을 전달하면 in-place로 파싱하여 불필요한 메모리 할당 없이 데이터를 추출합니다. (zero-copy 파싱)
rayon
// 병렬 파일 처리: .iter() → .par_iter() 한 줄 변경
let entries: Vec<UsageEntry> = files
.par_iter()
.flat_map(|f| parse_file(f))
.collect();
rayon 데이터 병렬 처리 라이브러리입니다. .iter()를 .par_iter()로 바꾸는 것만으로 CPU 전체 코어를 활용한 병렬 처리가 가능합니다. work-stealing 알고리즘으로 코어 간 부하를 자동으로 분산합니다.
Node.js에서는 병렬로 파싱하려면 직렬화가 필요하다는 딜레마가 있었지만, Rust에서는 각 스레드가 독립적으로 파일을 읽고 파싱한 뒤 결과만 모으면 됩니다. 직렬화 오버헤드가 없습니다.
그렇게 완성된 toktrack
Apple Silicon (M-series), 2,772개 JSONL 파일, 3.4GB 기준 측정
# ccusage (Node.js)
$ time ccusage daily --offline
# 43.26s
# toktrack (Rust) - cold start, 캐시 없음
$ time toktrack
# Cold start: ~ 1000ms
# Warm start: ~ 40ms
toktrack은 Claude Code만이 아니라, 현재는 Gemini-CLI, Codex도 지원합니다. 앞으로 GLM, Opencode등을 빠르게 지원할 예정입니다.
캐시 아키텍처
성능을 0.04s까지 끌어올렸던 캐싱 전략입니다.
- Cold Start: 전체 glob 스캔 → 병렬 SIMD 파싱 → 캐시 생성 → 집계 → 1000ms
- Warm Start (cached): 캐시된 일별 요약 로드 → 오늘 데이터만 재파싱 (과거 날짜는 캐시에서 제공) → 병합 → 집계 → 40ms
캐싱으로 엄청난 성능 최적화를 했지만, 한 가지 의도가 더 숨어있습니다.
CLI에 따라 세션을 디폴트로 삭제할 때가 있습니다. 특히 Claude Code는 기본적으로 cleanupPeriodDays: 30으로 설정되어, 30일이 지나면 JSONL 파일이 사라집니다. 파일이 삭제되면 토큰 사용량과 비용 이력도 함께 사라집니다.
toktrack의 일별 캐시가 이 문제를 해결합니다. 과거 날짜는 불변(immutable)입니다.
한번 집계된 일별 요약은 이후 수정되지 않습니다. Claude Code가 한 달 뒤에 원본 세션 파일을 삭제하더라도, 비용 이력은 ~/.toktrack/cache/에 그대로 남아있습니다.
마치며
오픈소스는 메인테이너의 일정에 따라 반영되는데 지연이 될 수 있는 것을 잘 알고 있습니다.
그래서 저 또한 여러 이슈들의 PR을 합치고, 현재 프로젝트의 아이디어인 캐싱 전략까지 포함하여 ccusage에서 해결하려고 시도해봤지만 구조적 한계가 있었습니다.
- JSON.parse의 순차 처리
- libuv 스레드풀 확장해도 22% 한계
- Worker Threads 병렬화의 직렬화 오버헤드
- 50초 동안 504번의 GC
Rust의 SIMD JSON 파싱, 제로 코스트 병렬 처리, GC 없는 메모리 관리.
이 조합이 40배 속도 향상을 가능하게 했습니다. 똑똑한 알고리즘이 아니라, 병목 자체를 제거한 결과입니다.
toktrack은 오픈소스로 공개되어 있습니다. 피드백이나 기여는 언제든 환영합니다!
GitHub - mag123c/toktrack: Ultra-fast token & cost tracker for Claude Code, Codex CLI, and Gemini CLI
Ultra-fast token & cost tracker for Claude Code, Codex CLI, and Gemini CLI - mag123c/toktrack
github.com
References.
https://v8.dev/blog/v8-release-76
https://v8.dev/blog/json-stringify
https://v8.dev/blog/trash-talk
https://v8.dev/blog/concurrent-marking
https://deepu.tech/memory-management-in-v8/
https://docs.libuv.org/en/v1.x/design.html
https://simdjson.org/
https://deepwiki.com/simdjson/simdjson
https://arxiv.org/abs/1902.08318
https://nodesource.com/blog/State-of-Nodejs-Performance-2024