
어느새 부식되는 docs, 에이전트가 고치게 만든다 ── LLM 없는 결정적 드리프트(Drift) 검출 OSS를 만들었다
요약
코드와 문서(docs) 간의 불일치를 LLM 없이 결정론적(Deterministic)으로 검출하는 오픈소스 도구 staledocs를 소개합니다. 에이전트 주도 개발 환경에서 문서 부식으로 인한 오작동을 방지하기 위해 구문 분석 없이 기계적인 방식으로 문서의 노후화를 특정합니다.
핵심 포인트
- 에이전트 시대의 문서 부식은 단순 불편함을 넘어 오작동의 원인이 됨
- LLM의 불확실성 대신 결정론적(Deterministic) 판정 방식 채택
- 코드와 문서의 정합성을 행 단위까지 기계적으로 검출
- API 비용 절감 및 신뢰할 수 있는 경보 시스템 구축
이 글은 누구를 위한 것인가
- AI 에이전트에게 구현을 맡기고 있으며, docs가 코드의 속도를 따라가지 못하는 상황을 겪어본 사람
- "README가 오래됨"을 누군가 속을 때까지 검출하지 못하는 운영 방식에 불안함을 느끼는 사람
- docs 검사에 LLM 판정을 넣고 싶지 않은 사람 ── 경보가 울렸을 때 "정말로 틀린 것인가, 아니면 모델의 기분인가"를 의심하고 싶지 않은 사람
docs를 쓰는 방법이나 생성에 관한 이야기는 하지 않습니다. 이 글이 다루는 것은 작성된 docs와 코드의 불일치를 의미를 추측하지 않고 기계적으로 행 단위까지 특정하는 이야기입니다.
이를 위해 OSS를 만들었고, 현재 보유한 모든 리포지토리에서 운용하고 있습니다.
어느 날의 장면
심야, 에이전트가 자율적으로 구현을 진행하고 있습니다. 참조하고 있는 것은 docs/auth.md
── 3주 전 리팩터링(Refactoring)으로 사라진 함수를, 지금도 현역인 것처럼 설명하고 있는 문서입니다.
에이전트는 docs를 의심하지 않습니다. 적혀 있는 내용은 전제가 되며, 사라진 사양 위에 새로운 코드가 자신만만하게 쌓여갑니다. 인간이라면 "어라, 이 함수 아직 있었나?"라며 손을 멈출 상황에서, 에이전트는 멈추지 않습니다. docs의 부식은 에이전트 시대에는 "읽기 불편함"이 아니라 "오작동의 입력"이 됩니다.
그리고 docs는 조용히 부식됩니다. 코드를 바꿔도 문서는 옛날 동작을 계속 설명하고, 누군가 속을 때까지 눈치채지 못합니다. 이 부식에 대한 공포가 "docs를 쓰고 싶지 않다"의 정체이기도 합니다 ── 작성한 1페이지가 모두 기억해 두어야 할 유지보수 채무가 되기 때문입니다.
이렇게 되었으면 하는 것입니다.
$ staledocs check
[!!] DOC_STALE docs/auth.md
code moved: src/auth/token.py
...
실제 출력(데모용 리포지토리에서 촬영)
코드가 동작하는 순간, 어느 문서의 몇 번째 줄이 거짓이 되었는지가 기계적으로 출력됩니다. 이를 위해 만든 것이 staledocs입니다. 검출 경로에 LLM 제로, 코드의 구문 분석(Parsing) 제로, 언어 독립적(Language-independent) 설계를 적용하여, 동일한 입력이라면 반드시 동일한 판정을 반환합니다. 기존 도구들도 찾아보았지만, 이 검출을 **결정적(Deterministic)**으로 수행해 주는 것을 찾을 수 없었습니다(후반부에서 비교합니다). 없기에, 만들었습니다.
왜 "주의하기"나 "LLM에게 보여주기"로는 지켜낼 수 없는가
코드에는 테스트와 CI라는 기계적 방어 수단이 있습니다. docs에는 아무것도 없습니다. "변경하면 관련 docs도 수정한다"는 인간의 기억에 의존하며, 에이전트 주도 개발에서는 변경 횟수가 인간의 수십 배에 달합니다 ── 기억은 가장 먼저 파괴되는 부품입니다.
그렇다면 LLM에게 "코드와 docs가 서로 맞지 않아?"라고 계속 물어보면 되느냐 하면, 그것도 방어 수단이 되지 않습니다. 동일한 입력에 동일한 답이 돌아온다는 보장이 없으며, 경보가 울려도 "정말로 틀린 것인가"를 인간이 다시 확인해야 하는 상황이 발생하고, 매 커밋(commit)마다 실행하면 API 비용이 쌓입니다. 검사 장치는 지루할 정도로 결정적이어야 합니다 ── 경보가 울렸다 = 사실로서 틀렸다,를 신뢰할 수 있는 것이 토대입니다.
staledocs의 도박은 여기 있습니다. 의미를 추측하여 판정하지 않습니다. 대신, 결정적으로 판정할 수 있는 범위를 철저히 넓혀서, 의미의 주장도 가능한 한 그 범위 안으로 끌어들입니다.
메커니즘 ── 검출은 3층 구조, 모두 결정적
L1: 페어 장부(Pair Ledger). 모든 문서를 그것이 설명하는 코드와 한 쌍으로 묶고(CODEOWNERS 방식의 glob 1장), 양자의 정합성을 확인한 시점의 git blob hash를 기록합니다. 이후 한쪽만 변경되면 양방향으로 검출됩니다 ── 코드만 움직이면 DOC_STALE,
문서만 움직이면 CODE_LAG(미구현 사양).
docs 측이 앞서 나가는 케이스까지 대칭적으로 잡아내는 것이 포인트로, 설계서에 추가된 요구사항을 아무도 만들지 않은 상태가 조용히 통과되지 않게 합니다.
게다가 페어의 상대는 코드가 아니어도 됩니다 ── code:
측에는 다른 문서도 쓸 수 있습니다. 요구사항 정의서를 설계서의 상대방으로 설정하면, 요구사항 ↔ 설계 ↔ 코드의 체인이 동일한 장부와 동일한 행 단위 등급으로 연결되어, 요구사항의 변경이 "확인하라"는 메시지로 하류로 흘러갑니다. 코드가 관여하지 않는 추상 문서 간의 드리프트(Drift)도 동일한 기제로 막을 수 있습니다.
단, "페어 중 하나라도 움직이면 Red" 방식은 경보 피로를 유발합니다. Red가 되는 것은 변경이 문서가 명시한 대상을 건드렸을 때뿐입니다 ── 문서가 경로(path)로 언급한 파일이 움직였거나, 백틱(backtick)으로 인용한 식별자가 추가/삭제된 행에 나타났을 때입니다. 무관한 변경은 이유와 함께 AMBER로 표시됩니다.
로 강등됩니다. red의 긴급성을 지키기 위한 등급 분류입니다.
L2: 앵커 생존 검증 (Anchor Survival Verification). 문서는 자연스럽게 식별자, CLI 플래그, 경로를 백틱(backtick)으로 인용합니다. 이를 추출하여 쌍이 되는 코드 측에 여전히 존재하는지 검증하며, 부식된 문서의 행 번호까지 특정합니다. 파싱(Parsing)은 문서 측만 수행하고, 코드 측은 grep 하는 것뿐입니다 ── 이것이 언어 독립성의 원천이며, Python이든 Rust든 혼합 모노레포(Monorepo)든 동일하게 동작합니다.
세 번째, 포괄 게이트 (Coverage Gate). 모든 소스 파일(source file)은 어떤 문서에든 속해야 하며, 모든 문서는 분류되어야 합니다. 소유자가 없는 신규 파일은 즉시 red로 표시됩니다. 침묵은 커버리지(Coverage)가 아니라는 원칙입니다.
그리고 의미의 대조 ── "기술과 동작이 여전히 일치하는가"에 대한 최종 판단은 결정적으로 내릴 수 없습니다. 이 부분이 staledocs가 말하는 L3로, 의도적으로 검출 범위 밖에 둔 판단 계층입니다. 그렇다고 완전히 방치하는 것은 아니며, 의미의 주장을 단계적으로 결정적 검사로 변환해 나가는 방향으로 설계되어 있습니다. 인용된 식별자나 경로(path)의 실재 여부는 L2가 기계적으로 보장합니다. 실행 가능한 형태로 작성된 주장은 executable-docs 계층(후술)이 사용자의 테스트 러너(test runner)에 연결합니다. 그렇게 깎아내고 남은 것 ── 순수한 산문의 판단 ── 만을 check --json의 기계 판독 가능(machine-readable) 보고서와 함께 인간이나 에이전트에게 전달하며, 수정 후에는 ack로 루프가 닫힙니다.
가장 큰 발명 ── 읽지 않고는 찍을 수 없는 도장
여기서 한 가지 문제가 남습니다. 수정하는 것이 에이전트라면, 확인 도장(ack)을 읽지 않고 찍는 것도 당연히 가능해집니다. "리뷰한 뒤에 승인하라"고 지시문에 적는 것은 방어책이 아닙니다.
staledocs의 ack는 2단계 구조입니다.
$ staledocs ack docs/auth.md
docs/auth.md [DOC_STALE]
code moved: src/auth/token.py
...
실제 출력(1단계는 exit 3로 종료되며, 이 시점에서는 아무것도 기록되지 않습니다)
1단계는 증거를 제시합니다 ── 문서의 해당 행과, 그것을 건드린 변경 행을 병치합니다. 그리고 확인 토큰은 이 증거 출력 안에만 존재합니다. 2단계는 토큰에 더해, 증거의 내용을 구체적으로 지칭한 note가 없으면 거부됩니다 ("looks fine"은 통하지 않습니다). 또한 --all / --broken을 이용한 일괄 ack(온보딩이나 리팩토링 날용)는, 지칭해야 할 단일 증거가 존재하지 않기 때문에 비어 있지 않은 note가 조건입니다.
이것이 효과적인 이유는 LLM 에이전트의 구조적 특성을 역이용했기 때문입니다. 에이전트는 도구의 출력을 반드시 읽습니다 ── 읽지 않는다는 선택지 자체가 애초에 없습니다. 따라서 토큰을 증거 안에 구워 넣으면, 읽지 않고 도장을 찍는 것이 구조적으로 불가능해집니다. "에이전트를 신뢰하는" 것도 "에이전트에게 기도하는" 것도 아닌, 에이전트의 동작 원리 그 자체를 검사의 부품으로 만드는 설계입니다.
도입 후의 일상 ── 인간의 수고는 늘어나지 않는다
방어적인 이야기를 강조하면 "의식이 늘어날 것"처럼 보이기 때문에, 도입 후의 하루를 적어둡니다.
인간이 입력하는 명령어는 실질적으로 늘어나지 않습니다. check는 pre-commit / CI가 알아서 실행합니다. 코드와 docs를 같은 커밋(commit)으로 수정하면 자동으로 AMBER(잠정 정합) 상태가 되며, AMBER는 절대로 커밋을 차단하지 않습니다. "수정하는 김에 확인 완료 상태도 기록하고 싶다"면, 커밋 메시지에 trailer를 한 줄 추가하기만 하면 됩니다.
fix: 토큰의 TTL을 300으로 변경
Staledocs-Ack: docs/auth.md
이렇게 하면 ack가 성립됩니다 (토큰이 필요 없는 인간용 지름길). 2단계 ack가 필요한 경우는 "깨진 쌍을 나중에 수정했을" 때뿐이며, 그 엄격함은 주로 에이전트에게 부과되는 측입니다 ── 인간의 일상은 평소처럼 커밋하는 것뿐입니다.
에이전트 측은 깨진 부분을 그 자리에서 바로 수정해 나갑니다. Claude Code라면 hook에 check를 한 줄 심어두는 것만으로, 편집할 때마다 findings가 에이전트의 컨텍스트(context)로 흘러 들어갑니다.
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "staledocs check --gate warn 2>/dev/null | tail -5" } ] } ] } }
에이전트는 도구 출력(tool output)을 반드시 읽습니다 ── 즉, docs의 파손이 "훗날 누군가가 발견하는 사고"가 아니라 "그 자리에서 에이전트의 눈에 들어오는 작업 항목"으로 변합니다. explain --json의 증거(rename 추적 힌트 포함)를 읽고 수정하며, 2단계 ack로 종결합니다. 읽지 않고는 찍을 수 없는 도장을 읽지 않고 찍을 수 없는 것과 같은 성질을, 이번에는 공격적으로 사용하고 있는 형태입니다.
인간에게 남는 것은 가끔 pairs --health를 바라보는 업무뿐입니다 ── 어떤 docs에 얼마나 많은 앵커(anchor)가 있는지, ack가 얼마나 오래되었는지. 보호 상태 그 자체가 가시화됩니다.
검사를 약화시키는 뒷문에도 초인종을
또 하나, 방어 도구가 반드시 가지는 약점이 있습니다 ── 도구를 침묵시키는 우회로입니다. 페어(pair)를 대장(ledger)에서 삭제하거나, gate를 약화시키거나, ignore 리스트를 늘리는 것. 이 모든 것은 diff 상으로는 한 줄의 "청소"처럼 보입니다.
staledocs는 이러한 config 약화 자체를 검사 대상으로 삼고 있습니다. 이미 수락된 baseline보다 검사가 약해지는 변경은 check가 빨간색으로 표시하며, 이유가 적힌 note가 포함된 ack --config로만 통과할 수 있습니다. 뒷문을 막는 것이 아니라, 뒷문에 초인종을 다는 것 ── 통과해도 좋지만, 통과했다는 사실은 반드시 기록에 남는 형태입니다.
이는 docs의 은퇴에도 효과적입니다. 아카이브로 가는 문서는 docs.exclude나 standalone 선언 등으로 검사에서 제외할 수 있지만, 그 제외 자체도 약화로 간주되어 note를 요구받습니다 ── "더 이상 보호하지 않겠다"라는 판단이 이유와 함께 기록되는 감사 가능한(auditable) 작업이 됩니다. 제외할 자유와 제외했다는 사실의 기록이 세트로 움직입니다.
덧붙이자면, fail-safe의 방향은 대장 자체에도 일관되게 적용됩니다. 대장 엔트리(entry)가 merge conflict로 깨지면, 해당 페어는 "ack 없음" 상태로 돌아가 재검사됩니다 ── merge가 green을 조작하는 것은 불가능합니다.
다른 도구와의 차이점
동일한 문제를 공격하는 도구들과의 대비입니다.
| staledocs | LLM 판정계(CoDD 등) | Swimm(상용) | doctest 계열(Sybil / byexample) |
|---|---|---|---|
| 검출 | 결정적(hash + 문자열 대조) | LLM 판정(재현성 없음) | 독자적 휴리스틱 |
| ... | ... | ... | ... |
"코드 참조가 끊긴 docs"가 보편적인 문제라는 점은 3,000개 이상의 GitHub 프로젝트를 조사한 연구(ICSE 2024, Tan/Wagner/Treude)가 입증했습니다. 검출 방식 자체는 연구에도 존재합니다 ── 하지만 대장, 등급 매기기, ack까지 하나의 도구로 제품화된 자리는 비어 있었습니다.
참고로 사상의 소스(source)로서, 생성 측면에서 동일한 문제를 공격하는 CoDD에는 경의를 표합니다. staledocs는 의도적으로 반대의 도박을 했습니다 ── 결정적으로 검출하고, 아무것도 생성하지 않는다.
"지킬 수 없는 것"을 명문화하기
지난번 guard-dispatcher와 같은 방식으로, README에는 Limitations를 명시해 두었습니다.
| 지킴 ◎ | 지키지 않음 ✗ |
|---|---|
| 지정된 대상의 변경·인용의 죽음·미구현 사양·소유권 없는 코드·config 약화·맹목적 도장 | 인용 식별자가 모두 실재하는 산문의 거짓말(원리적 한계 ── 증거가 있는 ack가 수용함) |
| ... | ... |
산문의 거짓말에 대해서도 결정적인 탈출구가 하나 있습니다 ── 주장을 실행 가능한 예시로 작성하는 것입니다. config의 examples:에 fence 태그와 test runner의 대응을 선언하면, 예시의 실행은 매 CI마다 사용자의 runner(pytest doctest / Sybil / byexample)가 수행하며, staledocs는 선언이 누락된 예시 블록과 runner 배선이 끊긴 것을 감시합니다. staledocs 자체는 마지막까지 아무것도 실행하지 않습니다.
흥미로운 점은 인센티브의 방향성으로, 정밀하게 인용할수록 정밀하게 보호되는 구조로 되어 있다는 것입니다. "docs를 쓰면 부채가 늘어난다"였던 역학이, "쓴 만큼 기계의 방어가 깊어진다"로 반전됩니다 ── 이 점이 이 도구에서 제가 가장 마음에 들어 하는 부분입니다.
도구가 자신을 단련시킨 이야기
dogfooding(자사 제품 사용)은 첫날부터 실화의 보물창고였습니다.
제 README에 적은 예시 path를, 제 앵커 검사(anchor inspection)가 4건 잡아내어 다시 쓰게 만들었습니다. release 작업 중에는 제 CI가 제 PR을 떨어뜨렸고, 조사해 보니 진짜 버그를 2건 ── shallow clone과 squash merge가 commit trailer의 ack를 맹목적으로 만들게 하는 문제 ── 공개 전에 발견하여 근절할 수 있었습니다. 검사 도구가 가장 먼저 검사하는 대상은 바로 제작자입니다.
현재는 이 기사의 초안을 포함하여 수중에 있는 모든 리포지토리(repo)에서 가동 중입니다. 공개(initial commit)부터 v1.2.0까지 3일 동안, 그 사이의 기능 추가도 전부 이 장부(ledger) 아래에서 작성했습니다.
셋업 (Setup)
대상 repo에 필요한 것은 git과 Markdown뿐입니다. Python은 실행 측에서만 필요하므로, 비 Python 프로젝트에는 pipx를 사용하는 것이 깔끔합니다.
pipx install staledocs # or: pip install staledocs
cd your-repo
staledocs init --suggest # config 생성 + docs 자체의 앵커로부터 pairs 제안을 출력
...
도입 과정에서 가장 번거로울 "페어 표(pair table) 작성"은, init --suggest가 docs 자체의 인용 앵커로부터 제안을 출력해 주기 때문에, 리뷰하고 붙여넣기만 하면 됩니다 (제안만 할 뿐, config에 직접 쓰지는 않습니다).
기존 repo에 적용할 때는 gate: warn (보고만 수행)로 시작하여, check가 조용해지면 strict (빨간색으로 non-zero exit)로 전환하여 pre-commit / CI에 배치합니다. 에이전트 통합은 check --json과 explain --json이 API로 제공되며, 자세한 내용은 리포의 docs에 있습니다.
요약 ── 에이전트의 속도에 docs가 살아남기 위해서
에이전트에게 구현을 맡기면, docs는 이전보다 몇 배나 빠른 속도로 부식됩니다. 그리고 부식된 docs는 에이전트 자신에게 잘못된 입력으로 되돌아옵니다. 이 순환을 멈추기 위해 필요한 것은 "더 주의 깊게"가 아니라, 깨진 순간 깨진 위치가 기계적으로 출력되는 것과, 수정 확인을 하지 않고서는 넘어갈 수 없는 것이었습니다.
이 시리즈에서 줄곧 "오래 살아남는 에이전트의 기반을 만드는" 이야기를 하고 있습니다. 스마트폰의 조종석이 살아가기 위한 도구, 유출 방지 가드가 믿고 잠들기 위한 도구라고 한다면, staledocs는 계속 맡겨두어도 지식이 부식되지 않기 위한 도구입니다.
질문 및 지적은 X (@synforger)의 DM으로 부탁드립니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기