세션의 시작과 끝을 다뤘으니 이제 가장 자주 발화하는 곳, 도구 호출 하나하나의 앞뒤로 들어갑니다. PreToolUse는 "하기 전에 검사", PostToolUse는 "하고 나서 뒷정리"입니다. 우리 교육에서 "도구가 아니라 시스템"이라고 말할 때의 시스템이 바로 이 두 훅으로 만들어지는 검증 게이트입니다. 이 글에서는 두 이벤트의 입력과 역할을 나누고, 파일 편집 뒤 Prettier를 자동으로 돌리는 훅을 걸어 실제로 시험해 봅니다. 다음 글에서는 PreToolUse의 종료 코드로 위험 명령을 차단하는 방법으로 이어집니다.
핵심 요약
PreToolUse는 Claude가 도구 인자를 만든 직후, 실행 직전에 발화합니다. 차단·허용·질문·입력 수정이 가능합니다.PostToolUse는 도구가 성공한 직후 발화합니다. 되돌릴 수는 없고, 결과에 대한 피드백과 후처리를 맡습니다. 실패한 호출은PostToolUseFailure로 갑니다.- matcher에는 도구 이름을 적습니다.
Edit|Write처럼|로 여러 개를 묶고, MCP 도구는mcp__서버__도구이름으로 잡습니다. - 편집된 파일 경로는 stdin JSON의
tool_input.file_path에 들어옵니다. - 서브에이전트가 부른 도구에도 똑같이 발화하며, 입력에
agent_id·agent_type이 추가됩니다.
PreToolUse 훅은 무엇을 하나
Claude가 "이 도구를 이 인자로 쓰겠다"고 정한 순간, 실제 실행보다 먼저 호출됩니다. 입력에는 공통 필드와 함께 tool_name, tool_input, tool_use_id가 들어옵니다. tool_input의 모양은 도구마다 다릅니다. Bash면 command, description, timeout, run_in_background가, Write면 file_path와 content가, Edit면 file_path, old_string, new_string이 들어옵니다.
할 수 있는 일은 네 가지입니다. 종료 코드 2로 차단하기, JSON으로 permissionDecision을 allow·deny·ask 중 하나로 돌려주기(비대화형 -p 모드에서는 defer도 가능), updatedInput으로 인자를 고쳐서 실행시키기, additionalContext로 Claude에게 힌트 주기. 두 가지를 분명히 해 둡니다. 첫째, 종료 코드 0은 "이의 없음"이지 승인이 아닙니다. 평소의 권한 흐름은 그대로 적용됩니다. 둘째, PreToolUse는 권한 모드 검사보다 먼저 돌기 때문에 훅의 deny는 bypassPermissions 모드에서도 통합니다. 반대로 훅의 allow는 설정의 거부 규칙을 뚫지 못합니다. 훅은 조이는 쪽으로만 힘이 셉니다.
한 가지 함정이 있습니다. 프롬프트에서 @파일로 참조한 내용은 도구 호출 없이 삽입되므로 Read에 건 PreToolUse가 발화하지 않습니다. 특정 경로를 아예 못 보게 하려면 훅이 아니라 Read 거부 권한 규칙을 씁니다.
PostToolUse 훅은 무엇을 하나
도구가 성공적으로 끝난 직후 발화합니다. 입력에는 tool_input에 더해 도구가 돌려준 tool_response, 실행 시간 duration_ms가 있습니다. 이미 일어난 일이라 취소는 못 하지만 세 가지를 할 수 있습니다. decision: "block"과 reason으로 Claude에게 "이 결과는 문제 있다"고 알려 다음 행동을 바꾸게 하기, additionalContext로 결과 옆에 정보를 붙이기, updatedToolOutput으로 Claude가 보는 결과를 바꾸기(파일이나 명령은 이미 실행됐고 Claude의 눈에 보이는 것만 바뀝니다).
전형적인 쓰임은 포맷터·린터 실행, 편집 파일 로그, 테스트 트리거입니다. 도구가 실패한 경우는 PostToolUse가 아니라 PostToolUseFailure가 발화하며 입력에 error가 들어옵니다. "빌드가 깨졌을 때 알림"은 이쪽에 걸어야 합니다.
matcher 로 도구 지정하기
두 이벤트 모두 matcher는 tool_name과 비교됩니다. 기본 도구 이름은 Bash, Edit, Write, Read, Glob, Grep, Agent, WebFetch, WebSearch, AskUserQuestion, ExitPlanMode 등이고, MCP 도구는 mcp__github__search_repositories처럼 mcp__<서버>__<도구> 형식입니다.
- 정확히 하나:
"matcher": "Bash" - 여러 개:
"matcher": "Edit|Write"(v2.1.191 이상은"Edit, Write"도 동일) - 정규식:
"matcher": "mcp__github__.*"(.*같은 특수문자가 있으면 정규식으로 평가) - 전부: 비우거나
"*"
명령 내용까지 거르고 싶을 때는 matcher가 아니라 핸들러의 if 필드를 씁니다. "if": "Bash(git *)"처럼 권한 규칙 문법으로 적으면 git으로 시작하는 명령에서만 훅이 돕니다. matcher는 대소문자를 구별하므로 bash라고 적으면 아무것도 잡히지 않습니다. 자세한 패턴은 코스 뒤쪽 「Hooks 매처」 글에서 다룹니다.
실전: 파일 편집 후 Prettier 자동 포맷
Claude가 파일을 고칠 때마다 Prettier를 돌리면 "포맷 좀 맞춰 줘"를 다시 말할 일이 없어집니다. 스크립트로 떼어냅니다.
#!/bin/bash
# .claude/hooks/format.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
cd "$CLAUDE_PROJECT_DIR" && npx prettier --write "$FILE" >/dev/null 2>&1
;;
esac
exit 0
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh",
"timeout": 30
}
]
}
]
}
}
확장자로 거르는 이유는 Prettier가 모르는 파일(.sh, .py)에서 오류를 내며 훅 에러 알림을 띄우기 때문입니다. 한 줄짜리 jq -r '.tool_input.file_path' | xargs npx prettier --write도 되지만 예외 처리는 스크립트가 편합니다. Windows에서는 npx가 .cmd 래퍼라 이 예처럼 셸 형식(args 없이)으로 두어야 실행됩니다.
실제로 시험해 보기
chmod +x .claude/hooks/format.sh후/hooks에서PostToolUse아래[Project]항목을 확인합니다.- 일부러 들여쓰기가 엉망인
test.ts를 만들어 두고 Claude에게 "test.ts에 함수 하나 추가해 줘"라고 요청합니다. - 편집이 끝나면 파일을 열어 봅니다. 들여쓰기가 정리되어 있으면 훅이 돈 것입니다.
- 안 됐다면
echo '{"tool_input":{"file_path":"'$PWD'/test.ts"}}' | .claude/hooks/format.sh; echo $?로 스크립트를 단독 실행해 종료 코드를 봅니다.
훅이 실행됐는지 어떻게 확인하나
세 층위가 있습니다. 가장 가벼운 것은 Ctrl+O로 트랜스크립트 보기를 켜는 것입니다. 훅마다 한 줄 요약이 보이고 실패는 PostToolUse hook error와 stderr 첫 줄로 표시됩니다. 두 번째는 스크립트에서 stderr로 흔적을 남기는 것입니다(컨텍스트에 안 들어가고 디버그 로그로만 갑니다). 세 번째는 claude --debug-file /tmp/claude.log로 띄운 뒤 tail -f로 보는 것입니다. 매칭된 훅 수, 명령, 타임아웃, 종료 코드, stdout이 전부 찍힙니다. 우리 교육의 원칙은 "정지 규칙은 증거"입니다. 훅을 걸었다는 말이 아니라 포맷이 바뀐 파일, 늘어난 로그 줄, 디버그 로그의 status 0 줄이 완료의 증거입니다.
모든 도구에 훅을 걸 수 있나
기본 도구, MCP 도구, 그리고 서브에이전트를 띄우는 Agent 도구까지 전부 PreToolUse·PostToolUse의 대상입니다. 서브에이전트가 내부에서 부른 도구 호출에도 훅이 발화하며, 이때 입력에 agent_id와 agent_type이 추가되어 메인 스레드 호출과 구별할 수 있습니다. 다만 앞서 말한 @파일 참조처럼 도구 호출 없이 일어나는 일에는 걸리지 않습니다. 또 Claude가 Bash로 sed나 리다이렉트를 써서 파일을 바꾸면 Edit|Write matcher는 잡지 못합니다. 모든 파일 변경을 감시해야 한다면 Bash도 matcher에 넣고 스크립트에서 git status --porcelain으로 바뀐 파일을 나열하거나, 턴마다 한 번 도는 Stop 훅에서 작업 트리를 훑는 방법이 공식 권장입니다.
정리
PreToolUse는 실행 전 검사와 차단,PostToolUse는 실행 후 피드백과 후처리를 맡습니다.- matcher는 도구 이름(
Edit|Write,mcp__서버__.*), 명령 내용은if필드로 한 번 더 거릅니다. - 편집 파일 경로는
tool_input.file_path, 결과는tool_response에서 읽습니다. - Prettier 훅은 확장자 검사와
exit 0을 넣어 에러 알림 없이 조용히 돌게 합니다. - 실행 증거는
Ctrl+O요약, stderr 흔적,--debug-file로그에서 확인합니다.
자주 묻는 질문
PreToolUse 와 PostToolUse 는 어떻게 가려 쓰나요?
막아야 하거나 인자를 고쳐야 하면 PreToolUse, 실행 결과를 보고 뒷정리하거나 피드백해야 하면 PostToolUse입니다. PostToolUse는 도구가 이미 끝난 뒤라 취소가 불가능합니다. 실패한 호출은 PostToolUseFailure에서 따로 받습니다.
matcher 에는 무엇을 적나요?
도구 이름입니다. Bash, Edit, Write, Read, Agent 같은 기본 도구명이나 mcp__서버__도구 형식의 MCP 도구명을 적고, |로 여러 개를 묶습니다. .* 같은 특수문자가 들어가면 정규식으로 평가됩니다. 비우거나 *면 모든 도구입니다. 명령 내용(git *)은 matcher가 아니라 핸들러의 if 필드에 씁니다.
훅 스크립트는 편집된 파일 경로를 어떻게 알 수 있나요?
stdin으로 들어오는 JSON의 tool_input.file_path를 읽습니다. INPUT=$(cat) 후 echo "$INPUT" | jq -r '.tool_input.file_path // empty'가 정석입니다. Write와 Edit 모두 이 필드를 갖고 있고, PostToolUse라면 tool_response에서 성공 여부도 확인할 수 있습니다.
서브에이전트가 실행한 도구에도 훅이 발화하나요?
네. 서브에이전트 안에서 일어난 도구 호출에도 같은 PreToolUse·PostToolUse가 발화합니다. 입력 JSON에 agent_id(서브에이전트 식별자)와 agent_type(에이전트 이름)이 추가되므로, 메인 스레드와 다르게 처리하려면 스크립트에서 이 값으로 분기합니다.
settings.json 에 스크립트 경로를 절대 경로로 적어도 되나요?
됩니다. 다만 /Users/이름/... 같은 절대 경로는 다른 사람의 컴퓨터나 Windows에서 깨집니다. 프로젝트 훅은 "$CLAUDE_PROJECT_DIR"/.claude/hooks/..., 전역 훅은 "$HOME"/.claude/hooks/...처럼 변수로 적는 것이 공식 권장이며, "args": []를 붙이면 셸 없이 실행되어 따옴표 걱정도 사라집니다.