Skip to content

Claude Code PostToolUse Hook 활용 — 편집 직후 포맷팅·린트·테스트를 시스템에 못 박기

Published: at 07:35 PM

들어가며

28차시에서 Hooks 전체 구조를 훑었다 — lifecycle 카당스 다섯 그룹, settings.json 4단 우선순위, JSON 3단 중첩, matcher 규칙, exit code 의미, JSON 출력의 정교한 제어까지. 이번 29차시부터는 이벤트를 하나씩 골라 깊이 본다. 첫 타자는 가장 자주 쓰는 PostToolUse다.

PostToolUse는 한 줄로 말하면 “도구가 성공한 직후에 자동으로 실행되는 후처리” 이벤트다. Edit/Write 직후 Prettier를 돌리고, 코드 파일 저장 직후 ESLint --fix를 거는 것이 표준 사용법이다. 더 나아가면 변경된 파일과 관련된 테스트만 자동 실행하고, 실패하면 Claude에게 “고치고 다시 시도해” 신호를 돌려보내는 검증 게이트로도 쓸 수 있다.

이번 차시에서 다룰 것은 다섯이다.

발화 조건 — “성공한 직후”의 무게

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도구의 입력 인수 — 도구마다 스키마가 다르다 (Editfile_path, Bashcommand)
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 codestdout 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차시에서 본 함정이 여기에도 그대로 적용된다.

응답 매트릭스

의도exitstdout
조용히 끝0(없음)
Claude에게 정보 전달0additionalContext 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
          }
        ]
      }
    ]
  }
}

핵심 두 가지.

패턴 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을 화이트리스트 관리하면 조직 차원의 안전장치가 된다.

흔한 함정

  1. 차단은 exit 0 + JSON {"decision":"block"} — exit 2가 PreToolUse에서는 블로킹이지만 PostToolUse에서는 비블로킹이다. 도구는 이미 끝났기 때문에 차단할 게 없고, exit 2는 Claude에게 stderr 피드백만 전달한다.
  2. $CLAUDE_FILE_PATH 대신 jq — 환경 변수로 자동 매핑되던 옛 방식 대신 stdin JSON에서 jq -r '.tool_input.file_path'로 빼는 게 권장 방식. 도구마다 인수 스키마가 다르고, 자동 매핑이 보장되지 않는다.
  3. 공백 있는 경로prettier --write $file처럼 쿼팅 없이 쓰면 공백에서 깨진다. prettier --write "$file" 또는 exec form(args 배열)을 쓴다.
  4. timeout 기본은 600초 — 포맷터에는 너무 길다. "timeout": 30 같은 값으로 짧게 잡아 잘못된 hook이 세션을 멈추지 않도록 한다.
  5. 출력은 10KB 캡 — 평문 stdout과 additionalContext는 10,000자에서 잘리고 파일로 빠진다. 큰 린트 출력은 요약하거나 파일 경로만 컨텍스트에 넣어주는 식으로 줄인다.
  6. resume/continue 시 재실행 없음--resume 또는 --continue로 세션을 이어 받으면 과거 turn의 PostToolUse hook은 다시 돌지 않는다. 저장된 출력이 그대로 재생된다.
  7. 중복 핸들러는 1회만 — 동일 명령이 여러 매처 그룹에서 가리키더라도 이벤트 발화당 1회만 실행된다. 같은 명령을 두 번 돌리려면 다른 명령 문자열을 써야 한다.
  8. PostToolUseFailure는 별도 이벤트 — 도구가 실패하면 PostToolUse는 발화하지 않는다. 실패 후처리를 원하면 PostToolUseFailure에 별도 hook을 걸어야 한다.
  9. additionalContext는 사실로 — “X를 해라”가 아니라 “X는 이런 상태다”로 적어야 prompt injection 방어가 통과시킨다.
  10. /hooks로 등록 상태 확인 — 적용 안 되는 것 같으면 일단 /hooks 메뉴를 열어 어떤 스코프에서 무엇이 걸려 있는지 본다. 디버깅의 1순위 도구.

정리

핵심 요점

  1. PostToolUse = 도구가 성공한 직후 자동 발화 — 실패는 PostToolUseFailure로 분리
  2. 도구 실행은 못 막는다 — 이미 끝난 일에 대한 후처리 이벤트
  3. 입력은 stdin JSONtool_name, tool_input, tool_output, tool_use_idjq로 꺼낸다
  4. 응답 두 갈래decision: "block"(다음 모델 호출 차단) / additionalContext(컨텍스트 주입). 같이 써도 됨
  5. 차단은 exit 0 + JSON — PostToolUse에서 exit 2는 비블로킹이다 (PreToolUse와 반대)
  6. 매처는 세 단계로 좁힌다 — 도구 이름(matcher) → 인수 패턴(if) → 스크립트 내 분기
  7. 자동 포맷팅 (Prettier)Edit|Write 직후 prettier --write, 핸들러 여러 개로 ESLint --fix까지 연결
  8. 린트 + 차단 — 자동 수정 후 위반이 남으면 {"decision":"block","reason":"..."} 반환
  9. 관련 테스트 자동 실행jest --findRelatedTests 같은 옵션으로 변경 파일에 묶인 테스트만 돌림
  10. 컨텍스트 주입은 사실로 — 명령형 대신 상태 기술. prompt injection 방어와 호환
  11. 감사 로깅 — JSONL로 모든 편집 누적, 또는 type: "http"로 외부 시스템 전송
  12. timeout 짧게, 출력은 10KB 안에 — 기본 600초는 포맷터에 너무 길고, 큰 출력은 캡에 걸린다

다음 단계

다음 30차시에서는 도구 실행 자체를 막는 PreToolUse Hook을 다룬다 — rm -rf, DROP TABLE, 민감 경로 접근을 차단하는 보안 게이트, permissionDecisionallow/deny/ask/defer로 돌리는 4단 결정, updatedInput으로 입력 자체를 sanitize 하는 패턴까지.

참고 자료


Next Post
Claude Code Hooks 개념과 이벤트 — settings.json으로 세션 생명주기에 자동화 끼워 넣기