재현 안 되는 버그의 정답은 버그가 아니었다 — 비자명한 디버깅 기록 (Scrooge 3편)
1. 도입부 — 가장 비싼 버그는 "버그가 아닌 버그"다
디버깅의 교과서적 함정은 존재하지 않는 버그를 쫓는 것이다. 코드 어딘가가 틀렸다고 확신한 채로 가설을 세우고 검증하고 또 세우는데, 알고 보니 코드는 멀쩡했고 내 전제가 틀렸던 경우다.
scrooge를 굴리며 만난 두 버그가 정확히 이 결을 가졌다. 하나는 "무응답"으로 보였지만 사실 정상 동작이었고, 다른 하나는 "압축 때문"으로 보였지만 압축은 원인이 아니었다. 둘 다 표면 증상이 엉뚱한 범인을 가리키고 있었다.
이 글에서 당신이 얻어갈 것은 두 가지다. 하나는 가설을 체계적으로 배제해 "버그 아님"에 도달하는 절차, 다른 하나는 여러 원인이 겹친 현상에서 "진짜 원인"과 "가중 요인"을 분리하는 분석법이다. 둘 다 에이전트 툴링처럼 비결정적이고 여러 레이어가 얽힌 시스템에서 특히 쓸모 있다.
2. 핵심 개념 — 표면 증상과 root cause의 거리
에이전트 플러그인은 여러 레이어가 포개진 시스템이다. 마켓플레이스/배포 레이어, 호스트(Claude Code 등)의 hook 레이어, 플러그인의 command·skill 레이어, 그 아래 모델의 생성 레이어. 증상은 맨 위에서 보이지만 원인은 어느 층에든 있을 수 있다.
그래서 이런 시스템의 디버깅은 "코드를 읽어 버그를 찾는다"보다 "가능한 원인 레이어를 하나씩 배제해 범위를 좁힌다"에 가깝다. 의학의 감별 진단(differential diagnosis)과 같은 구조다. 그리고 그 배제 과정의 끝에서 가끔, "모든 레이어가 정상이다 = 버그가 아니다"라는 결론을 만난다.
용어 1줄 정의 — drift: 의도한 출력 양식에서 모델 출력이 조금씩 벗어나는 현상. 여기서는 한국어 출력에 한자(漢字)가 새어 나오는 "한자 drift"를 가리킨다.
3. 동작 원리 ① — "무응답"의 4겹 오진
/scrooge-stats(절감 통계를 보여주는 명령)가 아무 응답도 내지 않는 증상이 보고됐다. 분명 버그처럼 보였다. 하지만 결정적 단서가 하나 있었다 — 재현이 일정하지 않았다. 이 "재현 안 됨"이야말로 첫 번째 힌트였다. 확정적 버그라면 같은 조건에서 항상 재현돼야 하니까.
가설을 세우고 하나씩 배제해 나갔다.

가설 ① 플러그인 마켓플레이스 캐시 vs 로컬 repo 버전 차이.
가장 흔한 용의자. 마켓플레이스에 캐시 된 플러그인 버전과 로컬에서 개발 중인 repo 버전이 달라, 옛 코드가 도는 상황. → 배제. 버전을 맞춰 확인했지만 증상이 그대로였고, 무엇보다 "재현 안 됨"이 버전 불일치로는 설명되지 않았다.
가설 ② hook intercept가 입력을 가로채 먹어버림.
scrooge는 hook으로 동작에 개입한다. hook이 stats 호출을 가로채 응답을 삼켰을 수 있다. → 배제. hook intercept 경로를 시뮬레이션해 입력이 어디로 흐르는지 추적했지만, 여기서 응답이 사라지는 게 아니었다.
가설 ③ command와 skill의 이름 충돌(disable-model-invocation).
같은 이름의 command와 skill이 서로를 가리는(shadow) 관계가 있었다(이 그림자 관계는 메모리에도 기록돼 있다). 이게 stats 호출을 잘못된 대상으로 보냈을 수 있다. → 배제. shadow 관계는 실재했지만, 무응답의 직접 원인은 아니었다.
root cause ④ 측정할 대화가 없는 "0 턴 새 세션".
세 가설을 배제하고 나서야 전제를 의심했다. stats는 대화의 토큰을 집계해 절감률을 보여주는 명령이다. 그런데 호출된 시점이 막 시작한 0 턴짜리 새 세션 — 즉 집계할 대화 자체가 없는 상황이었다. stats는 정확히 동작하고 있었다. 셀 게 없으니 보여줄 것도 없었던 것이다. 무응답은 버그가 아니라, "측정 대상 없음"이 무응답처럼 보인 UX 문제였다.
핵심 교훈은 그림 맨 아래에 있다 — "재현 안 되는 버그"의 정답이 "버그 아님"일 수 있다. 그리고 가설을 하나씩 배제하는 과정 자체가, 결국 틀린 건 코드가 아니라 내 전제였음을 드러내 준다.
4. 동작 원리 ② — 한자 drift: 원인과 가중 요인 분리
두 번째 버그는 분류가 까다로웠다. 한국어 출력에 한자가 섞여 나오는 한자 drift다(2화의 정합성 가드가 막으려는 바로 그 현상). 가장 쉬운 결론은 "압축이 한국어를 한자로 치환해서"였다. 압축 강도를 올리면 더 자주 보였으니까. 하지만 이 결론은 틀렸다 — 정확히는, 인과의 방향을 착각한 결론이었다.

현상을 세 원인으로 분해했다.
- ① CJK 토크나이저 공유: 많은 모델이 한·중·일 문자를 같은 토큰 공간에서 다룬다. 한국어 한자어와 그에 대응하는 한자(漢字) 토큰이 토큰 공간상 가깝다.
- ② 의미벡터 근접: 한자어(예: 한글로 쓴 단어)와 그 한자 표기는 의미 공간에서도 가깝다. 모델 입장에서 둘은 "거의 같은 뜻"이라, 치환이 일어나기 쉬운 토양이 깔려 있다.
- ③ 샘플링 무작위성: 디코딩은 확률 분포에서 토큰을 뽑는 과정이라, 가끔 한자 토큰이 뽑힌다. 비결정성 그 자체다.
이 셋이 겹쳐 한자 drift가 생긴다. 그렇다면 압축은? 압축은 원인이 아니라 가중 요인(aggravator)이다. 압축 압력이 출력을 짧고 밀도 높게 몰면 한자 치환이 더 자주 유발되는 건 맞다. 하지만 압축을 꺼도 위 세 원인은 그대로 남으므로 drift가 0이 되지 않는다. 만약 "압축이 원인"이라 결론 냈다면, 압축을 약화하는 헛된 처방으로 갔을 것이다 — 토큰만 손해 보고 drift는 안 사라지는.
이 분리가 실무적으로 중요한 이유는 처방이 달라지기 때문이다. 원인이 모델 레벨(토크나이저·샘플링)에 있으므로, 올바른 대응은 압축을 줄이는 게 아니라 출력 레벨의 정합성 가드(2화의 한글 전용 제약)로 drift를 잡는 것이다. 원인과 가중 요인을 섞었다면 이 처방에 도달하지 못한다.
5. 장단점 및 고려사항 — 이런 디버깅에서 배운 것
| 잘한 점 (Good) | 빠지기 쉬운 함정 (Anti-Pattern) |
|---|---|
| ✓ "재현 안 됨"을 단서로 읽고 전제를 의심함 | ✗ "분명 버그다"라는 전제를 끝까지 안 놓음 |
| ✓ 가설을 레이어별로 하나씩 배제 (감별 진단) | ✗ 첫 그럴듯한 가설(캐시 차이)에 매달림 |
| ✓ 원인 vs 가중 요인을 분리 (압축은 가중 요인) | ✗ 상관(압축↑→drift↑)을 인과로 단정 |
| ✓ 원인 레이어에 맞는 처방(정합성 가드) | ✗ 가중 요인을 줄이는 헛된 처방(압축 약화) |
실무 팁 — 비 자명한 버그 체크리스트
- "재현이 일정한가?"를 가장 먼저 묻는다. 비일관적 재현은 종종 "버그 아님" 또는 "전제 오류"의 신호다.
- 가능한 원인을 레이어로 나열하고 하나씩 배제한다. 배제 과정 자체가 기록이 되고, 나중에 같은 증상이 오면 재사용된다.
- 세 가설을 배제했다면 전제를 의심한다. "셀 게 있긴 한가?"처럼, 코드가 아니라 입력 조건을 본다.
- 상관과 인과를 구분한다. "X를 올리면 Y가 는다"는 X가 Y의 원인이라는 증거가 아니다. X를 꺼도 Y가 남는지 확인한다.
- 원인 레이어에 처방을 건다. 모델 레벨 원인은 출력 가드로, 설정 레벨 원인은 설정으로. 레이어를 잘못짚으면 처방이 헛돈다.
핵심 3줄 요약
- "재현 안 되는 버그"의 정답은 "버그 아님"일 수 있다.
/scrooge-stats무응답은 세 가설을 배제한 끝에 "측정할 대화가 없는 0 턴 새 세션"이라는 전제 오류로 판명됐다. - 한자 drift의 진짜 원인은 CJK 토크나이저 공유·의미벡터 근접·샘플링 무작위성이며, 압축은 원인이 아니라 가중 요인이다.
- 디버깅의 핵심은 상관을 인과로 착각하지 않고, 원인이 사는 레이어에 처방을 거는 것이다.