들어가며
28차시에서 Hooks 전체 구조를 훑었다 — lifecycle 카당스 다섯 그룹, settings.json 4단 우선순위, JSON 3단 중첩, matcher 규칙, exit code 의미, JSON 출력의 정교한 제어까지. 이번 29차시부터는 이벤트를 하나씩 골라 깊이 본다. 첫 타자는 가장 자주 쓰는 PostToolUse다.
PostToolUse는 한 줄로 말하면 “도구가 성공한 직후에 자동으로 실행되는 후처리” 이벤트다. Edit/Write 직후 Prettier를 돌리고, 코드 파일 저장 직후 ESLint --fix를 거는 것이 표준 사용법이다. 더 나아가면 변경된 파일과 관련된 테스트만 자동 실행하고, 실패하면 Claude에게 “고치고 다시 시도해” 신호를 돌려보내는 검증 게이트로도 쓸 수 있다.
이번 차시에서 다룰 것은 다섯이다.
- 언제 발화하는가 — 성공 직후라는 조건의 무게, 실패 케이스(
PostToolUseFailure)와의 분리 - 입력 JSON —
tool_name,tool_input,tool_output,tool_use_id를 어떻게 꺼내 쓰는가 - 응답 두 갈래 —
decision: "block"로 다음 턴을 막을 것인가,additionalContext로 Claude에게 사실만 주입할 것인가 - 매처를 좁히는 세 단계 — 도구 이름,
if필드, 파일/MCP 패턴을 어떻게 조합하는가 - 실전 다섯 패턴 — 포맷팅 / 린트와 차단 / 관련 테스트 / 컨텍스트 주입 / 감사 로깅
발화 조건 — “성공한 직후”의 무게
PostToolUse는 성공한 도구 호출 직후에만 발화한다. 실패한 호출은 PostToolUseFailure라는 별도 이벤트로 빠진다 — 입력 JSON에 tool_error 필드가 추가되어 들어온다.
이 구분이 중요한 이유는 PostToolUse가 도구 실행을 막을 수 없다는 데 있다. 이미 끝난 일에 대한 후처리이기 때문이다. 도구 실행을 막고 싶다면 30차시에서 다룰 PreToolUse로 가야 한다.
대신 PostToolUse는 다음에 일어날 일은 제어할 수 있다. Claude의 다음 모델 호출을 막거나, 도구 결과 위에 추가 컨텍스트를 올려서 Claude의 판단을 한 번 더 흔들 수 있다. “이미 일어난 일은 받아들이고, 그 다음 흐름을 손본다”가 PostToolUse의 자리다.
“Edit 직후 포맷팅”을 CLAUDE.md에 적으면 모델이 잊을 수 있다. PostToolUse hook은 Edit가 성공할 때마다 시스템 차원에서 보장된다. 가이드라인이 아니라 불변 후처리 규약이다.
입력 JSON — 무엇이 들어오는가
PostToolUse가 stdin으로 받는 JSON의 핵심 필드는 다음과 같다.
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/Users/me/my-project",
"permission_mode": "default",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/Users/me/my-project/src/api.ts",
"content": "..."
},
"tool_output": {
"status": "success",
"message": "File written successfully"
},
"tool_use_id": "tool_use_12345"
}
| 필드 | 설명 |
|---|---|
tool_name | 발화시킨 도구 이름 ("Write", "Edit", "Bash" 등) |
tool_input | 도구의 입력 인수 — 도구마다 스키마가 다르다 (Edit는 file_path, Bash는 command) |
tool_output | 도구의 실행 결과 |
tool_use_id | 이번 호출의 고유 ID — 로깅·추적용 |
cwd | 호출 시점의 작업 디렉토리 |
permission_mode | 현재 권한 모드 |
스크립트 안에서는 jq로 필요한 필드를 빼서 쓴다.
input=$(cat)
file=$(jq -r '.tool_input.file_path' <<< "$input")
tool=$(jq -r '.tool_name' <<< "$input")
$CLAUDE_FILE_PATH같은 셸 환경 변수에 의존하던 과거 패턴이 있는데, 지금은 stdin JSON에서jq -r '.tool_input.file_path'로 꺼내는 게 권장 방식이다. 도구마다 입력 스키마가 다르기 때문에 환경 변수로 자동 매핑이 보장되지 않는다 — JSON에서 직접 꺼내라.
응답 — 두 갈래의 출력 패턴
PostToolUse의 응답은 exit code와 stdout JSON 두 축으로 정해진다. PreToolUse와 가장 큰 차이는 exit 2가 비블로킹이라는 점이다.
Exit code의 의미 (PostToolUse 한정)
| Exit code | 효과 |
|---|---|
| 0 | 정상. stdout이 JSON이면 파싱되어 동작, 평문이면 컨텍스트로 Claude에게 전달 |
| 2 | 비블로킹 에러. stderr가 Claude에게 피드백으로 표시되지만 도구 실행을 막지는 못함 (이미 끝남) |
| 그 외 | 비블로킹 에러. stderr는 디버그 로그에만 남음 |
흔한 함정 — PreToolUse에서는 exit 2가 블로킹이지만, PostToolUse에서는 블로킹 효과가 없다. 다음 모델 호출까지 막고 싶다면 exit 0 +
{"decision": "block"}JSON을 써야 한다.
Stdout JSON — 두 패턴
PostToolUse는 두 가지 응답 패턴을 제공한다. 둘은 같이 써도 된다.
패턴 A. decision: "block" — 다음 턴 차단
{
"decision": "block",
"reason": "ESLint 검사가 실패했다. 오류를 고친 뒤 다시 시도하라."
}
→ Claude가 다음 모델 호출을 중단하고 reason을 사용자/모델에게 전달한다. 도구 자체의 결과는 그대로 보이지만, 후속 작업이 강제로 끊긴다. 검증을 통과 못 한 편집 위에 더 작업을 쌓지 못하게 막을 때 쓴다.
패턴 B. hookSpecificOutput.additionalContext — 사실 주입
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "src/api.ts: Prettier 포맷 적용 완료, ESLint 통과, TypeScript 타입 OK."
}
}
→ Claude의 다음 컨텍스트에 추가 정보를 끼워 넣는다. 차단은 아니다. “이거 알고 넘어가” 신호용이다.
두 패턴을 같이
{
"decision": "block",
"reason": "테스트 5건 실패",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "src/auth.test.ts:45에서 expected 'admin', got 'user'. mock setup을 확인하라."
}
}
additionalContext는 명령형이 아니라 사실로 적는 게 안전하다. “X를 해라”가 아니라 “X는 이런 상태다”로 적어야 prompt injection 방어가 통과시킨다. 28차시에서 본 함정이 여기에도 그대로 적용된다.
응답 매트릭스
| 의도 | exit | stdout |
|---|---|---|
| 조용히 끝 | 0 | (없음) |
| Claude에게 정보 전달 | 0 | additionalContext JSON |
| 디버그 로그에만 에러 남기기 | 1 (또는 2) | stderr 메시지 |
| 다음 턴 차단 + Claude에게 이유 전달 | 0 | {"decision":"block","reason":"..."} |
매처를 어떻게 좁힐 것인가
PostToolUse 매처는 세 단계로 좁혀 들어간다.
1단계 — matcher로 도구 이름
{ "matcher": "Edit|Write" }
도구 이름은 알파벳/숫자/_/|만 쓰면 정확 매치 또는 파이프 리스트다. MCP 도구처럼 점이나 별표가 섞이면 정규식으로 평가된다.
| 패턴 | 의미 |
|---|---|
"Edit" | Edit 도구만 |
"Edit|Write" | Edit 또는 Write |
"Bash" | Bash만 |
"mcp__memory__.*" | memory MCP 서버의 모든 도구 (정규식) |
"*" 또는 생략 | 모든 도구 |
2단계 — if 필드로 인수 패턴
매처는 도구 이름만 거른다. 그 안에서 어떤 인수 호출만 잡으려면 if 필드를 쓴다. 권한 규칙과 같은 문법이다.
{
"type": "command",
"if": "Edit(*.ts)",
"command": "..."
}
| 패턴 | 의미 |
|---|---|
"Edit(*.ts)" | TypeScript 파일을 편집할 때 |
"Edit(src/**/*.tsx)" | src 하위 TSX 파일 |
"Write(*.md)" | 마크다운 파일 작성 |
"Bash(npm test)" | npm test 명령 |
3단계 — 핸들러 안에서 직접 분기
위 두 단계로 다 못 거르면, 핸들러 스크립트 안에서 jq와 셸 조건으로 직접 처리한다.
file=$(jq -r '.tool_input.file_path' <<< "$input")
case "$file" in
*.ts|*.tsx) npx eslint --fix "$file" ;;
*.py) black "$file" ;;
*.go) gofmt -w "$file" ;;
esac
매처를 너무 잘게 쪼개 hook 그룹이 20개씩 늘어나는 것보다, 큰 매처 하나에 핸들러 스크립트로 분기하는 게 보통 더 깔끔하다. 단, 조건이 정적이고 잘 안 변하면
if필드 쪽이 가독성이 좋다.
패턴 1 — 자동 포맷팅
가장 흔한 PostToolUse 용도다. Edit/Write가 성공하면 곧장 포맷터를 돌린다.
한 줄 명령 (간단)
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$(jq -r '.tool_input.file_path')\"",
"timeout": 30
}
]
}
]
}
}
→ prettier --write는 변경된 내용을 덮어쓰기로 정리한다. stdin에서 JSON을 받아 jq로 파일 경로를 꺼낸 뒤 prettier에 넘긴다.
여러 핸들러 묶기 (포맷 + 린트 fix)
같은 매처 그룹의 hooks 배열에 핸들러를 늘려놓으면 순서대로 모두 발화한다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"if": "Edit|Write(*.ts|*.tsx)",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$(jq -r '.tool_input.file_path')\"",
"timeout": 30
},
{
"type": "command",
"command": "eslint --fix \"$(jq -r '.tool_input.file_path')\"",
"timeout": 60
}
]
}
]
}
}
Prettier가 먼저 형식을 정리한 뒤 ESLint
--fix가 자동 수정 가능한 규칙 위반을 수정한다. 순서를 바꾸면 ESLint가 한 일을 Prettier가 되돌리는 경우가 있다.
언어별 분기 (스크립트로)
언어가 섞인 레포라면 스크립트 한 개에서 확장자로 분기하는 게 깔끔하다.
.claude/hooks/format.sh:
#!/bin/bash
set -e
input=$(cat)
file=$(jq -r '.tool_input.file_path' <<< "$input")
# 파일이 없거나 빈 경로면 조용히 종료
[ -z "$file" ] || [ ! -f "$file" ] && exit 0
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.md|*.yml|*.yaml)
npx --no-install prettier --write "$file" 2>/dev/null || true
;;
*.py)
command -v black >/dev/null && black --quiet "$file" || true
;;
*.go)
gofmt -w "$file"
;;
*.rs)
rustfmt "$file" 2>/dev/null || true
;;
esac
exit 0
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
"timeout": 60
}
]
}
]
}
}
chmod +x .claude/hooks/format.sh도 잊지 말 것.
패턴 2 — 자동 린트와 실패 시 차단
포맷팅은 항상 성공하지만, 린트는 자동 수정 불가능한 위반이 남을 수 있다. 이때 decision: "block"을 돌려주면 Claude는 위반을 인지하고 그 다음 턴에서 고치는 흐름으로 들어간다.
.claude/hooks/lint-and-block.sh:
#!/bin/bash
input=$(cat)
file=$(jq -r '.tool_input.file_path' <<< "$input")
[ -z "$file" ] || [ ! -f "$file" ] && exit 0
# TS/JS만 처리
case "$file" in
*.ts|*.tsx|*.js|*.jsx) ;;
*) exit 0 ;;
esac
# --fix 자동 적용 후, 남은 위반이 있으면 차단
if output=$(npx --no-install eslint --fix "$file" 2>&1); then
jq -n --arg f "$file" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ($f + ": ESLint 자동 수정 적용 완료, 위반 없음")
}
}'
exit 0
else
jq -n --arg f "$file" --arg out "$output" '{
decision: "block",
reason: "ESLint 검사 실패. 출력의 오류를 확인하고 수정한 뒤 다시 편집하라.",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ($f + " 린트 출력:\n" + $out)
}
}'
exit 0
fi
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-and-block.sh",
"timeout": 90
}
]
}
]
}
}
핵심 두 가지.
- 차단은
exit 0+ JSON — exit 2가 아니다. PostToolUse에서 exit 2는 비블로킹이다. reason은 Claude에게 가는 지시,additionalContext는 근거. 지시문은 reason 쪽에, 사실은 additionalContext 쪽에 분리해 두면 prompt injection 방어 통과율이 높다.
패턴 3 — 변경된 파일의 관련 테스트만 자동 실행
전체 테스트 스위트를 매번 돌리면 무겁다. Jest의 --findRelatedTests나 Vitest의 --changed 같은 옵션으로 변경 파일과 연결된 테스트만 돌리는 게 PostToolUse와 잘 맞는다.
.claude/hooks/related-tests.sh:
#!/bin/bash
input=$(cat)
file=$(jq -r '.tool_input.file_path' <<< "$input")
# 소스 파일에만 작동 (테스트 파일 편집은 제외)
case "$file" in
*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx) exit 0 ;;
*.ts|*.tsx|*.js|*.jsx) ;;
*) exit 0 ;;
esac
# Jest: --findRelatedTests로 관련 테스트만
if output=$(npx --no-install jest --findRelatedTests "$file" --silent 2>&1); then
jq -n --arg f "$file" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ("관련 테스트 통과: " + $f)
}
}'
exit 0
else
jq -n --arg f "$file" --arg out "$output" '{
decision: "block",
reason: "변경된 파일과 관련된 테스트가 실패했다. 출력을 확인하고 수정하라.",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ($f + " 테스트 출력:\n" + $out)
}
}'
exit 0
fi
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"if": "Edit|Write(src/**)",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/related-tests.sh",
"timeout": 120
}
]
}
]
}
}
Python이라면
pytest --testmon같은 도구가 비슷한 역할을 한다. Go는go test ./...에 패키지만 좁혀 넣으면 된다. 핵심은 변경된 파일과 관련된 것만 빠르게 돌린다는 점이다.
패턴 4 — additionalContext로 사실만 주입
차단 없이 정보만 더해주는 가벼운 hook도 유용하다. Claude가 다음 결정을 더 잘 내릴 수 있게 사실 한 줄을 끼워 넣는 식이다.
.claude/hooks/inject-status.sh:
#!/bin/bash
input=$(cat)
file=$(jq -r '.tool_input.file_path' <<< "$input")
# 기본 정보
branch=$(git -C "$(dirname "$file")" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "(no git)")
diff_stat=$(git -C "$(dirname "$file")" diff --stat -- "$file" 2>/dev/null | tail -1)
jq -n --arg f "$file" --arg b "$branch" --arg d "$diff_stat" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ($f + " 편집됨. 현재 브랜치: " + $b + ". 변경 통계: " + $d)
}
}'
exit 0
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-status.sh"
}
]
}
]
}
}
명령형(
"Run tests now") 대신 사실형("Tests have not been run yet")으로 적는 것을 권장한다. 명령형 컨텍스트는 prompt injection 패턴으로 인식되어 차단될 수 있고, Claude가 따르더라도 부자연스러운 흐름이 된다.
패턴 5 — 감사 로깅 (외부 시스템)
규정 준수나 추적이 필요한 환경에서는 모든 편집을 외부 로그로 보내는 hook이 유용하다.
.claude/hooks/audit-log.sh:
#!/bin/bash
input=$(cat)
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
tool=$(jq -r '.tool_name' <<< "$input")
file=$(jq -r '.tool_input.file_path // empty' <<< "$input")
session=$(jq -r '.session_id' <<< "$input")
jq -n \
--arg ts "$ts" \
--arg tool "$tool" \
--arg file "$file" \
--arg session "$session" \
'{timestamp: $ts, session: $session, tool: $tool, file: $file}' \
>> "$HOME/.claude/audit/edits.jsonl"
exit 0
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/audit-log.sh"
}
]
}
]
}
}
→ JSONL 한 줄당 한 이벤트로 누적된다. 나중에 jq로 집계하거나 Splunk/Datadog 같은 로그 시스템에 흘려 넣기 좋다.
원격 HTTP로 직접 보내려면 type: "http" 핸들러를 쓰는 방법도 있다.
{
"type": "http",
"url": "https://audit.internal/claude-events",
"headers": { "Authorization": "Bearer $AUDIT_TOKEN" },
"allowedEnvVars": ["AUDIT_TOKEN"],
"timeout": 10
}
28차시에서 본 것처럼, managed settings의
allowedHttpHookUrls로 외부 URL을 화이트리스트 관리하면 조직 차원의 안전장치가 된다.
흔한 함정
- 차단은 exit 0 + JSON
{"decision":"block"}— exit 2가 PreToolUse에서는 블로킹이지만 PostToolUse에서는 비블로킹이다. 도구는 이미 끝났기 때문에 차단할 게 없고, exit 2는 Claude에게 stderr 피드백만 전달한다. $CLAUDE_FILE_PATH대신jq— 환경 변수로 자동 매핑되던 옛 방식 대신 stdin JSON에서jq -r '.tool_input.file_path'로 빼는 게 권장 방식. 도구마다 인수 스키마가 다르고, 자동 매핑이 보장되지 않는다.- 공백 있는 경로 —
prettier --write $file처럼 쿼팅 없이 쓰면 공백에서 깨진다.prettier --write "$file"또는 exec form(args배열)을 쓴다. - timeout 기본은 600초 — 포맷터에는 너무 길다.
"timeout": 30같은 값으로 짧게 잡아 잘못된 hook이 세션을 멈추지 않도록 한다. - 출력은 10KB 캡 — 평문 stdout과
additionalContext는 10,000자에서 잘리고 파일로 빠진다. 큰 린트 출력은 요약하거나 파일 경로만 컨텍스트에 넣어주는 식으로 줄인다. - resume/continue 시 재실행 없음 —
--resume또는--continue로 세션을 이어 받으면 과거 turn의 PostToolUse hook은 다시 돌지 않는다. 저장된 출력이 그대로 재생된다. - 중복 핸들러는 1회만 — 동일 명령이 여러 매처 그룹에서 가리키더라도 이벤트 발화당 1회만 실행된다. 같은 명령을 두 번 돌리려면 다른 명령 문자열을 써야 한다.
PostToolUseFailure는 별도 이벤트 — 도구가 실패하면 PostToolUse는 발화하지 않는다. 실패 후처리를 원하면PostToolUseFailure에 별도 hook을 걸어야 한다.additionalContext는 사실로 — “X를 해라”가 아니라 “X는 이런 상태다”로 적어야 prompt injection 방어가 통과시킨다./hooks로 등록 상태 확인 — 적용 안 되는 것 같으면 일단/hooks메뉴를 열어 어떤 스코프에서 무엇이 걸려 있는지 본다. 디버깅의 1순위 도구.
정리
핵심 요점
- PostToolUse = 도구가 성공한 직후 자동 발화 — 실패는
PostToolUseFailure로 분리 - 도구 실행은 못 막는다 — 이미 끝난 일에 대한 후처리 이벤트
- 입력은 stdin JSON —
tool_name,tool_input,tool_output,tool_use_id를jq로 꺼낸다 - 응답 두 갈래 —
decision: "block"(다음 모델 호출 차단) /additionalContext(컨텍스트 주입). 같이 써도 됨 - 차단은 exit 0 + JSON — PostToolUse에서 exit 2는 비블로킹이다 (PreToolUse와 반대)
- 매처는 세 단계로 좁힌다 — 도구 이름(
matcher) → 인수 패턴(if) → 스크립트 내 분기 - 자동 포맷팅 (Prettier) —
Edit|Write직후prettier --write, 핸들러 여러 개로 ESLint--fix까지 연결 - 린트 + 차단 — 자동 수정 후 위반이 남으면
{"decision":"block","reason":"..."}반환 - 관련 테스트 자동 실행 —
jest --findRelatedTests같은 옵션으로 변경 파일에 묶인 테스트만 돌림 - 컨텍스트 주입은 사실로 — 명령형 대신 상태 기술. prompt injection 방어와 호환
- 감사 로깅 — JSONL로 모든 편집 누적, 또는
type: "http"로 외부 시스템 전송 - timeout 짧게, 출력은 10KB 안에 — 기본 600초는 포맷터에 너무 길고, 큰 출력은 캡에 걸린다
다음 단계
다음 30차시에서는 도구 실행 자체를 막는 PreToolUse Hook을 다룬다 — rm -rf, DROP TABLE, 민감 경로 접근을 차단하는 보안 게이트, permissionDecision을 allow/deny/ask/defer로 돌리는 4단 결정, updatedInput으로 입력 자체를 sanitize 하는 패턴까지.