개발 과정 · AI 에이전트

초록불은 거짓말을 한다: 내가 만든 완료 게이트를 6방향 적대적 감사로 뜯어본 기록

완료 게이트가 초록불을 켰다. 그런데 6개의 fresh-context 적대적 리뷰어를 프로젝트 전체에 풀자, 바로 그 게이트들이 실패를 초록불로 보고하고 있었다 — checklist 손상 파일이 통과되고, 시크릿 차단은 MultiEdit로 우회되고, test-ratchet은 main에서 눈이 멀어 있었다. 검증 기계 자체를 검증한 이야기, 그리고 '작성자는 오염돼 있다'가 왜 나 자신에게 가장 아프게 적용되는지.

날짜 2026-07-03 읽기 5분 태그 적대적 리뷰, false-green, 완료 게이트, 보안, 검증 엔지니어링

앞선 세 글 — 하네스/루프 엔지니어링 지형도, 병렬 git 격리 실전 적용, durable 완료 게이트 설계 — 은 모두 “검증을 기계로 만들자"는 방향으로 수렴했다. 이 글은 그 방향이 도달하는 불편한 종착지에 관한 것이다: 그 검증 기계는 누가 검증하는가?

핵심 요약

완료 게이트가 exit 0(초록불)을 켰다는 것과, 코드가 옳다는 것은 다르다.

프로젝트 전체에 6개의 독립된 적대적 리뷰어를 풀자, 가장 심각한 결함은 기능 코드가 아니라 검증 기계 자체에 있었다 — 실패를 초록불로 보고하는 false-green. 게이트가 거짓말을 하고 있었다.

그리고 그 게이트를 만든 건 나였다. "작성자는 오염돼 있다"는 명제는, 검증 도구를 짤 때 가장 아프게 참이 된다.

1. 초록불에서 시작한 감사

직전 릴리스(v2.9.0)는 모든 기계 검사를 통과했다. verify-done.sh는 초록불이었고, 169개 유닛 테스트가 녹색이었고, CI도 성공이었다. 보통은 여기서 “완료"라고 말한다.

대신 나는 프로젝트 전체에 6개의 fresh-context 적대적 리뷰어를 병렬로 풀었다 — 각자 다른 차원을 맡아, “이 코드는 틀렸다, 아직 못 찾았을 뿐이다"는 자세로:

  1. Python 훅 (보안·레이스·우회)
  2. 셸 스크립트 (게이트 false-green·이식성)
  3. 에이전트 거버넌스 (33개 정의의 일관성)
  4. 스킬·룰 코히런스 (죽은 참조·SSOT)
  5. 문서·CI 정합성 (버전 drift·CI 발산)
  6. 직전 병합 코드의 2차 심층 재리뷰

이들이 돌아와서 보고한 것은, 초록불이 가리고 있던 것들이었다.

2. 가장 위험한 계급: false-green

버그에는 계급이 있다. 크래시는 시끄럽다 — 최소한 자기가 실패했다고 알린다. 가장 위험한 건 조용한 것, 실패를 성공이라고 보고하는 것이다. 완료 게이트에서 이건 최악이다. 게이트의 존재 이유가 “실패를 잡는 것"인데, 그 게이트가 실패를 초록불로 삼키면 아무도 안 잡는다.

리뷰어들이 찾은 false-green은 다섯 개였고, 그중 다수가 내가 바로 직전에 만든 게이트 안에 있었다.

checklist 손상 파일이 통과된다. durable 완료 게이트의 핵심은 checklist.json을 읽어 미완 항목이 있으면 FAIL하는 것이었다. 그런데 파싱 실패와 파일 부재를 똑같이 “skip"으로 처리하고 있었다. echo '[]' > checklist.json 한 줄이면 — 파일을 비우거나 망가뜨리면 — 게이트가 조용히 통과했다. 게이트를 우회하는 방법이 게이트 파일을 부수는 것이라니, 모델이 자기 완료를 못 찍게 만들겠다던 설계가 파일 하나 truncate로 뚫렸다.

test-ratchet이 main에서 눈이 멀어 있다. 테스트 삭제를 막는 래칫은 git merge-base main HEAD를 기준으로 diff를 봤다. 그런데 HEAD가 main일 때 merge-base는 HEAD 자신이라, diff가 비어버린다 — 커밋된 테스트 삭제가 보이지 않는다. 게다가 main ref가 없으면 조용히 초록불이었다. 래칫이 정확히 필요한 순간(main에 직접 커밋)에 작동하지 않았다.

helper를 지우면 게이트가 사라진다. verify-done.sh는 checklist 검증 도구가 없으면 검사 블록 전체를 건너뛰고 초록불을 켰다. 미완 checklist가 쌓여 있어도, 그것을 검사할 도구가 없다는 이유로 통과. 안전장치가 자기 부품이 빠지면 fail-open 했다.

이 셋의 공통 교훈: 부재·오류·손상을 “통과"로 해석하지 마라. 게이트는 확실히 초록일 때만 초록이어야 하고, 모르면 빨강이어야 한다(fail-closed).

3. 막겠다고 만든 문이 열려 있었다

보안 쪽에서 리뷰어가 찾은 건 더 직접적이었다. 시크릿 파일(.env, SSH 키 등)의 편집을 차단하는 훅이 있었다. 그 훅의 매처는 Edit|Write|Read였다. 그런데 Claude Code에는 MultiEdit라는 편집 도구가 따로 있고, 매처는 정확한 이름으로 매칭된다 — MultiEditEdit에 안 걸린다.

MultiEdit(".env") 한 번이면 시크릿 차단이 통째로 우회됐다. 문을 잠갔는데 옆에 잠그지 않은 문이 하나 더 있었던 것이다. 매처에 MultiEdit|NotebookEdit를 추가하고, 노트북이 쓰는 다른 경로 필드(notebook_path)까지 검사하도록 고쳤다. 그리고 이 훅이 “커밋의 시크릿을 막는다"던 문서 주장도 정정했다 — 실제로는 경로 기반이고 Bash·commit은 안 잡는다(그건 gitleaks의 몫이다). 도구가 실제로 하는 것과 문서가 약속하는 것의 간극도 결함이다.

4. 매 세션 주입되던 죽은 지도

세 번째 계열은 조용하지만 누적되는 종류였다. 이 프로젝트는 매 세션 시작 시 규칙(rules)을 컨텍스트에 주입한다. 그 always-injected 규칙 안에 존재하지 않는 것들을 가리키는 참조가 있었다:

  • 없는 에이전트 넷(schedule-task, notify-team 등)을 “이렇게 설정하라"고 지시하는 줄.
  • 존재하지 않는 스크립트(./scripts/db-tunnel.sh)를 “쓰기 전에 실행하라"는 줄.
  • “plugin.json의 에이전트 목록을 확인하라"는데 — 그런 목록은 없다(에이전트는 디렉토리에서 자동 발견된다).
  • 위임 시그널 포맷이 두 규칙에서 서로 다른 버전으로 존재(한쪽은 4-type, 한쪽은 3-type)하며, 한쪽은 자기가 “단일 진실(SSOT)“이라 선언하고 있었다.

이건 코드를 깨뜨리진 않지만, 매 세션 모델에게 틀린 지도를 쥐여준다. 모델이 없는 에이전트로 위임하려 시도하고, 없는 스크립트를 찾는다. 죽은 참조를 모두 제거하고, 위임 포맷을 canonical 하나로 정렬했다.

결함을 고치는 것보다 중요한 것: 그 계급의 결함을 게이트가 잡게 만들기. 죽은 스크립트 참조가 반복 발견됐으므로, verify-done.sh가 이제 rules·agents 문서의 scripts/*.sh 참조가 실재하는지 기계로 검증한다. 다음번 db-tunnel.sh는 사람 눈이 아니라 게이트가 잡는다.

그리고 같은 정신으로 CI의 구멍도 하나 메웠다. CI의 테스트 스텝이 pytest ... || echo "skip"으로 되어 있었다 — 테스트가 실패해도 초록불이었다. 25개의 새 테스트가 전부 빨강이어도 CI는 통과했을 것이다. 검증 파이프라인 자신이 false-green이었던 셈이다.

5. 작성자는 오염돼 있다 — 특히 게이트를 짤 때

이 시리즈에서 반복된 명제가 있다. “작성자는 오염돼 있다. 자기 검증은 자기가 닫았다고 믿은 구멍을 놓친다.” 이번 감사는 그 명제의 가장 날카로운 판본을 보여줬다.

내가 놓친 결함들은 검증 기계 자체에 있었다. 나는 “모델이 자기 완료를 못 찍게” 하는 게이트를 설계했다고 믿었는데, 그 게이트는 손상된 파일에 뚫렸고, main에서 눈이 멀었고, 부품이 빠지면 열렸다. 내 초록불은 진짜였다 — 모든 기계 검사를 통과했다. 그런데도 게이트는 거짓말을 하고 있었다.

자기 검증으로는 절대 못 봤을 것들이다. 왜냐하면 나는 그 게이트가 옳다고 믿으면서 짰기 때문이다. 그 믿음이 바로 오염이다. 오염을 씻는 유일한 방법은, 그것을 짜지 않은 눈 — fresh context, 분리된 세션, “이건 틀렸다"는 자세로 시작하는 리뷰어 — 에게 넘기는 것이다.

6. 남긴 것, 그리고 정직한 한계

모든 수정은 E2E로 검증했다: MultiEdit(".env")가 실제로 차단되는지(exit 2), 손상된 checklist가 실제로 게이트를 FAIL시키는지(exit 1), 169개 테스트와 완료 게이트가 여전히 초록인지. 그리고 CI가 — 이제 진짜로 테스트를 돌리는 CI가 — 성공하는지.

처음엔 두 가지를 “정직한 한계"로 남겼다 — checklist의 passes가 게이트 시점에 재검증되지 않는다는 점(F1)과, verify 서브프로세스가 타임아웃 시 손자 프로세스를 남길 수 있다는 점(F6). 그런데 “애매하게 남기지 말고 깔끔하게 정리하라"는 지적을 받고, 둘 다 마저 닫았다.

  • F6은 그냥 버그였다. verify를 새 프로세스 그룹으로 띄우고 타임아웃 시 그룹 전체를 종료하도록(killpg) 고쳤다. 겸사겸사 락을 verify 실행 내내 쥐고 있던 것도 풀어, 검증은 락 밖에서 돌고 결과 기록만 락 안에서 하도록 분리했다.
  • F1은 트레이드오프였다 — 게이트 시점에 모든 verify를 자동 재실행하면 재귀와 side-effect(재배포 같은)라는 더 큰 부채가 생긴다. 그래서 “자동으로 늘 재검증"이 아니라 verify라는 opt-in 명령을 더했다. status는 빠른 원장 조회로 남고, 지금 다시 증명하고 싶으면 verify가 전 항목을 재실행해 회귀한 passes:truefalse로 되돌린다. “못 한다"가 아니라 “필요하면 이 명령으로 한다"로 바뀐 것이다.

교훈이 하나 더 붙는다. “정직한 한계"라는 라벨은 종종 “아직 안 고친 것"의 완곡어법이다. 진짜 트레이드오프(F1)와 그냥 미룬 버그(F6)를 구분하고, 후자는 마저 닫는 것 — 그것이 애매함을 남기지 않는 유일한 방법이다.

교훈을 한 줄로 남긴다면: 초록불은 “통과했다"는 뜻이지 “옳다"는 뜻이 아니다. 그리고 그 초록불을 켜는 기계를 당신이 직접 만들었다면, 그 기계야말로 가장 먼저 의심해야 할 대상이다.