Claude Code Hooks로 커밋 전 자동 검증 파이프라인 만들기: PreToolUse 실전 설정
Claude Code 에게 리팩터링을 맡기고 커피를 타러 갔다 왔더니, 스테이징 목록에 .env 파일이 올라와 있었습니다. 되돌리는 데 20분, 노출된 키를 폐기하고 재발급받는 데 그보다 더 걸렸죠. 훅을 걸어두면 이 장면 자체가 발생하지 않습니다. 도구 호출이 실행되기 전에 훅이 먼저 끼어들어 명령을 거부하거든요. 이 글을 끝까지 따라오면 PreToolUse 로 위험한 커밋을 막고, PostToolUse 로 파일 저장 직후 린트를 강제하는 설정 파일을 직접 만들 수 있습니다. 제가 blog-publisher 저장소에서 실제로 돌리고 있는 구성과, 훅이 아무 소리 없이 안 걸렸을 때 어디부터 봐야 하는지도 같이 정리했어요.
왜 커밋 직전이 아니라 도구 호출 시점에 막나요?
git 의 pre-commit 훅과 목적은 비슷하지만 발화 지점이 다릅니다. Claude Code 의 훅은 커밋이라는 한 순간이 아니라 에이전트 수명주기 전체에 붙거든요. 공식 문서(Claude Code Hooks reference) 기준으로 이벤트는 세 가지 주기로 나뉩니다. 세션당 한 번 도는 SessionStart·SessionEnd, 턴당 한 번 도는 UserPromptSubmit·Stop, 그리고 에이전트 루프 안에서 도구 호출마다 도는 PreToolUse·PostToolUse 입니다.
pre-commit 은 이미 파일이 다 쓰인 뒤에야 작동해요. 반면 에이전트가 시크릿 파일을 읽어 로그로 흘리거나 rm -rf 를 던지는 사고는 커밋 훨씬 전에 벌어집니다. PreToolUse 는 도구 호출이 실행되기 전에 개입해 거부 결정을 돌려줄 수 있는 유일한 지점이에요. 훅은 터미널이든 IDE 확장이든 데스크톱 앱이든 같은 이벤트를 발화합니다.
준비물은 뭐가 필요한가요?
무거운 준비는 없습니다. Claude Code 실행 환경, 프로젝트 루트의 .claude/ 디렉터리, 그리고 jq 세 가지면 시작할 수 있어요. 공식 문서의 Bash 예제들이 stdin 으로 들어오는 JSON 을 파싱할 때 jq 를 쓰기 때문에, PATH 에 jq 가 없으면 훅이 실패합니다.
진짜 갈림길은 설정 파일 위치입니다. ~/.claude/settings.json 은 내 모든 프로젝트에 적용되지만 이 머신 밖으로 나가지 않아요. .claude/settings.json 은 프로젝트 단위이고 저장소에 커밋해서 팀과 공유할 수 있습니다. .claude/settings.local.json 은 같은 프로젝트를 가리키지만 gitignore 대상이라 나만 씁니다. 팀 전체에 검증을 강제하는 게 목표라면 두 번째를 고르세요. 플러그인의 hooks/hooks.json 이나 스킬 frontmatter 로도 훅을 얹을 수 있습니다.
검증 훅은 어떻게 설정하나요?
1단계: PreToolUse 로 위험한 커밋 차단하기
설정 구조는 세 겹입니다. 이벤트를 고르고, matcher 그룹으로 언제 발화할지 좁히고, 그 안에 핸들러를 답니다.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-secret.sh" }
]
}
]
}
}
핸들러 스크립트는 stdin 으로 JSON 컨텍스트를 받습니다.
#!/usr/bin/env bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
if echo "$cmd" | grep -qE 'git commit.*\.env'; then
echo '{"permissionDecision":"deny","permissionDecisionReason":".env 커밋 금지"}'
fi
에이전트가 git commit -m "wip" .env 를 호출하면 → 훅이 deny 를 돌려주고, 도구 호출은 실행되지 않은 채 거부 사유 문자열이 모델에게 전달됩니다. 모델은 그 이유를 읽고 다른 방법을 찾죠. 파일을 저장한 뒤 chmod +x 를 거는 것을 잊지 마세요.
2단계: PostToolUse 로 저장 직후 린트 돌리기
PostToolUse 는 도구 호출이 성공한 다음에 발화합니다. 파일 편집만 잡으면 되니 matcher 를 좁힙니다.
"PostToolUse": [
{ "matcher": "Write|Edit",
"hooks": [
{ "type": "command",
"command": "npx eslint --fix \"$CLAUDE_PROJECT_DIR/src\"" }
] }
]
에이전트가 src/api.ts 를 수정하면 → 저장 직후 eslint 가 돌고, 자동 수정되지 않은 오류는 그대로 모델에게 흘러가 바로 고쳐집니다. 여기서 놓치기 쉬운 게 하나 있어요. 실패한 도구 호출은 PostToolUse 가 아니라 PostToolUseFailure 로 가니, 실패 케이스까지 잡으려면 이벤트를 하나 더 걸어야 합니다.
3단계: exit code 계약으로 기존 게이트와 연결하기
훅 핸들러를 매번 새로 짤 필요는 없습니다. blog-publisher 의 품질 게이트 16종은 exit 0 은 통과, 1 은 실패, 2 는 실행 오류라는 계약을 지키도록 만들어 뒀어요. 이 계약만 지키면 기존 스크립트를 command 에 그대로 물리면 끝입니다.
npm run gate runs/2026-08-19/drafts/draft-beaver.draft.md 를 훅에 걸어두면 → exit 1 일 때 차단, exit 0 일 때 통과로 갈립니다. 2 는 게이트 자체가 깨진 상황이라 차단과 구분해 알림으로 뺐습니다. 실패를 실행 오류와 뭉뚱그리면 게이트가 고장 났을 때 전부 통과로 새어 나가니까요. 더 세밀한 판정이 필요하면 1단계처럼 JSON 을 stdout 으로 돌려주면 됩니다.
훅이 조용히 안 걸릴 때는 어디를 봐야 하죠?
가장 많이 밟은 지뢰는 상대경로입니다. 처음에 command 를 node scripts/report/handoff-log.mjs 로 적었는데, 세션 작업 디렉터리가 하위 폴더로 바뀌자 훅이 아무 말 없이 아무 일도 안 했어요. 지금 이 저장소에서 돌아가는 SessionEnd 훅은 $CLAUDE_PROJECT_DIR 를 앞에 붙인 절대경로로 고쳤습니다. 훅은 실패해도 화려하게 죽지 않는다는 점을 기억하세요.
두 번째는 실행 권한과 의존성입니다. chmod +x 를 빼먹거나 jq 가 없으면 증상이 똑같아요. 훅은 안 걸리는데 세션은 멀쩡히 굴러갑니다. 셋째는 저장 위치 착각인데, .claude/settings.local.json 에 넣어두고 팀원 환경에서 왜 안 걸리냐고 30분을 헤맨 적이 있습니다. local 파일은 공유되지 않죠.
마지막 하나. 훅 하나로 방어선을 다 세우려 하지 마세요. 이 저장소는 .claude/settings.json 의 permissions.deny 목록에 git push --force, --no-verify, git config core.hooksPath 변경을 포함한 19개 패턴을 못 박아 뒀습니다. 훅 스크립트를 지우거나 hooksPath 를 갈아끼워 우회하는 경로 자체를 닫아두려는 장치예요.
지금 바로 해볼 것
가장 작은 훅부터 하나만 걸어보세요. matcher 를 Bash 로 두고 echo hooked >> /tmp/hook.log 한 줄만 실행하는 핸들러면 충분합니다. 발화 여부를 눈으로 먼저 확인하고 검증 로직을 채우는 순서가, 조용한 실패를 디버깅하는 시간보다 훨씬 쌉니다.
자주 묻는 질문
Q. 같은 이벤트에 훅을 여러 개 걸면 실행 순서가 보장되나요? A. matcher 그룹과 핸들러 배열 모두 여러 개를 둘 수 있습니다. 차단이 목적이라면 순서에 기대지 말고 각 핸들러가 독립적으로 판정하도록 짜세요.
Q. 셸 스크립트 말고 다른 방식도 되나요? A. 공식 문서 기준으로 훅 핸들러는 셸 명령 외에 HTTP 엔드포인트와 LLM 프롬프트를 지원합니다. HTTP 훅은 stdin 대신 POST 요청 바디로 같은 JSON 컨텍스트를 받습니다.
Q. 윈도우 PowerShell 환경에서는 어떻게 쓰나요? A. matcher 에 Bash|PowerShell 을 함께 적고 핸들러를 powershell.exe -NoProfile -ExecutionPolicy Bypass 로 호출합니다. -NoProfile 은 프로필 로딩을 건너뛰어 훅 시작을 앞당기고, Bypass 는 로컬 스크립트 실행을 허용합니다.
Q. 도구 호출마다 훅이 돌면 느려지지 않나요? A. matcher 와 조건으로 좁히면 됩니다. Bash 전체가 아니라 rm * 같은 하위 명령까지 좁히면 조건이 어긋날 때 핸들러 프로세스가 아예 뜨지 않습니다.