같은 작업을 두 번 시켰는데 결과가 다르게 나온 적이 있을 것이다. Claude에게 버그를 고쳐 달라고 했더니 어느 날은 테스트까지 돌리지만 다음 날은 코드만 수정하고 끝낸다. 이 차이는 모델의 잘못이 아니라 LLM이 본질적으로 확률 기반으로 다음 토큰을 고르기 때문이다. 그러나 실제 개발 현장은 결정론을 요구한다. 코드 수정 후에는 항상 포매터가 돌아야 하고, .env 파일은 절대 수정되면 안 되며, 권한 요청이 뜨면 즉시 알림이 가야 한다. Claude Code 훅(Hook)은 바로 이 결정론을 위한 장치다. LLM의 판단에 맡기는 게 아니라 라이프사이클의 특정 지점에서 항상 실행되는 셸 명령을 등록한다
훅이 해결하는 문제와 다섯 가지 사용 사례
훅의 본질은 두 가지로 압축된다. 첫째, LLM이 어떤 결정을 내리든 특정 시점에 반드시 실행된다. 둘째, 결과에 따라 작업을 차단하거나 피드백을 주입할 수 있다
공식 문서가 제시하는 대표 사용 사례는 다섯 가지다
- 알림: 권한 요청이나 작업 완료 시점에 데스크톱·모바일로 푸시
- 자동 포매팅: 파일 편집 직후 Prettier 등 포매터를 자동 실행
- 감사 로그: 모든 Bash 명령이나 파일 수정을 파일에 기록
- 자동 피드백: 컨벤션을 위반한 코드를 차단하고 Claude에 수정 사유 전달
- 사용자 정의 권한:
.env,package-lock.json,.git/등 보호 대상 편집을 LLM 모드와 무관하게 차단
마지막 항목이 특히 강력하다. PreToolUse 훅이 차단을 결정하면 --dangerously-skip-permissions 같은 우회 옵션이 켜져 있어도 도구 호출이 막힌다. 정책 강제용 도구로는 훅이 가장 윗단에 위치한다
훅은 이제 settings.json에 직접 등록한다
이전 버전의 Claude Code는 /hooks 슬래시 명령어로 훅을 직접 추가할 수 있었다. 현재 /hooks는 등록된 목록을 보여주는 read-only 메뉴로 바뀌었다. 등록과 수정은 두 가지 경로로 한다
- 직접 편집:
.claude/settings.json또는~/.claude/settings.json - Claude에 위임: “알림 훅을 등록해줘”라고 요청하면 Claude가 settings.json을 대신 수정
어느 쪽이든 결과는 같은 JSON 파일에 기록된다
설정 위치별 적용 범위는 다음과 같다
| 위치 | 범위 | Git 공유 |
|---|---|---|
~/.claude/settings.json | 모든 프로젝트 | 불가 |
.claude/settings.json | 단일 프로젝트 | 가능 (커밋) |
.claude/settings.local.json | 단일 프로젝트 | 불가 (gitignore) |
| 관리되는 정책 설정 | 조직 전체 | 관리자 제어 |
플러그인 hooks/hooks.json | 플러그인 활성 시 | 플러그인 동봉 |
팀이 공유할 규칙은 .claude/settings.json에 두고 Git에 커밋한다. 개인 단축은 ~/.claude/settings.json에 둔다
훅의 기본 구조와 첫 실습
훅 JSON은 세 층으로 구성된다
{
"hooks": {
"이벤트이름": [
{
"matcher": "어떤 경우에만 실행할지 필터",
"hooks": [
{ "type": "command", "command": "실행할 셸 명령" }
]
}
]
}
}
세 가지만 기억하면 된다
- 이벤트이름: 언제 실행할지 (예:
PreToolUse,Stop,Notification) - matcher: 어떤 도구·상황에서만 발동할지 (
Bash,Edit|Write, 빈 문자열은 전부) - command: 어떤 셸 명령을 실행할지
가장 단순한 실습으로, Bash 도구가 호출되기 직전에 한 줄을 파일에 남기는 훅을 만들어 본다. .claude/settings.json에 다음을 추가한다
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo 'test hook' >> hook-test.txt"
}
]
}
]
}
}
Claude Code를 재실행하고 “현재 디렉터리 목록을 보여줘”처럼 Bash가 호출될 만한 요청을 던지면, 그때마다 hook-test.txt에 한 줄씩 추가된다. Claude가 Bash를 세 번 사용하면 세 줄이 쌓인다
주요 훅 이벤트 한눈에 보기
훅 이벤트는 라이프사이클의 모든 핵심 지점을 덮는다. 자주 쓰는 이벤트만 정리하면 다음과 같다.
| 이벤트 | 발생 시점 | matcher 입력 |
|---|---|---|
PreToolUse | Claude가 도구를 호출하기 직전 | 도구 이름 (Bash, Edit|Write) |
PostToolUse | 도구 호출이 성공한 직후 | 도구 이름 |
UserPromptSubmit | 사용자가 프롬프트를 제출한 직후 | 없음 |
Notification | Claude가 입력·권한을 기다릴 때 | 알림 종류 |
Stop | Claude의 응답이 끝났을 때 | 없음 |
SubagentStop | 서브에이전트 작업이 끝났을 때 | 에이전트 이름 |
PreCompact | 컨텍스트 압축 직전 | manual, auto |
SessionStart | 새 세션 시작 또는 재개 | startup, resume, compact |
SessionEnd | 세션이 종료될 때 | 종료 사유 |
이 외에 FileChanged, CwdChanged, ConfigChange, PermissionRequest 등 더 세밀한 이벤트도 있다. 전체 목록은 공식 문서의 Hooks 참조에서 확인한다
Stop은 사용자가 ESC로 중단했을 때는 발생하지 않는다. 작업 완료에만 반응하므로 자동 커밋·백업 같은 마무리 작업에 쓰기 좋다
실전 – Slack으로 권한 요청과 작업 완료 알림 받기
가장 체감 효과가 큰 활용은 모바일 푸시 알림이다. 복잡한 작업을 맡기고 자리를 비웠다가 권한 승인 요청을 놓쳐 본 사람이라면 한 번에 이해한다. Slack Incoming Webhook URL을 발급받고, Notification과 Stop 두 이벤트에 webhook 호출 셸 스크립트를 연결하면 끝난다
먼저 Slack 워크스페이스에서 Incoming Webhook 앱을 채널에 추가해 URL을 발급받는다. URL은 민감 정보이므로 프로젝트 .env에 보관한다
# .env SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
훅 스크립트는 .claude/hooks/notify.sh에 둔다
#!/bin/bash
# notify.sh
INPUT=$(cat)
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
set -a
source "$CLAUDE_PROJECT_DIR/.env" 2>/dev/null
set +a
curl -s -X POST -H 'Content-Type: application/json' \
--data "{\"text\":\"[$EVENT] Claude Code 알림\"}" \
"$SLACK_WEBHOOK_URL" > /dev/null
실행 권한을 부여한다
chmod +x .claude/hooks/notify.sh
.claude/settings.json에 두 이벤트를 등록한다
{
"hooks": {
"Notification": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh" }
]
}
]
}
}
Claude가 권한 승인을 요청하면 Notification 훅이 발동하고, 응답이 끝나면 Stop 훅이 발동한다. 모바일 Slack 앱이 두 시점 모두 푸시로 받아 준다. 자리를 비워도 작업 흐름이 끊기지 않는다
여기서 한 가지 주의할 점이 있다. 계획만 보고 Claude가 “PreToolUse·PostToolUse를 쓰자”고 잘못 제안할 수 있다. 권한 요청은 Notification, 응답 완료는 Stop이라는 사실을 사람이 명확히 짚어 줘야 한다. 훅 이벤트의 정확한 의미는 LLM에 맡기지 말고 공식 문서로 직접 확인하는 편이 안전하다
jq로 훅 입력 JSON을 활용한다
훅 스크립트는 stdin으로 JSON을 받는다. 모든 이벤트가 공통으로 가지는 필드는 session_id, cwd, hook_event_name이고, 이벤트별로 추가 필드가 붙는다. PreToolUse는 tool_name과 tool_input을, Notification은 message를 추가로 전달한다
이 JSON에서 원하는 값을 꺼내려면 jq가 가장 짧다
- macOS:
brew install jq - Windows (10/11):
winget install jqlang.jq
winget은 Windows 10/11에 기본 내장되어 있어 별도 패키지 매니저 설치 단계가 필요 없다
Notification 훅에서 메시지 본문만 뽑아 Slack으로 보내려면 다음과 같이 작성한다
#!/bin/bash
INPUT=$(cat)
MESSAGE=$(echo "$INPUT" | jq -r '.message')
curl -s -X POST -H 'Content-Type: application/json' \
--data "{\"text\":\"권한 요청: $MESSAGE\"}" \
"$SLACK_WEBHOOK_URL"
Stop 훅에는 message 같은 필드가 없다. 대신 공통 필드인 hook_event_name을 활용해 어떤 이벤트가 끝났는지 표시한다
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
훅이 잘못 동작할 때 가장 흔한 원인 중 하나가 jq 미설치다. jq: command not found가 보이면 위 한 줄로 해결된다. 입력 데이터 구조를 모르겠다면 cat만 해서 파일에 한 번 덤프해 본 뒤 jq 표현식을 짜는 흐름이 가장 빠르다
종료 코드와 JSON 출력으로 동작을 제어한다
훅 스크립트는 종료 코드와 stdout으로 Claude Code에 결과를 전달한다
| 종료 코드 | 의미 |
|---|---|
0 | 작업 진행. UserPromptSubmit·SessionStart는 stdout 텍스트가 Claude 컨텍스트에 추가됨 |
2 | 작업 차단. stderr 메시지가 Claude에 피드백으로 전달 |
| 그 외 | 작업 진행하되 트랜스크립트에 오류 표시 |
차단이 필요한 시나리오에서는 exit 2가 가장 단순하다. .env 편집을 막는 훅은 다음 한 토막이면 된다
INPUT=$(cat) FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') if [[ "$FILE" == *".env"* ]]; then echo "Blocked: .env 편집은 금지되어 있습니다." >&2 exit 2 fi exit 0
더 정교한 제어가 필요하면 stdout에 JSON을 출력한다. PreToolUse에서 도구 호출을 거부하면서 사유를 함께 전달하는 형태는 다음과 같다
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "rg를 사용하라. grep은 성능상 권장되지 않는다."
}
}
permissionDecision은 allow, deny, ask 중 하나를 갖는다. 단, allow로 설정해도 settings의 거부 규칙은 우회되지 않는다. 훅은 제한을 더 강화할 수는 있어도 권한 정책 자체를 풀어 주지는 못한다
정리
정리하면 훅은 LLM의 확률적 판단 위에 결정론을 얹는 가장 빠른 도구다. 자동화·차단·알림 중 어느 하나라도 매번 일어나야 한다면 메모리 파일이나 프롬프트로 부탁하지 말고 훅으로 못 박는다. settings.json 한 줄 편집으로 끝난다
공식문서 – Hooks 참조, hooks를 사용하여 워크플로우 자동화