지난 글에
이어, 다른 문서에서 어떤 파일을 가리키던 링크를 merge·split 실행 뒤에
고쳐주는 함수(repairInboundLinks) 하나를 놓고, 리뷰가 세 번 서로
다른 각도에서 문제를 잡아냈다.
조건 하나는 맞았는데, 이유가 틀렸다 — narrowing 조각 예외
문서를 여러 개로 쪼개는 split 기능에는 원본 문서가 인용하던 raw 출처들을 각 조각에 나눠주는 과정이 있다. 문제는 이 배분이 항상 완전하지는 않았다는 것 — 원본이 인용하던 출처 중 일부가 어느 조각의 몫으로도 안 들어간 채 조용히 사라질 수 있었다. 이걸 막기 위해, 모든 조각의 몫을 다 합쳤을 때 원본이 인용하던 출처 전체를 빠짐없이 덮는지 검증하는 로직을 추가하기로 했다.
계획대로 구현이 끝나고 리뷰에 올라갔는데, 리뷰어가 계획에 없던 것 하나를 지적했다 — 구현자가 “조각이 1개뿐인 경우엔 이 검증을 하지 않는다”는 조건을 몰래 추가했다는 것이었다. 커밋 메시지에는 그 이유로 “이전에 확립된 원칙 때문”이라고 적혀 있었다.
계획에 없던 조건이 슬쩍 들어갔다는 건 그 자체로 리뷰가 걸어야 할 신호였다. 그런데 이 조건을 그냥 걷어내기 전에, 정말 불필요한 건지 먼저 확인해보기로 했다. 기존 테스트 하나를 직접 열어봤다. 거기엔 이미 확립된 동작이 있었다 — 조각이 하나뿐인 split은 항상 “재배치”를 뜻하지 않는다. AI가 원본 내용을 의도적으로 좁혀서(narrowing) 그 한 조각에만 담기로 판단한 경우일 수도 있는데, 이 경우엔 원본이 인용하던 출처 전체를 억지로 끼워 넣으면 오히려 그 의도적인 선택을 망가뜨리게 된다.
그러니까 구현자가 추가한 조건 자체는 맞았다. 다만 그 근거로 댄 이유는 이 상황과 무관한, 완전히 다른 원칙이었다. 코드는 우연히 맞았고, 이유는 틀렸던 것이다. 수정은 조건을 지우는 게 아니라, 주석을 고치는 것이었다 — “이전 원칙 때문”이라는 잘못된 설명을 “조각이 1개면 재배치가 아니라 의도적 narrowing일 수 있다”는 실제 이유로 바꿨다. 이 예외 자체가 지금까지 테스트로 보호받고 있지 않았다는 것도 함께 발견돼서, 그걸 확인하는 테스트도 새로 추가했다. 최종 리뷰에서 또 하나 걸렸다 — 계획 문서 자체가 여전히 옛날(조건 없는) 코드를 보여주고 있어서, 실제 구현과 계획서가 어긋나 있었다. 이것도 정정하고 나서야 마무리됐다.
이번엔 커밋 메시지의 설명이 틀렸다 — resolvedLinks 최적화
merge나 split을 실행하면 다른 문서에서 그 파일을 가리키던 링크를
찾아서 고쳐주는 이 기능이, 지금까지 대상을 찾는 방식이 좀 무식했다
— 보관함 안의 md 파일을 전부 읽어서 정규식으로 옛 경로가 있는지
검사하는 식이었다. Obsidian이 이미 메모리에 갖고 있는 링크
그래프(resolvedLinks)를 쓰면 파일 전체를 읽을 필요 없이 후보만
먼저 추릴 수 있어서, 이 최적화를 진행하기로 했다.
이 최적화를 검증하려면 테스트 환경(mock)도 resolvedLinks를 흉내
내야 했다. 계획에는 “링크가 가리키는 파일이 실제로 존재하는지”까지
확인하는 조건이 들어 있었는데, 실제 구현에서는 이 조건이 조용히
빠져 있었다. 커밋 메시지에는 “실제 Obsidian 동작과 일치시키려는
것”이라고 적혀 있었다.
최종 리뷰가 이 설명을 그냥 넘기지 않고, Obsidian의 실제 타입
선언(obsidian.d.ts)을 직접 열어서 대조했다. 결과는 정반대였다 —
실제 Obsidian은 존재하는 파일로 이어지는 링크만 “resolved”로
취급한다. 커밋 메시지의 설명이 사실과 반대로 적혀 있었던 것이다.
여기서 그 조건을 그냥 되살리는 건 답이 아니었다. 직접 확인해보니 그 조건을 되살리면 기존 테스트 8개가 한꺼번에 깨졌다 — 여러 기존 테스트가 “존재하지도 않는 옛 경로”를 대상으로 삼고 있었는데, 그것도 원래 그 테스트들의 목적(다른 파일의 링크가 제대로 고쳐지는지 확인하는 것)에는 문제가 없는 설계였다. 그래서 조건을 뺀 선택 자체는 실용적으로 맞았다는 게 확인됐다. 고친 건 그 선택의 이유를 설명하는 주석뿐이었다 — “실제 동작과 일치”라는 틀린 문장을, “기존 테스트를 지키기 위한 의도적 타협”이라는 진짜 이유로, 그리고 이 타협 때문에 생기는 커버리지 공백까지 명시적으로 적어뒀다.
리뷰는 한 발 더 나갔다. “이 공백을 문서화만 하고 넘어가지 말고, 직접 막는 전용 테스트를 하나 써라”고 제안한 것이다. 그 제안대로 새 테스트를 추가했는데, 이게 진짜로 뭔가를 검증하는지 확인하려고 일부러 원래 코드로 되돌려서 그 테스트가 실패하는 것까지 재현해본 뒤에야 마무리했다.
고친 코드가, 고치려던 문제를 다시 만들었다 — Related pages 재귀 오염
split으로 문서를 여러 조각으로 나눌 때, 그중 한 조각은 새 파일을 만드는 대신 원본 자리에 그대로 남는 경우가 있다. 이 경우 원본 경로는 사라진 게 아니라 여전히 유효한 문서인데, 링크를 고쳐주는 기능이 이 사실을 몰라서 멀쩡한 링크까지 무조건 다른 조각으로 옮겨버리는 문제가 있었다. 이번엔 정확히 이 문제를 막는 게 목표였다.
수정은 깔끔했다. 판단이 불확실하면 기본적으로 원본 링크를 그대로 두고, AI가 명확히 “이 언급은 새로 나뉜 조각 얘기다”라고 판단할 때만 옮기도록 했다. ”## Related pages” 같은 관련 문서 목록에도 원본 경로를 다시 포함시켰다. 태스크 단위 리뷰는 스펙 준수와 코드 품질 둘 다 깔끔하게 통과했다.
문제는 여기서 끝나지 않았다. 개별 태스크가 아니라 브랜치 전체를 처음 보는 눈으로 다시 훑는 최종 리뷰 단계에서, 실제로 mock AI를 돌려보는 재현 테스트를 통해 문제 하나가 드러났다. 관련 문서 목록에 원본 경로를 다시 넣어주는 로직 바로 다음에, “본문 어딘가에 남아있는 링크를 찾아서 AI에게 판단시키는” 로직이 이어서 실행되고 있었다. 그런데 그 “본문 링크 찾기” 단계가, 방금 막 목록에 다시 넣어준 원본 링크까지 본문 언급으로 착각해서 함께 주워버렸다. AI가 그 언급을 다른 조각으로 재배정하기로 판단하면, 목록에 있던 원본 링크가 그 판단에 따라 다시 지워지고 — 결국 이 수정이 막으려던 바로 그 증상(원본이 관련 문서 목록에서 사라짐)이 다른 경로로 재현되고 있었다.
원인은 명확했다. “관련 문서 목록을 갱신한 뒤 남은 링크는 전부 본문 언급”이라는 전제가 이번 수정 전까지는 항상 맞았는데, 원본 경로를 목록에 다시 넣어주는 로직이 새로 생기면서 그 전제가 깨진 것이었다. 해결책은 본문 링크를 찾는 스캔을, 관련 문서 목록 구간을 같은 길이의 공백으로 가려둔 사본에서 하도록 바꾸는 것이었다 — 길이가 같으니 실제 문서 안에서의 위치는 그대로 유효하게 유지된다. 고치고 나서 그냥 넘어가지 않고, 버그를 실제로 재현시키는 테스트를 새로 만들어서 수정 전 코드에서는 정말로 실패하고 수정 후 코드에서는 통과하는지 직접 확인했다.
배운 점
세 번 다 같은 함수(repairInboundLinks)를 놓고 벌어진 일이었지만,
잡힌 문제의 종류는 매번 달랐다. 첫 번째와 두 번째는 “코드 자체는
맞았는데 그 코드 옆에 남긴 설명이 틀렸다”는 같은 패턴이었다 —
구현자가 댄 근거가 틀렸다고 해서 코드까지 틀렸다고 단정하면 안
됐고, 반대로 코드가 결과적으로 맞았다고 해서 틀린 근거를 그냥
넘어가서도 안 됐다. 세 번째는 종류가 달랐다 — 코드도 근거도 다
맞았는데, 그 코드가 바로 옆의 다른 로직이 의존하던 전제를 조용히
깨뜨렸다. 태스크 단위 리뷰만으로는 셋 다 놓치기 쉬웠다는 게, 브랜치
전체를 처음 보는 눈으로 다시 검토하는 최종 리뷰가 왜 따로 필요한지를
세 번 연달아 보여준 셈이다.