[Claude Code, 끝까지 써보기] 4/6화: Claude Code Hooks 완전 정복 — 에이전트 제어의 기술
들어가며 — 3편까지의 여정, 그리고 “제어”라는 새로운 화두
지난 세 편에 걸쳐 우리는 Claude Code의 코어 메커니즘(1편), 슬래시 커맨드와 세션 라이프사이클(2편), 그리고 MCP·플러그인·스킬이라는 확장 생태계(3편)까지 살펴보았습니다. 돌이켜보면, 1편에서 3편까지의 흐름은 “Claude Code가 무엇을 할 수 있는가“를 넓혀가는 과정이었습니다.
그런데 능력이 커질수록 반드시 따라오는 질문이 있습니다. “이 에이전트가 하려는 행동을, 어디서 끊고 어디서 통과시킬 것인가?” — 이것이 바로 4편(상)의 주제, Hooks입니다.
Hooks는 Claude Code의 도구 호출 파이프라인 곳곳에 삽입할 수 있는 사용자 정의 셸 스크립트입니다. 에이전트가 파일을 쓰기 직전, 커밋을 날리기 직전, 심지어 사용자에게 응답을 반환하기 직전에 여러분이 작성한 스크립트가 먼저 실행됩니다. 통과시킬 수도, 차단할 수도, 변형할 수도 있습니다. 권한 모드가 “무엇을 허용할지”를 결정하는 정적 정책이라면, Hooks는 “실행 순간마다 조건부로 판단하는” 동적 정책입니다.
이 글에서는 Hook 이벤트 6종의 동작 순서와 차단 권한, 종료코드·페이로드·환경변수의 의미, 실전 패턴 모음, 그리고 Auto mode 환경에서 훅이 안전망 역할을 하는 설계까지 빠짐없이 다룹니다. 끝까지 읽으시면, “에이전트에게 자율성을 주되 통제권은 내가 쥔다”는 이상적인 균형을 직접 설계할 수 있게 될 것입니다.

1. Hooks란 무엇인가 — 권한 모드와의 차이점부터
1-1. 권한 모드: 정적 정책
1편에서 다뤘던 권한 모드를 간단히 떠올려 봅시다. Claude Code는 Default · Plan · Auto · –dangerously-skip-permissions라는 네 가지 권한 모드를 제공합니다. 이들은 “어떤 도구를 승인 없이 쓸 수 있는가”를 세션 전체에 걸쳐 결정합니다. 예를 들어 Default 모드에서는 파일 쓰기마다 사용자 승인을 요구하고, Auto 모드에서는 대부분의 도구를 자동 승인합니다.
하지만 정적 정책만으로는 해결할 수 없는 시나리오가 많습니다:
- “파일 쓰기는 허용하되,
.env파일만큼은 절대 건드리지 마라” — 도구 단위가 아니라 인자 단위의 제어 - “커밋 전에 반드시 lint를 통과시켜라” — 도구 실행 전후에 커스텀 로직 삽입
- “외부 API 호출 결과에 민감 정보가 포함되면 마스킹하라” — 도구 결과물의 변형
- “에이전트가 멈출 때마다 Slack으로 알림을 보내라” — 세션 이벤트에 대한 반응
이런 요구사항은 권한 모드의 ON/OFF 스위치로는 표현할 수 없습니다. 여기서 Hooks가 등장합니다.
1-2. Hooks: 동적 정책
Hooks는 Claude Code의 도구 실행 파이프라인에 삽입되는 셸 스크립트입니다. 핵심 특성을 정리하면 다음과 같습니다:
- 이벤트 기반: 6가지 이벤트(PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, Notification)에 바인딩
- 조건부 실행: 특정 도구 이름, 파일 경로, 환경 등 조건에 따라 선택적으로 작동
- 차단·변형 가능: 종료코드(exit code)로 실행을 차단하고, stdout으로 피드백을 모델에 전달하며, JSON 페이로드로 도구 입력·출력을 변형
- 사용자 공간에서 실행: Claude Code 프로세스와 동일한 사용자 권한으로 셸에서 실행되므로, 시스템 명령·외부 API 호출 등 자유도가 높음
비유하자면, 권한 모드가 건물 출입문의 출입증 등급이라면, Hooks는 각 방 앞에 서 있는 보안 검색대입니다. 출입증이 있어도 보안 검색을 통과해야 들어갈 수 있고, 검색대에서 소지품을 검사하거나 반입 금지 물품을 압수할 수도 있습니다.
1-3. 설정 파일 위치와 구조
Hooks는 Claude Code의 settings 파일에 정의합니다. settings 파일은 세 가지 레벨에 존재할 수 있습니다:
- 프로젝트 레벨:
.claude/settings.json(Git에 커밋, 팀 전체 적용) - 사용자-프로젝트 레벨:
.claude/settings.local.json(Git 무시, 개인 설정) - 글로벌 레벨:
~/.claude/settings.json(모든 프로젝트에 적용)
기본적인 Hook 설정 구조는 다음과 같습니다:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"command": "/path/to/my-hook.sh",
"timeout": 30000
}
],
"PostToolUse": [
{
"matcher": "Bash",
"command": "python3 /path/to/post-bash-hook.py"
}
],
"Stop": [
{
"matcher": "",
"command": "curl -X POST https://slack.webhook.url -d '{\"text\":\"Agent stopped\"}'"
}
]
}
}
각 이벤트 키 아래에 배열로 여러 훅을 등록할 수 있고, 같은 이벤트에 등록된 훅은 배열 순서대로 순차 실행됩니다. matcher는 도구 이름에 대한 정규식 패턴이며, 빈 문자열이면 모든 도구에 매칭됩니다. timeout은 밀리초 단위이고, 기본값은 60000(60초)입니다.
2. Hook 이벤트 6종 — 동작 순서와 차단 권한 완전 해부
Claude Code의 도구 실행 파이프라인에 삽입할 수 있는 이벤트는 정확히 6종입니다. 각 이벤트가 파이프라인의 어느 지점에서 발화되는지, 어떤 권한을 가지는지를 상세히 살펴봅시다.

2-1. PreToolUse — 도구 실행 직전의 관문
발화 시점: 모델이 도구 호출을 결정하고, 도구 입력(arguments)이 확정된 후, 실제 실행 직전에 발화됩니다. 권한 모드의 승인 확인보다 먼저 실행됩니다.
차단 권한: 있음. 종료코드 2로 도구 실행을 완전히 차단할 수 있습니다.
입력 페이로드 (stdin으로 JSON 전달):
{
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/src/config/database.ts",
"content": "export const DB_HOST = ..."
},
"session_id": "abc123",
"transcript_path": "/tmp/claude/transcript-abc123.json"
}
핵심 필드 설명:
tool_name: 호출되는 도구의 이름. Write, Edit, Bash, Glob, Grep, Read, Agent 등tool_input: 도구에 전달될 인자. 도구마다 구조가 다릅니다session_id: 현재 세션 식별자transcript_path: 전체 대화 로그 파일 경로 (디버깅에 유용)
활용 예시: 금지 파일 보호, 민감 정보 검출, 명령어 화이트리스트 적용, 도구 입력 변형
2-2. PostToolUse — 도구 실행 직후의 검사
발화 시점: 도구가 성공적으로 실행을 완료한 직후에 발화됩니다. 도구의 실행 결과(output)를 모델에 반환하기 직전입니다.
차단 권한: 없음. PostToolUse는 이미 실행된 도구의 결과를 검사하는 용도이므로, 도구 실행 자체를 되돌릴 수는 없습니다. 다만, 종료코드 2를 반환하면 모델에게 “이 결과에 문제가 있다”는 피드백을 줄 수 있습니다.
입력 페이로드:
{
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
},
"tool_output": {
"stdout": "Tests: 42 passed, 3 failed",
"stderr": "",
"exit_code": 1
},
"session_id": "abc123",
"transcript_path": "/tmp/claude/transcript-abc123.json"
}
활용 예시: 테스트 실패 시 자동 분석 스크립트 실행, 파일 변경 후 포맷터·린터 자동 실행, 결과물에서 민감 정보 마스킹, 변경 로그 기록
2-3. UserPromptSubmit — 사용자 입력 검증
발화 시점: 사용자가 프롬프트를 입력하고 전송한 직후, 모델에 프롬프트가 전달되기 직전에 발화됩니다.
차단 권한: 있음. 종료코드 2로 프롬프트 전송 자체를 차단할 수 있습니다.
입력 페이로드:
{
"hook_event_name": "UserPromptSubmit",
"tool_name": "",
"tool_input": {
"prompt": "프로덕션 DB를 드롭하고 다시 만들어줘",
"images": []
},
"session_id": "abc123",
"transcript_path": "/tmp/claude/transcript-abc123.json"
}
활용 예시: 위험 키워드 감지(예: “프로덕션”, “삭제”, “DROP”), 프롬프트에 자동 컨텍스트 주입, 사용량 카운터 연동, 팀 가이드라인 준수 검증
이 이벤트는 matcher 필드가 무의미합니다. 도구 호출이 아닌 사용자 입력에 반응하기 때문에, matcher를 빈 문자열로 두거나 생략합니다.
2-4. Stop — 에이전트 턴 종료 시점
발화 시점: 모델이 현재 턴의 실행을 완료하고 사용자에게 응답을 반환하려는 시점에 발화됩니다. 즉, 모든 도구 호출 체인이 끝나고 최종 응답을 생성한 후입니다.
차단 권한: 특수. 종료코드 2를 반환하면, 모델이 멈추지 않고 추가 작업을 계속하도록 만들 수 있습니다. 이것은 “차단”이라기보다는 “연장”에 가깝습니다.
입력 페이로드:
{
"hook_event_name": "Stop",
"tool_name": "",
"tool_input": {},
"stop_reason": "end_turn",
"session_id": "abc123",
"transcript_path": "/tmp/claude/transcript-abc123.json"
}
활용 예시: 작업 완료 알림(Slack, Telegram, 이메일), 세션 통계 기록, 자동 품질 검증(모든 테스트 통과 확인 후 미통과 시 재시도 유도), CI/CD 파이프라인 트리거
Stop 훅에서 종료코드 2와 함께 stdout으로 피드백을 보내면, 모델은 그 피드백을 읽고 추가 작업을 수행합니다. 예를 들어, “아직 3개의 테스트가 실패 중입니다. 수정을 계속하세요.”라는 메시지를 보내면 모델이 실패한 테스트를 수정하려 시도합니다. 이것은 자동화된 품질 루프를 구현하는 강력한 메커니즘입니다.
2-5. SubagentStop — 서브에이전트 턴 종료 시점
발화 시점: Named Subagent나 Agent 도구로 생성된 서브에이전트가 자신의 작업을 완료한 시점에 발화됩니다.
차단 권한: Stop과 동일. 종료코드 2로 서브에이전트에게 추가 작업을 지시할 수 있습니다.
입력 페이로드:
{
"hook_event_name": "SubagentStop",
"tool_name": "",
"tool_input": {},
"stop_reason": "end_turn",
"subagent_name": "code-reviewer",
"session_id": "abc123",
"transcript_path": "/tmp/claude/transcript-abc123.json"
}
이 이벤트는 다음 편인 4편(하) “Subagents”에서 더 깊이 다루겠지만, 핵심 포인트만 짚으면: 서브에이전트의 작업 품질을 검증하고, 기준 미달 시 재작업을 요구하는 “검증자 패턴”을 구현하는 데 사용됩니다.
2-6. Notification — 사용자 주의 요청 시점
발화 시점: Claude Code가 사용자에게 알림을 보내려는 시점에 발화됩니다. 예를 들어, 권한 승인 요청, 에러 발생, 또는 장시간 작업 완료 시 터미널 벨을 울리는 등의 기본 알림이 여기에 해당합니다.
차단 권한: 없음. 알림 이벤트 자체를 차단하는 것은 의미가 없으므로, 이 훅은 순수하게 알림 채널 확장 용도입니다.
입력 페이로드:
{
"hook_event_name": "Notification",
"tool_name": "",
"tool_input": {
"title": "Permission required",
"message": "Claude wants to run: rm -rf /tmp/build",
"type": "permission_request"
},
"session_id": "abc123",
"transcript_path": "/tmp/claude/transcript-abc123.json"
}
활용 예시: 기본 터미널 벨 대신 시스템 알림 센터 활용, 모바일 푸시 알림 전송, 사내 메신저 연동
3. 종료코드·stdout·환경변수 — 훅의 통신 프로토콜
훅은 셸 스크립트이므로, Claude Code와의 통신은 종료코드(exit code), 표준 출력(stdout), 표준 에러(stderr), 그리고 환경변수라는 전통적인 Unix 메커니즘을 사용합니다. 각각의 의미를 정확히 이해하는 것이 훅 설계의 핵심입니다.
3-1. 종료코드의 의미
훅의 종료코드는 세 가지 의미를 가집니다:
- 종료코드 0 (성공, 통과): 아무 문제 없음. 파이프라인이 정상적으로 계속 진행됩니다. stdout에 출력이 있으면 모델에 정보성 피드백으로 전달됩니다.
- 종료코드 2 (차단/연장): 이벤트에 따라 의미가 다릅니다:
- PreToolUse: 도구 실행을 차단합니다. stdout 내용이 모델에 “이 도구를 쓸 수 없는 이유”로 전달됩니다.
- UserPromptSubmit: 프롬프트 전송을 차단합니다.
- PostToolUse: 도구 실행은 이미 완료되었으므로, stdout 내용이 모델에 경고 피드백으로 전달됩니다.
- Stop / SubagentStop: 에이전트 종료를 막고, stdout 내용을 읽은 모델이 추가 작업을 계속합니다.
- Notification: 의미 없음 (무시됨)
- 종료코드 1 또는 기타: 훅 자체의 오류. 파이프라인은 계속 진행되지만, stderr 내용이 디버그 로그에 기록됩니다. 훅의 오류가 에이전트 실행을 막지는 않습니다 — 이것은 의도적인 설계입니다. 훅 버그 때문에 에이전트가 완전히 멈추는 것을 방지합니다.
이 설계에서 주목할 점은, 차단은 반드시 명시적(종료코드 2)으로만 가능하다는 것입니다. 실수로 훅 스크립트에 버그가 있어서 종료코드 1이 반환되어도 에이전트가 멈추지 않습니다. 안전한 기본값(fail-open) 설계입니다.
3-2. stdout과 stderr의 역할 분리
stdout은 모델에 전달되는 피드백 채널입니다. 여기에 쓴 내용은 모델의 컨텍스트에 삽입됩니다. 따라서:
- 차단 시 이유를 설명하면, 모델이 대안을 찾을 수 있습니다
- 경고를 주면, 모델이 수정 작업을 시도할 수 있습니다
- 추가 정보를 주입하면, 모델의 판단력이 향상됩니다
stderr는 디버그 로그 채널입니다. 여기에 쓴 내용은 모델에 전달되지 않고, Claude Code의 디버그 로그에만 기록됩니다. 훅 개발 중 디버깅 메시지를 남기기에 적합합니다.
#!/bin/bash
# PreToolUse hook: .env 파일 보호
INPUT=$(cat) # stdin에서 JSON 페이로드 읽기
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
echo "Checking file: $FILE_PATH" >&2 # 디버그 로그 (모델 안 봄)
if [[ "$FILE_PATH" == *.env* ]]; then
echo ".env 파일은 수정할 수 없습니다. 환경변수는 .env.example에 템플릿만 유지하세요."
exit 2 # 차단
fi
exit 0 # 통과
3-3. 환경변수 — 컨텍스트 전달
Claude Code는 훅 실행 시 여러 환경변수를 자동으로 설정합니다:
CLAUDE_SESSION_ID: 현재 세션 식별자CLAUDE_PROJECT_DIR: 프로젝트 루트 디렉터리 경로CLAUDE_MODEL: 현재 사용 중인 모델 이름CLAUDE_PERMISSION_MODE: 현재 권한 모드 (default, plan, auto, bypass)CLAUDE_HOOK_EVENT: 현재 이벤트 이름
이 환경변수들은 훅 스크립트가 현재 실행 컨텍스트를 파악하는 데 핵심적입니다. 예를 들어, Auto 모드에서만 추가 검증을 수행하거나, 특정 모델을 사용할 때만 토큰 사용량을 추적하는 등의 조건부 로직을 구현할 수 있습니다.
#!/bin/bash
# Auto 모드에서만 추가 보안 검사 수행
if [ "$CLAUDE_PERMISSION_MODE" = "auto" ]; then
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# 위험 명령어 패턴 검사
if echo "$COMMAND" | grep -qiE '(rm\s+-rf|DROP\s+TABLE|FORMAT|mkfs)'; then
echo "Auto 모드에서 위험 명령어가 감지되었습니다: $COMMAND"
echo "이 명령을 실행하려면 Default 모드로 전환하세요."
exit 2
fi
fi
exit 0
3-4. 타임아웃과 실패 처리
각 훅에는 timeout 필드로 최대 실행 시간을 설정할 수 있습니다. 기본값은 60초이며, 단위는 밀리초입니다.
- 타임아웃 초과 시: 훅 프로세스가 강제 종료되고, 종료코드 1(오류)과 동일하게 처리됩니다. 즉, 파이프라인은 계속 진행됩니다.
- 예외: 네트워크 호출이 포함된 훅(예: 외부 보안 스캐너 API)은 충분한 타임아웃을 설정해야 합니다.
타임아웃이 fail-open으로 처리된다는 점은 중요합니다. 보안 크리티컬한 훅이라면, 타임아웃 시에도 차단하고 싶을 수 있습니다. 이 경우, 훅 내부에서 자체 타임아웃 로직을 구현하고 명시적으로 종료코드 2를 반환해야 합니다:
#!/bin/bash
# 자체 타임아웃 로직으로 보안 스캔 실행
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 30초 타임아웃으로 보안 스캔 실행
SCAN_RESULT=$(timeout 30 security-scanner "$FILE_PATH" 2>/dev/null)
SCAN_EXIT=$?
if [ $SCAN_EXIT -eq 124 ]; then
# timeout 명령의 124 = 타임아웃 발생
echo "보안 스캔 타임아웃. 안전을 위해 파일 쓰기를 차단합니다."
exit 2
elif [ $SCAN_EXIT -ne 0 ]; then
echo "보안 스캔 실패. 안전을 위해 파일 쓰기를 차단합니다."
exit 2
fi
if echo "$SCAN_RESULT" | grep -q "VULNERABILITY"; then
echo "보안 취약점 발견: $SCAN_RESULT"
exit 2
fi
exit 0
4. 실전 패턴 모음 — 현업에서 바로 쓰는 훅 레시피
이론을 충분히 다뤘으니, 이제 실전에서 바로 활용할 수 있는 훅 패턴들을 살펴봅시다. 각 패턴은 문제 상황 → 설정 예시 → 훅 스크립트 → 동작 설명 순으로 구성합니다.
4-1. 패턴 1: 민감 정보 보호 게이트
문제: 에이전트가 .env, secrets.yaml, credentials.json 등 민감 파일을 수정하거나 읽는 것을 방지하고 싶습니다.
settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|Read",
"command": "bash /scripts/hooks/protect-secrets.sh",
"timeout": 5000
}
]
}
}
protect-secrets.sh:
#!/bin/bash
set -euo pipefail
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty')
# 민감 파일 패턴 정의
SENSITIVE_PATTERNS=(
'\.env$'
'\.env\.'
'secrets?\.(yaml|yml|json|toml)$'
'credentials?\.(yaml|yml|json|toml)$'
'private[_-]?key'
'\.pem$'
'\.key$'
'id_rsa'
'token\.json$'
)
if [ -z "$FILE_PATH" ]; then
exit 0
fi
for pattern in "${SENSITIVE_PATTERNS[@]}"; do
if echo "$FILE_PATH" | grep -qiE "$pattern"; then
echo "⛔ 민감 파일 접근 차단: $FILE_PATH"
echo ""
echo "이 파일은 보안 정책에 의해 보호됩니다."
echo "민감 정보가 포함된 파일을 직접 수정하지 마세요."
echo ""
echo "대안:"
echo "- 환경변수 템플릿이 필요하면 .env.example을 수정하세요"
echo "- 설정 구조를 변경하려면 config 스키마 파일을 수정하세요"
exit 2
fi
done
# Write/Edit인 경우, 파일 내용에 민감 정보 패턴 검사
if [ "$TOOL_NAME" = "Write" ] || [ "$TOOL_NAME" = "Edit" ]; then
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
# API 키, 비밀번호 등의 하드코딩 감지
if echo "$CONTENT" | grep -qiE '(api[_-]?key|password|secret|token)\s*[:=]\s*["\x27][A-Za-z0-9+/=]{16,}'; then
echo "⚠️ 하드코딩된 민감 정보가 감지되었습니다."
echo "API 키, 비밀번호 등은 환경변수로 관리하세요."
echo "예: process.env.API_KEY 또는 os.environ['API_KEY']"
exit 2
fi
fi
exit 0
이 패턴의 핵심은 이중 검사입니다. 파일 경로 기반의 1차 차단과, 파일 내용 기반의 2차 차단을 결합하여, 민감 파일 접근과 민감 정보 하드코딩을 모두 방지합니다.
4-2. 패턴 2: 정적 분석 자동 삽입 (린트·포맷 게이트)
문제: 에이전트가 파일을 수정할 때마다 자동으로 린터와 포맷터를 실행하고, 위반 사항이 있으면 모델에 피드백하고 싶습니다.
settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"command": "bash /scripts/hooks/auto-lint.sh",
"timeout": 30000
}
]
}
}
auto-lint.sh:
#!/bin/bash
set -uo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty')
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
exit 0
fi
EXTENSION="${FILE_PATH##*.}"
ISSUES=""
case "$EXTENSION" in
ts|tsx|js|jsx)
# ESLint 실행
LINT_OUTPUT=$(npx eslint "$FILE_PATH" --format compact 2>/dev/null || true)
if [ -n "$LINT_OUTPUT" ]; then
ISSUES="$ISSUES\n📋 ESLint 결과:\n$LINT_OUTPUT"
fi
# Prettier 검사 (수정은 하지 않음)
if ! npx prettier --check "$FILE_PATH" 2>/dev/null; then
ISSUES="$ISSUES\n🎨 Prettier 포맷팅 불일치가 있습니다."
# 자동 포맷 적용
npx prettier --write "$FILE_PATH" 2>/dev/null
ISSUES="$ISSUES\n→ 자동 포맷이 적용되었습니다."
fi
;;
py)
# Ruff 실행
LINT_OUTPUT=$(ruff check "$FILE_PATH" 2>/dev/null || true)
if [ -n "$LINT_OUTPUT" ]; then
ISSUES="$ISSUES\n📋 Ruff 결과:\n$LINT_OUTPUT"
fi
# Black 포맷 검사
if ! black --check "$FILE_PATH" 2>/dev/null; then
ISSUES="$ISSUES\n🎨 Black 포맷팅 불일치가 있습니다."
black "$FILE_PATH" 2>/dev/null
ISSUES="$ISSUES\n→ 자동 포맷이 적용되었습니다."
fi
;;
java|kt)
# Checkstyle 또는 ktlint
LINT_OUTPUT=$(ktlint "$FILE_PATH" 2>/dev/null || true)
if [ -n "$LINT_OUTPUT" ]; then
ISSUES="$ISSUES\n📋 ktlint 결과:\n$LINT_OUTPUT"
fi
;;
esac
if [ -n "$ISSUES" ]; then
echo -e "파일 $FILE_PATH 수정 후 정적 분석 결과:$ISSUES"
echo ""
echo "위 이슈들을 확인하고 필요하면 수정하세요."
exit 0 # 정보성 피드백 (차단 아님)
fi
exit 0
주목할 점: 이 훅은 PostToolUse에서 종료코드 0으로 실행됩니다. 린트 결과를 모델에 정보로 전달하되, 실행을 차단하지는 않습니다. 모델은 이 피드백을 받고 자발적으로 코드를 수정합니다. PostToolUse에서 종료코드 2를 반환해도 실행을 되돌릴 수는 없으니, 정보 전달 후 모델의 자율적 수정에 맡기는 것이 올바른 설계입니다.
4-3. 패턴 3: 커밋·푸시 게이트
문제: 에이전트가 git commit이나 git push를 실행할 때, 반드시 테스트를 통과하고 커밋 메시지 컨벤션을 준수하도록 강제하고 싶습니다.
settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "bash /scripts/hooks/git-gate.sh",
"timeout": 120000
}
]
}
}
git-gate.sh:
#!/bin/bash
set -uo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# git commit 감지
if echo "$COMMAND" | grep -qE 'git\s+commit'; then
echo "🔍 커밋 전 검증을 실행합니다..." >&2
# 1. 커밋 메시지 컨벤션 검사
COMMIT_MSG=$(echo "$COMMAND" | grep -oP '(?<=-m\s)["\x27]([^"\x27]+)["\x27]' | tr -d "\"'" || true)
if [ -n "$COMMIT_MSG" ]; then
# Conventional Commits 패턴 검사
if ! echo "$COMMIT_MSG" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,72}$'; then
echo "⛔ 커밋 메시지가 Conventional Commits 규칙을 따르지 않습니다."
echo ""
echo "올바른 형식: type(scope): description"
echo "type: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
echo "예: feat(auth): add OAuth 2.0 login flow"
echo ""
echo "현재 메시지: $COMMIT_MSG"
exit 2
fi
fi
# 2. 스테이징된 파일에 민감 정보 검사
STAGED_FILES=$(git diff --cached --name-only 2>/dev/null || true)
for file in $STAGED_FILES; do
if echo "$file" | grep -qiE '\.(env|pem|key)$|secret|credential'; then
echo "⛔ 민감 파일이 커밋에 포함되어 있습니다: $file"
echo "git reset HEAD $file 로 스테이징을 해제하세요."
exit 2
fi
done
# 3. 테스트 실행 (package.json에 test 스크립트가 있는 경우)
if [ -f "package.json" ] && jq -e '.scripts.test' package.json >/dev/null 2>&1; then
echo "🧪 테스트를 실행합니다..." >&2
if ! npm test 2>/dev/null; then
echo "⛔ 테스트가 실패했습니다. 테스트를 통과한 후 커밋하세요."
exit 2
fi
fi
fi
# git push 감지
if echo "$COMMAND" | grep -qE 'git\s+push'; then
# force push 차단
if echo "$COMMAND" | grep -qE '\-\-force|\-f'; then
echo "⛔ Force push는 허용되지 않습니다."
echo "force push가 꼭 필요하다면, 사용자에게 직접 실행을 요청하세요."
exit 2
fi
# main/master 브랜치 직접 push 차단
CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
if echo "$CURRENT_BRANCH" | grep -qE '^(main|master)$'; then
echo "⛔ main/master 브랜치에 직접 push할 수 없습니다."
echo "feature 브랜치를 만들고 PR을 통해 머지하세요."
exit 2
fi
fi
exit 0
이 패턴은 에이전트의 git 조작을 세 겹의 안전망으로 보호합니다: 커밋 메시지 규칙 준수, 민감 파일 커밋 방지, 테스트 통과 확인. 그리고 force push와 메인 브랜치 직접 push를 원천 차단합니다.
4-4. 패턴 4: Bash 명령어 화이트리스트/블랙리스트
문제: Auto 모드에서 에이전트가 실행할 수 있는 셸 명령어를 제한하고 싶습니다.
command-filter.sh:
#!/bin/bash
set -uo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# 절대 허용하지 않는 명령어 (블랙리스트)
BLACKLIST_PATTERNS=(
'rm\s+-rf\s+/' # 루트 삭제
'rm\s+-rf\s+~' # 홈 디렉터리 삭제
'dd\s+if=' # 디스크 직접 쓰기
'mkfs\.' # 파일시스템 포맷
'>\s*/dev/sd' # 디스크 장치 직접 쓰기
'chmod\s+-R\s+777' # 과도한 권한 부여
'curl.*\|\s*bash' # 파이프 실행 (원격 코드 실행)
'wget.*\|\s*bash' # 파이프 실행
'eval\s+' # eval 사용
'sudo\s+' # sudo 사용
'shutdown' # 시스템 종료
'reboot' # 시스템 재부팅
'systemctl\s+(stop|disable|mask)' # 서비스 중지
)
for pattern in "${BLACKLIST_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qiE "$pattern"; then
echo "⛔ 보안 정책에 의해 차단된 명령어입니다."
echo "감지된 패턴: $pattern"
echo "명령어: $COMMAND"
echo ""
echo "이 명령이 꼭 필요하다면, 사용자에게 직접 실행을 요청하세요."
exit 2
fi
done
# Auto 모드에서 추가 제한
if [ "$CLAUDE_PERMISSION_MODE" = "auto" ]; then
# 네트워크 요청 제한 (허용 도메인만)
if echo "$COMMAND" | grep -qiE '(curl|wget|fetch|http)'; then
ALLOWED_DOMAINS="github.com|npmjs.com|pypi.org|api.anthropic.com|registry.yarnpkg.com"
if ! echo "$COMMAND" | grep -qiE "($ALLOWED_DOMAINS)"; then
echo "⛔ Auto 모드에서 허용되지 않은 외부 도메인 접근입니다."
echo "허용 도메인: github.com, npmjs.com, pypi.org"
echo "다른 도메인 접근이 필요하면 Default 모드로 전환하세요."
exit 2
fi
fi
fi
exit 0
4-5. 패턴 5: 사내 보안 스캐너 연동
문제: 에이전트가 파일을 수정할 때마다 사내 보안 스캐너(Snyk, SonarQube, Semgrep 등)를 자동으로 실행하고, 취약점이 발견되면 차단하고 싶습니다.
security-scan.sh:
#!/bin/bash
set -uo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Write/Edit 이후에만 실행 (PostToolUse 전용)
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
exit 0
fi
EXTENSION="${FILE_PATH##*.}"
ISSUES=""
# Semgrep 실행 (로컬 룰셋)
if command -v semgrep &>/dev/null; then
SEMGREP_OUTPUT=$(semgrep --config auto --json "$FILE_PATH" 2>/dev/null || true)
FINDING_COUNT=$(echo "$SEMGREP_OUTPUT" | jq '.results | length' 2>/dev/null || echo "0")
if [ "$FINDING_COUNT" -gt 0 ]; then
ISSUES="$ISSUES\n🔒 Semgrep 보안 스캔 결과: $FINDING_COUNT 건의 이슈 발견"
# 각 결과를 요약
echo "$SEMGREP_OUTPUT" | jq -r '.results[] | " - [\(.extra.severity)] \(.check_id): \(.extra.message) (라인 \(.start.line))"' 2>/dev/null | while read -r line; do
ISSUES="$ISSUES\n$line"
done
# severity가 ERROR인 것이 있으면 차단
HAS_ERROR=$(echo "$SEMGREP_OUTPUT" | jq '[.results[] | select(.extra.severity == "ERROR")] | length' 2>/dev/null || echo "0")
if [ "$HAS_ERROR" -gt 0 ]; then
echo -e "⛔ 심각한 보안 취약점이 발견되었습니다.$ISSUES"
echo ""
echo "ERROR 수준의 취약점을 수정한 후 다시 시도하세요."
exit 2
fi
fi
fi
# npm audit (package.json 변경 시)
if [ "$(basename "$FILE_PATH")" = "package.json" ]; then
AUDIT_OUTPUT=$(npm audit --json 2>/dev/null || true)
VULN_COUNT=$(echo "$AUDIT_OUTPUT" | jq '.metadata.vulnerabilities.high + .metadata.vulnerabilities.critical' 2>/dev/null || echo "0")
if [ "$VULN_COUNT" -gt 0 ]; then
ISSUES="$ISSUES\n🚨 npm audit: $VULN_COUNT 건의 고위험 취약점 발견"
echo -e "$ISSUES"
echo "npm audit fix 또는 의존성 버전 업그레이드를 검토하세요."
exit 0 # 정보성 피드백
fi
fi
if [ -n "$ISSUES" ]; then
echo -e "보안 스캔 결과:$ISSUES"
fi
exit 0
4-6. 패턴 6: 작업 완료 알림과 자동 품질 루프
문제: 에이전트가 작업을 마치면 Telegram으로 알림을 보내고, 테스트가 모두 통과했는지 자동으로 확인한 뒤, 실패하면 재시도하게 하고 싶습니다.
settings.json:
{
"hooks": {
"Stop": [
{
"matcher": "",
"command": "bash /scripts/hooks/quality-loop.sh",
"timeout": 180000
}
],
"Notification": [
{
"matcher": "",
"command": "bash /scripts/hooks/notify-telegram.sh",
"timeout": 10000
}
]
}
}
quality-loop.sh:
#!/bin/bash
set -uo pipefail
# 테스트 파일이 존재하는지 확인
if [ ! -f "package.json" ] && [ ! -f "pytest.ini" ] && [ ! -f "pom.xml" ]; then
exit 0 # 테스트 프레임워크 없으면 스킵
fi
# 테스트 실행
if [ -f "package.json" ]; then
TEST_OUTPUT=$(npm test 2>&1 || true)
TEST_EXIT=$?
elif [ -f "pytest.ini" ] || [ -f "pyproject.toml" ]; then
TEST_OUTPUT=$(python -m pytest --tb=short 2>&1 || true)
TEST_EXIT=$?
elif [ -f "pom.xml" ]; then
TEST_OUTPUT=$(mvn test -q 2>&1 || true)
TEST_EXIT=$?
fi
if [ "${TEST_EXIT:-0}" -ne 0 ]; then
echo "🔴 테스트가 실패했습니다. 작업을 계속하세요."
echo ""
echo "실패한 테스트 결과:"
echo "$TEST_OUTPUT" | tail -30
echo ""
echo "위 테스트 실패를 분석하고 코드를 수정하세요."
exit 2 # 에이전트가 멈추지 않고 계속 작업
fi
echo "✅ 모든 테스트 통과. 작업이 완료되었습니다." >&2
exit 0
notify-telegram.sh:
#!/bin/bash
INPUT=$(cat)
TITLE=$(echo "$INPUT" | jq -r '.tool_input.title // "Claude Code"')
MESSAGE=$(echo "$INPUT" | jq -r '.tool_input.message // "알림"')
TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
if [ -n "$TELEGRAM_TOKEN" ] && [ -n "$TELEGRAM_CHAT_ID" ]; then
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
-d "text=🤖 ${TITLE}: ${MESSAGE}" \
-d "parse_mode=HTML" \
>/dev/null 2>&1
fi
exit 0
Stop 훅의 자동 품질 루프는 매우 강력합니다. 에이전트가 “작업 끝”이라고 판단해도, 테스트가 실패하면 종료코드 2로 에이전트를 다시 작동시킵니다. 모델은 실패한 테스트 결과를 읽고 코드를 수정하고, 다시 멈추려 할 때 또 Stop 훅이 발화되어 테스트를 돌립니다. 테스트가 모두 통과할 때까지 이 루프가 반복됩니다.
단, 무한 루프 방지를 위한 안전장치가 필요합니다. 실전에서는 환경변수나 파일 기반 카운터로 최대 재시도 횟수를 제한하는 것이 좋습니다:
#!/bin/bash
# 무한 루프 방지: 최대 3회 재시도
COUNTER_FILE="/tmp/claude-stop-hook-counter-${CLAUDE_SESSION_ID}"
if [ -f "$COUNTER_FILE" ]; then
COUNT=$(cat "$COUNTER_FILE")
else
COUNT=0
fi
COUNT=$((COUNT + 1))
echo "$COUNT" > "$COUNTER_FILE"
if [ "$COUNT" -gt 3 ]; then
echo "⚠️ 품질 검증 루프가 3회를 초과했습니다. 강제 종료합니다." >&2
rm -f "$COUNTER_FILE"
exit 0 # 통과시켜 종료
fi
# ... 테스트 실행 로직 ...

5. 훅의 실행 순서와 다중 훅 조합
5-1. 같은 이벤트에 여러 훅이 등록된 경우
하나의 이벤트에 여러 훅을 배열로 등록할 수 있습니다. 이때의 실행 규칙은:
- 순차 실행: 배열 순서대로 하나씩 실행됩니다 (병렬 아님)
- 단락 평가(short-circuit): PreToolUse에서 앞선 훅이 종료코드 2를 반환하면, 뒤의 훅은 실행되지 않고 즉시 차단됩니다
- 피드백 누적: 종료코드 0인 훅의 stdout 출력은 모두 누적되어 모델에 전달됩니다
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"command": "bash /scripts/hooks/protect-secrets.sh"
},
{
"matcher": "Write|Edit",
"command": "bash /scripts/hooks/check-file-size.sh"
},
{
"matcher": "Bash",
"command": "bash /scripts/hooks/command-filter.sh"
}
]
}
}
위 설정에서 Write 도구가 호출되면: ① protect-secrets.sh 실행 → 통과 시 ② check-file-size.sh 실행. ①에서 차단되면 ②는 실행되지 않습니다. Bash 도구가 호출되면 ①과 ②는 matcher가 맞지 않으므로 건너뛰고 ③만 실행됩니다.
5-2. 설정 파일 레벨 간 병합
프로젝트 레벨, 사용자-프로젝트 레벨, 글로벌 레벨에 모두 훅이 정의된 경우, 모든 레벨의 훅이 병합되어 실행됩니다. 실행 순서는:
- 글로벌 레벨 (
~/.claude/settings.json) - 프로젝트 레벨 (
.claude/settings.json) - 사용자-프로젝트 레벨 (
.claude/settings.local.json)
이 순서는 의미가 있습니다: 글로벌 훅(회사 보안 정책 등)이 먼저 실행되어 차단할 수 있으므로, 프로젝트 레벨의 훅으로 글로벌 정책을 우회할 수 없습니다.
5-3. matcher의 정규식 패턴
matcher 필드는 JavaScript 정규식으로 도구 이름에 대해 매칭됩니다. 주요 도구 이름과 유용한 패턴을 정리합니다:
- 파일 조작 도구:
Write,Edit,Read,MultiEdit - 검색 도구:
Glob,Grep - 실행 도구:
Bash - 에이전트 도구:
Agent - MCP 도구:
mcp__서버명__도구명형식 (예:mcp__github__create_pull_request)
유용한 matcher 패턴:
"Write|Edit|MultiEdit"— 모든 파일 수정 도구".*"— 모든 도구 (빈 문자열과 동일)"mcp__.*"— 모든 MCP 도구"mcp__github__.*"— GitHub MCP 서버의 모든 도구"^(?!Read|Glob|Grep).*$"— 읽기 전용 도구를 제외한 모든 도구
6. Auto 모드와 –dangerously-skip-permissions에서의 안전망 설계
Hooks의 진짜 가치는 고자율성 모드에서 빛납니다. Default 모드에서는 매번 사용자가 승인하므로 훅의 필요성이 상대적으로 낮지만, Auto 모드나 --dangerously-skip-permissions에서는 훅이 유일한 안전망이 됩니다.
6-1. Auto 모드의 빈틈
Auto 모드는 대부분의 도구를 자동 승인합니다. 이것은 생산성을 크게 높이지만, 동시에 다음과 같은 위험을 수반합니다:
- 에이전트가 의도치 않게 중요 파일을 덮어쓸 수 있음
- 위험한 셸 명령어가 검토 없이 실행될 수 있음
- API 키나 비밀번호가 코드에 하드코딩될 수 있음
- 테스트 없이 커밋·푸시가 이뤄질 수 있음
이 빈틈들을 훅으로 메꿀 수 있습니다.
6-2. –dangerously-skip-permissions의 현실
--dangerously-skip-permissions 플래그는 CI/CD 파이프라인이나 자동화 스크립트에서 Claude Code를 무인 실행할 때 사용합니다. 이름 그대로 모든 권한 확인을 건너뛰는 위험한 모드입니다.
하지만 현실적으로, CI 환경에서 Claude Code를 활용하는 사례가 늘고 있습니다. PR 자동 리뷰, 코드 자동 수정, 테스트 자동 작성 등. 이런 환경에서 --dangerously-skip-permissions는 불가피한 선택이 되기도 합니다.
이때 훅은 마지막 방어선 역할을 합니다. --dangerously-skip-permissions가 권한 확인을 건너뛰어도, 훅은 여전히 실행됩니다. 이것은 의도적인 설계입니다.
6-3. 안전망 설계: 계층적 방어
실전에서 권장하는 안전망 구성은 다음과 같습니다:
1계층 — 글로벌 훅 (모든 프로젝트에 적용)
// ~/.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "bash ~/.claude/hooks/global-command-filter.sh",
"timeout": 5000
},
{
"matcher": "Write|Edit",
"command": "bash ~/.claude/hooks/global-secret-guard.sh",
"timeout": 5000
}
]
}
}
2계층 — 프로젝트 훅 (팀 정책, Git 커밋)
// .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "bash .claude/hooks/git-gate.sh",
"timeout": 120000
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"command": "bash .claude/hooks/auto-lint.sh",
"timeout": 30000
}
],
"Stop": [
{
"matcher": "",
"command": "bash .claude/hooks/quality-loop.sh",
"timeout": 180000
}
]
}
}
3계층 — 개인 훅 (알림, 커스텀 워크플로우)
// .claude/settings.local.json
{
"hooks": {
"Notification": [
{
"matcher": "",
"command": "bash .claude/hooks/notify-slack.sh",
"timeout": 10000
}
]
}
}
이 3계층 구조에서:
- 1계층은 조직 전체의 보안 기준을 강제합니다. 개별 프로젝트가 우회할 수 없습니다.
- 2계층은 프로젝트 특화 규칙을 적용합니다. 팀 전체가 공유합니다.
- 3계층은 개인 편의 기능을 추가합니다. 다른 팀원에게 영향을 주지 않습니다.
6-4. CI/CD 환경에서의 훅 설계
CI/CD 파이프라인에서 Claude Code를 --dangerously-skip-permissions로 실행할 때의 훅 설계 포인트입니다:
#!/bin/bash
# CI 전용 PreToolUse 훅: ci-safety.sh
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# CI 환경 감지
if [ "${CI:-}" != "true" ]; then
exit 0 # 로컬 환경에서는 스킵
fi
# CI에서 절대 허용하지 않는 도구
case "$TOOL_NAME" in
Agent)
echo "⛔ CI 환경에서 서브에이전트 생성은 허용되지 않습니다."
echo "비용 폭주 방지를 위해 단일 에이전트만 허용됩니다."
exit 2
;;
esac
# CI에서 Bash 명령어 추가 제한
if [ "$TOOL_NAME" = "Bash" ]; then
# 패키지 설치 차단 (CI에서는 사전 설치된 도구만 사용)
if echo "$COMMAND" | grep -qiE '(npm install|pip install|apt install|brew install)'; then
# 단, devDependencies 설치는 허용
if ! echo "$COMMAND" | grep -qiE '(npm ci|npm install --production=false)'; then
echo "⛔ CI 환경에서 새로운 패키지 설치는 허용되지 않습니다."
exit 2
fi
fi
# 네트워크 요청 로깅 (감사 추적)
if echo "$COMMAND" | grep -qiE '(curl|wget|fetch)'; then
echo "[AUDIT] CI network request: $COMMAND" >> /tmp/claude-ci-audit.log
fi
fi
exit 0
CI 환경에서는 특히 비용 제어가 중요합니다. 서브에이전트 생성을 차단하고, 불필요한 패키지 설치를 막고, 네트워크 요청을 로깅하는 것이 핵심입니다.
7. 디버깅 노하우 — 훅이 기대대로 동작하지 않을 때
7-1. 디버그 모드로 훅 실행 추적하기
Claude Code는 --debug 또는 CLAUDE_DEBUG=1 환경변수로 디버그 로깅을 활성화할 수 있습니다. 디버그 모드에서는 훅의 실행 여부, 종료코드, stdout/stderr 출력이 모두 로그에 기록됩니다.
# 디버그 모드로 Claude Code 실행
CLAUDE_DEBUG=1 claude
# 또는
claude --debug
디버그 로그에서 “hook” 키워드로 검색하면 훅 관련 이벤트를 추적할 수 있습니다.
7-2. 훅 스크립트 단독 테스트
훅을 Claude Code에 등록하기 전에, 단독으로 테스트하는 것이 좋습니다. 훅은 stdin으로 JSON을 받으므로, 테스트용 JSON을 파이프로 전달하면 됩니다:
# PreToolUse 훅 테스트
echo '{
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/src/.env",
"content": "DB_PASSWORD=secret123"
},
"session_id": "test-123",
"transcript_path": "/tmp/test-transcript.json"
}' | bash /scripts/hooks/protect-secrets.sh
# 종료코드 확인
echo "Exit code: $?"
이렇게 하면 Claude Code 없이도 훅의 동작을 검증할 수 있습니다.
7-3. 흔한 실수와 해결법
실수 1: stdin을 읽지 않음
# ❌ 잘못됨: stdin을 읽지 않으면 파이프 에러 발생 가능
#!/bin/bash
echo "Hello"
exit 0
# ✅ 올바름: 사용하지 않더라도 stdin을 소비
#!/bin/bash
cat > /dev/null # stdin 소비
echo "Hello"
exit 0
실수 2: jq 없이 JSON 파싱 시도
# ❌ 잘못됨: grep으로 JSON 파싱
FILE=$(echo "$INPUT" | grep -o '"file_path":"[^"]*"')
# ✅ 올바름: jq 사용
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path')
실수 3: set -e로 인한 의도치 않은 종료
# ❌ 위험: set -e 때문에 grep 매칭 실패 시 스크립트가 종료코드 1로 끝남
set -e
echo "$COMMAND" | grep -q "rm" # 매칭 실패 → exit 1 → fail-open
# ✅ 안전: set -e 대신 개별 에러 처리, 또는 || true 사용
set -uo pipefail # -e 제외
if echo "$COMMAND" | grep -q "rm"; then
exit 2
fi
실수 4: Windows/macOS 줄바꿈 차이
Windows에서 작성한 스크립트를 Linux/macOS에서 실행하면 \r\n 줄바꿈 때문에 오류가 발생할 수 있습니다. 항상 LF 줄바꿈을 사용하세요. .gitattributes에 *.sh text eol=lf를 추가하는 것도 좋은 습관입니다.
7-4. transcript_path 활용
훅 페이로드에 포함된 transcript_path는 전체 대화 로그의 파일 경로입니다. 이 파일을 읽으면 현재 세션에서 모델이 수행한 모든 도구 호출과 결과를 확인할 수 있습니다. 고급 훅에서는 이를 활용하여:
- 같은 도구를 반복 호출하는 무한 루프를 감지
- 세션 전체의 파일 변경 히스토리를 분석
- 에이전트의 “의도”를 대화 맥락에서 추론
할 수 있습니다. 다만, transcript 파일은 용량이 클 수 있으므로 파싱 시 주의가 필요합니다.
8. 훅 설계 원칙 — 좋은 훅과 나쁜 훅
8-1. 좋은 훅의 특성
- 빠르다: 5초 이내에 완료되는 것이 이상적. 훅이 느리면 에이전트의 전체 응답 시간이 늘어남
- 결정적(deterministic)이다: 같은 입력에 항상 같은 결과를 반환. 외부 상태(네트워크, 시간 등)에 의존하지 않는 것이 좋음
- 피드백이 명확하다: stdout으로 차단 이유와 대안을 구체적으로 제시. “차단됨”보다 “이 파일은 보안 정책에 의해 보호됩니다. 대신 .env.example을 수정하세요”가 좋음
- 실패에 안전하다: 훅 자체의 버그가 에이전트를 멈추지 않음. fail-open 설계를 기본으로 하되, 보안 크리티컬한 경우만 fail-close
- 테스트 가능하다: 단독으로 실행하여 동작을 검증할 수 있음
8-2. 나쁜 훅의 특성
- 모든 도구에 무겁게 걸림: matcher를 빈 문자열로 두고 모든 도구 호출마다 복잡한 검사를 실행하면, 에이전트가 극도로 느려짐
- 과도한 차단: 너무 많은 것을 차단하면 에이전트가 아무것도 못하게 됨. 차단은 진짜 위험한 것에만 적용
- stdout에 쓸데없는 출력: 훅의 stdout은 모델의 컨텍스트에 삽입됨. 불필요한 디버그 메시지를 stdout에 출력하면 컨텍스트가 낭비됨 (디버그는 stderr로)
- 부작용이 큰 훅: 훅이 파일을 수정하거나 외부 상태를 변경하면, 예측하기 어려운 문제가 발생할 수 있음
8-3. 훅 vs. CLAUDE.md 지시 — 언제 무엇을 쓸까
에이전트의 행동을 제어하는 방법은 훅만 있는 것이 아닙니다. CLAUDE.md에 지시를 적는 것도 효과적입니다. 언제 무엇을 써야 할까요?
- CLAUDE.md 지시가 적합한 경우: 코딩 스타일, 아키텍처 규칙, 네이밍 컨벤션 등 “모델이 자발적으로 따르면 되는” 가이드라인. 예: “함수명은 camelCase로 작성하세요”
- 훅이 적합한 경우: “절대로 위반해서는 안 되는” 하드 제약. 모델이 실수하더라도 기계적으로 차단해야 하는 규칙. 예: “.env 파일 수정 금지”, “테스트 통과 없이 커밋 금지”
CLAUDE.md의 지시는 소프트 가드레일이고, 훅은 하드 가드레일입니다. 모델은 CLAUDE.md를 대부분 잘 따르지만, 복잡한 작업 중에 실수할 수 있습니다. 훅은 그 실수를 기계적으로 잡아냅니다. 둘을 조합하는 것이 가장 효과적입니다.
9. 고급 패턴: 훅 간 통신과 상태 관리
9-1. 파일 기반 상태 공유
훅은 독립적인 셸 프로세스로 실행되므로, 훅 간에 상태를 공유하려면 외부 저장소가 필요합니다. 가장 간단한 방법은 파일 시스템입니다:
#!/bin/bash
# PreToolUse 훅: 도구 호출 횟수 추적
STATE_DIR="/tmp/claude-hooks/${CLAUDE_SESSION_ID}"
mkdir -p "$STATE_DIR"
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# 도구별 호출 횟수 카운팅
COUNT_FILE="$STATE_DIR/count-$TOOL_NAME"
COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
COUNT=$((COUNT + 1))
echo "$COUNT" > "$COUNT_FILE"
# 특정 도구가 너무 많이 호출되면 경고
if [ "$COUNT" -gt 50 ]; then
echo "⚠️ $TOOL_NAME 도구가 이 세션에서 $COUNT 회 호출되었습니다."
echo "무한 루프에 빠진 것이 아닌지 확인하세요."
echo "현재 접근 방법을 재검토하고 다른 전략을 시도하세요."
exit 2
fi
exit 0
9-2. PreToolUse + PostToolUse 연동
PreToolUse에서 시작 시간을 기록하고, PostToolUse에서 종료 시간과 함께 분석하는 패턴입니다:
#!/bin/bash
# PreToolUse: 시작 시간 기록
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
STATE_DIR="/tmp/claude-hooks/${CLAUDE_SESSION_ID}"
mkdir -p "$STATE_DIR"
echo "$(date +%s%N)" > "$STATE_DIR/start-$TOOL_NAME"
exit 0
#!/bin/bash
# PostToolUse: 실행 시간 측정 및 로깅
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
STATE_DIR="/tmp/claude-hooks/${CLAUDE_SESSION_ID}"
START_TIME=$(cat "$STATE_DIR/start-$TOOL_NAME" 2>/dev/null || echo 0)
END_TIME=$(date +%s%N)
if [ "$START_TIME" -gt 0 ]; then
DURATION=$(( (END_TIME - START_TIME) / 1000000 )) # 밀리초
echo "$TOOL_NAME,$DURATION,$(date -Iseconds)" >> "$STATE_DIR/performance.csv"
if [ "$DURATION" -gt 30000 ]; then
echo "⚠️ $TOOL_NAME 실행이 ${DURATION}ms 걸렸습니다. 최적화를 고려하세요." >&2
fi
fi
exit 0
9-3. 환경변수를 통한 동적 설정
훅의 동작을 런타임에 변경하고 싶을 때, 환경변수를 활용할 수 있습니다:
# 엄격 모드로 Claude Code 실행
CLAUDE_HOOK_STRICT=1 claude
# 또는 특정 검사만 비활성화
CLAUDE_SKIP_LINT=1 claude
#!/bin/bash
# 환경변수로 동작 제어
INPUT=$(cat)
# 엄격 모드: 모든 경고를 차단으로 격상
if [ "${CLAUDE_HOOK_STRICT:-0}" = "1" ]; then
BLOCK_ON_WARNING=true
else
BLOCK_ON_WARNING=false
fi
# 린트 검사 스킵 옵션
if [ "${CLAUDE_SKIP_LINT:-0}" = "1" ]; then
exit 0
fi
# ... 린트 실행 로직 ...
if [ -n "$LINT_ISSUES" ]; then
echo "$LINT_ISSUES"
if [ "$BLOCK_ON_WARNING" = "true" ]; then
exit 2 # 엄격 모드: 차단
else
exit 0 # 일반 모드: 경고만
fi
fi
exit 0
10. UserPromptSubmit 훅의 활용 — 입력 단계에서의 제어
지금까지 대부분의 패턴이 도구 실행 시점(PreToolUse/PostToolUse)에 초점을 맞췄지만, UserPromptSubmit은 완전히 다른 차원의 제어를 가능하게 합니다. 모델이 프롬프트를 받기 전에 개입하는 것이기 때문입니다.
10-1. 프롬프트 컨텍스트 자동 주입
사용자가 어떤 프롬프트를 입력하든, 자동으로 현재 프로젝트 상태를 컨텍스트로 주입하는 패턴입니다:
#!/bin/bash
# UserPromptSubmit: 프로젝트 상태 컨텍스트 주입
INPUT=$(cat)
# 현재 git 브랜치와 상태를 stdout으로 출력 → 모델 컨텍스트에 삽입
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
DIRTY_FILES=$(git status --short 2>/dev/null | wc -l)
LAST_COMMIT=$(git log --oneline -1 2>/dev/null || echo "no commits")
echo "📌 현재 프로젝트 상태:"
echo " 브랜치: $BRANCH"
echo " 변경된 파일: $DIRTY_FILES 개"
echo " 마지막 커밋: $LAST_COMMIT"
exit 0
10-2. 위험 키워드 감지
#!/bin/bash
# UserPromptSubmit: 위험 키워드 감지
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.tool_input.prompt // empty')
# 프로덕션 관련 위험 키워드
if echo "$PROMPT" | grep -qiE '(프로덕션|production|prod)\s.*(삭제|제거|드롭|drop|reset|초기화)'; then
echo "⛔ 프로덕션 환경에 대한 파괴적 작업 요청이 감지되었습니다."
echo "이 작업은 안전을 위해 차단됩니다."
echo "프로덕션 변경은 수동으로 진행하세요."
exit 2
fi
exit 0
11. 훅과 MCP의 조합 — 외부 도구 호출 제어
3편에서 다뤘던 MCP 도구도 훅으로 제어할 수 있습니다. MCP 도구의 이름 형식은 mcp__서버명__도구명이므로, matcher에서 이 패턴을 활용합니다.
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__github__create_pull_request",
"command": "bash /scripts/hooks/pr-gate.sh",
"timeout": 30000
},
{
"matcher": "mcp__slack__post_message",
"command": "bash /scripts/hooks/slack-filter.sh",
"timeout": 5000
},
{
"matcher": "mcp__database__.*",
"command": "bash /scripts/hooks/db-readonly.sh",
"timeout": 5000
}
]
}
}
pr-gate.sh (PR 생성 전 검증):
#!/bin/bash
INPUT=$(cat)
TITLE=$(echo "$INPUT" | jq -r '.tool_input.title // empty')
BODY=$(echo "$INPUT" | jq -r '.tool_input.body // empty')
BASE=$(echo "$INPUT" | jq -r '.tool_input.base // "main"')
# PR 제목 컨벤션 검사
if ! echo "$TITLE" | grep -qE '^\[(feat|fix|docs|chore)\]'; then
echo "⛔ PR 제목이 컨벤션을 따르지 않습니다."
echo "형식: [type] description"
echo "예: [feat] 사용자 인증 기능 추가"
exit 2
fi
# PR 본문에 최소 정보 포함 확인
if [ ${#BODY} -lt 50 ]; then
echo "⛔ PR 본문이 너무 짧습니다 (최소 50자)."
echo "변경 사항, 테스트 방법, 관련 이슈를 포함하세요."
exit 2
fi
# main 브랜치로의 직접 PR만 허용 (릴리즈 프로세스)
if [ "$BASE" != "main" ] && [ "$BASE" != "develop" ]; then
echo "⛔ base 브랜치는 main 또는 develop만 허용됩니다."
exit 2
fi
exit 0
db-readonly.sh (데이터베이스 읽기 전용 강제):
#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
QUERY=$(echo "$INPUT" | jq -r '.tool_input.query // .tool_input.sql // empty')
# SELECT만 허용
if [ -n "$QUERY" ]; then
if echo "$QUERY" | grep -qiE '^\s*(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE)'; then
echo "⛔ 데이터 변경 쿼리는 허용되지 않습니다."
echo "읽기(SELECT) 쿼리만 실행할 수 있습니다."
echo "데이터 변경이 필요하면 마이그레이션 파일을 작성하세요."
exit 2
fi
fi
exit 0
12. 실전 사례: 팀 환경에서의 훅 운영
12-1. 온보딩 시나리오
새로운 팀원이 Claude Code를 사용하기 시작할 때, 프로젝트의 .claude/settings.json에 이미 팀 훅이 설정되어 있으므로 별도의 설정 없이 팀 정책이 자동으로 적용됩니다. 이것은 Guard Rails as Code의 실현입니다.
12-2. 팀 훅 저장소 패턴
훅 스크립트를 프로젝트마다 복사하지 않고, 공유 저장소에서 관리하는 패턴입니다:
# 팀 훅 저장소 클론 (1회)
git clone https://github.com/my-org/claude-hooks.git ~/.claude/team-hooks
# 글로벌 settings에서 참조
// ~/.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": ".*",
"command": "bash ~/.claude/team-hooks/security-gate.sh"
}
]
}
}
팀 훅 저장소를 업데이트하면 모든 팀원의 Claude Code에 즉시 반영됩니다. CI/CD로 훅 스크립트 자체의 테스트를 자동화할 수도 있습니다.
12-3. 훅 비활성화 프로토콜
특수한 상황에서 훅을 일시적으로 비활성화해야 할 때가 있습니다. 이를 위한 안전한 프로토콜:
#!/bin/bash
# 모든 훅 스크립트의 최상단에 추가
# 비활성화 토큰 파일이 존재하면 스킵
DISABLE_TOKEN="/tmp/claude-hooks-disabled-${CLAUDE_SESSION_ID}"
if [ -f "$DISABLE_TOKEN" ]; then
# 비활성화 사유를 로깅
REASON=$(cat "$DISABLE_TOKEN")
echo "[AUDIT] Hook disabled: $REASON" >> /tmp/claude-hooks-audit.log
cat > /dev/null # stdin 소비
exit 0
fi
비활성화 시에도 감사 로그를 남기는 것이 중요합니다. 누가, 언제, 왜 훅을 비활성화했는지 추적할 수 있어야 합니다.

13. 정리 — Hook 이벤트 빠른 참조표
지금까지 다룬 내용을 한눈에 정리합니다:
| 이벤트 | 발화 시점 | 차단 가능 | exit 2 의미 | 대표 활용 |
|---|---|---|---|---|
| PreToolUse | 도구 실행 직전 | O | 실행 차단 | 파일 보호, 명령 필터 |
| PostToolUse | 도구 실행 직후 | X | 경고 피드백 | 린트, 보안 스캔 |
| UserPromptSubmit | 프롬프트 전송 직전 | O | 전송 차단 | 위험 키워드, 컨텍스트 주입 |
| Stop | 에이전트 턴 종료 | 연장 | 작업 계속 | 품질 루프, 알림 |
| SubagentStop | 서브에이전트 턴 종료 | 연장 | 작업 계속 | 검증자 패턴 |
| Notification | 알림 발생 | X | 무시됨 | Slack/Telegram 알림 |
종료코드 규약:
- 0: 통과 (stdout → 모델에 정보 피드백)
- 2: 차단/연장 (stdout → 모델에 이유/지시 전달)
- 1 또는 기타: 훅 오류 (fail-open, 파이프라인 계속)
14. 마무리 — 그리고 다음 편 예고
Hooks는 Claude Code에서 “자율성과 통제의 균형”을 실현하는 핵심 메커니즘입니다. 핵심을 세 문장으로 요약하면:
- 6가지 이벤트가 도구 실행 파이프라인의 모든 주요 지점을 커버합니다.
- 종료코드 0/2/1이라는 단순한 프로토콜로 통과·차단·오류를 표현합니다.
- 셸 스크립트라는 범용 인터페이스 덕분에, 린터·보안 스캐너·알림 시스템 등 기존 도구와 자유롭게 연동됩니다.
Auto 모드에서 에이전트에게 자율성을 주되, 훅으로 경계를 설정하고, 품질 루프로 결과물을 보장하는 것 — 이것이 프로덕션 수준의 에이전트 운영에 필요한 제어의 기술입니다.
다음 편인 4편(하) “Subagents — 1인 다역에서 멀티 에이전트 오케스트레이션으로”에서는, 오늘 잠깐 언급했던 SubagentStop 훅의 진짜 무대가 펼쳐집니다. 하나의 Claude Code 세션 안에서 리뷰어·테스터·리서처 역할을 분리하고, 메인 에이전트가 이들을 오케스트레이션하는 멀티 에이전트 패턴을 다룹니다. 훅이 “하나의 에이전트를 제어하는 기술”이라면, 서브에이전트는 “여러 에이전트를 협업시키는 기술”입니다. 기대해 주세요.
이미지는 Leonardo AI 로 생성되었습니다.
이미지는 Claude AI 로 생성되었습니다.
◀ 이전 3화 (다음 차수는 아직 게시되지 않았습니다)


