본문 바로가기

개발/Claude Code

[Claude Code] 훅(Hooks) (9)

실습 목표: 클로드에게 부탁하듯이 지시를 내리는 것이 아니라 클로드가 강제로 동작하도록 하는 규칙을 만드는 것.

 

Step 0. 훅(Hooks)이란?

지금까지의 실습을 돌아보면 지시사항을 CLAUDE.md에 작성하거나 스킬 본문에 작성하는 것 등은 클로드가 읽고 판단해서 따르는 것이고, 따르지 않아도 막을 방법이 없다.

훅은 클로드의 판단 바깥에서 동작한다.

특정 이벤트가 발생할 때 내가 만든 스크립트가 실행되고, 그 스크립트가 작업을 막을 수 있다. 
클로드의 판단이나 승인을 거치지 않고 이벤트 발생 시 무조건 실행된다.

요약하면, 훅은 클로드 코드 실행 중 특정 이벤트가 발생할 때 사용자가 지정한 Shell 명령, HTTP 요청, 프롬프트 등을 자동으로 실행해 주는 자동화 기능이다.

이번 실습에서 두 가지 훅을 만들어 보자.

  • tests/ 폴더 수정을 차단하는 훅 — 이전 테스트 실습에서 검증 과정에서 테스트 코드를 수정하지 못하도록 하는 규칙
  • 코드를 고칠 때마다 pytest를 자동 실행하는 훅

Step 1.  준비 — jq 설치

훅 스크립트는 클로드 코드가 보내주는 JSON 데이터를 읽어야 한다.
jq는 그 복잡한 JSON 데이터에서 원하는 값을 쏙 골라내는 명령줄(Command line) 도구이다.

 

터미널에 다음 명령어를 입력하여 jq를 설치한다.

brew install jq

 

Step 2. 첫 번째 훅 — tests/ 하위 파일 수정 차단하기

Shell 스크립트를 만든다.

cd ~/shop-api && mkdir -p .claude/hooks
cat > .claude/hooks/protect-tests.sh <<'EOF'
#!/bin/bash
# tests/ 아래 파일의 수정을 차단한다.

input=$(cat)
file_path=$(jq -r '.tool_input.file_path // empty' <<<"$input")

case "$file_path" in
  */tests/*)
    echo "차단됨: tests/ 아래 파일은 훅으로 보호되어 있습니다. 테스트를 고쳐서 통과시키지 말고 구현 코드를 고치세요." >&2
    exit 2
    ;;
esac

exit 0
EOF
chmod +x .claude/hooks/protect-tests.sh

chmod +x를 빠뜨리면 안 된다. 
실행 권한이 없으면 스크립트가 시작조차 못 하기 때문에 차단이 되지 않는다.

 

이제 이 스크립트를 언제 실행할지 등록하자.

다음 명령어를 실행하여 이 프로젝트의 .claude 폴더에 settings.json 파일을 생성하고 다음 내용을 작성한다.

cat > .claude/settings.json <<'EOF'
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-tests.sh",
            "args": []
          }
        ]
      }
    ]
  }
}
EOF

 

설정 구조 읽기

  1. PreToolUse — 언제 실행할지. 도구가 실행되기 직전이다. 아직 아무 일도 일어나지 않은 시점이라 막을 수 있다.
  2. matcher: "Edit|Write" — 어떤 도구일 때만 실행할지. 파일을 고치거나 새로 쓸 때만이고, Bash나 Read에는 반응하지 않는다.
  3. command — 무엇을 실행할지. ${CLAUDE_PROJECT_DIR}는 프로젝트 루트로 바뀌는 자리표시자이다. 현재 작업 폴더가 어디든 스크립트를 찾을 수 있게 해 준다.

 

protect-tests.sh 셸 스크립트에서 exit 2가 핵심이다.

스크립트의 마지막 두 줄을 보면

echo "차단됨: ..." >&2
exit 2
종료 코드 결과
0 아무 결정도 하지 않음. 평소대로 진행
2 작업을 차단. stderr에 쓴 내용이 클로드에게 차단 사유로 전달됨
그 외 (1 포함) 오류로 기록될 뿐, 막지 못하고 진행됨

exit 1은 막지 못한다. 유닉스에서 1은 보통 실패를 뜻하지만, 훅에서는 2만 차단한다.
>&2는 "표준 오류로 출력하라"는 뜻이다. 이 내용이 클로드에게 전달되므로, 여기에 왜 막혔고 대신 무엇을 하라를 적어야 한다.

 

Step 3. 실제로 훅에 의해 차단되는지 확인해 보자.

새 대화를 열고 다음 프롬프트를 클로드에게 보내본다.

tests/test_pricing.py에 아무 주석이나 한 줄 추가해줘.

그렇다면 위와 같이 훅에 의해 수정이 차단된 것을 확인할 수 있다.

 

여기서 훅에 의한 차단은 권한 모드와 무관하게 막힌다. Edit automatically로 바꿔도, 승인 창에서 허용해도 막힌다. 승인 절차 이전에 훅이 먼저 실행되기 때문이다.

 

이전 실습부터 쌓아온 안전망 층 위에 하나가 더 얹힌 것이다.

훅		→  클로드의 판단 바깥. 조건에 맞으면 무조건 차단
권한 설정		→  승인 창. 사람이 판단
CLAUDE.md	→  클로드가 읽고 따르기를 기대

 

Step 4. 두 번째 훅을 만들어 보자. — 편집 후 자동 검사

이번엔 막는 게 아니라 확인이다.

 

터미널을 열고 다음 명령어를 실행하여 두 번째 훅에 대한 shell 스크립트를 만든다.

cat > .claude/hooks/run-tests.sh <<'EOF'
#!/bin/bash
# 편집 후 테스트를 돌려 실패하면 Claude에게 알린다.

cd "$CLAUDE_PROJECT_DIR" || exit 0

if ! output=$(pytest -q 2>&1); then
  echo "편집 후 테스트가 실패했습니다:" >&2
  echo "$output" >&2
  exit 2
fi

exit 0
EOF
chmod +x .claude/hooks/run-tests.sh

 

이제 이 프로젝트의 .claude/settings.json 파일을 열고 다음 설정을 추가한다.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-tests.sh",
            "args": []
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests.sh",
            "args": [],
            "timeout": 60
          }
        ]
      }
    ]
  }
}

PostToolUse에서 exit 2는 뜻이 다르다.

PostToolUse는 도구가 이미 실행된 뒤에 일어난다. 이미 실행된 뒤라 되돌릴 수 없으니 차단이라는 개념 자체가 성립하지 않는다.

  PreToolUse PostToolUse
시점 실행 전 실행 후
exit 2 차단 stderr에 쓴 내용이 클로드에게 차단 사유로 전달됨(차단 X)

그래서 run-tests.sh의 exit 2는 "실행을 막아라"가 아니라 "에러 내용(stderr)을 클로드에게 알려라"이다. 
테스트 실패 내용을 그대로 전달하는 것이다.

 

클로드가 코드를 고친 직후에 실패를 알게 되니, 나중에 테스트 과정에서 발견하는 것보다 훨씬 빨리 오류를 수정할 수 있게 된다.

이제 코드를 클로드가 고치면 두 번째 훅에 의해 자동으로 테스트(pytest)를 거쳐 오류를 잡아낸다.

 

Step 5. 이제 두 번째 훅의 동작을 실제로 확인해 보자.

다음 프롬프트를 클로드에게 보낸다.

@app/pricing.py SHIPPING_FEE를 5000으로 바꿔줘.

위와 같이 클로드가 코드를 수정한 직후에 테스트를 돌리는데 테스트가 깨져 실패를 알아채고 반응하는 것을 볼 수 있다.

 

Step 6. 훅이 동작하지 않을 때 확인 순서

  1. 실행 권한 — ls -l .claude/hooks/ 로 x가 있는지
  2. 종료 코드 — 막으려는데 exit 1을 쓰지 않았는지
  3. 경로 — 스크립트를 못 찾으면 Failed with non-blocking status code: 메시지가 뜨고 조용히 통과한다.
  4. JSON 문법 — 설정 파일에 쉼표나 괄호가 빠지지 않았는지

3번이 특히 위험하다. 차단 훅을 만들어놓고 경로를 틀리면, 막고 있다고 믿는 동안 아무것도 막히지 않는다. 
훅을 만든 직후 한 번은 실제로 막히는지 시험해 볼 필요가 있다.

 

Step 7. 차단 훅 제거

실습을 계속 진행함에 앞서 차단 훅을 빼는 편이 편하다. 
(훅은 클로드의 판단 이전에 동작하며 강제적이기 때문에 이전 실습에서 만든 /tdd 스킬을 사용하지 못한다.)
.claude/settings.json에서 PreToolUse 항목만 지우고 PostToolUse는 남긴다. 
자동 테스트 실행은 방해가 안 되고 유용하기 때문이다.

 

 

마지막으로, 훅은 내 권한으로 실행되는 임의의 명령이다. 
남이 만든 프로젝트를 열 때 .claude/settings.json에 무엇이 들어 있는지 확인해 볼 필요가 있다.

git add .
git commit -m "feat: 훅 추가"

 

 

핵심 정리

1. 훅은 클로드의 판단 바깥에서 실행된다.
CLAUDE.md와 스킬은 클로드가 읽고 따르는 것이지만, 훅은 사건이 일어나면 무조건 실행된다.

2. 설정은 세 겹이다.
언제(이벤트) → 어떤 도구일 때(matcher) → 무엇을 실행할지(command).

3. PreToolUse는 클로드의 동작을 막을 수 있고 PostToolUse는 못 막는다.
전자는 실행 전, 후자는 실행 후. 같은 exit 2라도 전자는 차단, 후자는 클로드에게 에러 내용을 알리는 것이다.

4. exit 2만 차단한다.
exit 1은 오류로 기록될 뿐 막지 못한다. 훅이 안 먹히는 가장 흔한 원인.

5. stderr에 쓴 내용이 클로드에게 전달된다.
왜 막혔고 대신 무엇을 하라를 여기에 적는다.

6. 실행 권한과 경로가 틀리면 조용히 통과한다.
차단 훅은 만든 직후 반드시 실제로 막히는지 시험한다.

7. 훅은 권한 설정보다 먼저 동작한다.
승인 모드를 어떻게 바꿔도 훅이 막으면 막힌다.

8. 편집 직후 검사는 수정 속도를 크게 높인다.
클로드가 실패를 즉시 알게 되므로, 예를 들어, 몇 단계 뒤에 테스트 과정에서 발견하는 것보다 빨리 고칠 수 있다.

9. 훅은 상황을 판단하지 못한다.
정당한 작업까지 같이 막힌다. 예외 없는 규칙만 훅으로 만들고, 상황에 따라 달라지는 것은 CLAUDE.md에 둔다.

10. 훅은 내 권한으로 임의의 명령을 실행한다.
남의 프로젝트를 열 때 .claude/settings.json을 확인한다.