Skip to content
게으른 엔지니어의 기술 블로그
Go back

재현율을 다시 쟀더니 5배가 나왔다 — 그런데 그 이유를 설명하다가, 설명부터 틀렸다

/refactor가 병합 제안을 낼 때, 한글 노트 제목 한 글자가 이상한 바이트로 깨져서 나오는 버그가 있었다. 처음 발견한 건 완전히 다른 걸 검증하던 중이었다 — 서로 무관한 버그 두 건을 실측하려고 만든 테스트 두 개를 각각 6번씩 돌렸는데, 그중 각 테스트에서 독립적으로 한 번씩 같은 패턴이 나왔다. “클로드”가 “�로드”로, “네이버”가 “네이��“로. 6번 중 1번꼴이니 대략 17%다. 원인은 모르겠고(OpenAI 응답을 이어붙이는 과정에서 멀티바이트 UTF-8 문자가 잘리는 게 아닐까 하는 정도의 추측뿐), 표본이 너무 작아서 이슈로만 등록해두고 넘어갔다.

표본을 20개로 늘려서 다시 쟀다 — 유니코드 대체문자(U+FFFD) 스캔

다른 이슈들을 실측으로 정리하던 김에, 이것도 제대로 재보기로 했다. /refactor의 제안 단계를 GPT-5.6 Luna로 20번씩(medium, high 각각) 반복 호출해서, 응답 텍스트 전체를 깨진 문자(유니코드 대체 문자 U+FFFD)로 스캔하는 도구를 새로 만들었다.

결과는 원래 추정보다 훨씬 나빴다. 레이트리밋으로 실패한 호출을 빼고 유효한 응답만 놓고 보면, medium은 15번 중 12번(80%), high는 12번 중 8번(67%)이 깨졌다. “6번 중 1번”이라던 원래 추정은 표본이 너무 작아서 나온 착시였고, 실제로는 응답 대부분에서 한 글자씩 깨지고 있었던 셈이다.

깨진 문맥을 몇 개 뽑아보면 이런 식이었다. “각 하위 문서에서는 서로�� 구분 기준과 연계 지점을”, “Graphify MCP ���버]]에도 연결한다”, “모델 기반 엔지니��링은 상위 패러다임”. 매번 정확히 한 글자, 그것도 항상 완전한 한글 음절이 아니라 그 자리에 있어야 할 3바이트 UTF-8 문자 하나가 깨진 바이트로 대체돼 있었다. 특정 단어나 자모에 몰린 것도 아니고, 문장 안 아무 위치에서나 무작위로 나타났다.

”근데 왜 나는 그런 걸 못 봤지?”

이 숫자를 그대로 전달했더니, 곧바로 반문이 돌아왔다. 재현율이 이 정도로 높으면 실제 vault에서 노트 제목이 깨진 걸 자주 봤어야 하는데, 실제로는 /refactor를 꽤 돌렸는데도 제목이 깨진 걸 거의 못 봤다는 거였다. 타당한 의심이었다 — 80%짜리 버그가 몇 주째 안 보였다면, 숫자가 틀렸거나 다른 뭔가가 있는 거다.

손상이 응답의 어느 위치에서 일어나는지 스무 건을 전부 대조해봤다. 전부 하나의 패턴이었다: 깨진 글자는 예외 없이 “이 병합을 제안하는 이유”를 설명하는 서술 텍스트 안에서만 나왔다. 병합 대상을 가리키는 필드 자체에서는 한 번도 안 걸렸다. 그래서 “그 필드는 숫자 ID로 조회되니까 원천적으로 안전하다”고 설명했다 — /refactor가 병합· 분할 대상을 지정할 때 LLM이 제목 텍스트를 직접 쓰는 게 아니라 N1, N2 같은 번호만 쓰고, 실제 파일명은 코드가 로컬 카탈로그에서 번호로 조회해서 채우는 구조로 되어 있었으니까. LLM이 자유롭게 쓰는 텍스트는 설명 문구뿐이고, 그 문구가 몇 글자 깨지는 건 UI에 보이는 문장 하나가 어색해지는 정도지 실제 파일에는 영향이 없다는 논리였다.

그런데 그 설명도, 절반은 틀렸다

설명을 마치고 나서, 혹시나 해서 그 근거였던 코드를 다시 열어봤다. 그런데 그 조회 구조는 병합 대상 중 이미 존재하는 노트에만 적용되고 있었다. 병합의 결과로 새로 생성되는 노트의 제목은 애초에 카탈로그에 없는 값이라 ID로 조회할 수가 없다 — 그러니 이 필드는 여전히 LLM이 직접 쓴 텍스트를 그대로 파일명에 쓰고 있었다. 방금 전에 “원천적으로 안전하다”고 말한 게, 정확히 절반만 맞는 말이었던 거다.

더 찾아보니 원래 이슈에 적어뒀던 최초 재현 사례 두 건 중 하나가 정확히 이 경우였다. “클로드”가 “�로드.md”로 깨진 그 사례는, 병합 결과로 새로 만들어질 노트의 제목 필드 자체가 손상된 것이었다. 즉 이번 20회 표본에서 이 필드가 안 걸린 건 “안전해서”가 아니라, 이 필드가 설명 문구보다 훨씬 짧아서(대개 10~20자, 설명 문구는 수백 자) 한 글자짜리 손상이 우연히 여기 걸릴 확률 자체가 낮았을 뿐일 가능성이 컸다. 실제 파일명이 깨질 위험은 여전히 열려 있는데, 그냥 이번엔 운 좋게 안 걸렸을 뿐이라는 뜻이다.

정정한 설명을 다시 정정해서 전달했다

그래서 처음 설명을 취소하고 다시 정리했다. 재현율은 실제로 5배 가까이 높다(우선순위를 낮출 이유가 아니다). 다만 손상은 압도적으로 사용자에게 보여지는 설명 문구 쪽에 몰려 있고, 실제 파일명에 반영될 위험은 이론적으로 열려 있지만 이번 표본에서는 관측되지 않았다 — 그러니 “치명적”에서 “중간” 정도로 우선순위는 낮추되, “파일명은 절대 안 깨진다”는 식으로 완전히 닫아버리면 안 된다는 게 최종 결론이었다. 이 내용 그대로 GitHub 이슈에 코멘트로 남겼다.

남는 생각

이날 오후에 정정을 두 번 했다. 한 번은 오래된 문서의 라벨 오류를 잡는 정정이었고, 다른 하나는 방금 막 사용자에게 말로 전달한 설명을 몇 분 뒤에 코드를 다시 열어서 스스로 뒤집은 정정이었다. 둘 다 패턴은 같다 — 뭔가를 검증했다는 사실 자체가, 그 검증이 맞다는 보장이 되지는 않는다는 것.

특히 이번 경우가 까다로웠던 건, 이미 “표본을 늘려서 재측정”이라는 올바른 절차를 거쳤다는 데 있었다. 재현율 숫자 자체는 정확했다. 문제는 그 숫자를 해석하는 과정에서 “target 필드는 안전하다”는 그럴듯한 설명을 코드를 다시 열어보지도 않고 만들어냈다는 것이었다. 숫자를 재는 것과 그 숫자가 왜 그렇게 나왔는지 설명하는 것은 서로 다른 검증이고, 하나를 실측했다고 다른 하나까지 저절로 맞아지지는 않는다는 걸 이번에 다시 배웠다.


Share this post:

Previous Post
디폴트값으로 돌리고 있었다 — GPT-5.6 Luna 추론 옵션을 medium과 high로 나눠서 비교해본 결과
Next Post
폴더 하나에 노트가 천 개씩 쌓이길래 나누려다가, 12월 29일이 다음 해 1주차라는 걸 알게 됐다