/lint 결과에 이상한 게 하나 있었다. AI 가드레일 Guardrails.md라는 문서의 source
필드에 [[fab09fe3c]]라는 값이 그대로 남아 있었다. 파일 경로가 아니라, 플러그인이
내부적으로 쓰는 10자리 hex ID였다. 3주 전에 원인까지는 파악해서 이슈로 남겨두고 백로그로
미뤄뒀던 버그였다. 오늘 그걸 다시 열었다.
원인은 이미 알고 있었다
/ingest는 파일을 한 번에 다 처리하지 않고 청크(chunk) 단위로 나눠서 순차 처리한다.
각 청크를 AI에 보낼 때, 파일 경로 대신 짧은 임시 ID를 붙여서 보낸다 — 파일 경로가
길고 한글이 섞여 있으면 AI가 그걸 그대로 옮겨 적다가 실수할 확률이 높아지는데, 짧은
hex ID는 그럴 일이 적기 때문이다.
// 청크별 순차 루프 처리
for (let index = 0; index < totalChunks; index++) {
const idMap = createFileIdMap(currentChunk); // 이번 청크 파일만 담김
...
}
문제는 이 idMap이 그 청크 안의 파일만 담고 있다는 거였다. AI가 세 번째 청크를
처리하다가 첫 번째 청크에서 봤던 파일을 참조하려고 하면(예: “이 문서, 아까 그 파일이랑
관련 있어 보이니 source에 같이 적어줄게”), 지금 청크의 idMap엔 그 ID가 없다. 변환에
실패하고, ID 문자열이 원본 그대로 저장된다. 원인 자체는 이미 문서에 정확히 적어놨었다.
첫 번째로 떠올린 수정안
원인이 “매 청크마다 idMap을 새로 만든다”는 거니까, 답은 뻔해 보였다. 루프 밖에서 한 번만 만들어서 전체 배치를 다 담으면 된다.
// 이렇게 하면 되지 않을까?
const idMap = createFileIdMap(currentBatch); // 전체 배치를 한 번에
for (let index = 0; index < totalChunks; index++) {
// idMap을 그대로 재사용
}
근데 코드를 실제로 고치기 전에, idMap이 정확히 어디에 쓰이는지 다시 훑어봤다.
같은 변수가 두 가지 일을 하고 있었다
idMap은 AI 응답의 ID를 실제 경로로 바꾸는 데도 쓰이지만, 그 전에 이번 청크에
보낼 프롬프트 본문을 조립하는 데도 쓰이고 있었다.
for (const [fileId, file] of idMap) {
const text = await vault.read(file);
rawCombinedText += `---\n[원본 파일 ID: "${fileId}"]\n...\n${text}\n\n`;
}
이 루프는 “이번 청크에 새로 등장하는 파일들의 원본 내용”을 프롬프트에 실어 보내는
부분이다. 만약 idMap을 전체 배치로 통째로 바꿔버리면, 이 루프도 전체 배치를 돌게
된다 — 즉 세 번째 청크를 처리할 때, 이미 첫 번째·두 번째 청크에서 다 보내고 처리까지
끝난 파일들의 원본 내용을 또 프롬프트에 실어 보내게 된다는 뜻이다. 청크를 나누는
이유 자체가 한 번에 보내는 양을 제한하려는 건데, 그 목적이 완전히 무너진다. 토큰
낭비고, AI가 이미 끝난 파일을 다시 처리하려 들 수도 있다.
같은 변수 하나가 “이번 청크에 새로 보낼 파일 목록”과 “지금까지 발급한 ID를 전부 알고 있는 조회 테이블”이라는 서로 다른 두 가지 역할을 겸하고 있었던 거다. 첫 번째 수정안은 후자만 보고 전자를 놓친 거였다.
역할을 둘로 쪼갰다
const cumulativeIdMap = new Map<string, TFile>(); // 루프 밖, 라운드 전체에서 누적
for (let index = 0; index < totalChunks; index++) {
const chunkIdMap = createFileIdMap(currentChunk); // 이번 청크만 (프롬프트용)
for (const [id, file] of chunkIdMap) {
cumulativeIdMap.set(id, file); // 누적 테이블에 합침
}
// 프롬프트 조립은 여전히 chunkIdMap만 사용 — 동작 그대로
for (const [fileId, file] of chunkIdMap) { ... }
// 응답 해석은 cumulativeIdMap 사용 — 이전 청크 ID도 다 알고 있음
const execResult = await callbacks.executeCommands(responseText, cumulativeIdMap);
}
프롬프트에 실리는 내용은 그대로 청크 단위를 지키고, AI 응답에서 ID를 되찾아오는 쪽만 지금까지 처리한 모든 청크를 아우르게 했다. 딱 필요한 만큼만 넓힌 셈이다.
고친 다음, 진짜 고쳐졌는지 거꾸로 확인했다
회귀 테스트를 쓰고 통과하는 걸 확인했는데, 최근 이 프로젝트에서 몇 번 겪은 일이 있어서 한 걸음 더 갔다 — 테스트가 통과한다는 게 곧 그 테스트가 실제로 버그를 잡아낸다는 뜻은 아니다. 그래서 방금 고친 코드를 잠깐 되돌려서, 그 상태에서 같은 테스트를 돌려봤다.
[T-ING-IDMAP-01] 두 번째 청크 호출 시 idMap이 첫 번째 청크의 fileId도 포함함
Result : FAIL ❌
Detail : snapshots=[["a8be3c4269"],["3c7b5e7402"]]
수정 전 코드에서는 두 번째 청크가 받은 ID 목록에 정말로 자기 것 하나만 있었다. 수정을 복구하고 다시 돌리니 통과했다. 이 정도 확인을 해야 “테스트를 추가했다”가 아니라 “이 테스트가 실제로 그 버그를 잡는다”고 말할 수 있다는 게, 최근 반복해서 다시 배우고 있는 것 같다.
안전망은 따로, 나중에
코드를 고치고 나서, 그래도 남는 극단적인 경우(예: 재시도 라운드를 넘어가는 참조)를 대비해 안전장치를 하나 더 붙일까 했다가, 안전망을 따로 붙이는 걸로 결론을 내렸다 — 핵심 수정만 먼저 커밋하고 문제없는지 지켜본 다음에.
그렇게 나눠서 진행하고 보니 실제로 도움이 됐다. 핵심 수정만 먼저 확정되고 나니, 안전망이 정확히 무엇을 위한 건지가 훨씬 또렷해졌다 — “매 청크마다 사라지는 흔한 케이스”가 아니라 “재시도 라운드를 넘어가는 극히 드문 케이스”만 남았다는 게 분명해졌고, 그래서 안전망도 욕심내지 않고 훨씬 보수적으로 설계할 수 있었다. 내용을 건드리거나 지우지 않고 그냥 로그만 남기는 쪽으로, 그리고 오탐 위험이 있는 패턴(따옴표나 맨 단어 형태로 등장하는 ID)은 아예 손대지 않기로 했다. 두 변경사항이 하나의 diff에 섞여 있었다면, 안전망의 범위를 이렇게 좁게 잡을 근거 자체가 뚜렷하지 않았을 것 같다.
남는 생각
이번 버그에서 제일 크게 배운 건 “원인을 안다”와 “고칠 방법을 안다”가 다르다는 거였다. 원인은 3주 전부터 정확히 문서에 적혀 있었다. 근데 그 원인만 보고 떠올린 첫 번째 수정안은, 정작 그 변수가 다른 곳에서도 쓰이고 있다는 걸 놓치고 있었다. 코드를 실제로 다시 읽지 않았다면 그 수정안을 그대로 밀어붙였을 거고, 겉보기엔 버그가 고쳐진 것처럼 보이면서 훨씬 알아채기 어려운 새 문제(청크마다 이미 끝난 파일 내용을 계속 재전송하는 것)를 만들었을 거다.
그리고 고친 다음에도, 테스트가 초록불이라는 것과 그 테스트가 진짜 그 버그를 검증한다는 건 다른 얘기였다. 고친 코드를 잠깐 되돌려서 테스트가 실제로 빨간불이 되는지 확인하고 나서야, 이 테스트를 믿을 수 있었다.