/tags 명령이 유사 태그를 병합할 때, 노트의 tags: 필드가 tags: [a, b]처럼
한 줄 대괄호 형식으로 적혀 있으면 병합이 조용히 스킵된다는 이슈가
있었다. 원인을 찾아보니 이 로직이 여러 줄 리스트 형태만 인식하는
정규식을 쓰고 있었다 — 사용자가 Obsidian 속성 편집 UI로 노트를 직접
고치면 가끔 한 줄 형태로 저장되는데, 이 정규식은 그 형태를 몰랐다.
고치다 보니 같은 병이 세 번 있었다 — YAML 프론트매터 정규식 나열 버그
간단한 정규식 수정이라고 생각하고 코드베이스를 훑어봤는데, 똑같은 패턴 — “YAML 값의 특정 형식만 정규식으로 나열하다가 다른 유효한 형식을 놓침” — 이 세 곳에서 반복되고 있었다.
가장 심각한 건 플러그인이 파일을 쓸 때마다 항상 거치는 핵심 정리
함수 안에 있었다. 여기도 대괄호 형식은 처리하는데, 태그가 하나뿐이고
대괄호도 리스트도 없는 tags: foo 같은 경우엔 그 값을 어디에도
저장하지 않고 그냥 버리고 있었다. 병합이 스킵되는 정도가 아니라
태그 자체가 사라지는, 훨씬 심각한 데이터 유실이었다.
사실 같은 패턴은 한 곳 더 있었다. 마크다운 파서 쪽 parseYamlFrontmatter에도
똑같이 형식을 나열하다 하나를 놓칠 위험이 남아 있었는데, 이번엔 손대지
않고 스코프 밖으로 남겨뒀다. 세 곳을 고친 걸로 끝난 게 아니라, 네
번째를 알면서도 미룬 것이었다.
정석적인 방법이 오히려 더 위험했다 — YAML round-trip 직렬화가 날짜를 타임스탬프로 오염
이걸 한 번에 깔끔하게 해결할 방법으로, Obsidian 공식 API로 frontmatter 전체를 다시 파싱해서 통째로 새로 쓰는 방안을 검토했다. 코드도 훨씬 짧아지고, 정규식을 아예 안 써도 될 것 같았다. “아까 제안한 것보다 이게 더 나은 것 같은데 어때?”라는 데 의견까지 모았고, 그 방향으로 가기로 정한 뒤였다.
근데 실제로 테스트해보기로 했다. 비슷한 YAML 라이브러리로 실제
frontmatter를 파싱했다가 다시 직렬화하는 걸 돌려보니, 원래
2026-07-19라는 평범한 날짜 문자열로 저장돼 있던 created 필드가
2026-07-19T00:00:00.000Z라는 전체 타임스탬프로 바뀌어 나왔다. 이
필드를 문자열로 그대로 기대하고 쓰는 다른 코드가 있었는데, 그 코드가
조용히 깨질 뻔했다. 이미 정하고 좋다고 동의까지 했던 방향을 그
자리에서 되돌려야 했다 — “더 정석적인 방법”이 태그 문제 하나를
고치려다 날짜 필드를 오염시키는 새로운 버그를 만들 뻔한 셈이다.
그래서 대신, “YAML 최상위 키는 항상 들여쓰기 없이 시작한다”는 구조적 규칙 하나로 값의 형식과 무관하게 범위만 찾는 방식을 골랐다. 대괄호든 리스트든 단일 값이든, 형식을 나열하지 않고 “이 키가 어디서 시작해서 어디서 끝나는지”만 판단하니, 애초에 이번 버그의 원인이었던 “형식 나열 후 하나 빠뜨림”이라는 실수 자체가 구조적으로 안 나게 됐다.
회귀 테스트를 통과했는데, 아무것도 증명하지 못하고 있었다
세 번째 버그는 source: 필드를 한 줄짜리 정규식으로만 찾는 함수였다.
여기도 똑같이 고치고, 여러 줄 형태의 source:에서도 정상 동작하는지
확인하는 회귀 테스트를 써서 리뷰에 올렸다.
리뷰에서 걸린 게 있었다. 이 테스트는 통과하는데, 수정 전 코드로 되돌려서 다시 돌려봐도 여전히 통과했다. 즉 이 테스트는 새 코드와 옛 코드를 구분하지 못하고 있었다 — 뭔가를 검증하고 있는 게 아니라 그냥 항상 통과하는 테스트였다.
원인을 추적해보니, 이 함수가 실제로 호출되는 실행 경로에서는 그 앞
단계가 여러 줄 source: 값을 항상 한 줄로 미리 정규화해버리고 있었다.
그러니까 이 함수가 여러 줄 형태를 받을 일 자체가 실제로는 없었던
것이다. 테스트가 만든 상황이 실행 경로 어디에서도 실제로 일어날 수
없는 가상의 시나리오였던 셈이다.
고치는 방법은 이 함수를 외부에서 직접 호출할 수 있게 열어서, 그 앞단계를 건너뛰고 진짜 여러 줄 입력으로 직접 테스트하는 것이었다. 그렇게 다시 만든 테스트는 수정 전 코드로 되돌리면 실제로 실패하고, 수정된 코드에서는 통과하는 걸 확인했다. 그리고 원래 이슈에도 “이 경로는 현재 실행 흐름에서 재현되지 않는다”는 걸 코멘트로 남겼다 — 고친 게 실제로 관찰된 버그가 아니라 방어적 조치였다는 걸 숨기지 않기 위해서였다.
배운 점
“테스트를 추가했다”와 “그 테스트가 실제로 뭔가를 검증한다”는 다른 질문이다. 테스트가 초록불이어도, 그 초록불이 코드 덕분인지 아니면 애초에 그 코드가 실행될 일이 없어서인지는 직접 확인해봐야 안다. 그리고 “더 정석적인 방법”이 항상 더 안전한 방법은 아니다 — 실제로 값을 넣어서 round-trip을 돌려보기 전까지는, 그 방법이 어떤 부작용을 숨기고 있는지 알 수 없었다.
이 결정에 대한 찜찜함은 그 자리에서 끝나지 않았다. “이번에는 국소 수정만 하는데, 언젠가는 이걸 다시 확인해야 할 듯… 근데, 여전히 직렬화하면서 생기는 문제는 고민이 되긴 하네”라는 말이 그대로 남았다. 손수 정규식으로 범위만 찾는 방식은 형식을 놓칠 위험은 있지만 타입을 오염시킬 위험은 없고, 정석 파서는 정확히 그 반대다 — 둘 중 뭘 골라도 위험이 사라지는 게 아니라 자리만 옮겨간다는 뜻이었다. 이번엔 국소 수정 쪽을 택했고, 그 빚은 갚지 않은 채로 남겨뒀다.