Skip to content

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

Published: at 10:06 PM

들어가며

지난 차시(27차시)에서 Subagent를 닫으며 한 줄이 남았다 — “다음 차시부터 Hooks를 본격적으로 다룬다.” 이번 차시가 그 시작이다.

27차시까지는 Skills와 Subagent로 Claude가 무엇을 하는가를 만들었다면, 섹션 6(Hooks)부터는 Claude가 일하는 동안 자동으로 무엇을 끼워 넣는가를 만든다. 파일을 편집한 직후 포맷터를 돌리는 일, rm -rf 명령이 나가기 전에 차단하는 일, 세션 시작 시 환경 변수를 주입하는 일 — 이런 부수 동작을 Claude가 매번 똑같이 한다고 믿는 대신 시스템에 못 박아 두는 게 Hooks다.

이번 차시는 본격적인 실전 패턴(29~31차시)에 들어가기 전 준비다. 다룰 것은 셋이다.

다음 차시부터는 이 이벤트들 중 가장 자주 쓰는 셋 — PostToolUse(29), PreToolUse(30), SessionStart/Stop(31) — 을 하나씩 깊이 본다.

Hook이란

핵심 정의다.

Hook = Claude Code의 정해진 lifecycle 시점에서 자동으로 실행되는 사용자 정의 액션

“사용자 정의 액션”은 셸 명령만이 아니다. 다음 다섯 가지 중 하나다 — 본문 뒤에 더 자세히 본다.

  1. 셸 명령 (type: command) — 가장 흔함
  2. HTTP 엔드포인트 (type: http) — 원격 서버로 POST
  3. MCP 도구 (type: mcp_tool) — 연결된 MCP 서버의 도구 호출
  4. LLM 프롬프트 (type: prompt) — Claude에게 yes/no 평가 위임
  5. 서브에이전트 (type: agent) — 검증을 위해 subagent 호출 (실험적)

발화 시점은 settings.json에 정의된 이벤트 이름으로 지정한다. 예시:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "prettier --write \"$CLAUDE_FILE_PATH\""
          }
        ]
      }
    ]
  }
}

→ Edit 또는 Write 도구가 실행된 직후 prettier를 자동으로 돌린다. Claude에게 “포맷터 돌려줘”라고 부탁하지 않아도 시스템이 보장한다.

Hook vs CLAUDE.md vs 시스템 프롬프트

같은 일을 시킬 수 있는 경로가 셋 있는데 성격이 다르다.

동작 시점신뢰성
CLAUDE.md / 시스템 프롬프트Claude가 따르기로 결정했을 때모델 판단에 의존
권한 시스템 (permissions.allow/deny)도구 호출 시 정적 규칙매우 높음
Hooklifecycle 이벤트 발화 시시스템 차원 보장

“편집 후 prettier를 돌려”를 CLAUDE.md에 적으면 모델이 잊을 수 있다. Hook은 반드시 돈다. 강한 보장이 필요하면 hook, 가이드라인이면 CLAUDE.md.

Hook이 빛나는 시나리오

이벤트 분류 — 다섯 가지 카당스

Hook 이벤트는 언제 발화하느냐로 다섯 그룹이다. 이름만 외우는 것보다 그룹으로 묶어 두면 어떤 이벤트가 있는지 감이 잡힌다.

1. Session 단위 (세션당 1회)

이벤트발화 시점
SessionStart세션 시작/재개. matcherstartup/resume/clear/compact 구분
SessionEnd세션 종료. matcherclear/logout/prompt_input_exit 구분
Setup--init-only-p --init/--maintenance 같은 일회성 트리거

용도 — 환경 변수 주입, 의존성 설치, 컨텍스트 사전 로딩.

2. Turn 단위 (사용자 요청마다 1회)

이벤트발화 시점
UserPromptSubmit사용자가 메시지 제출, Claude가 처리하기 전
UserPromptExpansion슬래시 명령이 확장될 때 (matcher는 명령 이름)
StopClaude가 응답을 마쳤을 때
StopFailureAPI 에러로 턴이 끝났을 때 (블록 불가, 로깅용)

용도 — 프롬프트 차단/증강, 작업 완료 알림, 추가 작업 강제.

3. 도구 호출 단위 (도구 호출마다)

이벤트발화 시점
PreToolUse도구 실행 . 허용/거부/사용자에게 묻기/입력 수정 가능
PostToolUse도구가 성공한
PostToolUseFailure도구가 실패한 후 (입력에 tool_error 포함)
PermissionRequest권한 다이얼로그가 뜨려고 할 때
PermissionDenied사용자가 권한을 거부했을 때
PostToolBatch병렬 도구 배치가 완료된 후, 다음 모델 호출 직전

용도 — 위험 명령 차단, 자동 포맷팅, 도구 입력 sanitize, 결과 후처리.

4. 비동기/외부 이벤트

이벤트발화 시점
Notification알림 발송 시 (권한 prompt, 인증 등). matcher로 종류 구분
ConfigChange설정 파일이 바뀌었을 때
CwdChanged작업 디렉토리가 바뀌었을 때
FileChanged감시 중인 파일이 바뀌었을 때 (matcher에 파일 이름 명시)
WorktreeCreate / WorktreeRemovegit worktree 생성/제거 시
InstructionsLoadedCLAUDE.md나 .claude/rules/ 파일이 로드될 때
PreCompact / PostCompact컨텍스트 압축 직전/직후

용도 — 외부 변경 감지, transcript 백업, 환경 동기화.

5. 에이전트/Task

이벤트발화 시점
SubagentStart / SubagentStop서브에이전트 시작/종료 (matcher로 agent type 구분)
TeammateIdleAgent team 동료가 idle 상태로 갈 때
TaskCreated / TaskCompletedTask 생성/완료 시 (exit code 2로 차단 가능)
Elicitation / ElicitationResultMCP 서버의 사용자 입력 요청

27차시에서 본 subagent 구성에서 hooks frontmatter로 PreToolUse/PostToolUse를 걸 수 있다고 했는데, 메인 세션의 Stop은 서브에이전트 안에서는 자동으로 SubagentStop으로 변환된다 — agent 내 정의 시 직접 SubagentStop을 쓰지 않아도 동작한다.

설정 파일 — 어디에 정의하나

Hook은 settings.jsonhooks 키 아래에 정의한다. settings 파일은 네 단(+ managed)으로 쌓이고, 같은 키가 충돌하면 우선순위가 높은 쪽이 이긴다.

우선순위위치범위git 공유
1 (최고)Managed (조직 정책)모든 사용자IT가 배포
2CLI flag (--settings)현재 세션
3.claude/settings.local.json프로젝트, 본인만자동 gitignore
4.claude/settings.json프로젝트, 팀 공유커밋됨
5 (최저)~/.claude/settings.json본인의 모든 프로젝트

권한 규칙(permissions.allow/deny)은 merge되는데, hooks도 마찬가지로 여러 스코프에서 정의된 게 모두 발화한다. 같은 이벤트에 hook을 user와 project 양쪽에서 걸면 둘 다 돈다.

기본 JSON 구조

세 단계로 중첩된다 — 이벤트, matcher 그룹, 핸들러 리스트.

{
  "hooks": {
    "EventName": [
      {
        "matcher": "ToolPattern",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/handler.sh",
            "timeout": 600
          },
          {
            "type": "command",
            "command": "another-handler"
          }
        ]
      }
    ]
  }
}

읽는 법 — hooks.EventNamematcher 그룹의 배열이고, 각 그룹의 hooks핸들러의 배열이다. matcher가 매치되면 그 그룹 안의 핸들러가 모두 순서대로 발화한다.

Matcher 패턴 규칙

matcher는 hook이 발화할지 말지를 거른다. 규칙은 세 갈래다.

패턴 형태평가 방식예시
"*", "", 생략모두 매치항상 발화
알파벳/숫자/_/|정확 매치 또는 파이프 리스트Bash, Edit|Write
그 외 문자 포함JavaScript 정규식^Notebook, mcp__memory__.*

이벤트별 matcher 의미

같은 matcher 문자열이 이벤트마다 다른 것을 거른다.

이벤트matcher가 거르는 것
PreToolUse/PostToolUse 등 도구 이벤트도구 이름 (예: Bash, mcp__memory__.*)
SessionStart시작 소스 (startup, resume, clear, compact)
SetupCLI 트리거 (init, maintenance)
SessionEnd종료 이유 (clear, logout, prompt_input_exit)
Notification알림 종류 (permission_prompt, auth_success)
SubagentStart/SubagentStopagent 이름 (Explore, general-purpose, 커스텀)
ConfigChange설정 소스 (user_settings, policy_settings)
FileChanged리터럴 파일 이름 (.envrc|.env)
CwdChanged, UserPromptSubmit, PostToolBatchmatcher 미지원 — 항상 발화

MCP 도구 매칭

MCP 도구는 mcp__<서버>__<도구> 패턴을 따른다. 정규식으로 묶으면 한 서버의 모든 도구를 잡을 수 있다.

{ "matcher": "mcp__memory__.*" }

if 필드 — 권한 규칙 문법

도구 이벤트에서 추가로 좁히고 싶을 때 if 필드를 쓴다 — 권한 규칙과 같은 문법이다.

{
  "type": "command",
  "if": "Bash(rm *)",
  "command": "./block-rm.sh"
}

→ Bash 도구의 rm 명령 호출에만 발화. matcher로 도구 종류를 거르고, if로 인수 패턴까지 좁힌다.

Hook 타입 — 다섯 가지

1. command — 셸 명령 (가장 흔함)

{
  "type": "command",
  "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
  "timeout": 600
}

stdin으로 JSON 입력을 받고, stdout으로 JSON 출력(또는 메시지)을 낸다.

Exec form vs Shell formargs를 같이 지정하면 셸을 거치지 않고 직접 실행 (공백 있는 경로 안전).

{
  "command": "node",
  "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/format.js", "--fix"]
}

args 없이 한 줄 문자열이면 셸이 토큰화 (파이프/글롭/&& 가능).

2. http — HTTP POST

{
  "type": "http",
  "url": "https://hooks.example.com/claude",
  "timeout": 30,
  "headers": { "Authorization": "Bearer $MY_TOKEN" },
  "allowedEnvVars": ["MY_TOKEN"]
}

원격 서버에 JSON을 POST한다. 2xx 응답에 결정 JSON을 담아 보내면 블로킹 효과 가능.

보안 가드: managed settings의 allowedHttpHookUrls 패턴에 매치하는 URL만 허용 가능, httpHookAllowedEnvVars로 인터폴레이션 가능한 env var 제한.

3. mcp_tool — MCP 서버 도구 호출

{
  "type": "mcp_tool",
  "server": "security_scanner",
  "tool": "scan",
  "input": { "file_path": "${tool_input.file_path}" }
}

연결된 MCP 서버의 도구를 hook에서 직접 호출한다. 32차시 이후의 MCP와 묶이는 패턴.

4. prompt — Claude에게 평가 위임

{
  "type": "prompt",
  "prompt": "Is this command safe? $ARGUMENTS",
  "model": "fast-model",
  "timeout": 30
}

Claude에게 yes/no 평가를 시키고 그 결과로 hook 결정을 내린다. 패턴이 너무 복잡해 정적 규칙으로 거르기 어려울 때 쓴다 (비용 듦).

5. agent — 서브에이전트 검증 (실험적)

{
  "type": "agent",
  "prompt": "Verify the deployment checklist. $ARGUMENTS"
}

서브에이전트를 띄워 검증한다. 무거운 검증이 필요할 때.

Hook 입출력 — 표준 인터페이스

입력 (stdin JSON)

모든 이벤트가 공통으로 받는 필드 (이벤트마다 추가 필드가 더 있음):

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/current/working/directory",
  "permission_mode": "default",
  "hook_event_name": "PostToolUse",
  "effort": { "level": "high" },
  "agent_id": "subagent-uuid",
  "agent_type": "agent-name"
}

도구 이벤트에는 tool_nametool_input(전체 입력)이 추가로 들어온다 — tool_input.command(Bash), tool_input.file_path(Edit) 같은 식으로 jq로 빼서 쓴다.

환경 변수

스크립트가 받는 주요 환경 변수:

변수설명
$CLAUDE_PROJECT_DIR프로젝트 루트
$CLAUDE_PLUGIN_ROOT플러그인 설치 경로
$CLAUDE_PLUGIN_DATA플러그인 영구 데이터 디렉토리
$CLAUDE_EFFORT현재 effort 레벨
$CLAUDE_ENV_FILEenv var 파일 (SessionStart/Setup/CwdChanged/FileChanged에서만)

입력 JSON의 tool_input.file_path도 셸 환경 변수로 풀어주는 게 과거 동작이었다. 현재는 stdin JSON에서 jq -r '.tool_input.file_path'로 빼는 게 권장 방식 — $CLAUDE_FILE_PATH 같은 이름의 환경 변수가 자동으로 채워진다는 보장은 없다.

Exit code의 의미

Exit code의미
0성공. stdout이 JSON이면 파싱되어 처리됨
2블로킹 에러. stderr가 사용자/Claude에게 표시됨. 이벤트별로 효과 다름 (예: PreToolUse면 도구 실행 차단)
그 외비블로킹 에러. stderr는 transcript 첫 줄에, 전체는 디버그 로그에

흔한 함정 — 블로킹하려면 exit 1이 아니라 exit 2다. exit 1이나 다른 값은 에러 보고만 하고 작업은 그대로 진행된다.

JSON 출력

stdout JSON으로 더 정교한 제어가 된다 (exit 0 + JSON, exit 2면 JSON 무시).

모든 이벤트 공통 필드:

{
  "continue": true,
  "stopReason": "messages",
  "suppressOutput": false,
  "systemMessage": "warning"
}

컨텍스트 추가 — Claude에게 추가 정보 전달:

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "Current branch: main"
  }
}

결정 제어 — PreToolUse 같은 이벤트에서 허용/거부:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow|deny|ask|defer",
    "permissionDecisionReason": "Reason",
    "updatedInput": { "modified": "tool_input" }
  }
}

출력은 10KB로 캡 — 넘으면 파일로 저장된다. additionalContext는 명령형이 아니라 사실로 적는 게 안전 (prompt injection 방어 작동).

작은 예제 — 두 가지 형태

A. 한 줄 명령 (Edit 후 prettier)

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "prettier --write \"$(jq -r '.tool_input.file_path')\""
          }
        ]
      }
    ]
  }
}

B. 스크립트 + JSON 결정 (Bash rm -rf 차단)

.claude/hooks/block-rm.sh:

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -qE '\brm\s+-rf\b'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "rm -rf blocked by safety hook"
    }
  }'
  exit 0
fi

exit 0

.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

chmod +x도 잊지 말 것.

자세한 코드와 변형은 30차시(PreToolUse와 보안)에서 더 깊이 본다 — 이번 차시는 구조만.

/hooks 명령으로 등록 확인

세션 내에서 /hooks를 입력하면 현재 활성 hook이 카탈로그로 뜬다.

> /hooks

표시되는 정보:

여러 스코프에서 정의된 게 다 합쳐져 보이므로, 같은 이벤트에 user/project/local 어디서 뭐가 걸려 있는지 확인 가능. 디버깅의 1순위 도구.

일괄 비활성화

전체 hook을 끄려면:

{
  "disableAllHooks": true
}

CLI에서 임시로:

claude --debug

→ 디버그 모드는 hook 실행을 비활성화하지 않지만 자세한 로그를 보여주어 무엇이 발화했는지 추적 가능.

Managed settings의 hook은 사용자/프로젝트 disableAllHooks로 꺼지지 않는다. 조직 정책이 우선.

Skills/Agents의 frontmatter hook

27차시에서 본 subagent frontmatter도 hooks를 받는다 — 컴포넌트가 활성화된 동안만 동작.

---
name: secure-ops
description: ...
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/security-check.sh"
---

→ 이 subagent가 위임받았을 때에만 hook이 걸린다. settings.json에 적는 것과 같은 JSON 구조지만 스코프가 컴포넌트 단위다. Skills frontmatter도 마찬가지.

Stop hook을 subagent frontmatter에 적으면 자동으로 SubagentStop으로 변환된다.

흔한 함정

  1. 블로킹은 exit 2 — exit 1은 비블로킹 에러
  2. JSON 출력은 exit 0에서만 — exit 2면 JSON 무시되고 stderr만 처리됨
  3. matcher 정규식 vs 정확 매치 — 알파벳/숫자/_/|만 있으면 정확 매치, 그 외면 정규식. Bash. 같은 점 하나로 동작이 바뀜
  4. 공백 있는 경로 — exec form(args 배열) 쓰거나 셸에서 명시적 쿼팅. prettier --write $CLAUDE_FILE_PATH처럼 따옴표 없이 쓰면 공백에서 깨짐
  5. $CLAUDE_ENV_FILE는 일부 이벤트만 — SessionStart/Setup/CwdChanged/FileChanged 외에는 없음
  6. additionalContext를 명령형으로 — “do X”가 아니라 “X is the case”로 적어야 prompt injection 방어가 통과시킴
  7. 여러 스코프의 hook 중복 — user와 project에 같은 matcher 두면 양쪽 다 발화 (의도된 동작)
  8. /hooks로 확인 — 적용 안 되는 것 같으면 일단 /hooks 메뉴로 등록 여부 확인

정리

핵심 요점

  1. Hook = 정해진 lifecycle 시점에 자동 실행되는 사용자 액션 — CLAUDE.md 가이드보다 시스템 보장 강함
  2. 다섯 타입command(셸), http(POST), mcp_tool(MCP), prompt(Claude 평가), agent(서브에이전트, 실험)
  3. 다섯 카당스 — session 단위 / turn 단위 / tool 호출 단위 / 비동기/외부 / 에이전트·task
  4. 자주 쓰는 이벤트PreToolUse(보안 게이트), PostToolUse(포맷팅/검사), SessionStart(환경 설정), Stop(완료 후속 작업), UserPromptSubmit(프롬프트 증강)
  5. Settings 4단 + Managed~/.claude/settings.json < .claude/settings.json < .claude/settings.local.json < CLI < Managed
  6. JSON 구조 3단hooks.이벤트matcher 그룹 배열 → hooks 핸들러 배열
  7. Matcher"*"/생략은 전부, 알파벳/숫자/_/|만이면 정확 매치, 그 외는 정규식
  8. 이벤트별 matcher 의미가 다름 — 도구 이벤트는 도구 이름, SessionStart는 시작 소스, Notification은 알림 종류 등
  9. if 필드로 더 좁힘 — 권한 규칙 문법(Bash(rm *), Edit(*.ts))
  10. Exit code — 0 정상, 2가 블로킹, 그 외 비블로킹 에러
  11. JSON 출력으로 정교한 제어hookSpecificOutput.permissionDecision로 허용/거부/묻기/연기
  12. 환경 변수$CLAUDE_PROJECT_DIR, $CLAUDE_PLUGIN_ROOT, $CLAUDE_ENV_FILE(특정 이벤트)
  13. /hooks 메뉴 — 어디 스코프에서 무엇이 걸려 있는지 확인. 디버깅의 1순위
  14. disableAllHooks — 일괄 끄기 (managed hook은 안 꺼짐)
  15. Skill/Subagent frontmatter — 같은 JSON 구조로 컴포넌트 스코프 hook 가능

다음 단계

다음 세 차시에서 가장 자주 쓰는 이벤트를 하나씩 깊이 본다.

참고 자료


Next Post
Claude Code Subagent 스킬 — Explore·Plan·general-purpose와 커스텀 에이전트로 컨텍스트 격리하기