AI 엔지니어링

재현 안 되는 버그의 정답은 버그가 아니었다 — 비자명한 디버깅 기록 (Scrooge 3편)

Kir93 2026. 6. 14. 17:46
728x90
반응형

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줄 요약

  1. "재현 안 되는 버그"의 정답은 "버그 아님"일 수 있다. /scrooge-stats 무응답은 세 가설을 배제한 끝에 "측정할 대화가 없는 0 턴 새 세션"이라는 전제 오류로 판명됐다.
  2. 한자 drift의 진짜 원인은 CJK 토크나이저 공유·의미벡터 근접·샘플링 무작위성이며, 압축은 원인이 아니라 가중 요인이다.
  3. 디버깅의 핵심은 상관을 인과로 착각하지 않고, 원인이 사는 레이어에 처방을 거는 것이다.
반응형