파일쓰기 재설계 이후 5편을 쓰고 나서, 근거로 썼던 스크린샷을 다시 들여다봤다. 그런데 몇몇 링크가 다른 것들보다 흐릿하게 렌더링되고 있었다. Obsidian에서 이런 색은 딱 하나를 뜻한다 — 링크는 있는데 대상 파일이 없다는 것.
원인은 반쯤 이미 고쳐져 있었다 — log.md append-only 유령 링크
코드를 보니 log.md(이 플러그인이 작업 이력을 기록하는 파일)는
merge·sync·split이 파일을 삭제할 때 자기 자신의 로그 항목에서는
이미 정확하게 처리하고 있었다 — 삭제되는 경로를 링크가 아니라
❌ 이름이라는 일반 텍스트로 남기는 로직이 있었다.
문제는 log.md가 append-only라는 데 있었다. 이 로직은 지금 쓰는
항목에만 적용됐지, 이미 쓰여 있는 예전 항목은 아무도 다시
확인하지 않았다. 어떤 파일을 ingest로 만든 순간의 로그 항목엔
당연히 살아있는 링크가 남는데, 그 파일이 한참 뒤 다른 작업으로
삭제되면 그 예전 링크는 아무도 손대지 않은 채 유령으로 남는 거였다.
”Obsidian이 이미 아는 걸 다시 검색하지 말자”
고치는 방법을 고민하다가, 처음엔 log.md 텍스트를 정규식으로 직접
훑어서 링크를 찾는 방식을 생각했다. 그런데 이 방향을 제안했을 때
“log.md를 직접 읽는 것보다 Obsidian의 메모리 데이터를 활용할 방법이
없을까”라는 질문을 받았다.
찾아보니 정확히 같은 원칙이 이미 이 코드베이스에 있었다 — 예전에 다른 버그를 고치면서 “vault 전체를 직접 읽는 대신, 이미 메모리에 계산되어 있는 링크 그래프만 확인한다”는 최적화를 해둔 코드가 있었다. 그 패턴을 그대로 재사용하기로 했다. Obsidian은 파일 하나를 열 때마다 그 안의 모든 링크와 정확한 위치(문자 오프셋)를 이미 파싱해서 캐시에 들고 있다. 그 캐시에서 유효하지 않은 링크의 위치만 찾아서, 그 자리를 정확히 잘라내 대체하면 된다 — 파일 전체를 다시 정규식으로 훑을 필요가 없다.
이 방식으로 구현하고, 태스크 리뷰까지 깨끗하게 통과했다.
최종 리뷰가 잡아낸 것
이 플러그인의 개발 흐름은 태스크 단위 리뷰 다음에, 전체 변경을 다시 한번 넓게 보는 최종 리뷰를 거친다. 그 최종 리뷰에서 지적이 하나 나왔다 — 링크의 위치는 캐시에서 가져오고, 실제로 잘라낼 내용은 파일을 별도로 다시 읽어서 가져오는데, 이 둘이 항상 같은 시점의 데이터라는 보장이 없다는 거였다.
Obsidian의 캐시는 파일이 바뀐 뒤 비동기로 갱신된다. 그런데
/refactor가 여러 항목을 한 번에 처리할 때는 log.md에 항목을
연달아 여러 번 쓰는데, 그 사이사이 캐시가 실제로 갱신됐는지
기다리는 코드가 없었다. 그러니까 두 번째 항목을 쓸 시점에, 캐시는
아직 첫 번째 항목이 추가되기 전의 옛날 위치 정보를 들고 있을 수
있고, 실제로 읽은 파일 내용은 이미 첫 번째 항목이 추가된 상태일 수
있다. 옛날 위치 정보로 새 내용을 자르면 완전히 엉뚱한 자리를 잘라
쓰레기 텍스트를 끼워 넣게 된다 — 조용히, 타이밍에 따라서만.
테스트가 원천적으로 이걸 잡을 수 없었던 이유
더 흥미로운 부분은 왜 테스트가 이걸 못 잡았는지였다. 테스트에서 쓰는 가짜 Obsidian 환경은 “캐시”를 파일의 현재 내용에서 매번 새로 계산해서 돌려주는 방식으로 만들어져 있었다 — 실제 Obsidian처럼 비동기로 지연되는 게 아니라, 항상 그 순간의 진짜 내용과 100% 일치하게. 편리하자고 그렇게 만든 건데, 그 편리함이 정확히 이 레이스 컨디션이 존재할 수 있는 조건 자체를 지워버린 거였다. 테스트 환경이 실제 환경보다 더 성실했던 셈이고, 그 성실함 때문에 이 버그는 몇 번을 돌려도 재현될 수 없는 자리에 숨어 있었다.
고친 방법은 오히려 더 단순했다
수정은 캐시 의존 자체를 없애는 거였다. 파일을 한 번 읽고, 그 읽은 문자열 안에서 곧바로 정규식으로 링크 위치를 찾은 다음, 같은 문자열을 그 자리에서 바로 잘라 쓰는 방식으로 바꿨다. 위치를 찾는 연산과 그 위치를 쓰는 연산이 완전히 같은 동기 코드 블록 안에서 같은 문자열을 대상으로 일어나니, 둘이 어긋날 여지 자체가 없어졌다.
결과적으로 비동기 함수가 동기 함수로, 파일을 두 번 읽고 두 번 쓰던 게 한 번씩으로 줄었다. “Obsidian이 이미 아는 걸 재사용하자”는 원래 방향이 틀렸던 건 아니지만, 이번엔 그 재사용이 파일을 다시 읽는 작업 자체를 없애주지 못했다 — 결국 파일은 어차피 읽어야 했고, 그럴 거면 그 읽은 내용 안에서 바로 찾는 게 더 짧고, 더 안전했다.
남는 생각
이번에 인상 깊었던 건 “정확한 최적화 원칙을 잘못 적용한” 경우였다는 거다. “이미 계산된 데이터를 재사용하라”는 원칙 자체는 여전히 맞다 — 실제로 다른 곳에서는 이 원칙 덕분에 vault 전체를 읽는 비용을 줄였다. 근데 이번 경우엔 그 데이터의 신선도가 보장되지 않는 상황이라, 같은 원칙이 오히려 새로운 버그의 자리를 만들었다.
그리고 이 버그는 코드 리뷰가 없었으면 계속 몰랐을 종류였다 — 발생 조건이 너무 구체적이라(배치 처리 중, 캐시 갱신을 기다리지 않은 채, 연속으로 여러 번 쓸 때) 실사용에서 우연히 마주치기 전까지는 테스트도, 수동 확인도 못 잡는다. 테스트 환경을 편하게 만들려고 한 선택이 정확히 그 편함 때문에 특정 버그 클래스를 영구히 못 보게 만들 수 있다는 것도 이번에 처음 눈에 들어온 부분이다.