ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

Claude Code에는 Hooks라는 기능이 있다. 도구 실행 전후에 셸 명령을 자동으로 끼워 넣는 기능이다.

Git hooks를 써본 적 있으면 감이 올 것이다. 커밋 전에 린트를 돌리고, 푸시 전에 테스트를 돌리는 그 구조다. Hooks는 그걸 AI 에이전트 레벨에서 하는 것이다. Claude가 파일을 수정하기 전에 백업을 뜨고, 위험한 명령을 실행하려 하면 차단하고, 작업이 끝나면 Slack으로 알림을 보낸다.

설정 한 번으로 반복적인 안전장치와 자동화가 걸린다. 매번 프롬프트에 "이건 하지 마"라고 쓸 필요가 없다.

목차

  • Hooks가 뭔가
    • PreToolUse
    • PostToolUse
    • Notification
  • 기본 구조
  • 입출력 프로토콜
    • PreToolUse — 입력 (stdin)
    • PreToolUse — 출력 (stdout)
    • PostToolUse — 입력 (stdin)
    • Notification — 입력 (stdin)
  • 실전 레시피 8가지
    • 레시피 1: 위험한 명령어 차단
    • 레시피 2: 테스트 출력 필터링 (토큰 절약)
    • 레시피 3: 파일 수정 시 자동 백업
    • 레시피 4: 커밋 메시지 자동 검증
    • 레시피 5: Slack/Discord 알림
    • 레시피 6: 특정 디렉토리 수정 금지
    • 레시피 7: 빌드 명령어 자동 타임아웃
    • 레시피 8: 작업 로그 자동 기록
  • 레시피 조합 — 실전 settings.json
  • 디버깅 팁
  • 주의사항
    • 보안
    • 무한 루프
    • 설정 파일 위치
  • 요약

Hooks가 뭔가

Hooks는 Claude Code 이벤트에 반응해서 실행되는 셸 명령이다. settings.json에 설정하면, 특정 도구가 실행될 때 자동으로 지정한 스크립트가 돌아간다.

핵심은 세 가지 이벤트 타입이다:

PreToolUse

도구가 실행되기 전에 동작한다. 가장 강력한 훅이다.

  • 명령어를 수정할 수 있다 (테스트 출력에 | head -50 붙이기)
  • 실행을 차단할 수 있다 (rm -rf / 같은 위험 명령 거부)
  • 입력을 변환할 수 있다 (타임아웃 래핑 등)
  • 아무것도 안 하면 그냥 통과시킨다

PostToolUse

도구가 실행된 후에 동작한다.

  • 결과를 로깅할 수 있다 (모든 도구 호출을 파일에 기록)
  • 외부 알림을 보낼 수 있다 (Slack, Discord)
  • 출력을 수정할 수 있다 (민감 정보 마스킹)

Notification

Claude가 알림을 보낼 때 동작한다. 주로 작업 완료 시점이다.

  • 장시간 작업 후 데스크톱 알림
  • 메신저 알림
  • 사운드 재생

세 타입 모두 공통점이 있다: stdin으로 JSON을 받고, stdout으로 JSON을 내보낸다. 출력이 없으면 기본 동작(허용)으로 처리된다.

기본 구조

프로젝트 루트의 .claude/settings.json에 설정한다:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/log-all.sh"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/slack-notify.sh"
          }
        ]
      }
    ]
  }
}

각 필드의 의미:

  • matcher: 어떤 도구에 훅을 걸 것인가. "Bash", "Edit", "Write", "Read" 등 도구명을 지정한다. "*"이면 모든 도구에 적용된다. Notification은 도구가 아니므로 빈 문자열이다.
  • type: 항상 "command"이다. 현재는 셸 명령만 지원한다.
  • command: 실행할 셸 명령 또는 스크립트 경로다. 절대경로나 ~ 경로를 쓴다.

설정 파일은 두 곳에 둘 수 있다:

  • 프로젝트별: .claude/settings.json — 해당 프로젝트에서만 적용
  • 글로벌: ~/.claude/settings.json — 모든 프로젝트에 적용

입출력 프로토콜

Hooks는 stdin/stdout으로 JSON을 주고받는다. 이 프로토콜을 정확히 알아야 훅을 작성할 수 있다.

PreToolUse — 입력 (stdin)

{
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  }
}

도구 이름과 도구에 전달될 입력이 들어온다. Bash면 command, Edit면 file_pathold_string/new_string 등이다.

PreToolUse — 출력 (stdout)

세 가지 선택지가 있다.

1. 허용 (기본값)

{}

빈 JSON이거나 아무 출력도 없으면 그대로 실행을 허용한다.

2. 입력을 수정해서 허용

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": {
      "command": "npm test 2>&1 | head -50"
    }
  }
}

원래 명령을 바꿔치기한다. Claude는 수정된 명령이 실행된 줄 모른다.

3. 차단

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "reason": "Production database access blocked"
  }
}

실행을 거부한다. reason이 Claude에게 전달되어 왜 차단됐는지 알려준다.

PostToolUse — 입력 (stdin)

{
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  },
  "tool_output": "... test results ..."
}

PreToolUse와 동일한 구조에 tool_output이 추가된다. 실행 결과를 볼 수 있다.

Notification — 입력 (stdin)

{
  "message": "Task completed successfully"
}

알림 메시지 텍스트가 들어온다.

실전 레시피 8가지

이론은 여기까지다. 바로 쓸 수 있는 레시피 8개를 정리했다.

레시피 1: 위험한 명령어 차단

프로덕션 데이터를 날리거나, 루트 디렉토리를 삭제하거나, 메인 브랜치에 force push하는 걸 막는다. Claude가 아무리 똑똑해도 실수는 한다.

#!/bin/bash
# block-dangerous.sh — PreToolUse, matcher: "Bash"
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')

dangerous_patterns=(
  "rm -rf /"
  "DROP TABLE"
  "DROP DATABASE"
  "git push.*--force.*main"
  "git push.*--force.*master"
  "git reset --hard"
  "chmod 777"
  "mkfs\\."
  "> /dev/sda"
)

for pattern in "${dangerous_patterns[@]}"; do
  if echo "$cmd" | grep -qiE "$pattern"; then
    echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"reason\":\"Blocked: matches dangerous pattern '$pattern'\"}}"
    exit 0
  fi
done
echo "{}"

패턴 리스트는 프로젝트에 맞게 확장하면 된다. 예를 들어 프로덕션 DB 호스트명을 패턴에 넣으면 실수로 프로덕션에 쿼리 날리는 것도 막을 수 있다.

설정:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ]
  }
}

레시피 2: 테스트 출력 필터링 (토큰 절약)

npm test를 돌리면 수천 줄의 출력이 나온다. 통과한 테스트 목록은 Claude에게 필요 없다. 실패한 부분만 보여주면 컨텍스트 토큰을 대폭 줄일 수 있다.

#!/bin/bash
# filter-test-output.sh — PreToolUse, matcher: "Bash"
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')

if [[ "$cmd" =~ ^(npm\ test|npx\ jest|pytest|go\ test|\.\/gradlew\ test|cargo\ test) ]]; then
  filtered="$cmd 2>&1 | grep -A 5 -E '(FAIL|ERROR|error:|FAILED|panic:|assertion)' | head -100"
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{\"command\":\"$filtered\"}}}"
else
  echo "{}"
fi

원래 명령에 grephead를 파이프로 붙여서, 에러 관련 줄만 추출한다. 10,000줄 출력이 100줄 이하로 줄어든다. 비용 절약 글에서도 다뤘던 전략인데, 이게 그 구체적인 구현이다.

레시피 3: 파일 수정 시 자동 백업

Claude가 파일을 수정하기 전에 원본을 백업해둔다. Edit이나 Write가 실행될 때마다 동작한다.

#!/bin/bash
# auto-backup.sh — PreToolUse, matcher: "Edit" 또는 "Write"
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')

if [ -n "$file" ] && [ -f "$file" ]; then
  backup_dir="$HOME/.claude/backups/$(date +%Y%m%d)"
  mkdir -p "$backup_dir"
  relative_path=$(echo "$file" | sed "s|$HOME/||" | tr '/' '_')
  cp "$file" "$backup_dir/${relative_path}.$(date +%H%M%S).bak"
fi
echo "{}"

~/.claude/backups/20260411/ 같은 디렉토리에 날짜별로 쌓인다. 파일 경로를 언더스코어로 변환해서 백업 파일명에 넣으므로 어떤 파일의 백업인지 추적할 수 있다.

설정 — Edit과 Write 두 도구에 동시 적용:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/auto-backup.sh" }
        ]
      },
      {
        "matcher": "Write",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/auto-backup.sh" }
        ]
      }
    ]
  }
}

레시피 4: 커밋 메시지 자동 검증

Claude가 git commit을 실행한 후, 커밋 메시지가 Conventional Commit 형식인지 검증한다. 아니면 경고를 남긴다.

#!/bin/bash
# validate-commit.sh — PostToolUse, matcher: "Bash"
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')

# git commit 명령인지 확인
if ! echo "$cmd" | grep -qE "^git commit"; then
  echo "{}"
  exit 0
fi

# 마지막 커밋 메시지 가져오기
last_msg=$(git log -1 --pretty=%s 2>/dev/null)

# Conventional Commit 패턴: type(scope): message
if ! echo "$last_msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+"; then
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"notification\":\"Warning: commit message '$last_msg' does not follow Conventional Commit format\"}}" 
else
  echo "{}"
fi

PostToolUse이므로 커밋 자체는 이미 완료된 상태다. 차단이 아니라 경고를 남기는 방식이다. 커밋을 되돌리고 싶으면 PreToolUse에서 커밋 메시지를 미리 파싱해야 하는데, 그건 좀 더 복잡해진다.

레시피 5: Slack/Discord 알림

Claude가 장시간 작업을 끝내면 Slack으로 알림을 보낸다. Notification 이벤트를 사용한다.

#!/bin/bash
# slack-notify.sh — Notification
input=$(cat)
message=$(echo "$input" | jq -r '.message // "Task completed"')
timestamp=$(date '+%Y-%m-%d %H:%M:%S')

# Slack Webhook
if [ -n "$SLACK_WEBHOOK_URL" ]; then
  curl -s -X POST "$SLACK_WEBHOOK_URL" \
    -H 'Content-Type: application/json' \
    -d "{\"text\": \"[${timestamp}] Claude Code: ${message}\"}" \
    > /dev/null 2>&1
fi

# Discord Webhook (선택)
if [ -n "$DISCORD_WEBHOOK_URL" ]; then
  curl -s -X POST "$DISCORD_WEBHOOK_URL" \
    -H 'Content-Type: application/json' \
    -d "{\"content\": \"[${timestamp}] Claude Code: ${message}\"}" \
    > /dev/null 2>&1
fi

echo "{}"

환경변수 SLACK_WEBHOOK_URL이나 DISCORD_WEBHOOK_URL을 설정해두면 된다. 둘 다 없으면 아무것도 안 하고 넘어간다.

설정:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/slack-notify.sh" }
        ]
      }
    ]
  }
}

레시피 6: 특정 디렉토리 수정 금지

generated/, dist/, node_modules/, .git/ 같은 디렉토리는 Claude가 건드리면 안 된다. 자동 생성 파일을 수동 수정하면 다음 빌드에서 덮어씌워지기 때문이다.

#!/bin/bash
# protect-dirs.sh — PreToolUse, matcher: "Edit" 또는 "Write"
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')

protected_dirs=(
  "/generated/"
  "/dist/"
  "/build/"
  "/node_modules/"
  "/.git/"
  "/__pycache__/"
  "/vendor/"
)

for dir in "${protected_dirs[@]}"; do
  if echo "$file" | grep -q "$dir"; then
    echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"reason\":\"Protected directory: files under '$dir' should not be manually edited\"}}"
    exit 0
  fi
done
echo "{}"

Claude에게 "이 디렉토리는 수정하지 마"라고 프롬프트에 쓰는 것보다 훨씬 확실하다. 프롬프트는 무시될 수 있지만, Hooks는 물리적으로 차단한다.

레시피 7: 빌드 명령어 자동 타임아웃

npm run builddocker build 같은 명령이 10분 이상 돌아가면 문제가 있는 것이다. 자동으로 타임아웃을 건다.

#!/bin/bash
# auto-timeout.sh — PreToolUse, matcher: "Bash"
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')

# 타임아웃을 걸 명령 패턴
long_running_patterns=(
  "npm run build"
  "yarn build"
  "docker build"
  "gradle build"
  "mvn package"
  "cargo build"
  "make"
)

TIMEOUT_SEC=300  # 5분

for pattern in "${long_running_patterns[@]}"; do
  if echo "$cmd" | grep -qE "^${pattern}"; then
    wrapped="timeout ${TIMEOUT_SEC} $cmd"
    echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{\"command\":\"$wrapped\"}}}"
    exit 0
  fi
done
echo "{}"

timeout 명령으로 래핑한다. 5분(300초) 안에 안 끝나면 프로세스를 종료한다. Claude Code가 무한 빌드 루프에 빠지는 걸 방지한다.

레시피 8: 작업 로그 자동 기록

모든 도구 호출을 로컬 파일에 기록한다. 나중에 Claude가 뭘 했는지 감사(audit)할 때 유용하다.

#!/bin/bash
# log-all.sh — PostToolUse, matcher: "*"
input=$(cat)
tool=$(echo "$input" | jq -r '.tool_name // "unknown"')
timestamp=$(date '+%Y-%m-%d %H:%M:%S')

log_dir="$HOME/.claude/logs"
mkdir -p "$log_dir"
log_file="$log_dir/$(date +%Y%m%d).jsonl"

# 도구명, 타임스탬프, 입력을 JSONL로 기록
echo "$input" | jq -c "{timestamp: \"$timestamp\", tool: .tool_name, input: .tool_input}" >> "$log_file"

echo "{}"

날짜별 JSONL 파일로 쌓인다. 나중에 jq로 분석할 수 있다:

# 오늘 Claude가 수정한 파일 목록
cat ~/.claude/logs/20260411.jsonl | jq -r 'select(.tool == "Edit") | .input.file_path' | sort -u

# 오늘 실행한 Bash 명령 목록
cat ~/.claude/logs/20260411.jsonl | jq -r 'select(.tool == "Bash") | .input.command'

설정:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/log-all.sh" }
        ]
      }
    ]
  }
}

레시피 조합 — 실전 settings.json

위 레시피들을 조합한 실전 설정 예시다:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/block-dangerous.sh" },
          { "type": "command", "command": "~/.claude/hooks/filter-test-output.sh" },
          { "type": "command", "command": "~/.claude/hooks/auto-timeout.sh" }
        ]
      },
      {
        "matcher": "Edit",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/protect-dirs.sh" },
          { "type": "command", "command": "~/.claude/hooks/auto-backup.sh" }
        ]
      },
      {
        "matcher": "Write",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/protect-dirs.sh" },
          { "type": "command", "command": "~/.claude/hooks/auto-backup.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/validate-commit.sh" }
        ]
      },
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/log-all.sh" }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/slack-notify.sh" }
        ]
      }
    ]
  }
}

같은 matcher에 여러 훅을 걸 수 있다. 순서대로 실행되며, 하나라도 deny를 반환하면 이후 훅은 실행되지 않고 도구 호출이 차단된다.

디버깅 팁

Hooks가 기대대로 동작하지 않을 때 확인할 것들:

1. 입력 확인 — 로그를 찍어라

#!/bin/bash
input=$(cat)
echo "$input" >> /tmp/hook-debug.log
# ... 나머지 로직

/tmp/hook-debug.log를 열어서 stdin으로 실제로 뭐가 들어오는지 확인한다. 예상과 다른 JSON 구조가 올 수 있다.

2. stderr 확인

Hook 스크립트의 stderr는 Claude Code에 표시된다. 에러가 나면 터미널에서 바로 볼 수 있다.

3. jq 의존성

거의 모든 훅이 jq를 사용한다. 설치 안 되어 있으면 당연히 동작 안 한다.

# 확인
which jq

# 설치
# macOS
brew install jq
# Ubuntu/Debian
sudo apt install jq
# Windows (Git Bash)
# jq 바이너리를 PATH에 넣기

4. 실행 권한

chmod +x ~/.claude/hooks/*.sh

스크립트에 실행 권한이 없으면 아무 일도 안 일어난다.

5. 성능

Hook은 도구 호출마다 실행된다. 무거운 스크립트는 Claude Code 전체를 느리게 만든다. HTTP 요청(Slack 알림 등)은 백그라운드로 보내고, 파일 I/O는 최소화하라.

주의사항

보안

Hooks는 로컬 머신에서 셸 명령으로 실행된다. .claude/settings.json을 Git에 커밋하면 팀원들의 머신에서도 실행된다. 악의적인 훅이 섞이면 보안 사고가 난다.

  • 훅 스크립트의 출처를 확인하라
  • 팀 공유 훅은 코드 리뷰를 거쳐라
  • 민감한 훅(DB 접근 등)은 글로벌 설정(~/.claude/settings.json)에 두고 Git에 커밋하지 마라

무한 루프

PreToolUse에서 Bash 훅이 Bash 명령을 호출하면 무한 루프가 발생할 수 있다. 예를 들어 Bash matcher에 걸린 훅이 내부적으로 curl을 실행하고, 그 curl이 다시 Bash 도구로 처리되면서 같은 훅이 또 트리거되는 식이다.

해결법:

  • matcher를 최대한 구체적으로 설정한다 ("*" 대신 "Bash", "Edit" 등)
  • 훅 스크립트 안에서 셸 명령을 직접 실행할 때는 Claude Code의 도구가 아닌 순수 셸에서 돌아가므로 재귀는 보통 발생하지 않지만, 구조가 복잡해지면 주의가 필요하다

설정 파일 위치

위치 적용 범위 Git 추적
.claude/settings.json 해당 프로젝트만 가능 (팀 공유)
~/.claude/settings.json 모든 프로젝트 불가 (개인용)

두 곳에 같은 이벤트/matcher 조합이 있으면 둘 다 실행된다. 프로젝트 설정이 글로벌 설정을 덮어쓰는 게 아니라 합쳐진다.

요약

  • Hooks는 Claude Code 도구 실행 전후에 셸 명령을 자동 실행하는 기능이다
  • PreToolUse: 실행 전 — 차단, 수정, 허용을 제어한다
  • PostToolUse: 실행 후 — 로깅, 알림, 검증을 한다
  • Notification: 작업 완료 시 — 외부 알림을 보낸다
  • stdin으로 JSON을 받고, stdout으로 JSON을 내보내는 단순한 프로토콜이다
  • 위험 명령 차단, 테스트 출력 필터링, 자동 백업, 커밋 검증, Slack 알림, 디렉토리 보호, 타임아웃, 감사 로그 — 이 8가지 레시피를 조합하면 대부분의 자동화 요구를 커버한다
  • 디버깅은 /tmp/hook-debug.log로 입력을 찍어보는 것부터 시작하라
  • 보안과 무한 루프에 주의하라

프롬프트에 "이건 하지 마", "저건 조심해"를 반복해서 쓰고 있다면, 그건 Hooks로 해결할 수 있다는 신호다.


관련글:

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.