Skip to content

Claude Code SessionStart·Stop·SessionEnd Hook — 세션의 양 끝에 환경·검증·정리를 박는 법

Published: at 06:43 PM

들어가며

29차시에서 PostToolUse(성공 직후 후처리)를, 30차시에서 PreToolUse(실행 직전 게이트)를 봤다. 둘 다 도구 단위로 발화하는 hook이다 — Edit·Write·Bash 같은 호출 하나하나에 붙어 돈다.

이번 31차시는 한 단계 위로 올라간다. 도구가 아니라 세션 단위로 발화하는 hook 세 개 — SessionStart, Stop, SessionEnd다. 세션이 시작될 때 환경을 깔고, Claude가 응답을 끝내려는 순간 한 번 더 검증하고, 세션이 닫힐 때 정리를 거는 흐름이다.

이 셋은 도구 hook과 같은 구조를 공유하면서도 역할이 다르다 — 도구 단위가 아니라 세션의 경계에서 동작한다. 그래서 다루는 문제도 다르다.

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

세션의 양 끝 — 세 이벤트를 한눈에

먼저 세 이벤트를 한 표에 모아 비교한다. 도구 hook(29·30차시)과 다른 점이 여기서 다 드러난다.

항목SessionStartStopSessionEnd
발화 시점세션 시작 / 재개 / /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 실행 후
compactauto 또는 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과 비교해 핵심 차이는 두 가지다.

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 비대화형 모드에서 첫 사용자 메시지로 들어간다. 프롬프트가 함께 있으면 그 에 두 번째 턴으로 붙는다
watchPathsFileChanged 이벤트로 감시할 절대 경로 배열. 이 세션 동안만 등록

plain stdout도 컨텍스트로 들어간다

SessionStart는 JSON이 아닌 그냥 stdout도 Claude의 컨텍스트로 흘러간다. 그래서 가장 간단한 컨텍스트 주입은 echo 한 줄이다.

echo "현재 브랜치: $(git branch --show-current). 최근 커밋: $(git log -1 --oneline)"

이 출력이 그대로 Claude의 첫 컨텍스트로 들어간다. JSON 구조를 짤 필요가 없다.

additionalContext vs plain stdout — 단순한 텍스트 한 덩어리만 넣을 거면 plain stdout이 가볍다. JSON으로 가야 하는 경우는 동시에 다른 필드(initialUserMessage, watchPaths, systemMessage 등)를 같이 내야 할 때, 그리고 28차시에서 본 명령형이 아니라 사실 형태로 prompt injection 방어를 통과시키고 싶을 때다.

지원 hook 타입 — commandmcp_tool

도구 hook과 달리 SessionStart에는 HTTP hook과 prompt hook이 안 된다. typecommand 또는 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 testnode ./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, FileChanged hook도 같은 변수에 쓸 수 있다. 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
}

세 가지가 눈에 띈다.

응답 옵션 — 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_exitCtrl+D 또는 /exit
bypass_permissions_disabledbypass 권한 모드가 해제됨
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사용자에게 보이는 경고 메시지
suppressOutputtrue면 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 -5gh 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
          }
        ]
      }
    ]
  }
}

핵심 두 가지.

CLAUDE_CODE_REMOTE지역적 의미의 원격이 아니라 정확히 Claude Code on the web의 클라우드 세션 신호다. SSH로 들어간 EC2 같은 환경에서는 이 변수가 세팅되지 않는다. 그건 그냥 로컬 CLI로 인식된다.

패턴 3 — direnvCLAUDE_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 shellenvdevbox 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
          }
        ]
      }
    ]
  }
}

핵심 셋.

더 정교한 검증을 원하면 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 기반 분기로 왜 끝났는지에 따라 다른 정리를 걸 수 있다.

{
  "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는 이미 끝났다는 신호를 받고 정리를 트리거하는 자리다.

흔한 함정

  1. SessionStart는 차단할 수 없다 — exit 2를 내도 stderr만 사용자에게 보이고 세션은 정상 시작된다. 보안 게이트는 PreToolUse(30차시)의 자리다.
  2. SessionStart는 commandmcp_tool — HTTP, prompt, agent hook은 지원되지 않는다. 그 외 타입을 적으면 hook이 무시된다.
  3. source 매처 4값startup, resume, clear, compact. 매처를 비우면 네 경우 모두에 발화한다. 의존성 설치 같은 한 번만 하면 되는 일startup으로 좁혀라.
  4. CLAUDE_ENV_FILE은 일부 hook만 — SessionStart, Setup, CwdChanged, FileChanged. Stop·SessionEnd·도구 hook에는 없다. 거기서 $CLAUDE_ENV_FILE을 참조하면 빈 문자열이라 >> ""에서 깨진다.
  5. Stop의 무한 루프 — 항상 block을 내는 Stop hook은 8회 캡에 걸려 무력화된다. 반드시 조건부 block이어야 하고, stop_hook_active 체크로 재귀를 막아야 한다.
  6. CLAUDE_CODE_STOP_HOOK_BLOCK_CAP — 8회 캡은 환경 변수로 늘릴 수 있지만(v2.1.140+), 늘리는 것보다 조건을 정교하게가 먼저다.
  7. CLAUDE_CODE_REMOTE는 클라우드 한정 — Claude Code on the web에서만 "true". SSH로 들어간 원격 서버에서는 세팅되지 않는다(로컬과 같음).
  8. SessionEnd는 출력만 가능 — decision도 exit code도 무시된다. 차단 시나리오를 짜려고 SessionEnd를 쓰면 안 된다.
  9. prompt_input_exit는 사용자의 의도적 종료 — Ctrl+D나 /exit로 끝난 경우다. 강제 종료(SIGTERM 등)는 other로 들어간다.
  10. /clear는 SessionEnd + SessionStart 둘 다 발화/clear 직후 이전 세션의 SessionEnd(reason: "clear")가 돌고, 새 세션의 SessionStart(source: "clear")가 돈다. 둘이 다른 hook이라는 점에 주의.
  11. timeout 기본 600초가 길다 — 가벼운 컨텍스트 주입에는 30~60초로 줄여라. 의존성 설치처럼 진짜 오래 걸리는 경우만 늘린다.
  12. 셸 프로필이 JSON 출력 오염~/.zshrc, ~/.bashrc에 무조건 실행되는 echo가 있으면 그 출력이 hook의 JSON 앞에 붙어 파싱이 깨진다. if [[ $- == *i* ]]; then ... fi로 대화형 셸에서만 출력하게 묶어라.
  13. /hooks로 등록 확인 — 적용 안 되는 것 같으면 /hooks 메뉴부터. 세션 hook은 매처별로 어떤 이벤트에서 발화하는지 한눈에 보여 준다.

정리

핵심 요점

  1. 세 이벤트는 세션 단위 — 도구 hook과 달리 호출이 아니라 세션의 경계에서 발화한다. 매처는 도구 이름이 아니라 이벤트가 발생한 이유다.
  2. SessionStart 4값startup(새 세션), resume(이어받기), clear(/clear 후), compact(compaction 후).
  3. SessionStart는 차단 불가 — exit 2는 stderr만 사용자에게 보일 뿐 세션은 계속 시작된다. 보안 게이트는 PreToolUse의 일이다.
  4. SessionStart 응답 — additionalContext / initialUserMessage / watchPathshookSpecificOutput에 담는다. plain stdout도 컨텍스트로 들어간다.
  5. commandmcp_tool — SessionStart는 HTTP·prompt·agent hook 미지원.
  6. CLAUDE_ENV_FILE — SessionStart, Setup, CwdChanged, FileChanged 전용. 이 파일에 export 문을 append하면 이후 모든 Bash 명령에 자동 적용된다.
  7. Stop은 응답 종료를 막는다decision: "block"로 한 턴 더 작업하게 만든다. 검증 게이트(테스트 통과 강제 등)에 쓴다.
  8. stop_hook_active로 무한 루프 방지 — 재귀 깊이를 hook이 직접 체크해야 한다. 백업으로 8회 블록 캡(CLAUDE_CODE_STOP_HOOK_BLOCK_CAP, v2.1.140+).
  9. SessionEnd 6값clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other. 종료 사유별로 다른 정리를 분기할 수 있다.
  10. SessionEnd는 출력만decision과 exit code는 무시. systemMessage, suppressOutput, terminalSequence로 사이드 이펙트만.
  11. compact 후 컨텍스트 재주입SessionStart + compact 매처에 git 상태나 핵심 규약을 echo로 출력. CLAUDE.md를 보완.
  12. 원격 세션 의존성CLAUDE_CODE_REMOTE == "true" 체크 후 npm install. 로컬에서는 조용히 종료.
  13. direnv 통합 — SessionStart + CwdChanged 페어로 direnv export bash > "$CLAUDE_ENV_FILE". 디렉토리별 환경 자동 로드.
  14. Stop 검증stop_hook_active 체크 → 테스트 실행 → 실패 시 block + reason + additionalContext.
  15. SessionEnd 정리 — 임시 파일 삭제, JSONL 메트릭 로깅, reason별 분기.

다음 단계

다음 32차시부터는 섹션 7 MCP 서버 연동으로 넘어간다. Hook이 Claude Code 내부의 제어 흐름을 손보는 거였다면, MCP는 외부 도구·데이터베이스·API를 Claude Code 안으로 끌어들이는 표준이다. 32차시는 MCP의 개념과 전송 방식(HTTP·SSE·stdio), 그리고 처음 MCP 서버를 연결할 때 알아야 할 것들을 다룬다.

참고 자료


Previous Post
Claude Code MCP 개념과 설치 — 외부 도구·DB·API를 Claude Code 안으로 끌어들이는 표준
Next Post
Claude Code PreToolUse Hook과 보안 — 도구가 실행되기 전에 위험한 명령과 민감 파일을 막는 게이트