Obsidian 플러그인 설정 화면을 보다가 눈에 걸리는 토글 하나를 발견했다. “Verbose Debug
Mode”. 켜봤는데 아무 일도 안 일어났다. 코드를 뒤져보니 진짜였다 — 타입 정의, 기본값
false, 토글 UI까지만 있고, 이 값을 실제로 읽어서 뭔가 하는 코드는 어디에도 없었다.
누가 봐도 만들다 만 기능이었다.
뭘 만들지보다 어떻게 만들지가 더 오래 걸렸다
방향은 금방 잡혔다 — 나중에 플러그인이 이상하게 동작하면, 이 옵션을 켜고 재현한 뒤 “디버그 정보 보내기” 한 번으로 진단에 필요한 걸 모아서 보낼 수 있게 만들자. 근데 설계 대화가 시작되자마자 “더 좋은 방법 없을까?”가 다섯 번 넘게 반복됐다.
- 파일로 로그를 남기자 → 근데 크래시가 나면 그 세션 로그는 날아가지 않나?
- 그럼 파일에도 즉시 써야 한다 → 근데 사용자가 그 백업 파일을 어떻게 쉽게 찾지?
- 클릭 한 번으로 메일 첨부까지 되게 하자 →
mailto:는 실제 파일 첨부가 안 된다 (웹 URI 스킴 자체의 제약) - 그럼 본문에 텍스트로 넣자 → 근데 노트 내용이 섞여 들어가면 개인정보 문제 아닌가?
- 그럼 사용자가 보내기 전에 검토하고 지울 수 있어야 한다 → 근데 지운 부분이 그냥 사라지면, 나중에 로그 읽을 때 “왜 이 구간이 빠졌지” 혼란만 남지 않나?
이 마지막 질문에서 설계가 한 겹 더 깊어졌다. 지워도 조용히 사라지는 대신
[삭제됨: 개인정보로 판단되어 제거]로 자리를 남기기로 했다. 그리고 애초에 로그
자체에 자유 문자열이 못 들어가게, TypeScript discriminated union으로 못을 박았다.
export type DebugLogEntry =
| { code: 'EMPTY_CONTENT_SKIP'; path: string }
| { code: 'SOURCE_LOST'; path: string; droppedSources: string[] }
| { code: 'TAG_FALLBACK'; path: string }
| { code: 'GHOST_LINK_DEMOTED'; path: string; link: string };
노트 본문을 로그에 넣을 방법 자체가 타입 시스템 수준에서 막혀 있다. “본문은 넣지 않는다”는 규칙을 사람이 기억하는 대신, 코드가 강제하게 만든 거다.
설계가 끝나갈 무렵, 6단계 프로세스를 하나 정했다 — 오버뷰 설계, 수정 계획, 문서화, 테스트 계획, 테스트 케이스 작성, 코드 작성. 단계마다 승인을 받고서야 다음으로 넘어가기로 했다. 테스트를 먼저 다 써놓고 일부러 실패하는 걸 확인한 다음(모듈이 아직 없으니 당연히 실패한다) 구현에 들어가는 순서였다.
다 됐다고 생각한 순간, 질문 하나가 떠올랐다
코드까지 다 짜고, 타입 체크·린트·테스트 34개 스펙 파일 전부 통과. 커밋도 했다. 여기서 끝난 줄 알았는데 — “이제 우리가 겪은 다양한 버그들을 이 디버그 모드로 찾을 수 있을지 시뮬레이션해보자”라는 생각이 들었다.
이번 세션에서 겪은 실제 버그들을 하나씩 로그 코드와 대조해봤다. 결과가 딱 절반이었다.
EMPTY_CONTENT_SKIP이랑 TAG_FALLBACK은 실제로 코드에 연결돼 있었다. 근데
SOURCE_LOST는 — 타입은 정의해놨는데, 정작 그걸 방출하는 debugLog() 호출이
어디에도 없었다. update_file이 기존 source frontmatter를 조용히 날려버리던
버그(이전 글에서 다룬 그 버그)를 막는 preserveDroppedSources() 함수는 이미 유실된
source 목록을 스스로 계산하고 있었는데, 그 값을 로그로 내보내는 한 줄이 그냥 빠져
있었다.
// 이미 있던 코드 — missing을 계산은 하는데 아무도 모른다
const missing = oldPaths.filter(p => !newPathsSet.has(normStr(p)));
if (missing.length === 0) return newContent;
당장 고쳤다. 함수가 유실됐다가 복구한 목록을 반환하도록 바꾸고, 호출부에서
SOURCE_LOST 로그를 남기게 했다. 회귀 테스트도 하나 추가했다.
”확인했다”의 기준을 한 단계 올렸더니 또 나왔다
그런데 다시 보니, 방금 “확인됐다”고 말했던 나머지 세 개(EMPTY_CONTENT_SKIP,
GHOST_LINK_DEMOTED, TAG_FALLBACK)도 사실 grep으로 “호출문이 코드에 있다”만
확인한 거였다. SOURCE_LOST만 실제로 테스트를 돌려서 버퍼에 값이 찍히는지 검증했다.
코드에 호출문이 존재하는 것과, 그 경로가 실제로 실행돼서 맞는 값을 남기는 건 다른
얘기다.
그래서 세 개 버그의 기존 회귀 테스트에 verboseDebug: true로 실행한 뒤 로그
버퍼를 실제로 검사하는 assertion을 추가했다.
const emptySkipEntry = getDebugLogBuffer().find(e => e.code === 'EMPTY_CONTENT_SKIP');
assert(
'T10-EXEC-EMPTY-SKIP-DEBUGLOG',
'verboseDebug=true일 때 EMPTY_CONTENT_SKIP 디버그 로그가 남음',
emptySkipEntry !== undefined &&
emptySkipEntry.path === 'wiki/2026/Q3/Empty Satellite Note.md'
);
셋 다 실제로 찍혔다. 근데 이 검증 방식을 한 단계 올려서 코드를 다시 훑는 과정
자체가, 또 다른 걸 걸러냈다. AI가 raw 파일명을 살짝 다르게 적었을 때 Levenshtein
거리로 제일 비슷한 실제 파일에 자동으로 연결해주는 correctRawSourceLinks()라는
함수가 있는데, 여기도 console.warn만 있고 debugLog()는 없었다. SOURCE_LOST와
정확히 같은 모양의 구멍이었다.
이건 좀 더 신경 쓰이는 종류였다. 이 자동 보정이 틀린 파일로 잘못 연결할 수도
있다는 리스크가 원래 있었기 때문이다. 비슷한 이름의 raw 파일 두 개가 있으면
오매칭이 날 수 있는데, 그런 일이 실제로 일어났는지 사후에 확인할 방법이 지금까지
없었던 거다. RAW_LINK_CORRECTED { path, from, to, distance } 코드를 새로 만들고
연결했다.
곁다리로 나온 것 둘
디버그 로그 코드를 다시 살펴보다가 설계 단계에서 놓친 것도 두 개 더 나왔다.
첫째, mailto: URL은 실사용 메일 클라이언트 기준으로 대략 2000자 안팎에서 잘리거나
아예 안 열리는 경우가 있다. 로그가 최대 200개까지 쌓일 수 있는데, 길이 체크가 전혀
없었다. 리포트가 일정 길이(1800자)를 넘으면 mailto: 대신 클립보드 복사로 전환하는
안전장치를 넣었다.
둘째, 개인정보 경고가 리뷰 모달 안에만 있었다. 정작 토글을 켜는 시점(설정 화면)에는 아무 경고도 없었다. “파일 경로나 제목에 민감한 내용이 있으면 로그에도 남을 수 있다”는 한 줄을 토글 설명에 추가했다 — 문제가 다 벌어진 뒤 검토 화면에서 처음 알려주는 것보다, 켜는 순간부터 알고 켜는 게 낫다.
세 번째 — 이번엔 이번 세션 밖으로 넓혀봤다
여기까지 하고 나서 커밋하고 푸시했다. 진짜 끝난 줄 알았다. 근데, 이번 세션 버그 말고, 프로젝트 전체 이력으로도 다시 시뮬레이션해봐야겠다는 생각이 들었다. 번호가 매겨진 이슈 21개, 백로그 2개까지 전부 대조했다.
대부분은 예상대로 범위 밖이었다 — 초기화 로직, 쿼리 버그, 로그 포맷팅, dead code
정리 같은 것들. 근데 #16이 걸렸다. AI가 본문 wikilink에 .md 확장자를 붙여서
보내면(그러면 Obsidian이 링크를 resolve 못 해서 유령 링크가 된다) 정규식으로
조용히 잘라내는 코드였는데, console.warn조차 없었다. SOURCE_LOST,
RAW_LINK_CORRECTED에 이어 정확히 같은 모양의 구멍이 세 번째로 나온 거다.
이번엔 연결하는 게 앞의 두 번보다 조금 더 걸렸다. 이 함수(NoteFormatter.format)가
순수 함수라 애초에 로그를 남길 방법이 없었을 뿐 아니라, 호출하는 곳이 한 군데가
아니었다. 노트를 새로 만들 때 쓰는 경로 하나, 그리고 리팩토링(병합/분할)할 때 쓰는
경로 두 개, 총 세 곳이었다. 함수가 “잘라낸 링크 목록”을 반환하도록 시그니처를
바꾸고, 그 반환값을 세 호출부 전부에서 로그로 남기게 고쳤다. 리팩토링 쪽 함수는
애초에 “로그를 켰는지” 정보 자체를 안 갖고 있어서, 그 정보를 한 겹 더 위(UI
렌더러)에서부터 관통시켜야 했다 — 이미 존재하던 값을 한 단계 더 전달하기만 하면
됐지만, 건드리는 파일이 3개에서 6개로 늘었다.
남는 생각
이번에 반복해서 겪은 패턴은 하나였다. 코드에 호출문이 있는 것과, 실제로 실행해서 맞는 값이 나오는 건 다른 얘기라는 거였다. 처음엔 설계가 끝났다고 생각했다. 그다음엔 코드에 호출문이 있으니 구현이 끝났다고 생각했다. 그다음엔 grep으로 확인했으니 검증이 끝났다고 생각했다. 세 번 다 틀렸다 — 매번 검증의 기준을 한 단계 더 엄격하게(코드 존재 → 실행 확인 → 범위 확대) 올릴 때마다, 그 전 단계에서는 안 보이던 구멍이 나왔다.
SOURCE_LOST, RAW_LINK_CORRECTED, MD_EXTENSION_STRIPPED — 셋 다 모양이
똑같은 구멍이었다. “AI 출력을 코드가 조용히 고쳤는데, 그 사실 자체는 아무 데도
안 남는다”는 패턴. 하나를 찾을 때마다 “혹시 같은 모양이 또 있나” 하고 한 번 더
훑었기 때문에 다음 것도 잡을 수 있었다.
근데 이 정도면 충분한 걸까 — 이 질문에 대한 답은 다음 편에서.