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

확신을 실측 앞에 세워봤다 4편 — 끊어진 링크는 잡아주는데, 안 끊어진 채 엉뚱한 곳을 가리키는 링크는 못 잡았다

확신을 실측 앞에 세워봤다 · 4/5편

지난 글에서 create_file이 배치 안에서 어떻게 파일을 쓰고, 그 파일들끼리 어떻게 링크를 검증하는지 이야기했다. 오늘은 그 로직에 오래 방치돼 있던 이슈 하나를 마무리 지은 이야기다.

오래 미뤄뒀던 질문

/ingest가 한 번에 여러 노트를 만들 때, 새로 만들려는 파일 이름이 이미 vault에 있는 파일과 겹치면 자동으로 “Dallas 2.md”처럼 번호를 붙여서 리네임한다. 그런데 같은 배치 안에서 다른 노트가 그 파일을 원래 이름(“Dallas”)으로 링크를 걸어놓았다면 어떻게 될까? 이 질문이 꽤 오래 이슈로만 남아 있었다 — 착수는 안 했지만 언젠가 확인은 해야 한다고 적어둔 채로.

이번에 다른 이슈들을 정리하던 김에 이것도 마저 들여다보기로 했다.

코드를 읽고, 이미 잡아준다고 결론 내렸다

파일 쓰기 로직을 다시 훑어봤다. 배치가 끝난 뒤에는 “End-of-batch ghost-link cleanup”이라는 패스가 있다 — 배치 안에서 만들어진 모든 파일을 다시 열어서, 그 안의 모든 위키링크가 실제로 존재하는 노트를 가리키는지 vault의 최종 상태(리네임까지 다 끝난 뒤의 진짜 상태) 기준으로 재검증하는 코드다. 이건 예전에 다른 이슈를 고치면서 일부러 넣어둔 안전장치였다.

이 로직이라면 리네임과 링크가 같은 배치에서 겹치는 경우도 이미 잡아줄 거라고 판단했다. 배치가 다 끝난 뒤에 최종 상태를 기준으로 다시 검사하니까, “Dallas”가 리네임돼서 “Dallas 2.md”가 됐다면 그 사실도 최종 상태에 반영돼 있을 거고, 그러면 “Dallas”를 가리키던 링크는 깨진 링크로 잡혀서 원문 그대로(일반 텍스트)로 내려갈 거라는 논리였다. 이 결론을 그대로 전달했다.

”실제로 돌려서 확인할 수 있는 거 아니야?”

그런데 이건 LLM 응답 품질 문제가 아니라 순수한 결정론적 코드 로직이었다. API를 부를 필요도 없이, 딱 그 시나리오만 만들어서 실제로 실행해보면 바로 답이 나온다. 코드를 읽고 논리적으로 맞다고 생각하는 것과, 진짜로 그렇게 동작하는지는 다른 질문이다.

그래서 정확히 이 시나리오를 재현하는 테스트 (T23-EXEC-BATCH-COLLISION-LINK)를 새로 만들었다: 이미 “Dallas.md”가 있는 vault에, 같은 배치로 (1) 리네임될 새 “Dallas” 노트와 (2) “[[Dallas]]“로 그 새 노트를 가리키려는 다른 노트를 함께 create_file로 써보는 테스트다.

돌려보니 실패했다.

원래 걱정은 “링크가 깨질 것”이었다. 그런데 실제로 벌어진 일은 링크가 깨지지도 않았다. “End-of-batch ghost-link cleanup”이 검증하는 건 “이 링크가 뭔가에 연결되는가”였지 “이 링크가 의도한 그 노트에 연결되는가”가 아니었다. “Dallas”라는 이름은 배치가 시작하기 전부터 이미 vault에 존재했다 — 리네임된 새 노트 때문이 아니라, 원래 있던 옛날 노트 때문에. 그래서 링크 검증 로직 입장에서는 “Dallas”가 뭔가 실제로 존재하는 파일을 가리키고 있으니 전혀 문제가 없다고 판단하고 그냥 통과시켰다.

결과적으로 [[Dallas]] 링크는 멀쩡하게 살아남았다. 다만 가리키는 대상이 새로 만들어진 “Dallas 2.md”가 아니라, 그 자리에 원래 있던 전혀 무관한 옛날 “Dallas.md”였다. 옵시디언에서 보면 이 링크는 완벽하게 정상으로 보인다 — 파란색으로 표시되고, 클릭하면 어딘가로 이동한다. 다만 그 어딘가가 사용자가 의도한 노트가 아닐 뿐이다.

원래 이슈가 걱정했던 “링크가 끊길 수 있다”는 눈에 보이는 문제라서 사용자가 금방 알아챌 수 있다. 실제로 벌어진 문제는 눈에 안 보인다. 아무 경고도 없이, 겉보기엔 멀쩡한 링크가 조용히 엉뚱한 내용을 가리키게 된다.

이 링크 검증 로직(demoteUnresolvedWikilinks)을 다시 보면 원인이 명확하다. 링크가 실제 노트를 가리키는지 확인하는 절차가 세 단계로 되어 있는데, 옵시디언 자체의 링크 해석 함수로 먼저 찾아보고, 그다음 이번 배치에서 새로 아는 경로 목록에서 찾아보고, 마지막으로 vault 전체를 훑어서 이름이 맞는 파일이 있는지 찾는다. 세 단계 전부 “이름이 같은 파일이 존재하느냐”만 묻는다. “이 배치가 의도한 그 파일이냐”는 애초에 물을 방법이 없는 질문이었다 — 배치 입장에서는 “Dallas”라는 이름으로 뭔가를 만들려고 했다는 사실만 알 뿐, 그 이름이 이미 다른 파일이 차지하고 있었다는 걸 링크 검증 단계까지 따로 기억하고 있지 않았다.

이 시나리오는 회귀 테스트로 코드베이스에 남았고, 곧이어 실제로 고쳤다. 처음 떠올린 방법은 이 링크를 그냥 일반 텍스트로 강등시키는 것이었다 — 어차피 의도했던 그 파일은 아니니까. 그런데 “파일 자체는 실제로 존재하는데(그냥 이름이 다를 뿐) 연결 정보를 통째로 버리는 건 과하지 않냐”는 지적이 나오면서 방향을 바꿨다. 링크를 없애는 대신 리네임된 실제 파일로 다시 이어주는 리다이렉트 방식으로 고쳤다 — [[Dallas]]로 걸린 링크를 실제로 만들어진 [[Dallas 2]]로 바꿔주는 식이다. 이번 실행에서 실제로 관찰된 리네임만 기록해뒀다가 그대로 쓰는 방식이라, 파일명이 “이름 + 숫자”처럼 생겼다고 추측하는 로직은 들어가지 않았다.

남는 생각

코드를 읽고 낸 결론이 틀린 것 자체는 새삼스러운 일은 아니다. 이번에 새삼스러웠던 건, 그 결론을 검증하는 데 드는 비용이 거의 0에 가까웠다는 점이다. LLM을 부를 필요도, 실사용 데이터를 기다릴 필요도 없었다 — 딱 그 조건만 만드는 테스트 하나 짜서 돌리면 몇 초 안에 답이 나오는 문제였다. 그런데도 코드를 눈으로 읽고 “이미 잡아줄 것”이라고 먼저 확신부터 하고 말았다.

그리고 그 확신이 틀렸을 때 드러난 실패가, 원래 걱정했던 것보다 더 나쁜 방향이었다는 것도 곱씹을 만했다. “링크가 끊긴다”는 티가 나는 실패지만, “링크는 안 끊기는데 딴 데를 가리킨다”는 티가 안 나는 실패다. 검증 코드를 짤 때 “이게 존재하는가”만 확인하고 “이게 내가 의도한 그것인가”는 확인하지 않으면, 겉보기엔 통과한 검증이 사실은 더 위험한 결과를 놓치고 있을 수 있다는 걸 이번에 테스트로 직접 확인했다.


Share this post:

확신을 실측 앞에 세워봤다

  1. 1. 확신을 실측 앞에 세워봤다 1편 — 반례는 어휘만 가르쳤다
  2. 2. 확신을 실측 앞에 세워봤다 2편 — 하나만 보고 두 번 틀렸다
  3. 3. 확신을 실측 앞에 세워봤다 3편 — 닫은 지 몇 시간 만에, 내가 다시 열었다
  4. 4. 확신을 실측 앞에 세워봤다 4편 — 끊어진 링크는 잡아주는데, 안 끊어진 채 엉뚱한 곳을 가리키는 링크는 못 잡았다
  5. 5. 확신을 실측 앞에 세워봤다 5편 — 왜 관련있다고 생각했냐고 물으니, 답을 못했다