
AI의 "했습니다"를 믿지 마라 ― Claude Code에 완료 증거를 요구하는 품질 게이트 설계
요약
Claude Code 사용 시 LLM의 허위 완료 보고 문제를 해결하기 위한 4단계 품질 게이트 설계 방법을 소개합니다. 규약 설정부터 hooks를 이용한 기계적 강제까지, 검증 가능한 완료 증거를 확보하는 실무적인 전략을 다룹니다.
핵심 포인트
- LLM의 자기 신고(Self-reporting)는 실제 완료의 증거가 될 수 없음
- CLAUDE.md에 규칙의 '이유'와 '기계적 증거(exit code)'를 명시할 것
- hooks 메커니즘을 활용해 LLM의 의존성을 배제한 강제 검증 체계 구축
- 검증 불가능한 작업은 '미검증' 상태로 관리하여 백로그로 전환
"수정했습니다!"의 몇 퍼센트는 수정되지 않았다
Claude Code를 매일 사용하다 보면 반드시 이 상황을 마주하게 됩니다.
"테스트도 통과했고, 수정이 완료되었습니다!"
……라고 활기차게 보고했는데, 실제로 빌드하면 통과하지 못합니다. 테스트는 애초에 실행되지 않았습니다. LLM은 "완료했다고 말하는 것"과 "완료되어 있는 것"을 반드시 구분해주지는 않습니다. 그럴듯한 완료 보고는 완료의 증거가 되지 않습니다.
저는 Windows 상에서 Claude Code를 업무 기반으로 상용하고 있으며 (개발뿐만 아니라 주간 리뷰나 태스크 관리까지 맡기고 있습니다), 이 "자기 신고 완료" 문제에 최근 몇 달간 시스템으로 대응해 왔습니다. 이 기사에서는 그 대책을 4개의 계층으로 나누어 소개합니다.
규약 계층: CLAUDE.md에 "완료의 정의"를 작성한다 -
강제 계층: hooks를 통해 기계적으로 체크한다 -
퇴피 계층: 검증할 수 없는 것은 "미검증"으로 백로그에 떨어뜨린다 -
평가 계층: 만든 본인에게 리뷰를 시키지 않는다
모두 특별한 도구는 필요 없으며, 계층 1만이라면 3줄의 추가만으로 오늘부터 시작할 수 있습니다.
계층 1: CLAUDE.md에 "완료의 정의"를 작성한다
먼저 규약입니다. 저의 글로벌 CLAUDE.md (~/.claude/CLAUDE.md)에는 서두에 다음과 같이 적혀 있습니다.
## 완료의 정의 (이유: LLM의 자기 신고는 완료의 증거가 되지 않음)
- 코드 변경 후에는 build / test / 실행 중 하나에서 exit code를 확인한 후 완료 보고할 것
- 검증할 수 없는 경우에는 "미검증"이라고 명시하여 보고할 것
포인트는 두 가지가 있습니다.
"이유"를 병기한다. LLM은 규칙의 의도를 이해하고 있으면, 예외적인 상황에서도 의도에 부합하는 판단을 내리기 쉬워집니다. "지켜라"라고만 적힌 규칙은 문맥이 길어지면 형해화되기 쉽습니다.
완료의 증거를 "exit code"로 고정한다. "동작을 확인한다"와 같은 모호한 표현을 사용하면, "코드를 읽고 문제가 없음을 확인했습니다"라는 육안 확인으로 완료 보고를 해버립니다. exit code라는 기계적인 증거로 한정함으로써 허점을 차단합니다.
하지만 솔직히 말해서 규약만으로는 지켜지지 않는 경우가 있습니다. 컨텍스트가 길어졌을 때, 대화가 요약되었을 때, 규칙의 우선순위가 낮아지는 것을 여러 번 관찰했습니다. 그래서 계층 2입니다.
계층 2: hooks로 기계적으로 강제한다
Claude Code에는 hooks라는 메커니즘이 있어, 툴 실행 전후나 세션 종료 시에 임의의 스크립트를 삽입할 수 있습니다. 중요한 점은 hooks가 LLM에 대한 "부탁"이 아니라 하네스(harness) 측의 체크라는 점입니다. 프롬프트 지시와 달리 컨텍스트가 길어져도 열화되지 않습니다.
사용하는 이벤트는 두 가지입니다.
| 이벤트 | 역할 |
|---|---|
PostToolUse | Edit / Write가 실행되면 "미검증 플래그"를 세운다. Bash에서 테스트 계열 명령어가 실행되면 플래그를 지운다 |
Stop | 응답을 마치려 하는 시점에 플래그가 남아있다면, 정지를 차단하고 검증을 요구한다 |
~/.claude/settings.json 설정은 다음과 같습니다.
{
"hooks": {
"PostToolUse": [
...
참고로 Windows에서는 hook 커맨드 내의 ~ 전개가 작동하지 않는 환경이 있으므로, 실운용 시에는 절대 경로 지정이 안전합니다.
플래그를 조작하는 두 가지 보조 스크립트는 이것뿐입니다.
# mark-dirty.ps1: 코드 계열 파일의 편집으로 "미검증 플래그"를 세운다
$p = [Console]::In.ReadToEnd() | ConvertFrom-Json
if ($p.tool_input.file_path -notmatch '\.(md|txt|json)$') {
...
# mark-verified.ps1: 테스트/빌드 계열 명령어 실행으로 플래그를 지운다
$p = [Console]::In.ReadToEnd() | ConvertFrom-Json
if ($p.tool_input.command -match 'test|build|pytest|vitest') {
...
mark-dirty 측에서 .md
문서 계열의 확장자를 제외하고 있는 것은 복선으로, 초판에서 이곳을 제한하지 않고 운용했을 때는 문서 편집만을 위한 세션에서 게이트가 발화하는 오탐(False Positive) 문제로 골머리를 앓았습니다(후술).
게이트 본체의 간략 버전입니다(실운용 버전에서 요점만 뽑아낸 개념 데모입니다).
# verification-gate.ps1 (간략 버전)
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
# 중요: Stop hook 블록에서 재실행된 경우에는 통과시켜 무한 루프를 방지함
...
decision: "block"을 반환하면 Claude는 정지할 수 없으며, reason의 내용이 지시 사항으로 전달됩니다. 즉, 정지하기 전에 반드시 한 번, "검증할 것인지 아니면 미검증임을 명시할 것인지"를 기계가 요구하게 되는 것입니다. 정확히 말하면 이것은 하드한 강제 사항은 아닙니다. 다음 항목의 무한 루프 방지책에 따라 독촉은 정지 시도 1회당 한 번만 이루어지며, 2회차에는 그대로 통과합니다. 그럼에도 불구하고 "검증 누락이 누구에게도 지적되지 않은 채 완료되는" 상황만큼은 일어나지 않게 됩니다.
구현 시 주의사항 세 가지.
- 무한 루프 방지책은 필수입니다. Stop hook이 블록하면 Claude는 작업을 계속하고, 다시 정지하려고 시도하면서 hook이 재발화합니다. 페이로드(payload)의
stop_hook_active를 확인하여 2회차에는 통과시키지 않으면, 영원히 끝나지 않는 세션이 발생합니다(한 번 실수한 적이 있습니다). - Fail-open 방식으로 설계하십시오. 게이트 스크립트 자체가 예외(Exception)를 발생시켰을 때는
exit 0(허용) 쪽으로 처리합니다. 품질 게이트의 버그 때문에 모든 세션이 정지되는 것은 본말전도이므로, "고장 나면 통과시킨다. 단, 로그는 남긴다"가 정답이었습니다. - 플래그 삭제 판정은 느슨하게 시작하십시오. 간략 버전에서는 테스트 계열 명령(
test|build|pytest등)의 실행을 검증 완료로 간주합니다. 엄격하게 exit code까지 확인하고 싶다면, Stop hook 측에서transcript_path의 세션 로그(JSONL)를 읽어, 마지막 Edit/Write 이후에 성공한 Bash 실행이 있는지를 판정하는 방식이 견고합니다. 저는 운용하면서 단계적으로 엄격하게 만들었습니다.
계층 3: "미검증"을 백로그(Backlog)로 관리하기
현실에서는 그 자리에서 즉시 검증할 수 없는 경우가 있습니다. 실제 API 키가 필요하거나, 외부 서비스의 응답을 기다려야 하거나, 인간의 실기 확인이 필요한 경우 등입니다.
이때 최악의 상황은 "검증하지 못했다는 사실"이 완료 보고 속에 녹아 사라지는 것입니다. 저는 미검증 전용 백로그 파일을 하나 두고 있습니다.
# pending-verification.md (미검증 백로그)
| # | 항목 | 미검증 사유 | 기표일 | 상태 |
|---|---|---|---|---|
...
운용 규칙은 단 하나입니다. "미검증"이라고 보고에 적은 항목은 반드시 이 파일에 추가한다. 해결되면 날짜와 함께 완료로 옮긴다. 이렇게 하면 "미검증"이 면죄부가 아니라 추적 가능한 태스크가 됩니다. 계층 2의 게이트도 실운용 버전에서는 검증 대신 최종 응답에 "미검증"을 명기하는 것을 허용하는 판정을 넣어두었기에(앞서 언급한 간략 버전에서는 생략함), "검증할 것인가, 미검증으로 기록할 것인가"의 이지선다로 떨어지는 설계입니다.
계층 4: 만든 본인에게 리뷰를 맡기지 마라
exit code로 담보할 수 있는 것은 코드뿐입니다. 문서, 설계, 문구와 같은 성과물은 어떻게 해야 할까요?
해서는 안 될 행동은 같은 대화 안에서 "방금 만든 것을 리뷰해줘"라고 부탁하는 것입니다. 직전에 자신이 만든 성과물에 대한 자기 리뷰는 체감상 거의 확실하게 느슨해집니다. 생성 당시의 문맥(이렇게 만드는 것이 옳다는 전제)을 그대로 유지한 채 리뷰하기 때문에, 전제 자체가 틀린 케이스를 검출할 수 없습니다.
저는 CLAUDE.md에 다음과 같이 적어두고 있습니다.
- 생성물의 품질 판정은 만든 본인과 별도의 컨텍스트(reviewer)에서 수행한다.
(이유: 동일 컨텍스트에서의 자기 리뷰는 영합(迎合)으로 인해 느슨해짐)
구체적으로는, 읽기 전용 도구만 가진 reviewer 서브 에이전트를 별도의 컨텍스트에서 기동하여 "약점 발견·반증·수락 기준 체크"만 수행하게 합니다. 수정 권한을 주지 않는 이유는, 리뷰어가 스스로 고치기 시작하면 비판이 관대해지기 때문입니다. 외부로 내보낼 문구는 이 reviewer의 합격 기록을 파일 상단에 주석으로 남기는 방식으로 운용하고 있습니다 (예: <!-- reviewer: 2026-07-07 조건부 합격 -->).
효과는 어떠했는가
솔직히 말씀드리면, 엄격한 A/B 테스트를 수행하지는 않았습니다. 그럼에도 관찰할 수 있었던 변화는 다음과 같습니다:
"미검증"이라는 단어가 보고서에 일상적으로 등장하게 되었습니다. 도입 전에는 "아마 작동할 것"인 상태가 완료된 것으로 처리되어 흘러갔습니다. 지금은 미검증 백로그(Backlog)에 월 십수 건이 생성되며, 그 대부분은 추후 검증을 통해 회수되고 있습니다. -
Stop hook 블록은 한 달에 몇 번씩 발화(Fire)합니다. 발화가 의미하는 것은 "검증되지 않은 채 완료될 뻔한 변경 사항을 포착했다"는 것까지이며, 그 모든 것이 실제로 망가져 있었던 것은 아닙니다 (아래의 오탐지 포함). 그럼에도 불구하고, 검증 누락이 누구에게도 제지받지 않고 그대로 통과되는 경로가 차단되었다는 것을 확인하는 계기가 되었으며, 빈도가 낮다는 사실 자체가 레이어 1(Layer 1)의 규약이 평소에 잘 작동하고 있다는 증거라고 해석하고 있습니다. -
부작용도 있었습니다. 초판에서는 문서 편집만 하는 세션에서도 게이트(Gate)가 발화하는 오탐지가 있어, 대상 도구 및 대상 확장자를 좁히는 조정이 필요했습니다 (앞서 언급한 mark-dirty의 확장자 필터는 그 결과물입니다). 게이트는 "엄격하게 만들어서 완화하기"보다 "느슨하게 만들어서 조이기"가 운영하기 더 편합니다.
요약: 오늘부터 시작하는 첫걸음
4개 레이어를 정리하면:
| 레이어 | 수단 | 비용 |
|---|---|---|
| 규약 | CLAUDE.md에 완료 정의(3줄) | 5분 |
| ... |
전부 다 할 필요는 없습니다. 우선 레이어 1의 3줄을 당신의 CLAUDE.md에 추가해 보세요. 그것만으로도 완료 보고의 풍경이 바뀔 것입니다. "미검증"이라는 단어가 Claude의 입에서 나오기 시작하면, 이 글을 떠올리며 레이어 2 이후를 추가해 나가는──그 정도의 온도감이 딱 적당하다고 생각합니다.
LLM과의 협업에서 중요한 것은 지능을 신뢰하는 것이 아니라, 검증을 시스템(Mechanism)으로 만드는 것이었습니다. 같은 문제로 소모되고 계신 분들에게 참고가 된다면 기쁘겠습니다.
Discussion

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