
Claude Code를 성선설에 기반해 운용하는 것을 그만두다 ── exit 2로 멈추는 기계적 게이트, 5가지 구현 패턴
요약
Claude Code 운용 시 지시서(CLAUDE.md)의 한계를 극복하기 위해 훅(Hook)을 활용한 기계적 제어 패턴을 제안합니다. 모델의 자율성에 의존하는 대신 exit 2를 이용해 물리적으로 동작을 차단하는 5가지 구현 패턴을 소개합니다.
핵심 포인트
- 지시서 기반의 규칙 준수는 컨텍스트 압축 시 한계가 있음
- Claude Code의 Hook 기능을 통해 툴 실행 전후에 물리적 제어 가능
- exit 2를 사용하여 조건 미충족 시 모델의 특정 동작을 강제 차단
- 저장 트리거 게이트 및 예산 게이트 등 구체적인 구현 패턴 제시
서론
Claude Code에 일상적인 운용을 맡길수록 CLAUDE.md가 비대해진다. "멋대로 저장하지 말 것", "운영 환경(Production)은 건드리지 말 것", "이 형식을 지킬 것". 적으면 적을수록 잘 지켜질까 생각했지만, 결과는 반대였다. 규칙이 늘어날수록 개별 규칙의 준수율은 떨어지고, 세션이 길어질수록, 컨텍스트 (Context)가 압축될수록, 모델이 바뀔수록 규칙을 어기게 된다.
이전 기사 이후, 여러 모델로 운용을 돌려본 3주간의 결과로 도달한 결론은 단순하다. 지시서는 모델에게 있어 "지켜질 확률을 높이는 장치"일 뿐이다. 깨지면 곤란한 것을 읽게 하고 기도하는 것이 아니라, 훅 (Hook)을 통해 물리적으로 막아야 한다. 이 기사에서는 그 구현을 일반화된 코드로 5가지 패턴을 소개한다.
전제: Claude Code의 훅을 30초 만에 이해하기
Claude Code는 툴 (Tool) 실행 전후 등에 임의의 스크립트를 삽입할 수 있다. 설정은 settings.json에 작성한다.
{
"hooks": {
"UserPromptSubmit": [
...
요점은 세 가지다. PreToolUse에 등록한 스크립트는 툴 실행 직전에 호출되며, stdin으로 JSON (tool_name, tool_input, session_id, transcript_path 등)이 흘러 들어온다. exit 2로 종료하면 해당 툴 실행은 차단되며, stderr에 작성한 문구가 모델에게 피드백된다. UserPromptSubmit은 사용자의 발언이 있을 때마다 호출되므로, 상태 기록이나 문맥 (Context) 주입에 사용할 수 있다.
즉, "편집 직전에 개입하여, 조건을 만족하지 않으면 멈추고, 그 이유를 모델에게 납득시킨다"를 기계적으로 수행할 수 있다. 아래는 모두 이것을 응용한 사례다.
패턴 1: 저장 트리거 게이트 ── 지시된 직후의 턴만 작성 가능
처음 만든 것은 세션 간의 기억 파일 (메모리)를 보호하는 게이트였다. 모델은 내버려 두면 대화 도중에 무엇이든 기록하려 한다. "멋대로 저장하지 말 것"이라고 적어도 긴 세션에서는 지켜지지 않으므로, "인간이 저장을 지시한 직후의 턴 이외에는 메모리 하위로의 쓰기가 물리적으로 통과할 수 없다"로 설정했다.
메커니즘은 2단계로, 먼저 UserPromptSubmit 측 (arm)이 최근 사용자 발언을 세션별로 저장한다.
#!/bin/bash
# save-gate.sh
mode="$1"; input=$(cat)
...
PreToolUse 측 (check)은 쓰기 대상이 메모리 하위라면 최근 발언을 확인하여, 저장 지시어가 없으면 exit 2로 멈춘다.
# save_gate_check.py
import json, os, re, sys
d = json.load(sys.stdin)
...
효과는 극적이었으며, 도입한 날부터 "어느샌가 메모리에 이상한 학습 내용이 쌓여 있다"는 현상이 사라졌다. 모델은 차단당하면 저장 후보를 본문에서 제안하는 방식으로 바뀌었으며, 채택 여부는 우리가 결정할 수 있게 되었다.
패턴 2: 예산 게이트 ── 초과 중에는 "순증"만 차단
메모리에는 인덱스 파일이 있으며, 이는 세션 시작 시 매번 읽힌다. 즉, 비대해지는 것은 컨텍스트의 상시적인 압박이 된다. "150행·25KB 이내로 유지할 것"이라고 규칙 문서에 적었지만, 이 역시 지켜지지 않았다. 솔직히 말하자면 상위 모델조차 지키지 않았다.
그래서 예산을 코드로 만들었다. 흥미로운 점은 판정의 단순함인데, 확인하는 것은 "편집 후에 인덱스의 행수가 편집 전보다 늘어나는가"뿐이다.
# 패턴 1의 check에 추가하는 형태. INDEX.md 에 대한 Edit/Write를 대상으로 함
BUDGET_LINES, BUDGET_BYTES = 150, 25_600
idx = os.path.expanduser("~/.claude/memory/INDEX.md")
...
초과 중이라도 기존 행에 대한 통합·단축·삭제는 허용된다. 즉, "넣고 싶다면 먼저 버려라"라는 one-in-one-out 방식만을 강제하여, 정리 행위 자체를 방해하지 않는다. 의지에 의존하던 시절에는 몇 번을 정리해도 몇 주 만에 원래대로 돌아갔지만, 이를 도입한 이후로는 돌아가지 않는다.
패턴 3: 동결 및 불변 조항 ── 보호 대상을 매니페스트로 선언
다음은 설계 자산의 보호다. 훅 본체, 규칙의 원본, 생성 스크립트와 같이 "망가지면 사고가 연쇄적으로 발생하는 파일"을 TSV 매니페스트로 선언하여 3단계로 보호한다.
A hooks/rule-router.sh 동기화 누락이 사고의 근원이므로 동결
B docs/style-core.md 서두의 불변 조항 블록만 보호
C logs/decisions.md 추가만 가능 · 기존 내용의 수정은 불가
레벨 A는 동결 상태로, 신규 생성 이외의 쓰기 작업을 상시 차단한다. 레벨 C는 추가를 우선하며, Edit의 old_string이 new_string에 통째로 남아 있다면 「지우지 않고 덧붙인 것」으로 간주하여 통과시킨다. 그 사이에 끼어 있는 레벨 B가 기술적 핵심인데, 파일 내에 불변 조항 마커를 삽입하여 해당 구간을 건드리는 편집만 차단하는 방식이다.
<!-- INVARIANT-START 이 블록의 변경은 인간의 명시적 해제만 가능 -->
> 불변: 집필 4단계의 구조와 순서 / 단락 2~3문장 원칙
> 변경 가능: 예문 교체 / 그 외 모든 섹션
...
처음에는 「중요한 파일은 전부 동결」하는 방식으로 시작했으나, 이는 운영 측면에서 불가능하다. 일상적인 가필과 수정까지 매번 차단되므로, 해제 작업이 일상적인 동선에 포함되어 버린다. 우리가 지키고 싶은 것은 파일 자체가 아니라 설계 판단이므로, 조항을 문장으로서 고정하고 그 외의 가필은 자유롭게 두는 B 방식이 실제로는 가장 활용도가 높다.
패턴 4: Bash 우회 대책 ── 「쓰기 대상」만을 확인한다
지금까지의 게이트는 Edit/Write 도구를 감시했지만, 모델은 Bash도 사용할 수 있다. sed -i나 리다이렉션(redirection)을 통해 작성하면 감시를 피해 갈 수 있으므로, Bash 명령어도 검사해야 한다.
초기 구현은 「보호 대상 경로 문자열과 쓰기 계열 동사가 동일한 명령에 나타나면 차단」하는 투박한 판정 방식이었다. 이는 오탐(false positive)의 산을 만들어냈다. 예를 들어 python3 lint.py draft.md라는 단순한 실행을 「lint.py에 대한 쓰기」로 오판하여, 매일 아침 수행하는 린트(lint) 작업을 전부 차단했다. 가드는 너무 과하게 막으면, 정당한 작업 때마다 해제를 요구하게 만드는 '양치기 소년'이 된다.
다시 만든 판정 로직은 다음과 같다. 명령어를 토큰(token)으로 분해하고, 「실제로 쓰기 대상이 되는 토큰」만을 수집하여, 그곳에 보호 경로가 포함되어 있을 때만 차단한다.
import os, re, shlex
def bash_write_targets(cmd):
"""명령어에서 "쓰기 대상" 토큰만을 수집한다.
...
이렇게 하면 grep def guard.sh도, md5sum hooks/*.sh > /tmp/list.txt도, python3 lint.py도 통과하며, sed -i 's/a/b/' guard.sh나 echo x > guard.sh만 차단된다. 완벽한 구문 분석(parsing)은 아니지만, 「읽기 · 실행 · 다른 곳으로 출력」을 오판하지 않는 정밀도에 도달한 시점에서, 해제 요청 빈도는 일상 운영이 가능한 수준까지 떨어졌다.
패턴 5: 해제어와 우회 금지 ── 차단 문구에 「다음 행동」까지 적는다
차단한 이후의 설계도 구현의 일부다. 해제는 인간이 생(raw) 프롬프트로 해제어(우리 팀에서는 「가드 해제」로 통일했다)를 말한 직후의 턴에만 허용한다. 판정은 패턴 1에서 저장한 lastprompt를 읽기만 하면 된다. 여기서 한 가지 함정이 있는데, 확인 다이얼로그의 선택지나 버튼으로 응답한 내용은 프롬프트로 남지 않을 수 있다. 감시 대상은 오직 생 발언으로만 한정해야 사고가 없다.
또 하나, 차단된 모델은 높은 확률로 우회를 시도한다. 악의가 아니라 선의로, 「이 파일을 편집할 수 없다면 다른 파일에 같은 내용을 써서 태스크를 완료할 수 있다」고 생각하기 때문이다. 따라서 stderr의 차단 문구에는 이유뿐만 아니라 다음 행동까지 적어준다.
이 파일은 동결 대상입니다. 차단을 이유로 다른 파일을
대신 수정하여 회피하지 마십시오. 시도하려 했던 수정 내용과
이유를 본문에 그대로 제시하고, 인간의 "가드 해제"를 기다리십시오.
이 두 줄을 넣기 전과 후로 모델의 거동은 눈에 띄게 달라졌다. 훅(hook)의 stderr는 단순한 에러 메시지가 아니라, 차단된 순간의 모델에게 읽히는 지시서라고 생각하면 설계하기 쉽다.
운영하며 깨달은 점
가드는 「너무 과하게 막는 것」에서 시작하여 정밀화해 나가는 것이었다. 처음부터 완벽한 판정 로직을 짜려고 하기보다, 우선 안전한 쪽으로 치우쳐 운영하면서 오탐이 발생할 때마다 「읽기 · 실행 · 다른 파일로 출력」을 통과시키는 방향으로만 완화한다. 반대 방향(보호 대상을 줄이는 것)의 완화는 사고가 발생하지 않는 한 수행하지 않는다.
그리고 게이트(Gate)를 도입한 후에도, 규칙 문서(Rule document)를 삭제하지 않고 남겨두고 있다. 지시서(Instruction)는 문화이고, 게이트는 법률과 같은 것이다. 문화는 일상의 품질을 높이고, 법률은 최악의 상황을 방지한다. 모델이 똑똑해질수록 문화의 준수율은 올라가지만, 똑똑한 모델일수록 선의의 판단이라는 명목하에 덮어쓰기(Overwrite)를 시도하기 때문에, 오히려 법률의 층위는 더욱 놓을 수 없게 된다. 운용의 전말은 별도의 기사에 작성했으므로, 경위까지 알고 싶은 분은 그쪽을 참고해 주시기 바란다.
마지막으로 한마디 하자면 "나는 엔지니어가 아니다"... 그런데 은퇴하고 나서 더 자세히 알게 되어가는 기분이 든다. 그런 의미에서 AI는 대단하며, "현역 엔지니어"에게는 존경심밖에 없다.
Discussion

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