권고가 아니라 강제 | Hooks
CLAUDE.md 지침을 AI가 건너뛰는 상황에서 Hook으로 강제 실행하는 원리를 이해하고, PostToolUse Hook으로 ESLint를 자동화합니다
Overview
Skill과 MCP로 AI에게 도구를 쥐여주는 방법을 배웠습니다. 이 도구들은 AI가 필요하다고 판단할 때 실행됩니다. 그런데 AI가 그 판단을 빠뜨리면 어떻게 될까요?
CLAUDE.md에 "파일 수정 후 ESLint를 실행하라"고 써두어도, 컨텍스트가 길어지거나 급한 수정이 이어지면 AI가 생략할 수 있습니다. 이번 레슨에서는 AI의 판단과 무관하게 특정 시점에 항상 실행되는 장치인 Hook을 배웁니다.
학습 목표
- CLAUDE.md 지침(권고)과 Hook(강제)의 차이를 구별합니다
- Hook의 세 요소(Event, Matcher, Handler)를 이해합니다
- PostToolUse Hook을 직접 작성해 파일 수정 후 검증을 자동화합니다
- Hook이 적합한 상황과 오히려 방해가 되는 상황을 판단합니다
시작하기 전 확인사항
- Claude Code 설치 완료 (
claude --version) - 프로젝트에
.claude/폴더 존재 - jq 설치 (
jq --version으로 확인). 없으면 OS별로 설치- macOS:
brew install jq - Windows:
winget install jqlang.jq
- macOS:
- 실습 프로젝트 시작 브랜치 (
git checkout ch08-01)
ch08-01 브랜치에는 ESLint가 미리 설정되어 있습니다.
CLAUDE.md 지침의 한계
CLAUDE.md에 이런 규칙을 써두었다고 합시다.
"TypeScript 파일을 수정한 뒤에는 반드시
bunx eslint --fix를 실행하세요."
Lint(린트)란?
코드의 문법 오류, 스타일 위반, 잠재적 버그를 자동으로 찾아주는 도구입니다. 맞춤법 검사기가 글의 오타를 찾듯, Lint는 코드의 문제를 찾습니다. ESLint는 JavaScript/TypeScript용 Lint 도구입니다.
처음 몇 번은 잘 따라옵니다. AI가 Edit 도구로 파일을 고친 다음 Bash 도구로 ESLint를 돌립니다.
그런데 한 세션에서 파일을 10개 넘게 연속으로 고치는 상황이 오면, AI가 ESLint 실행을 한두 번씩 빼먹기 시작합니다. 컨텍스트가 길어지면서 "이번엔 괜찮겠지"라는 판단이 끼어들기도 하고, 다른 맥락에 집중하느라 그냥 잊기도 합니다.
CLAUDE.md 지침은 AI가 읽고 판단하는 "부탁"입니다. AI가 따를지 말지를 스스로 결정합니다. 개발자가 직접 "lint 돌렸어?"라고 확인하지 않으면, 규율은 조용히 무너집니다.
Hook: AI 판단을 거치지 않는 자동 실행 장치
CLAUDE.md 지침
권고실행 여부가 AI 판단에 달려 있습니다
Hook
강제판단 없이 매번 실행됩니다
Hook(훅)은 AI가 도구를 쓰거나 작업을 마칠 때, 지정된 스크립트를 Claude Code가 자동으로 실행하는 장치입니다. AI의 판단이 끼어들지 않습니다. Edit 도구가 호출되면, 스크립트는 무조건 실행됩니다.
CLAUDE.md가 "지켜줬으면 하는 메모"라면, Hook은 그 메모를 "실행 단계에 고정해 둔 장치"입니다.
| CLAUDE.md 지침 | Hook | |
|---|---|---|
| 실행 주체 | AI가 읽고 판단 | Claude Code 런타임이 실행 |
| 실행 보장 | AI가 건너뛸 수 있음 | 매번 100% 실행 |
| 실패 시 반응 | 개발자가 직접 발견해야 함 | 스크립트가 에러를 Claude에게 피드백 |
Hook의 3 요소
Hook은 세 가지 질문에 답하는 설정입니다. 언제 실행할지(Event), 어떤 조건에서 실행할지(Matcher), 뭘 실행할지(Handler)입니다.
settings.json으로 보면 세 요소가 어디에 들어가는지 명확합니다.
{
"hooks": {
"PostToolUse": [ // ① Event: 도구 실행 직후
{
"matcher": "Write|Edit", // ② Matcher: Write 또는 Edit 도구일 때
"hooks": [
{
"type": "command", // ③ Handler 타입
"command": "lint.sh" // ③ 실행할 명령
}
]
}
]
}
}Event: 언제 발동할지
Claude Code는 여러 시점에 Hook을 걸 수 있습니다. 이번 레슨에서는 PostToolUse로 실습합니다.
| Event | 시점 | 대표 용도 |
|---|---|---|
PreToolUse | 도구 실행 직전 | 위험한 명령 차단 |
PostToolUse | 도구 실행 직후 | 수정 결과 검증 |
Stop | AI가 응답을 마치려 할 때 | 작업 완료 여부 최종 점검 |
UserPromptSubmit | 사용자 입력 직후 | 프롬프트 로깅·보강 |
Matcher: 어떤 조건에서 실행할지
Matcher는 도구 이름을 필터링합니다. "Write|Edit"는 "Write 또는 Edit이라는 이름의 도구"에만 반응합니다. Bash, Read 같은 다른 도구는 무시합니다.
Matcher는 기본적으로 정규식이 아닙니다
Matcher 문자열에 영문자·숫자·_·|만 들어있으면 정확 일치 방식으로 평가됩니다. "Write|Edit"는 "Write" 또는 "Edit"만 매칭하고, "MultiEdit"는 매칭하지 않습니다. 실무에서는 MultiEdit도 잡아야 하는 경우가 많으므로 "Write|Edit|MultiEdit"로 명시하는 걸 권장합니다. 반대로 ".*Edit"처럼 특수문자가 섞이면 정규식으로 평가됩니다.
Handler: 무엇을 실행할지
Handler는 네 가지 타입이 있습니다. 가장 많이 쓰는 건 command(셸 스크립트)입니다.
| 타입 | 실행 방식 | 적합한 상황 |
|---|---|---|
command | 셸 스크립트 실행 | lint, 빌드, 테스트처럼 코드로 판정 가능한 검증 |
prompt | LLM에게 1회 질의 | 의도 파악 등 코드로 판정하기 어려운 검증 |
http | HTTP POST 전송 | 외부 서비스 연동 (Slack 알림, 감사 로그) |
mcp_tool | 연결된 MCP 서버의 도구 호출 | MCP로 이미 연결된 외부 시스템에 검증 위임 |
이 레슨에서는 command 타입으로 실습합니다.
[실습] 파일 수정 후 ESLint 자동 실행
AI가 TypeScript 파일을 수정할 때마다 ESLint가 자동 실행되는 PostToolUse Hook을 만듭니다.
Step 1: Hook 스크립트 작성
.claude/hooks/lint.sh를 생성합니다.
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path')
bunx eslint --fix "$FILE_PATH"각 줄의 역할은 다음과 같습니다.
jq -r '.tool_input.file_path': Hook은 호출된 도구의 입력값을 JSON으로 stdin에 전달합니다.jq로 그 중 수정된 파일 경로를 꺼냅니다.bunx eslint --fix: 꺼낸 경로를 ESLint에 넘겨 자동 수정 가능한 문제(세미콜론, import 순서 등)를 바로 고칩니다.
실행 권한을 줍니다.
chmod +x .claude/hooks/lint.shjq란?
JSON 데이터에서 원하는 값을 꺼내는 CLI 도구입니다. 엑셀에서 "C3 셀 값만 추출"하는 것을 명령줄로 하는 셈입니다. Hook은 도구 정보를 JSON으로 넘기므로, 스크립트에서 필요한 값을 꺼낼 때 jq가 필수입니다.
Step 2: settings.json에 Hook 등록
.claude/settings.json에 다음을 추가합니다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$(git rev-parse --show-toplevel)\"/.claude/hooks/lint.sh"
}
]
}
]
}
}"matcher": "Write|Edit|MultiEdit": 파일 생성·단일 편집·다중 편집 세 도구 모두에 반응합니다.git rev-parse --show-toplevel: 프로젝트 루트 경로를 자동으로 찾습니다. 어느 디렉토리에서 실행되든 올바른 스크립트 경로를 가리킵니다.- 스크립트 외부화: 명령을 settings.json에 직접 쓰지 않고
.sh파일로 분리하면, 조건이 늘어나도 깔끔하게 관리할 수 있습니다.
기존 settings.json이 있다면
이미 다른 설정이 있다면 hooks 키만 추가합니다. 전체 파일을 덮어쓰지 않도록 주의합니다.
Step 3: Hook이 동작하는지 확인
Claude Code를 실행한 뒤 AI에게 간단한 수정을 요청합니다.
"app/page.tsx에 제목을 let으로 할당해줘"
AI가 let title = "Todo"처럼 변수를 선언합니다. let이지만 재할당되지 않으므로 ESLint의 prefer-const 규칙에 걸립니다. 이 규칙은 --fix로 자동 수정이 가능하므로, Hook이 실행되는 순간 let이 const로 즉시 바뀝니다.
"lint 돌려줘"라고 지시한 적이 없는데도 Hook이 파일 수정을 감지해 자동으로 실행한 것입니다.
그럼 자동 수정이 불가능한 에러는 어떻게 될까요?
Step 4: 자동 수정 불가 에러 발견
이어서 이렇게 요청해봅니다.
"title에 any 타입을 붙여줘"
AI가 const title: any = "Todo"로 수정합니다. @typescript-eslint/no-explicit-any 규칙은 any 사용을 금지하는데, 기본 설정에서는 --fix로 자동 수정되지 않습니다. ESLint가 에러를 출력하며 exit 1을 반환합니다.
그런데 AI는 이 에러를 모른 채 다음 작업으로 넘어갑니다. 왜 그럴까요?
Step 5: exit code로 에러 전달
Hook은 스크립트의 exit code로 결과를 판단합니다.
| exit code | 의미 | 피드백 대상 |
|---|---|---|
0 | 성공 | (없음) |
2 | 차단(Block) | Claude에게 stderr 피드백 → 자동 수정 시도 |
그 외 (1, 3 ...) | 비차단 에러 | 사용자에게만 알림 (Claude는 모름) |
여기에 함정이 있습니다. ESLint는 린트 에러가 있으면 관습적으로 exit 1을 반환합니다. 그런데 Hook 입장에서 exit 1은 "비차단 에러"입니다. 사용자 터미널에만 살짝 뜨고 Claude에게는 전달되지 않습니다. 그래서 AI는 에러가 있었는지조차 모르고 다음 단계로 넘어갑니다.
해결책은 ESLint의 exit 1을 Hook의 exit 2로 변환하는 데 있습니다. lint.sh를 다음과 같이 교체합니다.
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path')
if [[ ! "$FILE_PATH" =~ \.(js|jsx|ts|tsx|mjs)$ ]]; then
exit 0
fi
RESULT=$(bunx eslint --fix "$FILE_PATH" 2>&1)
ESLINT_EXIT=$?
if [ $ESLINT_EXIT -eq 0 ]; then
exit 0
elif [ $ESLINT_EXIT -eq 1 ]; then
echo "$RESULT" >&2
exit 2
else
exit 1
fi포인트만 짚으면 다음과 같습니다.
- 확장자 필터링:
.ts,.tsx같은 JS/TS 파일이 아니면 ESLint를 돌릴 이유가 없으니 즉시 exit 0 RESULT=$(... 2>&1): ESLint 출력(stdout + stderr)을 변수에 캡처ESLINT_EXIT=$?: 직전 명령의 exit code (ESLint는 0=깨끗함, 1=린트 에러, 2=설정 오류)echo "$RESULT" >&2; exit 2: 린트 에러일 때 전체 결과를 stderr로 내보내고 Hook exit code를 2로 바꿈 → Claude에게 "이 에러들 고쳐"라고 전달
스크립트를 교체한 뒤 같은 요청을 다시 보냅니다.
"title에 any 타입을 붙여줘"
이번엔 Hook이 ESLint의 exit 1을 exit 2로 바꾸면서 no-explicit-any 에러 메시지를 Claude에게 넘깁니다. Claude는 이 피드백을 읽고 any를 string으로 자동 수정합니다.
Hook을 피해야 하는 경우
Hook은 강력하지만, 모든 규율을 Hook으로 바꾸면 오히려 역효과가 납니다.
CLAUDE.md로 충분한 경우는 Hook으로 옮기지 않습니다. "주석은 한국어로 써달라"처럼 AI가 대부분 따라오는 스타일 권장은 지침으로 남겨둡니다. Hook은 "한 번만 빠져도 문제가 생기는 작업"에 씁니다. lint, 시크릿 파일 접근 차단, 빌드 검증 등입니다.
매 도구 호출마다 실행되므로 무거운 작업은 금물입니다. 전체 빌드나 E2E 테스트 같은 수십 초짜리 작업을 PostToolUse에 넣으면, AI가 파일을 수정할 때마다 긴 지연이 생깁니다. 이런 검증은 Stop Hook(작업 완료 시)이나 CI에 맡기는 편이 낫습니다.
판단이 필요한 작업에 command 타입은 맞지 않습니다. "이 작업이 TDD의 RED 단계인지 GREEN 단계인지"처럼 맥락 이해가 필요한 검증은 exit code로 표현할 수 없습니다. 이런 경우 prompt 타입을 쓸 수 있지만, LLM 호출 비용과 지연을 감수해야 합니다.
핵심 포인트 정리
- CLAUDE.md는 권고, Hook은 강제: 지침은 AI가 판단해 건너뛸 수 있지만, Hook은 지정된 시점에 Claude Code 런타임이 무조건 실행합니다.
- Event·Matcher·Handler 세 요소: 언제 발동할지(Event), 어떤 조건에서(Matcher), 뭘 실행할지(Handler)를 조립해 Hook을 구성합니다. Matcher는 기본적으로 정규식이 아니라 정확 일치 방식이라는 점에 주의합니다.
- exit code가 소통 언어:
0은 성공,2는 Claude에게 에러 피드백, 그 외는 Claude에게 전달되지 않습니다. 외부 도구의 exit code를 Hook의 exit code로 바꿔주는 스크립트가 있어야 Claude가 에러를 인식합니다.
FAQ
-
Q: Hook이 너무 많아지면 전체 속도가 느려지지 않나요?
- A: 대부분의 Hook은 밀리초 단위로 끝납니다. 문제는 무거운 작업(전체 빌드, E2E 테스트)을 PostToolUse에 넣을 때 생깁니다. 파일 단위 검증(lint, grep)은 부담이 거의 없고, 전체 검증은
StopHook이나 CI에 분리해두면 지연이 누적되지 않습니다.
- A: 대부분의 Hook은 밀리초 단위로 끝납니다. 문제는 무거운 작업(전체 빌드, E2E 테스트)을 PostToolUse에 넣을 때 생깁니다. 파일 단위 검증(lint, grep)은 부담이 거의 없고, 전체 검증은
-
Q: 스크립트가 에러로 죽으면 AI 작업이 전부 중단되나요?
- A: Hook 자체가 죽으면 exit code가 0이 아닌 값으로 끝나는데, exit 2만 Claude에게 전달됩니다. exit 1이나 예기치 않은 종료는 사용자 transcript에만 표시되고, AI는 계속 작업합니다.
-
Q: Matcher에
"Write|Edit"라고 쓰면MultiEdit도 잡히나요?- A: 아닙니다. Matcher에 특수문자가 없으면 정확 일치 리스트로 처리되므로,
"Write|Edit"는"Write"또는"Edit"만 매칭합니다.MultiEdit을 포함하려면"Write|Edit|MultiEdit"로 명시하거나".*Edit"같은 정규식을 사용하세요.
- A: 아닙니다. Matcher에 특수문자가 없으면 정확 일치 리스트로 처리되므로,
-
Q: CLAUDE.md에 "파일 수정 후 ESLint 실행"이라고 써두면 충분하지 않나요?
- A: 짧은 세션에서는 충분히 동작합니다. 문제는 컨텍스트가 길어지거나 연속 수정이 많아지는 긴 세션입니다. 그런 상황에서 AI가 한두 번 생략하는 실수가 쌓이면 린트 에러가 조용히 누적됩니다. Hook은 세션 길이와 무관하게 매번 실행됨을 보장합니다.
이어서 배울 내용
Hook으로 AI의 행동을 강제로 가로채는 방법을 배웠습니다. 다음 레슨에서는 반대 방향의 문제를 다룹니다. AI가 너무 많은 파일을 읽어서 컨텍스트가 오염될 때, 작업을 별도 컨텍스트로 격리하는 Custom Agent입니다.
- Subagent의 컨텍스트 격리 원리
.claude/agents/에 Custom Agent 정의하기- 컨텍스트 오염이 큰 작업을 Agent로 분리하는 기준