GitHub ↗
CHAPTER 05 OF 10
🪝

Hook Engineering — Deterministic Automation

훅 엔지니어링 — 결정론적 자동화

훅은 하네스에서 유일하게 결정론적인 제어 메커니즘이다 — CLAUDE.md 규칙이 확률적인 것과 달리, exit 2 PreToolUse 훅은 시스템이 보장한다.

Hook Engineering — Deterministic Automation cheatsheet
🍌 NANO BANANA CHEATSHEET · CH 05

Overview

개관

훅은 하네스 엔지니어링의 '결정론적 레이어'다. CLAUDE.md 규칙은 모델이 따를 확률이 높은 지침이지만, 무시될 수 있다. 반면 훅은 시스템 수준에서 실행된다 — 모델의 의도와 무관하게.

LangChain의 Terminal Bench 30위→5위 이동은 정확히 3가지 훅 패턴으로 달성했다: LocalContextMiddleware(컨텍스트 주입), PreCompletionChecklistMiddleware(자기 검증 강제), LoopDetectionMiddleware(루프 감지). 모델 변경 없이 하네스만으로.

가장 중요한 단일 사실: exit 1은 Claude Code에서 무시된다. 훅이 블로킹하려면 반드시 exit 2를 써야 한다. 이것을 모르면 '안전 훅'이 아무 일도 하지 않는다. 현장에서 가장 자주 보는 실수다.

🎯 Learning Goals
  • PreToolUse와 PostToolUse의 차이와 각각의 주요 활용 사례를 설명할 수 있다
  • exit 0, 1, 2의 정확한 의미와 Claude Code에서의 동작을 설명할 수 있다
  • JSON 출력 스키마를 사용해서 훅에서 Claude 컨텍스트에 정보를 주입할 수 있다
  • 6가지 핵심 훅 패턴(민감 파일 보호, 자동 포맷, 감사 로그 등)을 구현할 수 있다

Sections

본문

30개 훅 이벤트 분류

Claude Code에는 30개의 훅 이벤트가 있다. 6개 범주로 분류된다:

세션 레벨: SessionStart, Setup, SessionEnd 턴 레벨: UserPromptSubmit, UserPromptExpansion, Stop, StopFailure 도구 레벨: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDenied 에이전트 레벨: SubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle 파일/설정: FileChanged, CwdChanged, WorktreeCreate, WorktreeRemove, ConfigChange, InstructionsLoaded 컨텍스트: PreCompact, PostCompact UX: Notification, MessageDisplay, Elicitation, ElicitationResult

블로킹 가능한 훅: UserPromptSubmit, PreToolUse, PermissionRequest, PostToolBatch, Stop, SubagentStop, TaskCreated, PreCompact, Elicitation

실용적으로 중요한 4개: PreToolUse(안전 게이트), PostToolUse(품질 루프), UserPromptSubmit(컨텍스트 주입), Stop(완료 검증)

Exit Code 의미론 — 가장 흔한 실수

Exit Code Claude Code에서 의미
0 성공 — stdout을 JSON으로 파싱해서 처리
2 블로킹 오류 — 행동 취소, stderr를 Claude에게 피드백
기타 논블로킹 오류 — 사용자에게 보여주고 계속 진행

가장 흔한 실수: Unix 관례를 따라 exit 1을 쓰면 Claude Code는 이를 무시하고 계속 진행한다. '안전 훅'이라고 작성했지만 실제로는 아무것도 차단하지 않는다.

올바른 차단 훅 패턴:

#!/bin/bash
INPUT=$(cat)
COMMAND=(echo"(echo "INPUT" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tool_input',{}).get('command',''))" 2>/dev/null)

if echo "$COMMAND" | grep -qE 'git push --force|rm -rf /|sudo'; then
  echo "위험한 명령어 차단: $COMMAND" >&2
  exit 2  # 반드시 2, 1이 아님!
fi

exit 0

stderr의 역할: exit 2와 함께 stderr에 쓴 내용이 Claude의 컨텍스트에 피드백으로 들어간다. 에러 메시지를 잘 작성하면 Claude가 스스로 수정한다.

JSON 출력 스키마 — 훅의 초능력

훅은 단순히 블로킹만 하는 것이 아니다. JSON 출력으로 Claude의 컨텍스트에 정보를 주입하거나, 도구 입력을 수정하거나, 권한 결정을 내릴 수 있다.

전체 JSON 출력 스키마:

{
  "continue": true,
  "stopReason": "빌드 실패",
  "suppressOutput": false,
  "systemMessage": "경고 메시지",
  "decision": "block",
  "reason": "이유 설명",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny|allow|ask|defer",
    "permissionDecisionReason": "설명",
    "additionalContext": "Claude 컨텍스트에 주입할 추가 정보",
    "updatedInput": { "수정된": "도구 입력" }
  }
}

additionalContext의 활용: PostToolUse 훅에서 린터 결과를 additionalContext로 반환하면, Claude가 그것을 보고 스스로 수정한다.

updatedInput의 활용: PreToolUse 훅에서 도구 입력을 수정할 수 있다. 예: git commit 명령에 자동으로 서명을 추가.

6가지 핵심 훅 패턴

패턴 1: 민감 파일 보호 (PreToolUse, 블로킹)

# .claude/hooks/block-sensitive-files.sh
FILE=$(cat | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tool_input',{}).get('file_path',''))" 2>/dev/null)
for pattern in '.env' '.ssh' 'credentials' 'secrets'; do
  case "FILE"in"FILE" in *"pattern"*) echo "민감 파일 보호: $FILE" >&2; exit 2;; esac
done

패턴 2: 린터 설정 보호 (PreToolUse)

FILE=$(cat | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tool_input',{}).get('file_path',''))" 2>/dev/null)
for p in '.eslintrc' 'biome.json' 'pyproject.toml' 'tsconfig.json'; do
  case "FILE"in"FILE" in *"p"*) echo "린터 설정 보호: 코드를 고치세요, 린터가 아니라." >&2; exit 2;; esac
done

패턴 3: 자동 포맷 (PostToolUse)

# .claude/hooks/auto-format.sh
FILE=$(cat | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)
case "$FILE" in
  *.ts|*.tsx) npx prettier --write "$FILE" 2>/dev/null;;
  *.py) ruff format "$FILE" 2>/dev/null;;
  *.go) gofmt -w "$FILE" 2>/dev/null;;
esac

패턴 4: JSONL 감사 로그 (PostToolUse, 모든 이벤트)

LOG_DIR="$HOME/.claude/audit"
mkdir -p "$LOG_DIR"
printf '{"ts":"%s","event":%s}\n' "(dateu+(date -u +%Y-%m-%dT%H:%M:%SZ)" "(cat)" >> "LOGDIR/LOG_DIR/(date -u +%Y-%m-%d).jsonl"

패턴 5: 컨텍스트 자동 주입 (UserPromptSubmit)

DATE=$(date +%Y-%m-%d)
BRANCH=$(git branch --show-current 2>/dev/null || echo 'not-in-git')
DIRTY=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
echo "{\"additionalSystemPrompt\": \"[date=DATEbranch=DATE branch=BRANCH dirty_files=$DIRTY]\"}"

패턴 6: 완료 전 테스트 검증 (Stop 훅)

if ! npm test --silent 2>/dev/null; then
  echo '테스트 실패: 완료 전 테스트를 통과하세요' >&2
  exit 2
fi
💡 Analogy · 비유
공항 보안 검색대

훅은 공항 보안 시스템과 같다. PreToolUse는 탑승 전 보안 검색 — 여기서 막히면 비행기를 타지 못한다. exit 2는 '탑승 불가' 스탬프다. 이것은 승객(에이전트)의 의지와 무관하다.

PostToolUse는 착륙 후 세관 검사 — 이미 일어난 일을 되돌릴 수는 없지만(편집은 이미 됐다), 결과를 처리하고 다음 단계로 넘길지 결정할 수 있다. 자동 포맷팅, 린팅, 로깅이 여기에 해당한다.

UserPromptSubmit은 체크인 카운터 — 비행기에 타기 전에 탑승자 정보(컨텍스트)를 자동으로 시스템에 등록한다. 매번 말하지 않아도 자동으로 처리된다.

exit 1은 '탑승 불가'를 외치는 직원 — 하지만 게이트에 아무 효과가 없다. exit 2는 실제로 게이트를 잠그는 시스템 명령이다.

훅 설정을 자동으로 생성하고 settings.json에 삽입하는 Python 스크립트. 자주 쓰는 훅 패턴들을 선택하면 자동으로 설정파일에 추가해준다.

python
#!/usr/bin/env python3
"""hook-installer.py — 권장 훅 패턴 자동 설치"""
import json
from pathlib import Path
import os

HOOKS_DIR = Path.home() / '.claude' / 'hooks'
SETTINGS_PATH = Path.home() / '.claude' / 'settings.json'

HOOK_SCRIPTS = {
    'block-sensitive': (
        'pre-bash-sensitive.sh',
        '''#!/usr/bin/env bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tool_input',{}).get('file_path',''))" 2>/dev/null)
for pattern in '.env' '.ssh' 'credentials' 'secrets' '.aws'; do
  case "$FILE" in *"$pattern"*) echo "민감 파일 보호: $FILE" >&2; exit 2;; esac
done
exit 0
'''
    ),
    'auto-format': (
        'post-format.sh',
        '''#!/usr/bin/env bash
FILE=$(cat | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)
[ -z "$FILE" ] && exit 0
case "$FILE" in
  *.ts|*.tsx|*.js|*.jsx) command -v npx && npx prettier --write "$FILE" 2>/dev/null;;
  *.py) command -v ruff && ruff format "$FILE" 2>/dev/null;;
  *.go) gofmt -w "$FILE" 2>/dev/null;;
esac
exit 0
'''
    ),
    'audit-log': (
        'post-audit.sh',
        '''#!/usr/bin/env bash
LOG_DIR="$HOME/.claude/audit"
mkdir -p "$LOG_DIR"
printf \'{"ts":"%s","event":%s}\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(cat)" >> "$LOG_DIR/$(date -u +%Y-%m-%d).jsonl"
exit 0
'''
    ),
    'inject-context': (
        'submit-context.sh',
        '''#!/usr/bin/env bash
DATE=$(date +%Y-%m-%d)
BRANCH=$(git branch --show-current 2>/dev/null || echo "not-in-git")
DIRTY=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
echo "{\\"additionalSystemPrompt\\": \\"[date=$DATE branch=$BRANCH dirty_files=$DIRTY]\\"}"
exit 0
'''
    )
}

HOOK_CONFIG = {
    'block-sensitive': ("PreToolUse", "Edit|Write"),
    'auto-format': ("PostToolUse", "Edit|Write"),
    'audit-log': ("PostToolUse", ".*"),
    'inject-context': ("UserPromptSubmit", ".*")
}

def install_hook(name: str):
    HOOKS_DIR.mkdir(parents=True, exist_ok=True)
    filename, content = HOOK_SCRIPTS[name]
    script_path = HOOKS_DIR / filename
    script_path.write_text(content)
    os.chmod(script_path, 0o755)
    print(f"✅ {script_path} 생성")
    
    settings = json.loads(SETTINGS_PATH.read_text()) if SETTINGS_PATH.exists() else {}
    hooks = settings.setdefault('hooks', {})
    event, matcher = HOOK_CONFIG[name]
    event_hooks = hooks.setdefault(event, [])
    
    new_hook = {"matcher": matcher, "hooks": [{"type": "command", "command": str(script_path)}]}
    event_hooks.append(new_hook)
    
    SETTINGS_PATH.write_text(json.dumps(settings, indent=2))
    print(f"✅ settings.json 업데이트")

if __name__ == '__main__':
    print("설치할 훅 패턴을 선택하세요:")
    for i, name in enumerate(HOOK_SCRIPTS, 1):
        event, _ = HOOK_CONFIG[name]
        print(f"  {i}. {name} ({event})")
    print("  a. 모두 설치")
    choice = input("선택: ").strip()
    if choice == 'a':
        for name in HOOK_SCRIPTS:
            install_hook(name)
    elif choice.isdigit():
        install_hook(list(HOOK_SCRIPTS.keys())[int(choice)-1])
    print("\n완료! Claude Code를 재시작하세요.")

훅 스크립트 파일을 ~/.claude/hooks/에 생성하고, settings.json에 자동으로 훅 설정을 추가한다. 'a'를 선택하면 4개의 핵심 훅(민감 파일 보호, 자동 포맷, 감사 로그, 컨텍스트 주입)이 모두 설치된다.

🏭 현업에서의 평가
훅은 하네스 완성도를 가장 극적으로 보여주는 지표다. 한 번도 훅을 설정해본 적 없는 팀과, PostToolUse에서 자동 포맷팅이 돌고 PreToolUse에서 민감 파일이 보호되는 팀 — 에이전트 신뢰성이 완전히 다르다.

✅ 시니어가 보는 것

  • PreToolUse 블로킹 훅이 있고 exit 2를 올바르게 쓰는가
  • PostToolUse 자동 포맷팅이 설정되어 있는가
  • 훅이 git에 커밋되어 팀 전체에 적용되는가
  • 훅 스크립트가 idempotent한가

⚠️ 레드 플래그

  • exit 1을 쓰는 '블로킹' 훅 (전혀 블로킹 안 됨)
  • 훅 스크립트에 하드코딩된 절대 경로
  • 무거운 PostToolUse 훅 (에이전트 워크플로우 차단)
  • 훅 없이 CLAUDE.md만으로 '안전하다'고 판단

🎤 예상 인터뷰 질문

  1. PreToolUse 훅에서 exit 2를 반환했을 때 Claude Code의 정확한 동작은? stderr는 어디로 가는가?
  2. PostToolUse 훅에서 additionalContext를 JSON으로 반환하면 어떻게 활용되는가?
  3. LangChain이 Terminal Bench에서 사용한 3가지 훅 패턴은 무엇이고 각각 어떤 문제를 해결했는가?
숙달 vs 익숙함: 단순히 아는 사람은 exit 2를 쓴다. 마스터한 사람은 stderr 메시지를 자기 수정 프롬프트로 설계하고, JSON additionalContext로 린터 결과를 주입하고, updatedInput으로 도구 입력을 수정하고, PreCompact 훅으로 중요 정보를 압축에서 보호한다.

Key Takeaways

핵심 정리

exit 2만 블로킹

Claude Code에서 exit 1은 무시된다. 반드시 exit 2를 써야 행동이 취소된다.

30개 이벤트

실용적으로 중요한 4개: PreToolUse(안전 게이트), PostToolUse(품질 루프), UserPromptSubmit(컨텍스트 주입), Stop(완료 검증).

stderr = Claude 피드백

exit 2와 함께 stderr에 쓴 내용이 Claude에게 피드백으로 전달된다. 에러 메시지를 잘 써야 Claude가 스스로 수정한다.

additionalContext로 린터 주입

PostToolUse에서 JSON의 additionalContext 필드에 린터 결과를 넣으면 Claude가 자동으로 수정한다.

updatedInput으로 입력 수정

PreToolUse에서 updatedInput을 반환하면 도구의 입력 파라미터를 수정할 수 있다.

LangChain 3가지 패턴

컨텍스트 주입, 자기 검증 강제, 루프 감지 — 이 3가지로 순위 30→5위. 모델 변경 없이.

피드백 속도 계층

PostToolUse 훅(ms) > pre-commit(초) > CI(분) > 코드 리뷰(시간). 빠를수록 수정 루프가 짧다.

훅은 git에 커밋

.claude/hooks/를 git에 커밋하면 팀 전체에 자동 배포된다. 개인 설정은 ~/.claude/hooks/에.