AI에게 파일 쓰기를 맡긴 뒤 생긴 일 — Obsidian Vault에 검증 하네스를 만든 과정
이전 글에서는 Claude Code로 작업한 내용을 Obsidian에 기록하는 과정을 자동화했다.
여러 에이전틱 코딩 세션을 병렬로 진행하다 보면 작업은 끝냈는데 /obsidian-save를 실행하는 걸 종종 잊었다. 그래서 gh pr create가 성공한 순간을 작업의 체크포인트로 잡고, PR이 만들어지면 해당 세션의 작업 내용을 Obsidian에 남기도록 했다.
이 과정은 이전 글에서 정리했다.
그 자동화를 실제로 사용하기 시작하면서 기록 누락 문제는 줄었다.
그런데 다른 문제가 생겼다.
이번에는 기록을 너무 잘 남기기 시작하면서 하나의 Obsidian vault를 여러 Claude Code 세션이 계속 수정하게 됐다.
어느 날에는 5개의 Claude Code 세션이 각각 작업을 마친 뒤 같은 vault에 내용을 기록했다. 다섯 세션이 정확히 같은 순간에 파일을 쓴 것은 아니다. 각 세션이 순차적으로 작업을 끝내고 같은 vault를 수정했다.
자동화가 제대로 동작하고 있는지 확인하려고 실제 노트를 열어봤다.
그런데 프로젝트 노트의 구조가 이상했다.
## Key Decisions ... ## Links ... ## Key Decisions ... ## Links ... ## Key Decisions ... ## Links ...
Key Decisions가 세 개, Links도 세 개 있었다.
Related Tasks도 중복되어 있었고, 작업 보드에는 같은 의미의 This Week 섹션이 서로 다른 형태로 존재했다.
## This Week ... ## 📋 This Week ...
기록이 누락되는 문제를 해결했더니 이번에는 여러 에이전트가 같은 지식베이스에 쓰는 방법 자체를 다시 생각해야 했다.
MCP 서버를 열어보니 병합 기능 자체가 없었다
Obsidian 노트를 수정할 때 사용하던 도구는 MCP(Model Context Protocol) 서버가 제공하는 obsidian_update_note였다.
MCP는 Claude Code 같은 AI 클라이언트가 외부 도구를 일정한 인터페이스로 호출할 수 있게 해주는 프로토콜이다. 이 환경에서는 Obsidian 노트를 검색하거나 읽고 수정하는 기능이 MCP 도구로 제공되고 있었다.
나는 처음에 heading을 넘기면 MCP 서버가 같은 헤딩을 찾아 해당 섹션에 내용을 추가한다고 생각했다.
예를 들어 다음과 같은 호출이었다.
heading = "Key Decisions" section = "이번에 결정한 내용..."
기존 노트에 ## Key Decisions가 있다면 그 아래에 내용이 들어갈 것이라고 생각했다.
문제가 생긴 뒤 MCP 서버의 실제 구현을 확인했다.
if heading: new_body = new_body.rstrip() + f"\n\n## {heading.strip()}\n\n{section}\n"
코드는 예상보다 단순했다.
기존 문서에서 Key Decisions를 찾는 부분이 없었다.
헤딩의 개수를 세지도 않았다.
기존 섹션의 범위를 찾지도 않았다.
그냥 현재 문서의 마지막에 새로운 헤딩과 내용을 추가하고 있었다.
기존 파일 ## Key Decisions A
여기에 같은 도구를 한 번 호출하면:
## Key Decisions A ## Key Decisions B
가 된다.
한 번 더 실행하면:
## Key Decisions A ## Key Decisions B ## Key Decisions C
가 된다.
처음에는 “헤딩 병합이 실패하고 있다”고 생각했다.
실제로는 병합에 실패한 것이 아니었다.
병합이라는 동작을 애초에 시도하고 있지 않았다.
여러 Claude Code 세션이 같은 vault를 순차적으로 수정한 것 자체가 직접적인 문제는 아니었다. 각 세션이 동일한 append-only 동작을 반복하면서 잘못된 구조가 계속 누적된 것이 문제였다.
MCP 서버를 직접 고치지는 않았다
가장 직접적인 해결책은 MCP 서버 코드를 수정하는 것이다.
obsidian_update_note가 기존 헤딩을 찾아 그 위치에 내용을 삽입하도록 구현하면 된다.
하지만 그렇게 하지 않았다.
사용하고 있는 MCP 서버는 내가 관리하는 vault 자체가 아니라 플러그인이 제공하는 코드였다. 여기에 직접 패치를 넣으면 플러그인이 업데이트될 때 변경사항이 사라질 수 있었다.
그래서 MCP 서버를 수정하는 대신 내 vault에서 필요한 쓰기 규칙을 별도의 계층으로 분리했다.
Obsidian MCP
│
├─ 검색
├─ 읽기
├─ 일반적인 노트 작업
│
└─ 기존 기능 유지
내 Vault의 구조적 섹션 수정
│
└─ section_inject.py
모든 Markdown 헤딩을 일반적으로 처리하는 도구를 만들려고 하지는 않았다.
실제로 문제가 발생한 구조적인 섹션만 별도의 안전한 쓰기 경로로 우회했다.
Key DecisionsLinksRelated TasksThis Week계열
여기서부터 자동화의 기준도 바꿨다.
자동화가 파일 구조를 추측하지 않도록 했다.
헤딩이 없으면 만드는 대신 거부했다
새로 만든 section_inject.py의 inject 모드는 일부러 보수적으로 동작하게 했다.
대상 헤딩을 찾았을 때 세 가지 경우가 나온다.
0개 → 어디에 써야 하는지 확신할 수 없음 → 거부 1개 → 구조가 명확함 → 해당 섹션에 삽입 2개 이상 → 이미 구조가 손상됐을 가능성이 있음 → 거부
보통 자동화를 만들 때는 헤딩이 없다면 새로 만들어주는 쪽이 더 편해 보인다.
헤딩 없음? → 그러면 새로 만들자
이번에는 그렇게 하지 않았다.
예를 들어 Key Decisions가 없는 것이 정말 오류인지, 해당 노트 타입에서는 원래 없어야 하는 것인지 스크립트는 알 수 없다.
헤딩 위치도 마찬가지다.
스크립트가 임의로 파일 마지막에 만들면 다시 기존 MCP 도구와 비슷한 문제를 만들 수 있었다.
그래서 inject가 담당하는 것은 “정확히 하나 존재하는 것으로 이미 확인된 구조 안에 내용을 추가하는 것”까지만으로 제한했다.
모른다 → 추측한다
가 아니라
모른다 → 실패한다
를 선택했다.
자동화를 덜 편하게 만드는 대신 잘못된 상태를 조용히 만들어내는 것을 피하려고 했다.
정상 쓰기와 복구도 분리했다
이미 중복 헤딩이 생긴 파일은 inject로 수정할 수 없다.
2개 이상의 헤딩을 발견하면 의도적으로 거부하기 때문이다.
그래서 별도의 consolidate 모드를 만들었다.
두 모드의 책임을 나눴다.
inject 정상 상태의 파일 → 헤딩 정확히 1개 → 내용 추가
consolidate 이미 손상된 파일 → 중복된 헤딩 확인 → 하나의 섹션으로 병합
consolidate는 일반적인 저장 과정에서 매번 사용하는 기능이 아니다.
이미 문제가 발생한 파일을 확인한 뒤 복구할 때 사용하는 관리용 기능에 가깝게 만들었다.
실제 데이터에는 같은 의미의 다른 헤딩도 있었다
초기 consolidate 설계에도 문제가 있었다.
처음에는 동일한 문자열의 헤딩이 여러 개 존재하는 경우만 생각했다.
## Key Decisions ... ## Key Decisions
그런데 실제 Boards/Work.md를 확인해보니 손상 형태가 달랐다.
## This Week
와
## 📋 This Week
가 함께 있었다.
사람이 보면 같은 역할의 섹션이라는 것을 알 수 있지만 문자열은 다르다.
초기 구현대로라면 둘을 서로 다른 헤딩으로 판단한다.
설계 리뷰에서 이 문제가 발견됐고, consolidate에는 canonical heading과 variant를 함께 넘길 수 있도록 수정했다.
canonical
This Week
variant
📋 This Week
↓
하나의 This Week 섹션으로 병합
여기서도 테스트하기 편한 형태의 데이터보다 실제로 vault에서 발생한 손상 형태를 처리할 수 있는가가 중요했다.
리뷰를 통과한 코드도 실제 파일에서는 문제가 있었다
section_inject.py는 처음부터 테스트를 같이 작성했다.
첫 구현에서는 13개의 테스트가 있었다.
독립적인 코드 리뷰를 진행했고, 서로 다른 레벨의 canonical/variant 헤딩이 중첩될 때 내용이 중복될 수 있다는 문제가 발견됐다.
수정 후 테스트는 14개가 됐고 재리뷰에서도 문제가 나오지 않았다.
여기까지만 보면 구현을 끝내도 될 것 같았다.
하지만 바로 실제 파일을 수정하지 않고 먼저 --dry-run을 실행했다.
--dry-run에서는 원본 파일을 쓰지 않고 변경될 diff만 출력한다.
구현 ↓ 13 tests ↓ code review ↓ 수정 ↓ 14 tests ↓ re-review clean ↓ 실제 손상 파일 --dry-run
여기서 새로운 문제가 발견됐다.
헤딩을 병합하는 경계에서 빈 줄 처리가 일관되지 않았다.
어떤 경우에는 빈 줄이 없어지고, 어떤 경우에는 두 줄 이상 남았다.
합성 테스트에서는 놓쳤고 구현 에이전트와 리뷰 에이전트도 발견하지 못한 문제였다.
실제로 깨져 있던 Markdown 파일에 넣어보고 나서야 보였다.
결국 병합 경계의 빈 줄을 정규화하는 로직을 추가했고 테스트도 보강했다.
최종적으로 section_inject.py에는 17개의 단위 테스트가 남았다.
13 tests → review → fix → 14 tests → re-review → 실제 파일 dry-run → 추가 버그 발견 → fix → 17 tests
이 과정에서 다시 확인한 것은 테스트 개수보다 검증 단계마다 입력의 성격이 달라야 한다는 점이었다.
단위 테스트가 보는 입력과 실제 vault의 오래된 파일이 가진 상태는 달랐다.
코드 리뷰도 실제 데이터의 모든 형태를 대신해주지는 못했다.
그래서 이 작업에서는 실제 손상 파일을 수정 전에 dry-run으로 통과시키는 단계가 하나의 검증 장치가 됐다.
파일 수정부터 Git commit까지 하나의 쓰기 작업으로 봤다
여러 세션이 하나의 vault를 사용한다면 다른 문제가 하나 더 있다.
두 프로세스가 동시에 같은 파일을 읽고 수정할 수 있다.
그래서 section_inject.py에서는 fcntl.flock을 이용해 exclusive lock을 잡았다.
잠금 범위는 파일을 저장하는 순간까지만이 아니었다.
flowchart LR
A["Lock"] --> B["Read"]
B --> C["Validate"]
C --> D["Modify"]
D --> E["Write"]
E --> F["Git Commit"]
F --> G["Unlock"]
파일 수정 이후 로컬 Git commit까지 끝난 뒤 lock을 해제했다.
한 번의 vault 쓰기 작업을 단순한 파일 변경만으로 보지 않았기 때문이다.
파일을 변경했다 + 그 변경 상태를 Git에 기록했다
여기까지를 하나의 작업 단위로 취급했다.
만약 파일을 저장한 직후 lock을 풀어버리면 다른 세션의 수정이 끼어든 뒤 첫 번째 세션의 commit 과정과 섞일 가능성이 생긴다.
그래서 read-modify-write와 commit을 같은 임계 구역에 넣었다.
반대로 push까지 이 안에 넣지는 않았다.
매번 바로 push하는 것도 다시 생각했다
초기 계획에서는 저장할 때마다 다음 흐름으로 처리하려 했다.
파일 수정 → commit → push
하지만 설계를 검토하면서 실시간 push가 정말 필요한지 다시 생각했다.
Obsidian 기록을 남기는 작업에서 중요한 것은 우선 로컬 파일의 일관성을 유지하고 변경 이력을 남기는 것이었다.
매번 네트워크 push까지 쓰기 경로에 묶을 필요는 없었다.
그래서 역할을 다시 나눴다.
실시간 쓰기 경로 section_inject.py → 파일 수정 → local commit
야간 검증 경로 vault 검증 → 필요한 유지보수 → remote push
기능을 하나 더 붙이는 대신 오히려 실시간 쓰기 작업의 책임을 줄였다.
안전한 쓰기 경로만 만들어서는 부족했다
section_inject.py를 만들어도 기존 obsidian_update_note 도구는 그대로 사용할 수 있다.
그러면 Claude가 기존 방식대로 다음 호출을 해버릴 수 있다.
obsidian_update_note heading = "Key Decisions"
그리고 MCP 서버는 또 파일 마지막에 새로운 ## Key Decisions를 추가한다.
안전한 길을 하나 추가했지만 위험한 길이 그대로 열려 있는 상태였다.
그래서 Claude Code의 PreToolUse Hook을 이용해 특정 MCP 호출을 가로채는 Guard도 추가했다.
PreToolUse는 도구 호출이 실제로 실행되기 전에 발생하며 도구 실행을 차단할 수 있다. MCP 도구 역시 일반 도구와 마찬가지로 mcp__<server>__<tool> 형태의 이름으로 Hook에서 식별할 수 있다.
최종 구조를 단순화하면 이렇다.
flowchart TD
A["Claude Code"] --> B{"어떤 방법으로 수정?"}
B -->|"section_inject.py"| C["Safe write path"]
C --> D["Validate heading"]
D --> E["Write + Commit"]
B -->|"obsidian_update_note"| F["PreToolUse Guard"]
F --> G{"보호 대상 heading?"}
G -->|"Yes"| H["Block"]
G -->|"No"| I["Allow"]
모든 obsidian_update_note를 막지는 않았다.
문제가 된 구조적 섹션으로 raw append를 시도하는 경우만 차단하도록 했다.
Claude Code의 공식 Hook 문서에서도 PreToolUse의 matcher는 실제 tool_name을 기준으로 동작하며, plugin에 포함된 MCP 서버는 plugin scope까지 들어간 전체 이름을 사용한다고 설명한다.
그런데 이 Guard를 만드는 과정에서도 같은 종류의 실수를 반복했다.
보호 장치가 원래 사고와 똑같은 실수를 했다
Guard 설계와 구현은 여러 번 따로 검토했다.
첫 번째 검토에서는 잠금 위치가 문제였다.
처음에는 vault에서 발생하는 Edit과 Write 호출을 전역 Hook으로 잡아 잠그려 했다.
하지만 실제 수정 경로는 달랐다.
하나는 Bash에서 실행되는 section_inject.py였고, 다른 하나는 MCP 도구 호출이었다.
감시하려던 것 Edit / Write 실제로 쓰는 것 Bash / MCP
즉 원래 계획대로라면 잠금 Hook은 실제 파일 쓰기의 핵심 경로를 하나도 잡지 못한다.
그래서 락 자체를 section_inject.py 내부로 옮겼다.
두 번째 검토에서는 MCP 도구 이름이 문제였다.
MCP 도구의 실제 이름은 단순히:
obsidian_update_note
가 아니었다.
이 환경에서는 plugin scope와 서버 이름이 포함된 전체 tool_name이 전달됐다.
mcp__plugin_obsidian-second-brain_vault__obsidian_update_note
짧은 이름을 기준으로 판단하면 실제 도구 호출과 매칭되지 않는다.
그래서 최종 설정에서는 PreToolUse를 받은 뒤 스크립트 안에서 실제 tool_name 전체 값을 확인하도록 했다.
하지만 가장 큰 문제는 구현을 끝낸 뒤 최종 검토에서 나왔다.
Guard가 차단하려던 값은 다음과 같이 작성돼 있었다.
## Key Decisions
문제는 MCP의 실제 호출값이었다.
obsidian_update_note가 받는 것은:
Key Decisions
였다.
##는 호출자가 붙이는 것이 아니었다.
서버 코드가 나중에 붙인다.
f"\n\n## {heading.strip()}\n\n"
그러니까 Guard는 다음과 같은 입력을 기다리고 있었다.
"## Key Decisions"
하지만 실제 입력은 항상:
"Key Decisions"
였다.
결과적으로 Guard가 존재해도 실제 호출과 조건이 일치하지 않았다.
여기서 특히 인상적이었던 것은 원래 사고를 막으려고 만든 코드가 원래 사고를 만든 것과 같은 종류의 가정 오류를 다시 만들었다는 점이었다.
원래 문제에서도 호출 인터페이스와 최종 Markdown 형태를 혼동했다.
MCP 호출 heading = "Key Decisions" 저장 결과 ## Key Decisions
Guard에서도 다시 둘을 혼동했다.
실제 입력 Key Decisions Guard가 예상 ## Key Decisions
이 문제는 최종 리뷰에서 MCP 서버 소스를 다시 읽으면서 발견했다.
수정 자체는 짧았다.
def normalize_heading(raw): return raw.strip().lstrip("#").strip()
비교 전에 # prefix를 제거하고 실제 의미의 heading 값만 비교하도록 했다.
코드 한두 줄의 문제였지만, 이걸 발견하기 위해서는 보호 코드를 읽는 것만으로는 부족했고 보호 대상 시스템의 실제 contract를 다시 확인해야 했다.
수정 직후 Guard가 실제로 나를 막았다
Guard를 수정한 뒤 pipe test도 다시 진행했다.
하지만 더 확실한 검증은 이후 실제 작업 중에 나왔다.
Obsidian 기록을 갱신하다가 무심코 예전 방식으로 보호 대상 섹션을 수정하려 했다.
Claude ↓ obsidian_update_note ↓ Key Decisions에 raw append 시도
이번에는 Guard가 실제로 발동했다.
PreToolUse Guard ↓ protected heading 확인 ↓ BLOCK
합성 테스트를 위해 만든 입력이 아니라 실제 /obsidian-save 후속 작업에서 잘못된 경로를 사용하려다 차단된 것이었다.
결국 처음 사고 이후 만들고 싶었던 상태가 여기서 실제로 확인됐다.
잘못된 쓰기 방법을 에이전트가 기억해서 피한다
가 아니라
잘못된 쓰기 방법을 시도해도 시스템이 막는다
로 바뀌었다.
이 차이가 이번 작업에서는 중요했다.
에이전트가 항상 규칙을 기억할 것이라고 기대하는 대신, 잘못된 행동이 실제 실행 단계까지 내려왔을 때 차단할 수 있게 만들었다.
17개 테스트만으로 끝내지는 않았다
최종적으로 section_inject.py에는 17개의 단위 테스트가 있었다.
Guard도 별도의 pipe test로 확인했다.
테스트 종류는 서로 달랐다.
section_inject.py → unit test 17개 Guard → stdin으로 실제 Hook payload를 흘려보내는 pipe test 실제 손상 파일 → --dry-run 최종 확인 → 실제 /obsidian-save 과정에서 Guard 발동
한 종류의 테스트로 모든 것을 확인하려 하지 않았다.
특히 이번 작업에서는 실제 파일과 실제 Hook 입력이 중요했다.
처음 문제부터 인터페이스를 실제와 다르게 가정해서 발생했기 때문이다.
밤마다 vault를 다시 확인하고 싶었다
쓰기 경로를 바꾼 뒤에도 한 가지 걱정이 남았다.
기존에 모르는 경로에서 비슷한 문제가 다시 생길 수 있었다.
실제로 vault 전체를 검사하는 과정에서 이번 사고보다 전에 만들어진 다른 노트에서도 비슷한 중복 구조를 발견했다.
그래서 하루에 한 번 vault 상태를 다시 확인하는 야간 자동 검증 파이프라인을 만들기로 했다.
대략 다음 작업을 수행한다.
vault health check
↓
알려진 구조 확인
↓
전체 중복 heading scan
↓
필요한 상태 검증
↓
Git commit / push
처음에는 Claude Code의 예약 실행 기능을 사용하는 방법을 검토했다.
그런데 생성 직전에 실행 환경을 다시 확인했다.
이번 작업에 필요한 것은 전부 로컬에 있었다.
로컬 Obsidian vault 로컬 Git repository 로컬 Claude Code 설정 로컬 MCP 환경
당시 검토한 예약 세션 환경에서는 이 로컬 자원들에 접근할 수 없었다.
스케줄 자체는 만들 수 있지만 정작 검사해야 할 vault가 없는 셈이었다.
그대로 만들었다면 매일 정해진 시간에 실행은 되지만 실제 유지보수는 하지 못하는 자동화를 만들 수 있었다.
그래서 클라우드 예약 실행 방식은 포기했다.
Mac 자체에서 실행하도록 변경했다.
macOS launchd
↓
claude -p
↓
local vault 검사
claude -p는 Claude Code를 비대화형으로 실행하는 방식이라 사람이 터미널 앞에 없어도 정해진 검증 작업을 실행할 수 있었다.
새벽 3시에 돌리자는 계획도 실제 사용 패턴과 맞지 않았다
처음 만든 launchd 설정은 단순했다.
매일 03:00 실행
야간 유지보수니까 자연스러워 보였다.
그런데 한 가지 문제가 있었다.
내 Mac은 새벽 3시에 대부분 완전히 꺼져 있었다.
내 환경에서 확인한 launchd 동작상 잠자기 때문에 놓친 일정과 달리 전원이 꺼져 있는 동안 놓친 고정 시각 작업은 나중에 Mac을 켰다고 다시 실행되지 않았다.
즉 자동 검증은 존재하지만 내 사용 패턴에서는 상당한 날에 실행되지 않을 수 있었다.
스케줄을 다시 바꿨다.
매시간 실행 기회 제공 + 로그인할 때도 실행 + 오늘 이미 완료했다면 종료
핵심은 “매시간 vault를 검사한다”가 아니다.
실제로는 하루에 한 번만 실행한다.
launchd가 실행
↓
오늘 성공 기록 존재?
┌──────┴──────┐
Yes No
↓ ↓
종료 검증 실행
.last-run-date 같은 상태 파일을 두고 오늘 이미 실행했다면 바로 종료하게 만들었다.
그러면 Mac을 언제 켜더라도 다음 실행 기회에 그날의 검증이 한 번 수행된다.
전원 문제를 해결했더니 다른 실패에도 도움이 됐다
설정을 바꾸고 launchd를 다시 등록하자 실제 첫 실행이 바로 발생했다.
그런데 첫 실행은 끝까지 가지 못했다.
당시 Claude 사용량 한도에 걸려 작업이 중단됐다.
첫 실행 → quota → 실패
고정된 새벽 3시 작업이었다면 그날 검증은 여기서 끝났을 것이다.
하지만 매시간 실행 기회를 주도록 바꾼 상태였다.
한 시간 뒤 다음 실행이 발생했다.
그때는 사용량 한도가 다시 풀린 상태였고 이번에는 작업이 끝까지 수행됐다.
1차 → 일시적 실패 1시간 뒤 → retry → health check → 검증 → duplicate scan → Git push → 완료
처음 스케줄을 바꾼 이유는 Mac이 새벽 3시에 꺼져 있는 문제였다.
그런데 결과적으로 같은 구조가 전혀 다른 종류의 일시적 실패에서도 재시도 기회를 만들었다.
운영 환경에서는 특정 원인 하나를 예측해서 해결하는 것보다, 실패 후 다시 실행될 기회를 만드는 것이 더 도움이 될 수 있다는 것을 이 과정에서 확인했다.
중복을 발견해도 전부 자동으로 고치지는 않았다
야간 검증에서는 vault 전체의 중복 헤딩도 검사한다.
처음에는 중복이 발견되면 consolidate로 자동 복구해도 되지 않을까 생각할 수 있다.
그렇게 만들지 않았다.
첫 실제 스캔에서 이유가 바로 나왔다.
Daily 노트에서 For future Claude라는 헤딩이 여러 번 발견됐다.
문법적으로만 보면 중복 헤딩이다.
## For future Claude ... ## For future Claude ... ## For future Claude
하지만 당시 vault의 운영 맥락에서는 해당 반복 구조를 잘못된 데이터로 보지 않았다.
만약 검사 프로그램이:
같은 heading이 여러 개다 = 잘못됐다 = 자동 consolidate
라고 판단했다면 정상적인 내용을 오히려 망가뜨릴 수 있었다.
그래서 전체 vault를 대상으로 하는 중복 스캔은 report-only로 만들었다.
이상 구조 발견
↓
보고
↓
자동 수정하지 않음
반면 Key Decisions처럼 이미 의미와 구조가 확인된 보호 대상은 별도의 안전 쓰기 규칙을 적용한다.
이 구분이 필요했다.
구조적인 이상을 탐지하는 것과, 그 구조가 의미적으로 잘못됐다고 판단하는 것은 같은 문제가 아니었다.
AI가 파일을 수정할 때도 마찬가지였다.
무언가 이상해 보인다는 이유만으로 가장 그럴듯한 상태를 만들어버리는 것보다, 판단 근거가 부족한 상황에서는 멈추는 편을 선택했다.
결과적으로 만든 것은 파일 수정 스크립트 하나가 아니었다
처음 문제를 발견했을 때는 MCP의 append 동작만 고치면 된다고 생각했다.
결과적으로는 그보다 여러 계층이 생겼다.
flowchart TD
A["Claude Code Session"] --> B{"Vault 수정"}
B -->|"일반 MCP 작업"| C["Obsidian MCP"]
B -->|"보호 대상 raw update"| D["PreToolUse Guard"]
D --> E["Block"]
B -->|"보호 대상 정상 수정"| F["section_inject.py"]
F --> G["flock"]
G --> H["구조 검증"]
H -->|"heading = 1"| I["Inject"]
H -->|"heading = 0 or 2+"| J["Reject"]
I --> K["File Write"]
K --> L["Local Git Commit"]
M["launchd"] --> N["Daily Verification"]
N --> O["Health Check"]
N --> P["Duplicate Scan"]
P --> Q["Report Only"]
N --> R["Git Push"]
각각의 역할도 다르다.
| 계층 | 역할 |
|---|---|
section_inject.py |
확인된 구조에만 안전하게 쓰기 |
consolidate |
이미 손상된 구조 복구 |
flock |
쓰기와 commit 작업 직렬화 |
PreToolUse Guard |
기존 위험한 MCP 쓰기 경로 차단 |
| 17개 단위 테스트 | 삽입·복구 로직 검증 |
실제 파일 --dry-run |
합성 테스트가 놓친 데이터 형태 검증 |
| 야간 자동 검증 | 운영 이후 vault 상태 재확인 |
| 전체 duplicate report | 모르는 문제는 자동 수정하지 않고 노출 |
처음에는 obsidian_update_note 대신 사용할 작은 스크립트를 만드는 문제처럼 보였다.
하지만 여러 Claude Code 세션이 하나의 지식베이스에 지속적으로 쓰기 시작하자 필요한 것은 단순한 함수 하나가 아니었다.
어떤 경로로 써야 하는지, 어떤 상태에서는 쓰지 말아야 하는지, 잘못된 경로를 어떻게 막는지, 동시에 쓰려 할 때 어떻게 직렬화할지, 적용한 뒤에도 어떻게 다시 검증할지가 함께 필요했다.
이번에는 기록을 남기는 것보다 기록을 믿을 수 있게 만드는 문제였다
이전 작업에서는 사람이 기록을 빼먹는 것이 문제였다.
그래서 기록하는 시점 자체를 자동화했다.
PR 생성 → /obsidian-save
그 다음에 생긴 문제는 달랐다.
자동화 덕분에 여러 세션이 꾸준히 기록을 남기자 이제는 그 기록 과정 자체가 안전한지를 보장해야 했다.
자동 기록 증가
↓
공유 vault에 반복적인 write
↓
구조 손상 발견
↓
Safe write path
+
Unsafe path guard
+
Lock
+
Tests
+
Real-data dry-run
+
Nightly verification
이번에도 AI가 실수하지 않도록 더 자세한 지시문을 작성하는 방법만으로 해결하지 않았다.
실제로 Guard를 만들고도 Guard 자체가 실제 MCP 입력 형태를 잘못 가정했다.
리뷰를 받은 코드도 실제 손상 파일을 넣자 또 다른 문제가 나왔다.
새벽 3시라는 그럴듯한 스케줄도 실제 사용 환경에서는 거의 실행되지 않을 수 있었다.
결국 에이전트의 판단이 항상 맞을 것이라고 기대하기보다, 틀렸을 때 그 사실이 드러나거나 실행 자체가 차단되는 구조를 하나씩 추가했다.
내가 이번 작업에서 생각한 Harness Engineering도 여기에 가까웠다.
에이전트가 더 많은 일을 할 수 있게 만드는 것만이 아니라, 에이전트가 실제 환경에서 안전하게 행동할 수 있도록 주변 실행 조건을 설계하는 일이었다.
이전에는 기록을 놓치지 않도록 만들었다.
이번에는 그렇게 자동으로 쌓이는 기록을 다시 믿을 수 있도록 만드는 작업을 했다.
첫 댓글을 남겨보세요.