들어가며
29차시에서 PostToolUse(성공 직후 후처리)를, 30차시에서 PreToolUse(실행 직전 게이트)를 봤다. 둘 다 도구 단위로 발화하는 hook이다 — Edit·Write·Bash 같은 호출 하나하나에 붙어 돈다.
이번 31차시는 한 단계 위로 올라간다. 도구가 아니라 세션 단위로 발화하는 hook 세 개 — SessionStart, Stop, SessionEnd다. 세션이 시작될 때 환경을 깔고, Claude가 응답을 끝내려는 순간 한 번 더 검증하고, 세션이 닫힐 때 정리를 거는 흐름이다.
이 셋은 도구 hook과 같은 구조를 공유하면서도 역할이 다르다 — 도구 단위가 아니라 세션의 경계에서 동작한다. 그래서 다루는 문제도 다르다.
- SessionStart — 의존성 자동 설치, 환경 변수 주입, 컨텍스트 사전 로딩
- Stop — Claude가 응답을 끝내기 직전, 테스트·검증 게이트
- SessionEnd — 임시 파일 정리, 세션 메트릭 로깅, 외부 알림
이번 차시에서 다룰 것은 여섯이다.
- 세 이벤트의 발화 조건 — 각각 언제, 그리고 SessionStart·SessionEnd만 가지는 세션 단위 매처
- 입력 JSON과 응답 옵션 —
source,reason,stop_hook_active같은 세션 hook 고유 필드, 그리고 차단 가능 여부의 차이 CLAUDE_ENV_FILE메커니즘 — SessionStart 전용 기능. Bash 환경을 미리 깔아두는 방식- Stop의 무한 루프 방지 —
decision: "block"을 쓸 때의 함정과stop_hook_active, 8회 블록 캡 - 실전 다섯 패턴 — compact 후 컨텍스트 재주입 / 원격 세션 의존성 설치 /
direnv환경 자동 로드 / Stop 검증 게이트 / SessionEnd 정리·로깅 - 흔한 함정 — SessionStart 차단 불가, command·mcp_tool만 지원, SessionEnd 출력 무시, 셸 프로필이 JSON 오염시키는 문제까지
세션의 양 끝 — 세 이벤트를 한눈에
먼저 세 이벤트를 한 표에 모아 비교한다. 도구 hook(29·30차시)과 다른 점이 여기서 다 드러난다.
| 항목 | SessionStart | Stop | SessionEnd |
|---|---|---|---|
| 발화 시점 | 세션 시작 / 재개 / /clear / compaction 후 | Claude가 응답을 마치기 직전 | 세션 종료 시 |
| 매처 값 | startup, resume, clear, compact | 없음(항상 발화) | clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other |
| 차단 가능? | 불가(exit 2는 stderr만 사용자에게 표시) | 가능 — decision: "block"로 응답 막음 | 불가 — 출력만 가능 |
| 컨텍스트 주입 | 가능 (additionalContext, plain stdout) | 가능 (additionalContext) | 불가 |
| 지원 hook 타입 | command, mcp_tool만 | 전 타입 (command/http/prompt/agent/mcp_tool) | 전 타입 |
CLAUDE_ENV_FILE 접근 | 가능 | 불가 | 불가 |
| 주 용도 | 환경·컨텍스트 사전 주입 | 응답 종료 게이트 | 정리·로깅 |
도구 hook과 비교해 두 가지가 두드러진다. 첫째, 매처가 도구 이름이 아니라 이벤트가 발생한 이유다 — “어떤 도구냐”가 아니라 “어떤 식으로 세션이 시작됐냐 / 끝났냐”. 둘째, SessionStart는 차단 권한이 없다. exit 2를 내도 세션은 정상 시작된다 — stderr는 사용자한테만 보이고 만다. SessionStart로 보안 게이트를 짜려고 하면 안 된다. 그건 PreToolUse의 자리다.
SessionStart — 세션이 시작될 때
가장 자주 쓰는 세션 hook이다. 의존성 설치, 환경 변수 주입, 사전 컨텍스트 로딩 — 세션이 어떻게 시작되든 항상 깔리는 준비 단계를 박는 자리다.
4값의 source 매처
SessionStart는 세션이 시작되는 방식에 따라 네 값으로 분기된다.
| Matcher | 발화 조건 |
|---|---|
startup | 새 세션을 처음 시작 |
resume | --resume, --continue, /resume으로 이전 세션 이어 받기 |
clear | 세션 안에서 /clear 실행 후 |
compact | auto 또는 manual compaction 직후 |
같은 hook을 모든 매처에 쓰려면 그룹을 분리하지 말고 매처를 빼면 된다. 한 매처에만 쓰려면 매처를 명시한다.
입력 JSON
{
"session_id": "abc123",
"transcript_path": "/Users/.../transcript.jsonl",
"cwd": "/Users/me/my-project",
"hook_event_name": "SessionStart",
"source": "startup",
"model": "claude-sonnet-4-6",
"agent_type": "general-purpose"
}
도구 hook과 비교해 핵심 차이는 두 가지다.
tool_name·tool_input·tool_output이 없다. 도구가 아니라 세션에 대한 이벤트이기 때문이다.- 대신
source(4값),model, 그리고--agent로 시작했다면agent_type이 들어온다.
source로 분기를 짜고 싶으면 jq로 꺼내면 된다.
input=$(cat)
source=$(jq -r '.source' <<< "$input")
case "$source" in
startup) echo "새 세션" ;;
resume) echo "이어받기" ;;
clear) echo "/clear 후" ;;
compact) echo "compact 후" ;;
esac
응답 옵션 — hookSpecificOutput 세 필드
SessionStart는 차단 권한이 없는 대신, Claude에게 컨텍스트를 주입하는 데 강하다. hookSpecificOutput 안에 세 필드를 쓸 수 있다.
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "현재 브랜치: feat/auth. 최근 이슈: #4211 OAuth2 마이그레이션.",
"initialUserMessage": "스프린트 컨텍스트를 알려줘",
"watchPaths": ["/Users/me/proj/.env", "/Users/me/proj/.envrc"]
}
}
| 필드 | 의미 |
|---|---|
additionalContext | 첫 프롬프트 앞에 끼워 넣는 컨텍스트. 여러 hook이 내면 연결되어 모두 들어간다 |
initialUserMessage | -p 비대화형 모드에서 첫 사용자 메시지로 들어간다. 프롬프트가 함께 있으면 그 뒤에 두 번째 턴으로 붙는다 |
watchPaths | FileChanged 이벤트로 감시할 절대 경로 배열. 이 세션 동안만 등록 |
plain stdout도 컨텍스트로 들어간다
SessionStart는 JSON이 아닌 그냥 stdout도 Claude의 컨텍스트로 흘러간다. 그래서 가장 간단한 컨텍스트 주입은 echo 한 줄이다.
echo "현재 브랜치: $(git branch --show-current). 최근 커밋: $(git log -1 --oneline)"
이 출력이 그대로 Claude의 첫 컨텍스트로 들어간다. JSON 구조를 짤 필요가 없다.
additionalContextvs plain stdout — 단순한 텍스트 한 덩어리만 넣을 거면 plain stdout이 가볍다. JSON으로 가야 하는 경우는 동시에 다른 필드(initialUserMessage,watchPaths,systemMessage등)를 같이 내야 할 때, 그리고 28차시에서 본 명령형이 아니라 사실 형태로 prompt injection 방어를 통과시키고 싶을 때다.
지원 hook 타입 — command와 mcp_tool만
도구 hook과 달리 SessionStart에는 HTTP hook과 prompt hook이 안 된다. type은 command 또는 mcp_tool 둘 중 하나다.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/init-session.sh",
"timeout": 60
}
]
}
]
}
}
왜 제한이 있나 — SessionStart는 세션이 준비되기 전에 도는 단계라 LLM 호출 기반 hook(
prompt,agent)이 의미가 없고, HTTP hook도 별도 인증·네트워크 단계가 필요해 시작 단계와 잘 안 맞아서다. 명시적 셸 명령 또는 이미 연결된 MCP 도구 호출만 허용한다.
CLAUDE_ENV_FILE — Bash 환경을 미리 깔아두기
SessionStart의 가장 강력한 메커니즘은 응답 JSON이 아니라 CLAUDE_ENV_FILE 환경 변수다.
이 변수는 파일 경로다. SessionStart hook이 그 파일에 export 문을 append 하면, Claude Code가 이후 모든 Bash 도구 호출 앞에 그 파일을 source한다. 즉, hook이 한 번 환경 변수를 세팅해 두면 그 세션의 모든 Bash 명령에 자동으로 들어간다.
#!/bin/bash
# .claude/hooks/setup-env.sh
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
echo 'export DEBUG_LOG=true' >> "$CLAUDE_ENV_FILE"
echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
fi
exit 0
이 hook이 SessionStart에 걸려 있으면, 이후 npm test든 node ./script.js든 모두 NODE_ENV=development로 실행된다.
환경 변경 자동 캡처
직접 export 줄을 쓰는 대신, 어떤 명령이 환경을 바꾼 결과를 통째로 캡처할 수도 있다. before/after 스냅샷의 차이만 CLAUDE_ENV_FILE에 넣는 패턴이다.
#!/bin/bash
ENV_BEFORE=$(export -p | sort)
source ~/.nvm/nvm.sh
nvm use 20
if [ -n "$CLAUDE_ENV_FILE" ]; then
ENV_AFTER=$(export -p | sort)
comm -13 <(echo "$ENV_BEFORE") <(echo "$ENV_AFTER") >> "$CLAUDE_ENV_FILE"
fi
exit 0
이 변수는 SessionStart 전용이 아니다.
Setup,CwdChanged,FileChangedhook도 같은 변수에 쓸 수 있다. Stop·SessionEnd·도구 hook(PreToolUse·PostToolUse 등)에는 없다 — 이미 도구가 도는 시점이라 환경을 깔아 두는 의미가 없기 때문이다.
Stop — Claude가 응답을 마치기 직전
Stop은 Claude가 응답을 끝내고 사용자에게 결과를 보여 주려는 순간 발화한다. 매처는 없고 항상 돈다.
이 hook이 다른 세션 hook과 결정적으로 다른 점은 — 응답 종료를 막을 수 있다. decision: "block"을 반환하면 Claude가 멈추지 못하고 그 턴을 계속 이어간다. 완료 검증을 시스템 차원에서 박는 자리다.
입력 JSON
{
"session_id": "abc123",
"transcript_path": "/Users/.../transcript.jsonl",
"cwd": "/Users/me/my-project",
"permission_mode": "default",
"hook_event_name": "Stop",
"effort": { "level": "medium" },
"stop_hook_active": false
}
세 가지가 눈에 띈다.
tool_name·tool_input은 없다 (세션 단위 hook이라).effort로 현재 작업 강도(low/medium/high/xhigh/max)를 알 수 있다.stop_hook_active는 이미 Stop hook이 발화 중인 상태인지를 알려 준다. 무한 루프 방지의 핵심이다. 잠시 뒤에 다룬다.
응답 옵션 — block과 reason
Stop의 응답 패턴은 두 갈래다.
방법 1 — exit 2 + stderr
#!/bin/bash
if ! tests_pass; then
echo "테스트 실패. 응답을 마치지 못한다." >&2
exit 2
fi
exit 0
→ exit 2면 응답이 차단되고 Claude는 stderr 메시지를 받아 계속 작업한다.
방법 2 — exit 0 + JSON
{
"decision": "block",
"reason": "테스트 5건 실패. 출력의 오류를 수정하고 다시 시도하라.",
"hookSpecificOutput": {
"hookEventName": "Stop",
"additionalContext": "src/auth.test.ts:45에서 assertion 실패"
}
}
→ decision: "block"이면 Claude가 그 턴을 계속 이어간다. reason은 왜 막혔는지 Claude에게 가는 설명이고, additionalContext는 근거(사실)다. 분리 권장은 29차시에서 본 것과 같다.
Stop은 PostToolUse와 다르게
decision: "block"이 의미가 다르다. PostToolUse의 block은 다음 모델 호출을 막는 거고, Stop의 block은 응답 종료를 막는다 — 즉 Claude가 한 턴 더 작업하게 된다. “끝났다고 했는데 사실 안 끝났으니 계속해” 신호다.
무한 루프 방지 — stop_hook_active와 8회 캡
Stop hook의 가장 큰 함정은 무한 루프다. 매 응답마다 decision: "block"을 내면 Claude는 영원히 작업을 끝내지 못한다.
방어는 두 단계다.
1차 방어 — stop_hook_active 체크
Stop hook이 block을 내서 다시 작업이 돌고, 그 작업이 끝나면서 또 다시 Stop hook이 발화하면 이번에는 stop_hook_active: true로 들어온다. 스크립트는 이 값을 보고 재귀를 멈춰야 한다.
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # 이미 한 번 block을 냈으므로 통과
fi
# 평소 검증 로직
if ! tests_pass; then
jq -n '{decision: "block", reason: "테스트 실패"}'
fi
exit 0
2차 방어 — 8회 블록 캡
설령 스크립트가 stop_hook_active를 무시해도, Claude Code는 연속 8회 block 이후로는 더 이상 받지 않고 세션을 종료한다. 한도를 늘리려면 환경 변수로 띄운다.
export CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=10
CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 환경 변수는 Claude Code v2.1.140 이상에서 동작한다.
어떤 패턴이 정상인가 — “조건이 충족될 때만 block”이 원칙이다. 항상 block을 내는 Stop hook은 8회 만에 캡에 걸려 결과적으로 검증이 무력화된다. 테스트가 통과하면 통과시키고, 실패하면 block — 이 분기가 있어야 hook이 본래 일을 한다.
SessionEnd — 세션 종료
세션이 닫히는 방식은 여섯 가지다 — /clear, 다른 세션으로 이동, 로그아웃, Ctrl+D나 /exit, bypass 모드 해제, 그 외. SessionEnd hook의 매처는 이 여섯 값을 그대로 받는다.
| Matcher | 발화 조건 |
|---|---|
clear | /clear 실행 후 |
resume | 다른 세션으로 이동(다른 세션을 resume) |
logout | 로그아웃 |
prompt_input_exit | Ctrl+D 또는 /exit |
bypass_permissions_disabled | bypass 권한 모드가 해제됨 |
other | 그 외 |
입력 JSON
{
"session_id": "abc123",
"transcript_path": "/Users/.../transcript.jsonl",
"cwd": "/Users/me/my-project",
"hook_event_name": "SessionEnd",
"reason": "prompt_input_exit"
}
특별한 필드는 reason 하나다. 매처 값과 같다.
차단할 수 없다 — 출력만 가능
이게 SessionEnd의 핵심 제약이다. decision도 exit code도 무시된다. 세션이 이미 닫히는 흐름이라 막을 게 없다.
쓸 수 있는 응답 필드는 셋이다.
{
"systemMessage": "세션 로그가 /tmp/session.log에 저장됨",
"suppressOutput": true,
"terminalSequence": "]777;notify;Session Complete"
}
| 필드 | 의미 |
|---|---|
systemMessage | 사용자에게 보이는 경고 메시지 |
suppressOutput | true면 stdout이 transcript에 안 보임 (디버그 로그에는 여전히 남음) |
terminalSequence | 터미널 이스케이프 시퀀스(OSC 0/1/2/9/99/777 또는 BEL) — 알림·창 제목·벨 |
요컨대 SessionEnd는 사이드 이펙트 전용이다. 임시 파일 정리, 외부 로그 시스템에 이벤트 전송, 데스크탑 알림 — 이런 작업이 들어갈 자리다.
세션이 끝났는지 검증하고 싶다면 Stop hook이지 SessionEnd가 아니다. SessionEnd는 이미 끝난 일이라 막을 게 없다 — PostToolUse가 도구 실행을 못 막는 것과 같은 이유다.
패턴 1 — compact 후 컨텍스트 재주입
가장 자주 쓰이는 SessionStart 패턴이다. 컨텍스트 윈도우가 차면 Claude Code가 compaction으로 대화를 요약해 공간을 비운다 — 이때 중요한 디테일이 빠질 수 있다. compact 매처에 hook을 걸어 매번 컨텍스트를 다시 깐다.
.claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo '리마인더: npm 대신 bun을 쓴다. 커밋 전 bun test. 현재 스프린트: auth refactor.'"
}
]
}
]
}
}
→ /compact 또는 자동 compaction이 끝날 때마다 이 echo 출력이 Claude의 컨텍스트로 다시 들어간다. CLAUDE.md에 적어 둔 핵심 규약이 요약 과정에서 흐려지지 않게 복원하는 게이트다.
동적 출력을 넣고 싶으면 git log --oneline -5나 gh issue list --assignee @me 같은 명령으로 바꾸면 된다.
{
"type": "command",
"command": "echo '최근 커밋:'; git log --oneline -5"
}
매 세션 시작마다 항상 같은 컨텍스트를 깔고 싶다면 CLAUDE.md가 맞다 — 그건 모델이 매 턴 받는 시스템 프롬프트 일부다. SessionStart hook은 동적인 정보(현재 브랜치, 최근 PR, 진행 중인 이슈)나 compaction 후 복구 같은 경우에 쓴다.
패턴 2 — 원격 세션에서만 의존성 자동 설치
npm install이나 pip install -r requirements.txt를 매 세션마다 돌리면 로컬에서는 낭비다 — 이미 깔려 있다. 하지만 원격(클라우드) 세션에서는 깨끗한 환경에서 시작하므로 의존성 설치가 필요하다.
CLAUDE_CODE_REMOTE 환경 변수가 분기 키다. 클라우드 세션(Claude Code on the web)에서는 "true"로 들어오고, 로컬 CLI에서는 세팅되지 않는다.
.claude/hooks/install-deps.sh:
#!/bin/bash
# 원격(클라우드) 세션에서만 실행
if [ "$CLAUDE_CODE_REMOTE" != "true" ]; then
exit 0
fi
echo "원격 세션 감지 — 의존성 설치 시작" >&2
# package.json이 있으면 npm 의존성 설치
if [ -f package.json ]; then
npm ci || npm install
fi
# requirements.txt가 있으면 pip 의존성 설치
if [ -f requirements.txt ]; then
pip install -r requirements.txt
fi
# Claude에게 결과를 알림
echo "원격 세션 의존성 설치 완료"
exit 0
.claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/install-deps.sh",
"timeout": 300
}
]
}
]
}
}
핵심 두 가지.
startup매처로 한정 — 매번/clear나/compact후에 의존성을 다시 깔 필요는 없다. 새 세션 시작 한 번만.CLAUDE_CODE_REMOTE체크 — 로컬에서는 조용히 종료. exit 2를 내면 stderr만 사용자에게 보이지 세션은 계속 진행된다 — SessionStart는 차단할 수 없기 때문이다.timeout을 넉넉히 —npm install은 길어진다. 기본 600초보다 짧게 잡으면 큰 레포에서 깨진다. 300초 정도로 잡고, 더 큰 프로젝트면 늘려라.
CLAUDE_CODE_REMOTE는 지역적 의미의 원격이 아니라 정확히 Claude Code on the web의 클라우드 세션 신호다. SSH로 들어간 EC2 같은 환경에서는 이 변수가 세팅되지 않는다. 그건 그냥 로컬 CLI로 인식된다.
패턴 3 — direnv와 CLAUDE_ENV_FILE로 디렉토리별 환경
여러 프로젝트가 디렉토리마다 다른 환경 변수를 쓴다면 direnv가 표준 해법이다. 일반 셸에서는 디렉토리 진입 시 .envrc를 자동 로드해 주지만, Claude Code의 Bash 도구는 그걸 자동으로 안 받는다.
해법은 SessionStart + CwdChanged hook 페어다.
~/.claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
}
]
}
],
"CwdChanged": [
{
"hooks": [
{
"type": "command",
"command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
}
]
}
]
}
}
→ SessionStart로 시작 디렉토리의 환경 변수를 깔고, CwdChanged로 Claude가 cd 할 때마다 새 디렉토리의 환경 변수를 다시 깐다. 둘 다 CLAUDE_ENV_FILE에 쓰고, Claude Code는 모든 Bash 명령 앞에 그 파일을 source한다.
direnv allow를 각 디렉토리의.envrc에 한 번 실행해 둬야 한다 — direnv 자체의 보안 정책이다. devbox나 nix를 쓰면direnv export bash자리에devbox shellenv나devbox global shellenv로 바꾸면 같은 패턴이 된다.
특정 파일 변경에만 반응
.envrc 한 줄을 바꿨을 때만 다시 로드하고 싶으면 FileChanged 이벤트를 쓴다. matcher에 감시할 파일명을 |로 묶는다.
{
"hooks": {
"FileChanged": [
{
"matcher": ".envrc|.env",
"hooks": [
{
"type": "command",
"command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
}
]
}
]
}
}
FileChanged의 매처는 정규식이 아니라 리터럴 파일명 리스트다. |로 구분된 각 토큰이 그대로 파일명 매칭에 쓰인다.
패턴 4 — Stop으로 테스트 통과 강제
Claude가 “다 했다”고 응답을 끝내려 할 때 테스트가 실패한 채로 종료하는 걸 막는 게이트다.
.claude/hooks/require-tests.sh:
#!/bin/bash
INPUT=$(cat)
# 이미 한 번 block을 낸 상태면 무한 루프 방지
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
# package.json이 없으면 검증 스킵
[ ! -f package.json ] && exit 0
# 테스트 실행 (조용히)
if output=$(npm test --silent 2>&1); then
exit 0
else
jq -n --arg out "$output" '{
decision: "block",
reason: "테스트가 실패한 상태로 응답을 마치지 마라. 실패 원인을 분석하고 수정한 뒤 다시 시도하라.",
hookSpecificOutput: {
hookEventName: "Stop",
additionalContext: ("npm test 출력:\n" + $out)
}
}'
exit 0
fi
.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/require-tests.sh",
"timeout": 180
}
]
}
]
}
}
핵심 셋.
stop_hook_active체크가 처음 — 이게 없으면 한 번 block한 뒤 또 block해서 결과적으로 8회 캡에 걸리며 검증이 무력화된다.- 차단은 exit 0 + JSON — PostToolUse와 같은 패턴이다. Stop에서도 exit 2가 차단으로 작동하긴 하지만,
reason과additionalContext를 같이 보내려면 JSON이 깔끔하다. - timeout 적절히 —
npm test가 무거우면 응답이 끝날 때마다 한참 멈춘다. CI에서 돌리는 전체 스위트보다는 빠른 smoke test를 거는 게 실용적이다.
더 정교한 검증을 원하면
type: "agent"hook을 쓸 수 있다 — 별도 subagent가 코드를 직접 읽고 테스트를 돌려서 통과 여부를 판단한다. 비용이 들지만 규칙으로 표현하기 어려운 판단(예: “이 PR의 기능이 실제로 동작하는가”)을 위임할 수 있다.
패턴 5 — SessionEnd로 임시 파일 정리·로깅
세션이 닫힐 때 항상 일어나야 하는 일 — 임시 파일 정리, 세션 메트릭 로깅, 외부 시스템 알림 — 을 박는 자리다.
.claude/hooks/cleanup.sh:
#!/bin/bash
INPUT=$(cat)
session=$(jq -r '.session_id' <<< "$INPUT")
reason=$(jq -r '.reason' <<< "$INPUT")
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# 1. 임시 스크래치 파일 정리
rm -f /tmp/claude-scratch-*.txt 2>/dev/null
# 2. 세션 메트릭 로깅
jq -n \
--arg ts "$ts" \
--arg session "$session" \
--arg reason "$reason" \
'{timestamp: $ts, session: $session, reason: $reason}' \
>> "$HOME/.claude/audit/session-end.jsonl"
# 3. /clear 후에는 더 깊은 정리
if [ "$reason" = "clear" ]; then
rm -rf /tmp/claude-build-* 2>/dev/null
fi
exit 0
.claude/settings.json:
{
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cleanup.sh"
}
]
}
]
}
}
reason 기반 분기로 왜 끝났는지에 따라 다른 정리를 걸 수 있다.
clear만 잡고 싶으면 매처를"clear"로.- 로그아웃 시에만 외부 시스템에 통지하려면
"logout"매처에 별도 hook. prompt_input_exit(Ctrl+D //exit)는 사용자가 의도적으로 닫은 경우라 시간 메트릭의 신호값으로 좋다.
{
"hooks": {
"SessionEnd": [
{
"matcher": "clear",
"hooks": [
{ "type": "command", "command": "rm -f /tmp/claude-scratch-*.txt" }
]
},
{
"matcher": "logout",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/notify-logout.sh" }
]
}
]
}
}
SessionEnd는 차단할 수 없다는 점을 다시 강조해야 한다. exit 2를 내도 세션은 그대로 종료된다. “이 작업을 마치지 않으면 끝낼 수 없다”를 박는 자리는 Stop이지 SessionEnd가 아니다. SessionEnd는 이미 끝났다는 신호를 받고 정리를 트리거하는 자리다.
흔한 함정
- SessionStart는 차단할 수 없다 — exit 2를 내도 stderr만 사용자에게 보이고 세션은 정상 시작된다. 보안 게이트는 PreToolUse(30차시)의 자리다.
- SessionStart는
command와mcp_tool만 — HTTP, prompt, agent hook은 지원되지 않는다. 그 외 타입을 적으면 hook이 무시된다. source매처 4값 —startup,resume,clear,compact. 매처를 비우면 네 경우 모두에 발화한다. 의존성 설치 같은 한 번만 하면 되는 일은startup으로 좁혀라.CLAUDE_ENV_FILE은 일부 hook만 — SessionStart, Setup, CwdChanged, FileChanged. Stop·SessionEnd·도구 hook에는 없다. 거기서$CLAUDE_ENV_FILE을 참조하면 빈 문자열이라>> ""에서 깨진다.- Stop의 무한 루프 — 항상 block을 내는 Stop hook은 8회 캡에 걸려 무력화된다. 반드시 조건부 block이어야 하고,
stop_hook_active체크로 재귀를 막아야 한다. CLAUDE_CODE_STOP_HOOK_BLOCK_CAP— 8회 캡은 환경 변수로 늘릴 수 있지만(v2.1.140+), 늘리는 것보다 조건을 정교하게가 먼저다.CLAUDE_CODE_REMOTE는 클라우드 한정 — Claude Code on the web에서만"true". SSH로 들어간 원격 서버에서는 세팅되지 않는다(로컬과 같음).- SessionEnd는 출력만 가능 — decision도 exit code도 무시된다. 차단 시나리오를 짜려고 SessionEnd를 쓰면 안 된다.
prompt_input_exit는 사용자의 의도적 종료 — Ctrl+D나/exit로 끝난 경우다. 강제 종료(SIGTERM 등)는other로 들어간다./clear는 SessionEnd + SessionStart 둘 다 발화 —/clear직후 이전 세션의 SessionEnd(reason: "clear")가 돌고, 새 세션의 SessionStart(source: "clear")가 돈다. 둘이 다른 hook이라는 점에 주의.- timeout 기본 600초가 길다 — 가벼운 컨텍스트 주입에는 30~60초로 줄여라. 의존성 설치처럼 진짜 오래 걸리는 경우만 늘린다.
- 셸 프로필이 JSON 출력 오염 —
~/.zshrc,~/.bashrc에 무조건 실행되는echo가 있으면 그 출력이 hook의 JSON 앞에 붙어 파싱이 깨진다.if [[ $- == *i* ]]; then ... fi로 대화형 셸에서만 출력하게 묶어라. /hooks로 등록 확인 — 적용 안 되는 것 같으면/hooks메뉴부터. 세션 hook은 매처별로 어떤 이벤트에서 발화하는지 한눈에 보여 준다.
정리
핵심 요점
- 세 이벤트는 세션 단위 — 도구 hook과 달리 호출이 아니라 세션의 경계에서 발화한다. 매처는 도구 이름이 아니라 이벤트가 발생한 이유다.
- SessionStart 4값 —
startup(새 세션),resume(이어받기),clear(/clear후),compact(compaction 후). - SessionStart는 차단 불가 — exit 2는 stderr만 사용자에게 보일 뿐 세션은 계속 시작된다. 보안 게이트는 PreToolUse의 일이다.
- SessionStart 응답 —
additionalContext/initialUserMessage/watchPaths—hookSpecificOutput에 담는다. plain stdout도 컨텍스트로 들어간다. command와mcp_tool만 — SessionStart는 HTTP·prompt·agent hook 미지원.CLAUDE_ENV_FILE— SessionStart, Setup, CwdChanged, FileChanged 전용. 이 파일에export문을 append하면 이후 모든 Bash 명령에 자동 적용된다.- Stop은 응답 종료를 막는다 —
decision: "block"로 한 턴 더 작업하게 만든다. 검증 게이트(테스트 통과 강제 등)에 쓴다. stop_hook_active로 무한 루프 방지 — 재귀 깊이를 hook이 직접 체크해야 한다. 백업으로 8회 블록 캡(CLAUDE_CODE_STOP_HOOK_BLOCK_CAP, v2.1.140+).- SessionEnd 6값 —
clear,resume,logout,prompt_input_exit,bypass_permissions_disabled,other. 종료 사유별로 다른 정리를 분기할 수 있다. - SessionEnd는 출력만 —
decision과 exit code는 무시.systemMessage,suppressOutput,terminalSequence로 사이드 이펙트만. - compact 후 컨텍스트 재주입 —
SessionStart+compact매처에 git 상태나 핵심 규약을 echo로 출력. CLAUDE.md를 보완. - 원격 세션 의존성 —
CLAUDE_CODE_REMOTE == "true"체크 후npm install. 로컬에서는 조용히 종료. direnv통합 — SessionStart + CwdChanged 페어로direnv export bash > "$CLAUDE_ENV_FILE". 디렉토리별 환경 자동 로드.- Stop 검증 —
stop_hook_active체크 → 테스트 실행 → 실패 시 block + reason + additionalContext. - SessionEnd 정리 — 임시 파일 삭제, JSONL 메트릭 로깅,
reason별 분기.
다음 단계
다음 32차시부터는 섹션 7 MCP 서버 연동으로 넘어간다. Hook이 Claude Code 내부의 제어 흐름을 손보는 거였다면, MCP는 외부 도구·데이터베이스·API를 Claude Code 안으로 끌어들이는 표준이다. 32차시는 MCP의 개념과 전송 방식(HTTP·SSE·stdio), 그리고 처음 MCP 서버를 연결할 때 알아야 할 것들을 다룬다.