코덱스 5시간 사용량은 0%인데 같은 화면의 주간 집계에는 7%로 찍혀 있었다. 다른 계정 하나는 더 이상해서 5시간도 주간도 전부 0%였다. 혹시 API 응답이 잘못된 게 아닐까? 로그를 열어 보니 원인은 API가 아니라 내가 만든 집계 로직에 있었다. 여러 사람이 코덱스·클로드 CLI 계정 하나를 나눠 쓰는 공유 자격증명 환경에서는 로컬 로그에 찍힌 토큰 사용량이 누구 몫인지 가려낼 길 없이 계정 하나로 뭉쳐 버린다.

이 앱을 만든 이유 자체가 여러 벤더를 한눈에 보고 싶어서였다. Codex, Claude, Gemini 세 CLI를 나란히 쓰다 보니 사용량 확인 창도 세 개였다. 터미널 하나, 상태 표시줄 하나, 또 다른 창 하나. 그런데 정작 한곳에 모아 놓고 보니 숫자 자체를 믿을 수 없는 문제가 먼저 터졌다. 창을 하나로 합치는 것보다 그 창에 뜨는 숫자가 맞는지가 더 급한 문제였다.

맥 메뉴바에 여러 CLI 계정의 사용량 게이지가 한 트레이 팝업 안에 나란히 표시되는 모습을 그린 일러스트
Codex·Claude·Gemini 계정 사용량을 한 트레이 팝업에 모아 보여주는 개념을 표현한 일러스트.

콘솔 세 개 대신 트레이 하나로

지금 쓰는 앱은 macOS 메뉴바와 Windows 작업표시줄에서 동작하는 트레이 앱으로 Tauri v2와 Rust로 다시 짰다. 이전에는 Swift로 만든 메뉴바 앱이 있었지만 참고용으로만 남기고 실제로 쓰는 건 이 버전이다. 구조는 크게 두 덩어리. crates/usage-core는 사용량 모델과 중복 제거 집계, 코덱스·클로드 쿼터 파서, 그리고 코덱스·클로드·제미나이 계열 로컬 JSONL 로그를 읽는 스캐너를 담고 있다. src-tauri는 트레이 팝업 UI, 키체인(macOS·Windows의 운영체제 자격 증명 저장소)에 저장하는 계정 스토어, OAuth 로그인, 토큰 갱신, 60초 주기 폴링 루프(주기적 상태 확인)를 맡는다.

화면에는 계정별로 5시간/7일 게이지가 뜬다. 다만 Gemini 계열(agy)은 서버 쿼터 API가 따로 없어서 퍼센트 게이지 대신 로컬 로그의 토큰 합계로만 보여준다. 여기서 중요한 원칙 하나를 정했다. 서버가 알려주는 정확한 퍼센트와 로컬 로그를 긁어 추정한 합계를 절대 같은 값처럼 섞어 보여주지 않는다는 것이다. 하나는 근거가 확실한 수치고 다른 하나는 추정치다. 둘을 같은 게이지로 그리면 둘 다 정확한 값처럼 보이는 착시가 생긴다.

Codex·Claude·Gemini 세 CLI의 로컬 로그를 usage-core가 수집하고 트레이 팝업이 계정별 게이지로 보여주는 구조도
구조도. usage-core가 세 벤더 로그를 모으고 트레이 팝업이 계정별로 나눠 보여준다.

메뉴바 아이콘 하나에 숫자를 몰아넣는 게 목표라면 파서 세 개만 붙이면 끝날 일이었다. 실제로는 그렇지 않다. 파서를 붙이자마자 계정 귀속(attribution, 로그 한 줄을 어느 계정 몫으로 셀지 정하는 일) 문제가 먼저 나타났다.

공유 계정과 동시 프로젝트가 사용량을 오염시킨다

코덱스 7%, 클로드 0%로 뒤집혔던 그 사례가 이 문제였다. 원인은 단순했다. 로컬 로그는 프로필 루트(CLI가 설정과 인증 정보를 저장하는 홈 디렉터리 경로) 단위로 쌓인다. 한 프로필 루트를 여러 사람이 공유하거나 같은 계정으로 프로젝트 여러 개를 동시에 열어 놓으면 그 루트 아래 쌓인 토큰 이벤트는 전부 한 계정 몫으로 더해지는 구조다. 프로필 루트 하나 = 계정 하나라는 가정이 깨지는 순간 합계가 틀어진다.

그래서 프로필 루트만 보고 합산하는 방식은 폐기하고 신원(identity)을 먼저 확인하는 쪽으로 바꿨다. account_id나 이메일처럼 계정을 특정할 수 있는 증거가 로그나 인증 파일에 남아 있으면 그 증거로 귀속시키는 게 원칙이다. 증거가 없을 때만 프로필 루트 단독 연관으로 되돌아가고 그마저도 애매하면 아예 집계에서 뺀다. 애매한 걸 대충 한쪽에 붙이는 대신 통계에서 제외하는 쪽을 택했다. 틀린 숫자보다는 빈 칸이 낫다고 판단했다.

여기에 또 하나 신경 쓴 지점이 있다. 같은 이벤트가 여러 프로필 루트에 걸쳐 중복으로 잡히기도 한다. 루트를 넘나드는 이벤트는 순서에 무관한 정렬 기준으로 고유 키를 만들어 중복 제거한다. 어느 루트를 먼저 스캔하느냐에 따라 결과가 달라지면 그 자체로 또 다른 오귀속이니까.

동시 프로젝트 쪽은 좀 더 미묘하다. 같은 계정으로 프로젝트 A와 B를 나란히 열어 두면 세션 로그가 겹치는 시간대에 각각 쌓인다. 계정이 하나뿐이면 합산 자체는 틀리지 않지만 문제는 그 계정이 애초에 "이 컴퓨터를 쓰는 나 혼자만의 것"이라는 보장이 없을 때 시작된다. 팀에서 자격 증명을 공유하거나 여러 사람이 번갈아 같은 터미널 프로필을 쓰는 경우가 그렇다. 계정 하나에 여러 사람이 물려 있으면 프로젝트 단위 합산으로는 "어느 계정 몫인지"조차 가려낼 수 없다. 판단 기준을 프로젝트가 아니라 계정 신원 자체로 옮긴 이유이지만 이 전환이 막아 주는 건 계정 사이의 오귀속이지 한 계정을 여럿이 나눠 쓸 때 사람별 몫까지 갈라 주는 건 아니다.

신원 증거 먼저, 프로필 루트는 그다음

실제로 짠 알고리즘은 2단계다. crates/usage-core/src/attribution.rsassign_local_usage 함수가 이 판정을 맡는다.

1단계(Phase A)는 신원 증거 우선이다. 코덱스의 account_id나 이메일이 딱 하나의 계정과만 매칭되면 그 계정으로 확정한다(UniqueProof). 두 개 이상과 매칭되면 Ambiguous로 처리하고 증거는 있는데 서로 어긋나면 Conflict로 본다(브라우저 OAuth로 로그인한 경우는 이 충돌 판정에서 예외로 뺀다). 다른 계정 소유로 이미 확인된 경우는 SharedProfileOther로 분류해 아예 넘기는 식이다.

2단계(Phase B)는 신원 증거가 아예 없을 때만 돈다. 프로필 루트 하나에만 계정이 연관돼 있으면 추정 귀속(Assumed)으로 인정한다. 루트 하나에 계정이 둘 이상 연관돼 있으면 여기서도 Ambiguous다. 두 단계의 결과는 심각도 순서(severity_rank)로 병합한다. AmbiguousConflict로 판정된 몫은 전부 합계에서 0으로 처리한다. 판단이 안 서면 0이다. 어느 한쪽에 임의로 배정하지 않는다.

계정 귀속 2단계 알고리즘 플로우 — 신원 증거 매칭 단계와 프로필 루트 연관 단계를 거쳐 모호하면 합계 0으로 처리하는 흐름
다이어그램. 신원 증거 단계를 먼저 거치고 증거가 없을 때만 프로필 루트로 넘어간다.
판정 결과 귀속 처리
신원 증거 단일 매칭 (UniqueProof) 해당 계정으로 정상 귀속
신원 증거 2개 이상 매칭 (Ambiguous) 합계에서 제외(0)
증거는 있으나 서로 불일치 (Conflict) 합계에서 제외(0), 단 브라우저 OAuth는 예외
증거 없음 · 프로필 루트 단독 연관 (Assumed) 추정 귀속(루트 기준)
증거 없음 · 한 프로필 루트에 계정 2개 이상 연관 (Ambiguous) 합계에서 제외(0)

이 표 하나가 오귀속 버그를 잡은 뒤의 규칙 전부다. 신원부터 보고 없으면 루트를 보고 그래도 애매하면 세지 않는다. 계정 사용량 앱에서 숫자를 지어내는 것보다 위험한 게 있을까? 틀린 숫자는 사람이 의사결정을 잘못 내리게 만든다.

토큰은 화면에도 로그에도 남기지 않는다

사용량을 서버 쿼터 API로 정확히 받으려면 로그인이 필요하다. 여기서 쓴 방식이 PKCE(코드 교환 증명 키, Proof Key for Code Exchange — 인증 코드를 가로채도 재사용 못 하게 막는 OAuth 확장) 기반 OAuth다. 로그인 도중 인증 코드를 누가 가로채더라도 그 코드만으로는 토큰을 재발급받을 수 없고 state 값으로 요청 세션과 콜백의 연관성을 확인한다. 리디렉션은 루프백(로컬 호스트로 되돌아오는) 경로와 HTTPS만 기본값으로 허용해 둔다. 로그인이 끝나면 받은 토큰은 키체인에 저장한다. 평문 파일이나 IPC(프로세스 간 통신) 메시지, 로그 어디에도 토큰을 남기지 않는다.

OAuth의 client_id나 엔드포인트 값은 문서에 적혀 있어도 실제 로그인으로 검증되기 전까지는 추정값 취급이다. 문서와 실제 동작이 다를 수 있다는 걸 이미 다른 데서 겪었기 때문이다. Windows 쪽 트레이·OAuth 플로우도 마찬가지로, 실제 Windows 환경에서 스모크 테스트(핵심 기능 작동 확인)를 마치기 전까지는 미검증 상태로 남겨 둔다. CI를 통과했어도 그 플랫폼에서 실제로 돌려 보지 않은 코드는 검증됐다고 부르지 않는다.

벤더마다 신원을 확인하는 방법도 다르게 짰다. 하나의 공용 함수로 "지금 로그인된 계정을 알려줘"를 처리할 수 없었다. 코덱스는 앱 서버에 프로브(상태 탐색 요청)를 던져 확인하고 클로드는 상태 표시줄 브리지를 거친다. 두 벤더가 신원을 노출하는 창구 자체가 다르니 각 벤더가 신원을 실제로 증명하는 방식에 맞춰 프로브를 따로 짜야 했다. 터미널에서 따로 로그인한 프로필은 앱의 CLI 토큰으로 그냥 가져다 쓰지 않고 격리해 둔다. 로컬 로그를 스캔할 때도 파일 수정 시각이 아니라 이벤트 자체의 타임스탬프를 기준으로 삼고 한 번에 훑는 파일 수에 상한을 두고 액세스 토큰은 어떤 경우에도 로그로 남기지 않는다.

조직 ID가 아니라 계정 ID에 뿌리를 내린다

가장 최근에 고친 버그가 이 원칙을 제일 잘 보여준다. 클로드 신원 확인 로직이 처음엔 organizationUuid(조직 단위 식별자)를 기준(어떤 값이 맞는 신원인지 판단할 때 기준으로 삼는 앵커 값)으로 삼았다. 같은 조직 안에서 다른 계정으로 재로그인하면 라벨이 뒤섞였다. 조직은 그대로인데 계정만 바뀌었으니 앱은 "같은 사람"이라고 착각한 것이다. 조직 ID는 여러 계정이 공유하는 값이라 신원을 특정하는 기준으로 쓰기엔 너무 넓었다.

고친 방식은 조직이 아니라 계정 고유 식별자(accountUuid)에 앵커를 옮기는 것이었다. 화면에 보이는 라벨은 이메일로 표시하되, 내부 판단 기준은 계정 단위로 좁힌다. 기존 로직에 있던 덮어쓰기 코드도 같이 걷어냈다. 앵커를 바꾸는 마이그레이션은 락으로 보호하고 여러 번 실행해도 같은 결과가 나오게 만들고 계정이 유실될 수 없게 짰다. 신원 기준을 바꾸는 작업 자체가 사용자 계정 데이터를 건드리는 일이라 되돌릴 여지를 항상 남겨야 하는 이유다. 이 변경을 포함한 릴리스는 무료판·프로판·코어 전 에디션에서 관련 테스트와 정적 분석(클리피, clippy — Rust의 정적 분석 린터)을 통과했다. 계정 데이터를 건드리는 마이그레이션일수록 검증을 마친 뒤에 내보내는 편이 마음이 놓였다.

비슷한 시기에 신원 확인 경로도 하나 더 고쳤다. GUI 앱은 최소한의 PATH 환경에서 실행되다 보니 claude 바이너리를 스폰(하위 프로세스로 실행)하는 방식이 종종 실패했다. 프로필 루트 아래 있는 .claude.json 파일을 직접 읽어 신원을 뽑아내는 쪽으로 우선순위를 바꾼 이유다. 커맨드라인 프로브는 그 파일을 못 읽을 때만 쓰는 폴백(실패 시 대체 수단)으로 내려간다. 계정 스토어 파싱도 같은 방향으로 관대해졌다. 무료 버전 바이너리가 유료 전용 프로바이더 항목이 섞인 공유 스토어를 읽다가 인식 못 하는 항목 하나 때문에 계정 목록 전체가 조용히 비어 버리던 버그가 있었다. 지금은 인식 못 하는 항목만 개별적으로 건너뛰고 나머지는 그대로 보존한다. 다만 인덱스 자체가 정말 손상됐을 때는 여전히 통과시키지 않는다.

이런 프로그래밍 작업을 거듭하며 확인한 건 결국 하나다. 신원을 무엇에 앵커링하느냐가 전부를 좌우한다. 조직처럼 넓은 값에 걸면 계정이 바뀌어도 못 알아챈다. 프로필 루트처럼 얕은 값에만 걸면 계정을 공유하는 순간 무너지는 게 문제다. 계정 고유 식별자처럼 좁고 확실한 값에 걸어야 재로그인이나 예상 못 한 형식 변화에도 버텨 내며 여러 계정이 얽힌 환경에서는 계정 간 오귀속을 줄인다.

남은 숙제는 두 가지다. Gemini는 여전히 서버 쿼터 API가 없어서 로컬 토큰 합계로만 보여준다. 이 추정치가 실제 사용량과 얼마나 벌어지는지는 아직 실측하지 못했다. Windows 트레이·OAuth 플로우는 CI에서는 초록이지만 실제 Windows 호스트에서 로그인부터 트레이 표시까지 끝까지 돌려 보는 스모크 테스트가 아직 남아 있다. 플랫폼 하나를 더 늘릴 때마다 신원 확인 창구도, 로그 위치도, 키체인 API도 매번 새로 확인해야 한다는 걸 이번에 다시 배웠다. 그래서 다음 벤더를 붙일 때 지킬 원칙은 셋으로 좁혔다. 계정 고유 ID가 있으면 그것부터 신원 기준으로 삼고 모호한 로그는 억지로 어느 한쪽에 붙이는 대신 합계에서 뺀다. 플랫폼별 수치는 실제 환경에서 돌려 확인이 끝난 값만 화면에 확정 표시한다.