Claude Code 훅으로 수정 직후·작업 종료 시 자동 검증 붙이기: PostToolUse와 Stop 훅 실전
바뀐 파일만 빠르게 검사하고, 실패 내용을 Claude가 읽고 고치게 만드는 종료 코드 설계

핵심 요약
- PostToolUse 훅은 도구가 성공한 뒤 실행되며, 종료 코드 2로 stderr를 Claude에게 보여 줘 바로 고치게 할 수 있다.
- Stop 훅의 종료 코드 2는 작업 종료를 막는다. stop_hook_active와 연속 8회 제한으로 무한 반복을 막는다.
- 0과 2가 아닌 종료 코드는 비차단 오류라, 훅 스크립트가 예외로 죽으면 검사가 조용히 건너뛰어진다.
- 실패 메시지에는 스택 트레이스 대신 파일·줄·기대값처럼 모델이 바로 고칠 수 있는 정보만 담는다.
AI 코딩 에이전트가 파일을 고칠 때마다 사람이 린트와 테스트를 대신 돌려 줄 수는 없다. Claude Code의 훅은 도구 실행 직후나 작업 종료 같은 시점에 지정한 명령을 자동으로 실행하고, 그 결과를 Claude에게 돌려줄 수 있다. 이 글은 수정한 파일을 바로 검사하는 PostToolUse 훅과, 작업을 끝내기 전에 테스트를 돌리는 Stop 훅을 공식 훅 문서(2026-10-11 확인) 기준으로 만들고, 실제 입력 형식으로 실행한 결과를 싣는다. Skills·Hooks·Subagents가 각각 무엇을 맡는지는 세 기능의 차이에서 먼저 정리했다.
어느 이벤트에 무엇을 붙일까
| 이벤트 | 실행 시점 | 붙이기 좋은 검사 | 종료 코드 2의 의미 |
|---|---|---|---|
| PreToolUse | 도구 실행 직전 | 위험한 명령·경로 차단 | 도구 실행을 막는다 |
| PostToolUse | 도구가 성공한 직후 | 바뀐 파일 하나의 구문·규칙 검사 | 도구는 이미 실행됐고, stderr를 Claude에게 보여 준다 |
| Stop | Claude가 응답을 끝내려 할 때 | 타입 검사, 영향 범위 테스트 | 종료를 막고 대화를 이어 간다 |
문서에 따르면 종료 코드 0은 성공, 2는 차단(이벤트별 의미는 위 표), 그 밖의 코드는 "비차단 오류"로 처리되어 작업이 그대로 진행된다. 이 차이가 아래 실습에서 실제로 문제가 됐다.
설정 순서
- 팀이 공유할 훅은 프로젝트의
.claude/settings.json에, 개인 설정은.claude/settings.local.json이나~/.claude/settings.json에 둔다. - 이벤트 → 매처 그룹 → 실행할 훅 순서로 적는다. PostToolUse는 매처
Edit|Write로 파일 수정 도구에만 반응하게 한다. - 검사 로직은 별도 스크립트로 분리하고, 경로는
${CLAUDE_PROJECT_DIR}로 프로젝트 루트를 기준으로 적는다. - 훅 스크립트에 문서의 입력 JSON을 직접 넣어 실행해 보고, 종료 코드와 stderr를 확인한다.
- 빠른 검사는 PostToolUse에, 느린 검사는 Stop이나 CI로 나눈다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/check-edited.mjs\"", "timeout": 30 }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/stop-tests.mjs\"", "timeout": 120 }]
}
]
}
}문서상 command 훅의 기본 시간 제한은 600초다. 파일 하나를 검사하는 훅은 몇 초면 끝나야 하므로 timeout을 짧게 둬서, 검사가 걸려도 작업 전체가 오래 멈추지 않게 했다.
PostToolUse 훅: 바뀐 파일 하나만 검사
훅은 stdin으로 JSON을 받는다. 문서의 PostToolUse 입력에는 tool_name, tool_input(도구에 넘긴 인자), tool_response가 들어 있고, 파일 도구의 tool_input.file_path는 항상 절대 경로이며 Windows에서는 백슬래시를 쓴다. 아래 스크립트는 그 파일만 node --check로 구문 검사하고, src 코드에 남은 console.log를 찾는다.
// PostToolUse 훅: Claude가 Edit/Write로 바꾼 파일 하나만 빠르게 검사한다.
// 문제가 있으면 stderr에 파일·줄·이유를 쓰고 exit 2 → Claude가 그 내용을 보고 고친다.
import { readFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { extname } from 'node:path';
const input = JSON.parse(readFileSync(0, 'utf8')); // stdin
const file = input.tool_input?.file_path;
// 파일이 없을 때 예외로 죽으면 종료 코드 1(비차단 오류)이 되어 검사가 조용히 건너뛰어진다. 명시적으로 처리한다.
if (!file || !existsSync(file)) process.exit(0);
const problems = [];
const ext = extname(file);
if (['.js', '.mjs', '.cjs'].includes(ext)) {
try {
execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' });
} catch (e) {
const msg = String(e.stderr).split('\n').filter(Boolean);
problems.push(`${msg[0]} — ${msg.find((l) => /Error/.test(l)) || '구문 오류'}`);
}
}
if (ext === '.json') {
try { JSON.parse(readFileSync(file, 'utf8')); } catch (e) { problems.push(`${file}: JSON 파싱 실패 — ${e.message}`); }
}
const text = readFileSync(file, 'utf8');
text.split('\n').forEach((line, i) => {
if (/console\.log\(/.test(line) && !/scripts[\/]/.test(file)) problems.push(`${file}:${i + 1}: console.log 남음 (src 코드에서는 logger 사용)`);
});
if (problems.length) {
process.stderr.write(`[check-edited] 수정한 파일에서 문제 ${problems.length}건:\n${problems.map((p) => `- ${p}`).join('\n')}\n`);
process.exit(2);
}
process.exit(0);문서의 입력 형식을 그대로 만들어 stdin으로 넣고 실행했다(2026-10-11, Windows, Node.js 24.15). broken.mjs는 닫는 중괄호가 빠진 파일, math.mjs는 console.log가 남은 파일이다.
$ echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"C:\\...\\src\\broken.mjs"}}' | node .claude/hooks/check-edited.mjs
[check-edited] 수정한 파일에서 문제 1건:
- C:\...\proj\src\broken.mjs:3 — SyntaxError: Unexpected end of input
exit=2$ # 같은 방식으로 src/math.mjs
[check-edited] 수정한 파일에서 문제 1건:
- C:\...\proj\src\math.mjs:2: console.log 남음 (src 코드에서는 logger 사용)
exit=2종료 코드 2와 함께 파일 경로, 줄 번호, 이유가 stderr로 나가므로 Claude가 다음 턴에서 바로 고칠 수 있다. 메시지에 "무엇을 어떻게 고쳐야 하는지"까지 쓰는 것이 핵심이다.
실수: 종료 코드 1로 검사가 조용히 건너뛰어졌다
처음 시험할 때 테스트용 경로를 잘못 넘겨 존재하지 않는 파일이 들어갔고, 스크립트는 ENOENT 예외로 죽으며 종료 코드 1을 냈다. 문서 기준으로 0과 2가 아닌 코드는 비차단 오류라 작업이 그대로 진행된다. 즉 검사 스크립트에 버그가 있으면 "통과"가 아니라 "검사 안 함"이 되는데, 겉으로는 구분이 안 된다. 그래서 위 스크립트에는 파일이 없으면 명시적으로 0을 반환하는 처리를 넣었다. 훅 스크립트는 예외로 죽지 않게 만들고, 검사 실패만 2로 내보내야 한다.
$ # 존재하지 않는 파일 경로를 넣었을 때 (수정 후)
exit=0Stop 훅: 작업을 끝내기 전에 테스트
Stop 훅이 종료 코드 2를 내면 Claude는 작업을 끝내지 않고 이어서 고친다. 이때 무한 반복을 막는 장치가 두 가지 있다. 문서에 따르면 입력의 stop_hook_active는 이미 Stop 훅 때문에 이어서 작업 중일 때 true가 되고, Stop 훅이 연속 8번 이어 가게 하면 Claude Code가 다음 차단을 무시하고 턴을 끝낸다. 아래 스크립트는 stop_hook_active가 true이면 다시 막지 않는다.
// Stop 훅: Claude가 작업을 끝내려 할 때 테스트를 한 번 돌린다.
// 실패하면 exit 2로 종료를 막고 실패 내용을 전달한다. 단, 이미 Stop 훅 때문에 이어서 작업 중이면
// (stop_hook_active=true) 다시 막지 않아 무한 반복을 피한다.
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
const input = JSON.parse(readFileSync(0, 'utf8'));
if (input.stop_hook_active) process.exit(0);
const r = spawnSync(process.execPath, ['--test', '--test-reporter=spec'], { cwd: input.cwd, encoding: 'utf8' });
if (r.status !== 0) {
// 마지막 몇 줄은 스택 트레이스뿐이라 고칠 단서가 없다. 실패한 테스트 이름과 기대값·실제값만 추린다.
const lines = (r.stdout + r.stderr).split('\n').map((l) => l.trim());
const tail = [...new Set(lines.filter((l) => /^✖ |^actual:|^expected:/.test(l)))].join('\n');
process.stderr.write(`[stop-tests] 테스트가 실패해 작업을 끝낼 수 없습니다:\n${tail}\n`);
process.exit(2);
}
process.exit(0);처음 버전은 테스트 출력의 마지막 8줄을 넘겼는데, 실제로 실행해 보니 그 8줄은 스택 트레이스뿐이었다. 모델이 무엇을 고쳐야 할지 알 수 없는 메시지다. 실패한 테스트 이름과 기대값·실제값만 추리도록 바꾼 뒤의 출력이다(테스트는 add(2, 2)가 5이길 기대하는 일부러 틀린 테스트).
$ echo '{"hook_event_name":"Stop","stop_hook_active":false,"cwd":"..."}' | node .claude/hooks/stop-tests.mjs
[stop-tests] 테스트가 실패해 작업을 끝낼 수 없습니다:
✖ add (1.3659ms)
✖ failing tests:
actual: 4,
expected: 5,
exit=2$ echo '{"hook_event_name":"Stop","stop_hook_active":true,"cwd":"..."}' | node .claude/hooks/stop-tests.mjs
exit=0무겁게 만들지 않는 기준과 공유 시 주의
- PostToolUse: 바뀐 파일 하나, 몇 초 안에 끝나는 검사만. 전체 테스트를 매 수정마다 돌리면 응답이 크게 느려진다.
- Stop: 타입 검사나 영향 범위 테스트처럼 수십 초 안에 끝나는 것. 전체 빌드와 통합 테스트는 CI로 보낸다.
- 기존 경고: 이미 있던 경고까지 실패로 처리하면 에이전트가 손댈 수 없는 상태가 된다. 새로 생긴 위반만 실패로 처리한다.
- 다른 경로의 수정: 문서에 따르면
Edit|Write매처의 PostToolUse는 Bash 명령이 같은 파일을 고칠 때는 실행되지 않는다. 그런 경로까지 잡으려면 Stop 훅이나 커밋 단계 검사가 함께 필요하다. - 공유: 훅 스크립트는 저장소에 커밋하고, 특정 OS 셸에 의존하지 않게 Node 같은 공통 런타임으로 쓴다. 비밀값을 스크립트에 넣지 않는다.
커밋 직전에 사람이 만든 변경까지 같은 기준으로 검사하려면 Git 훅을 함께 쓴다. 구성은 커밋 전 ESLint·Prettier·tsc 자동 적용에서 다뤘다. 훅과 권한 모드의 관계는 Claude Code Auto Mode 보안 점검을 참고한다.
초보자가 자주 실수하는 포인트
- 훅 스크립트가 예외로 종료 코드 1을 내 검사가 건너뛰어진 것을 모른다.
- PostToolUse에 전체 테스트를 걸어 매 수정마다 작업이 느려진다.
- Stop 훅에서 stop_hook_active를 확인하지 않아 같은 실패로 계속 이어 간다.
- 실패 메시지로 스택 트레이스 전체를 넘겨 모델이 원인을 찾지 못한다.
체크리스트
- 팀 공용 훅은 .claude/settings.json에, 개인 설정은 local 파일에 있다.
- 훅 스크립트에 문서의 입력 JSON을 넣어 종료 코드를 확인했다.
- 예외 상황에서 스크립트가 종료 코드 1로 죽지 않는다.
- PostToolUse 검사가 몇 초 안에 끝난다.
- Stop 훅이 stop_hook_active를 확인한다.
자주 묻는 질문
PostToolUse에서 종료 코드 2를 내면 수정이 취소되나요?
아닙니다. 문서에 따르면 PostToolUse는 도구가 이미 실행된 뒤라 수정은 그대로 남고, stderr만 Claude에게 보여 줍니다. 실행 자체를 막으려면 PreToolUse를 써야 합니다.
Git pre-commit 훅이 있으면 Claude Code 훅은 필요 없나요?
역할이 다릅니다. Git 훅은 커밋 시점에 사람과 에이전트의 변경을 모두 검사하고, Claude Code 훅은 에이전트가 파일을 고친 직후에 피드백을 줘서 커밋 전에 스스로 고치게 합니다. 둘을 함께 쓰는 것이 일반적입니다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 위 참고 자료를 바탕으로 정리했으며, 내용은 운영 과정에서 순차적으로 보완될 수 있습니다.