TokenPlant라는 걸 만들었습니다. 맥 메뉴바에 떠 있으면서 Claude Code나 Codex로 쓴 토큰을 물로 바꿔서 식물을 키워주는 앱이에요. 아무것도 안 눌러도 개발하다 보면 알아서 자랍니다. 다 자라면 정원으로 옮겨 심어지고요.
모이통이나 물받이를 놓으면 새가 날아와서 앉습니다.
그런데 어느 날 제 화분이 안 자랐습니다.
숫자는 68%쯤 찬 것처럼 보이는데 막대는 거의 비어 있습니다.
어제도 322,550. 오늘도 322,550. 한 자리도 안 움직였어요. 하루 종일 작업했는데요.
내가 만든 앱인데 왜 안 자라는지 모르겠더라고요.
일단 숫자부터 이상했습니다. 322,550 / 476,892면 68%쯤 찬 건데, 바로 밑의 막대는 10%쯤밖에 안 차 있었어요. 둘 중 하나는 틀린 거잖아요.
역산해봤습니다.
값 | |
|---|---|
이식까지 총량 | 772,295 mL |
Lv.8 만개 문턱 | 303,125 mL |
Lv.9 열매 문턱 | 476,892 mL |
지금 부은 물 | 322,550 mL |
둘 다 맞는 숫자였습니다.
왼쪽 322,550은 지금까지 부은 물 전체
오른쪽 476,892는 다음 단계 문턱
막대는 이번 단계 안에서의 진행도 — Lv.8에 막 들어왔으니 11%
각자는 다 맞는데, /로 이어 붙여놓으니 분수로 읽혀서 "68% 찼다"로 보였던 거예요. 그 옆에서 막대는 11%를 가리키고 있었고요. 두 개가 정반대를 말하고 있으니 "안 차고 있다"고 읽을 수밖에 없었습니다.
/를 없앴습니다.
/ 를 없애고 오른쪽을 「다음까지 남은 양」으로 바꿨습니다. (며칠 지난 화면이라 숫자는 더 자란 상태예요.)
분수로 안 보이면 오해할 여지가 없죠. 누적은 이 앱의 보람이라 남기고, 오른쪽은 문턱 대신 남은 양을 말하게 했습니다.
뿌듯하게 빌드하고 다시 열었습니다. 여전히 322,550이었습니다.
화면 문제가 아니었던 겁니다. 세이브 파일을 직접 열어봤어요.
세이브 파일 수정 시각: 9월 23일 15:06 ← 오늘은 9월 24일
lastDate: '2026-09-23'
pot.water: 322,550
앱은 멀쩡히 떠 있었습니다. 프로세스도 살아 있고요. 그런데 어제 오후 3시 이후로 파일에 아무것도 안 썼습니다. 하루가 통째로 날아간 거예요.
그 사이에 저는 그 앱을 계속 보고 있었고, 숫자가 그럴듯하게 떠 있으니까 "느린가 보다" 하고 넘겼습니다. 고장난 걸 몰랐어요.
뭐가 문제인지 보려고 유입 처리 쪽부터 뜯었습니다.
if save.rawWallet != walletBefore || save.rawEarnedTotal != rawBefore {
persist()
}
"지갑이 변했을 때만 저장한다." 합리적으로 보이죠. 쓸데없는 디스크 쓰기를 줄이려던 거였습니다.
그런데 유입 처리 함수는 지갑 말고 날짜도 바꿉니다. 그날 읽힌 사용량이 0이면 지갑은 그대로인데 lastDate는 오늘로 넘어가요. 그럼 저장이 안 되고, 파일엔 어제 날짜가 그대로 남습니다. 앱을 껐다 켜면 어제 날짜를 다시 읽어서 같은 일을 또 하고요. 무한히.
고치면서 생각이 좀 바뀌었습니다. 무엇이 바뀌었는지를 호출부가 판단하게 두면 이런 구멍이 계속 생겨요. 오늘은 지갑을 빼먹었고, 내일은 다른 걸 빼먹겠죠. 그래서 판단을 저장 함수 안으로 옮겼습니다.
private func persist() {
guard let data = try? JSONEncoder().encode(save) else { return }
guard data != lastWritten else { return } // 바뀐 게 없으면 안 쓴다
try? data.write(to: url, options: .atomic)
lastWritten = data
}
호출부는 이제 마음껏 부르면 됩니다. 바뀐 게 없으면 여기서 알아서 안 써요.
30초마다 도는 갱신 함수가 이렇게 생겼었습니다.
func refresh() {
guard !isRefreshing else { return }
isRefreshing = true
Task {
... 로그 읽고 물 적립하고 ...
let limits = await LimitsReader.read() // ← codex 프로세스를 띄운다
...
isRefreshing = false // ← 여기서만 풀린다
}
}
한도 조회가 codex app-server라는 외부 프로세스를 띄웁니다. 그게 한 번 안 끝나면 그 뒤의 isRefreshing = false까지 막혀요. 그럼 이후 모든 갱신이 첫 줄에서 되돌아갑니다. 30초 타이머도, 새로고침 버튼도 전부 무력화됩니다.
웃긴 건 제가 그 바로 위에 이렇게 주석을 달아놨다는 거예요.
한도 조회는 물 적립과 분리해 뒤에 돌린다. 실패가 흔하고 느리다 — 여기에 성장을 묶으면 한도를 못 읽는 날 화분이 멈춘다.
알고 있었는데, 같은 Task 안에 순서대로 넣어뒀습니다. 그건 분리한 게 아니라 묶은 거죠.
세 가지를 고쳤습니다. 한도 조회를 진짜 별도 Task로 떼고, defer로 어떤 경로로 빠져나가도 래치가 풀리게 하고, 90초 넘게 붙잡혀 있으면 놓아주는 감시견을 붙였습니다. 진짜 멈춘 await는 defer로도 못 푸니까요.
고치고 다시 돌렸습니다. 이번엔 제대로 돌았어요.
lastDate: 2026-09-24 ← 날짜 넘어감
pot.water: 322,926 ← +376 mL
rawWallet: +651,838
장부도 맞고 물도 들어왔습니다. 그런데 저 숫자를 보고 좀 멍했어요.
651,838. 그게 그날 제 로컬 토큰 전부였습니다. 65만 개요. 하루 종일 작업했는데.
TokenPlant은 ~/.claude/projects에 쌓이는 로컬 Claude Code 로그를 읽습니다. 그런데 저는 그날 웹에서 작업하고 있었어요. 그 토큰은 제 맥에 한 줄도 안 남습니다.
과거 기록을 보니 처음부터 티가 나 있었습니다.
2026-09-17 788,678,952
2026-09-18 491,616,038
2026-09-21 395,870,952
2026-09-22 359,832,775
2026-09-23 23,037,010 ← 하루 종일 작업한 날
7억 8천만 개를 쓴 날이 있고, 2천 3백만 개인 날이 있습니다. 차이는 그날 터미널에서 claude를 직접 돌렸는지 여부였어요.
버그 두 개는 진짜 버그였습니다. 고쳐야 했고 고쳤어요. 그런데 제가 처음에 이상하다고 느낀 그 현상은 버그가 아니었습니다. 앱은 제대로 읽고 있었고, 읽을 게 없었던 겁니다.
버그 자체보다 하루 동안 몰랐다는 게 더 걸렸습니다.
앱은 하루 동안 죽어 있었는데 화면은 멀쩡했어요. 마지막 숫자를 그대로 띄우고 있었으니까요. "안 보고 있어도 자란다"가 이 앱의 전부인데, 그 말은 고장난 걸 알아챌 방법이 없다는 뜻이기도 했습니다.
그래서 몇 가지를 바꿨습니다.
마지막 갱신 시각이 5분 넘으면 주황색으로 뜹니다. 그 전엔 흐린 회색으로 "1523분 전"이라고 적고 있었어요. 읽을 수 없는 숫자는 경고가 아니죠.
빈 상태 문구를 바꿨습니다. "터미널에서 Claude Code · Codex를 쓰면 저절로 자라요. 웹·데스크톱 앱 사용량은 맥에 기록이 안 남아서 셀 수 없어요." 저도 몰랐던 걸 사용자가 알 리가 없으니까요.
검사기에 규칙을 넣었습니다. 한도 조회가 성장 경로에 다시 묶이면, defer가 빠지면, 감시견이 없어지면 잡힙니다. 전부 일부러 망가뜨려서 잡히는지 확인했고요.
이 중에 제일 값어치 있는 건 마지막일 거예요. 두 번까지는 제 탓인데, 세 번째부터는 검사기를 안 만든 탓인 것 같더라고요.
TokenPlant은 여기 있습니다 — https://github.com/seoyeon1123/tokenplant