앞 글에서 훅 설정의 세 겹 구조와 첫 echo 훅을 익혔습니다. 이번에는 세션이 열리는 순간에 도는 SessionStart 훅입니다. 우리가 매일 쓰는 훅 중 가장 오래된 것이 바로 이것인데, 세션을 열 때마다 외부 인박스에 새 파일이 왔는지 확인하고, 컨텍스트가 압축된 뒤에는 잊혀지기 쉬운 프로젝트 규칙을 다시 주입합니다. 이 글을 읽고 나면 "세션을 열 때마다 손으로 하던 준비 작업"을 훅에 맡기고, 그 결과를 환경 변수로 세션 끝까지 들고 갈 수 있습니다. 다음 글은 반대쪽 끝, 응답이 끝날 때 도는 Stop 훅입니다.
핵심 요약
SessionStart는 새 세션(startup), 재개(resume),/clear, 압축(compact), 포크(fork) 다섯 경우에 실행되며 matcher로 골라 걸 수 있습니다.- 스크립트가 stdout에 쓴 텍스트는 그대로 Claude의 컨텍스트에 들어갑니다. 그래서 규칙 재주입에 쓰기 좋습니다.
- 같은 이벤트의 핸들러 여러 개는 병렬로 실행됩니다. 순서가 필요하면 스크립트 하나 안에서
&&로 잇습니다. $CLAUDE_ENV_FILE에export줄을 추가하면 그 변수가 세션 내내 Bash 도구에 적용됩니다.- 이 훅은 차단할 수 없습니다. 종료 코드 2를 내도 세션은 열리고 stderr만 사용자에게 보입니다.
SessionStart 훅은 언제 실행되나
SessionStart는 Claude Code가 세션을 시작하거나 기존 세션을 이어 갈 때 발생합니다. 입력 JSON의 source 필드와 matcher 값이 같은 다섯 가지입니다.
| matcher | 발생 시점 |
|---|---|
startup |
claude를 새로 실행한 새 세션 |
resume |
--resume, --continue, /resume으로 이어 갈 때 |
clear |
/clear로 대화를 비웠을 때 |
compact |
자동 또는 수동 압축 직후 |
fork |
세션을 포크해 새 갈래를 만들었을 때 |
matcher를 비우면 다섯 경우 모두에 반응합니다. 우리 설정도 두 부류로 나뉩니다. 인박스 확인 스크립트는 matcher 없이 매번 돌고, 규칙 재주입 스크립트는 compact에만 걸려 있습니다. 압축이 일어나면 대화 앞부분이 요약되면서 "운영 서버는 성역", "브랜치는 develop까지만" 같은 규칙이 흐려지는데, 그 순간에 규칙 파일 내용을 stdout으로 다시 흘려보내면 Claude가 압축 직후에 다시 읽게 됩니다.
입력에는 공통 필드(session_id, cwd, transcript_path, hook_event_name) 외에 source, 그리고 선택적으로 model(활성 모델, /clear 뒤에는 없을 수 있음), agent_type(claude --agent로 시작했을 때), session_title이 들어옵니다. 지원되는 핸들러 타입은 command와 mcp_tool 두 가지뿐입니다. 이 이벤트는 매 세션 돌기 때문에 공식 문서도 "빠르게 유지하라"고 못 박습니다.
간단한 예로 동작 확인하기
먼저 stdout이 컨텍스트로 들어간다는 사실을 눈으로 확인합니다.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "echo \"오늘 작업 브랜치: $(git branch --show-current 2>/dev/null || echo none)\""
}
]
}
]
}
}
새 세션을 열고 첫 질문으로 "지금 브랜치가 뭐야?"라고 물어보면 도구를 쓰지 않고도 답합니다. 훅이 넣어 준 문장을 이미 읽었기 때문입니다. Ctrl+O로 트랜스크립트 보기를 켜면 훅 출력이 한 줄로 보입니다. 정적인 내용(항상 같은 규칙)은 CLAUDE.md에 두고, 훅에는 git log --oneline -5처럼 그때그때 달라지는 정보를 맡기는 것이 공식 문서와 우리 교육의 공통 권장입니다.
여러 초기화를 순서대로 실행하려면
여기서 많이 틀리는 지점이 있습니다. hooks 배열에 핸들러를 여러 개 적으면 위에서 아래로 순서대로 도는 것 같지만, 실제로는 모두 병렬로 시작됩니다. 같은 명령 문자열은 중복 제거되어 한 번만 돌고요. 그래서 "의존성 설치 → 마이그레이션 → 시드"처럼 순서가 중요한 작업을 핸들러 세 개로 나누면 앞 단계가 끝나기 전에 뒤 단계가 실행될 수 있습니다.
순서가 필요하면 스크립트 하나 안에서 이어 줍니다.
#!/bin/bash
# .claude/hooks/session-init.sh
set -e
cd "$CLAUDE_PROJECT_DIR"
./scripts/check-tools.sh && \
./scripts/prepare-db.sh && \
echo "초기화 완료: $(date '+%H:%M')"
핸들러를 여러 개로 나누는 것이 맞는 경우는 서로 독립적일 때입니다. 우리 전역 설정의 "인박스 확인"과 "드라이브 확인"은 서로 결과를 주고받지 않아서 두 핸들러로 두었고, 병렬로 돌아 시간도 아낍니다.
실전: node_modules 가 없을 때만 npm install
매 세션 npm install을 돌리면 느리고 불필요합니다. 조건을 붙여 "없을 때만" 설치하게 합니다.
#!/bin/bash
# .claude/hooks/ensure-deps.sh
cd "$CLAUDE_PROJECT_DIR" || exit 0
if [ -f package.json ] && [ ! -d node_modules ]; then
echo "node_modules 없음, 의존성 설치 중" >&2
if command -v pnpm >/dev/null 2>&1 && [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile >/dev/null 2>&1
else
npm install >/dev/null 2>&1
fi
echo "의존성 설치 완료 (node_modules 생성됨)"
fi
exit 0
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ensure-deps.sh",
"timeout": 300
}
]
}
]
}
}
두 가지를 눈여겨보세요. 진행 메시지는 stderr(>&2)로 보내 컨텍스트를 더럽히지 않고, Claude가 알아야 할 결과 한 줄만 stdout으로 내보냅니다. 그리고 clear와 compact에는 걸지 않았습니다. 대화를 비웠다고 의존성을 다시 볼 이유는 없기 때문입니다. Windows에서는 같은 스크립트가 Git Bash로 실행되며, pnpm이 .cmd 래퍼로 설치된 경우 셸 형식(이 예처럼 args 없이)이어야 호출됩니다.
환경 변수를 세션 내내 유지하기: $CLAUDE_ENV_FILE
훅은 별도 프로세스라서 스크립트 안에서 export FOO=1을 해도 훅이 끝나면 사라집니다. 이를 위해 Claude Code는 SessionStart 훅에 CLAUDE_ENV_FILE이라는 파일 경로를 넘겨 줍니다. 이 파일에 export 줄을 추가(>>)해 두면, 그 세션에서 Claude가 실행하는 모든 Bash 명령 앞에 이 파일이 프리앰블로 실행됩니다.
#!/bin/bash
# .claude/hooks/session-env.sh
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
echo "export PROJECT_ROOT=\"$CLAUDE_PROJECT_DIR\"" >> "$CLAUDE_ENV_FILE"
fi
exit 0
nvm use 20처럼 여러 변수를 한꺼번에 바꾸는 설정 명령이라면 실행 전후의 export -p를 비교해 달라진 줄만 붙이는 방식도 공식 문서에 나옵니다. direnv를 쓰는 팀은 direnv export bash > "$CLAUDE_ENV_FILE" 한 줄이면 됩니다. 이 변수는 SessionStart, Setup, CwdChanged, FileChanged 네 이벤트에서만 제공되며 Stop이나 PreToolUse에서는 비어 있습니다. -n 검사로 감싸 두는 이유입니다. 자세한 활용은 코스 뒤쪽 「Hooks 환경 변수」 글에서 이어집니다.
초기화가 오래 걸리면 timeout 을 조정
command 훅의 기본 제한은 600초(10분)입니다. 그보다 길면 훅이 취소되고, 짧게 끊고 싶으면 핸들러에 "timeout": 60처럼 초 단위로 적습니다. 우리 전역 훅은 모두 timeout: 10을 걸어 두었습니다. 세션 시작이 10초 이상 걸리면 그 자체가 문제 신호이기 때문입니다. 정말 오래 걸리는 작업(대용량 클론, 컨테이너 기동)은 기다리지 않도록 "async": true로 백그라운드에 넘기되, 그 결과를 컨텍스트로 받아야 한다면 다음 턴에 전달된다는 점을 감안합니다. 비동기 훅은 별도 글에서 다룹니다.
사용할 때 주의할 점
- 매 세션 돈다는 사실을 잊지 마세요. 느린 훅 하나가 모든 세션의 첫 3초를 잡아먹습니다.
- stdout은 컨텍스트에 들어갑니다. 디버그 출력이나 진행 메시지는 stderr로 보내야 토큰을 아끼고 Claude의 주의도 흐트러지지 않습니다. 출력이 매우 길면 잘리고 파일로 대체될 수 있습니다.
sessionTitle을 JSON으로 돌려주면/rename과 같은 효과가 나지만startup과resume에서만 적용됩니다.- 훅이 스킬 파일을 새로 내려받았다면
{"hookSpecificOutput":{"hookEventName":"SessionStart","reloadSkills":true}}를 출력해야 같은 세션에서 바로 쓸 수 있습니다. - 운영 머신 전용 초기화는 호스트 이름을 검사해 아닌 곳에서는
exit 0으로 조용히 빠져나가게 합니다. 우리 스크립트들이 스튜디오와 미니 두 대에서 같은 설정 파일을 공유하면서도 사고가 나지 않는 이유입니다.
훅이 실패했을 때 원인 찾기
SessionStart는 차단 불가 이벤트라 실패해도 세션은 열립니다. 대신 트랜스크립트에 SessionStart hook error와 stderr 첫 줄이 표시됩니다(v2.1.199 이상, 그 이전 버전은 디버그 로그에만 남았습니다). 원인을 좁히는 순서는 앞 글과 같습니다. 샘플 입력으로 수동 실행, 경로와 실행 권한, jq 유무, 셸 프로필의 echo 출력, 그리고 claude --debug-file /tmp/claude.log로 전체 stdout/stderr 확인. SessionStart 전용으로 하나 더 있다면, source 값에 따라 분기하는 스크립트는 테스트할 때 {"hook_event_name":"SessionStart","source":"compact"}처럼 source를 바꿔 가며 넣어 봐야 한다는 점입니다.
정리
SessionStart는startup,resume,clear,compact,fork다섯 시점에 돌고, stdout이 곧 Claude의 컨텍스트가 됩니다.- 핸들러 여러 개는 병렬입니다. 순서가 필요하면 스크립트 하나에서
&&로 잇습니다. - 조건부 설치(
node_modules없을 때만)처럼 "필요할 때만" 도는 초기화가 좋은 초기화입니다. $CLAUDE_ENV_FILE에export줄을 추가하면 세션 내내 Bash 도구에 적용됩니다.- 차단은 못 하지만 실패는 보입니다. 짧은
timeout과 stderr 메시지로 실패를 시끄럽게 만드세요.
자주 묻는 질문
SessionStart 훅은 정확히 언제 실행되나요?
새 세션을 열 때(startup), --resume·--continue·/resume으로 이어 갈 때(resume), /clear 직후(clear), 자동·수동 압축 직후(compact), 세션을 포크했을 때(fork) 다섯 경우입니다. matcher에 이 값을 적어 특정 경우에만 걸 수 있고, 입력 JSON의 source 필드로 스크립트 안에서 분기할 수도 있습니다.
hooks 배열에 명령 여러 개를 나눠 적으면 순서대로 실행되나요?
아닙니다. 같은 이벤트에 매칭된 핸들러는 전부 병렬로 시작되고, 동일한 명령은 중복 제거됩니다. 앞 단계 결과가 뒤 단계에 필요하면 스크립트 하나 안에서 &&나 set -e로 순서를 잡으세요. 서로 독립적인 작업만 핸들러를 나눕니다.
훅에서 설정한 환경 변수를 세션 내내 쓰려면 어떻게 하나요?
훅 프로세스 안의 export는 훅이 끝나면 사라집니다. 대신 $CLAUDE_ENV_FILE 경로의 파일에 export NAME=value 줄을 >>로 추가하세요. 그 세션에서 Claude가 실행하는 모든 Bash 명령 앞에 이 파일이 실행됩니다. 이 변수는 SessionStart, Setup, CwdChanged, FileChanged 훅에서만 제공됩니다.
초기화가 오래 걸려 훅이 끊기면 어떻게 하나요?
기본 제한은 600초이고 핸들러의 timeout(초)으로 조정할 수 있습니다. 하지만 세션 시작을 수 분 붙잡는 초기화는 설계를 다시 보는 편이 낫습니다. 조건을 붙여 필요할 때만 돌리거나, "async": true로 백그라운드에 넘기고 결과는 다음 턴에 받는 방식을 권합니다.
훅이 실패하면 세션이 시작되지 않나요?
아닙니다. SessionStart는 차단 불가 이벤트라 종료 코드 2를 내도 세션은 정상적으로 열립니다. stderr 내용이 사용자에게 SessionStart hook error 알림으로 보일 뿐이고 Claude는 그 내용을 보지 못합니다. 실패를 Claude에게 알리고 싶다면 stdout으로 상태 문장을 내보내세요.