사내 Claude Code 세미나 3주차 회고: Hooks로 만드는 자동화와 안전장치
3주차 세미나를 끝내고 내가 가장 좋아하는 슬라이드 한 장을 마지막에 띄웠다. "Hooks는 Claude Code 위에 우리만의 빌드 시스템을 얹는 일" 이라는 한 줄. 발표를 들은 챕터원들이 그 그림에 가장 오래 머물러 있었다.
지난 두 주에서 "Claude Code = 한 번의 API 호출"이라는 멘탈 모델, 그리고 그 system 프롬프트에 우리가 직접 적는 CLAUDE.md/메모리까지 깔아두었다. 이번 주차의 주제는 그 위에 한 층 더 쌓는 일이다: Claude의 모든 동작 시점에 우리가 끼어드는 방법, Hooks. 이 글은 그날 발표를 백엔드 운영자의 시선에서 풀어 쓴 회고다.
1. Hooks란 무엇인가: Claude Code의 라이프사이클에 우리 코드를 끼워 넣기
Hooks는 한 마디로 Claude Code의 라이프사이클 특정 시점에 자동으로 실행되는 사용자 정의 명령이다. 발표 첫 슬라이드에 박은 네 가지 활용 카테고리는 이렇다.
- 워크플로우 자동화: 파일 저장 후 자동 포매팅, 빌드 검증, 테스트 실행
- 도구 호출 검증: 위험한 명령(
rm -rf,DROP TABLE등) 사전 차단, 보호 파일 보호 - 권한 관리: 안전한 명령은 자동 허용, 위험한 명령은 자동 거부
- 외부 시스템 연동: Slack 알림, Jira 연동, 감사 로그 기록
백엔드 운영자 입장에서 이게 왜 즉시 와닿느냐. 우리에겐 이미 너무도 익숙한 그림이기 때문이다. Spring의 HandlerInterceptor, 서블릿의 Filter, Express의 미들웨어 같은 자리다. 어디에 끼우고 무엇을 강제할 것인지의 문제. 발표장에서 이 비유를 꺼내자마자 모든 사람의 머리에 그림이 그려졌다.
2. Hook 라이프사이클: 어디에 끼어들 수 있나
발표 자료의 가장 큰 그림이다. Claude Code 세션의 시작부터 종료까지 모든 단계에 hook을 박을 수 있다.
핵심은 두 흐름이다.
메인 흐름 (세션 단위):
SessionStart → InstructionsLoaded → UserPromptSubmit → Claude 추론 → [Agentic Loop] → Stop → SessionEnd
Agentic Loop (도구 호출마다 반복):
PreToolUse → Tool Execution → PostToolUse
(실패 시 PostToolUseFailure)
이 그림에서 가장 중요한 건 어디에 차단 가능한 지점이 있느냐다. 빨간 박스로 표시한 것들: UserPromptSubmit, PreToolUse, Stop, PermissionRequest, ConfigChange, Elicitation. 이들은 hook이 exit 2로 끝나면 그 동작 자체가 차단된다. PostToolUse나 SessionEnd는 차단 불가. 즉, 막는 것은 일이 일어나기 전에만 가능하다. 너무도 당연한 원리지만, hook 설계의 모든 결정이 이 한 줄에서 출발한다.
3. Exit Code 규칙: hook과 Claude의 약속
여기서 한 슬라이드를 통째로 멈춰서 설명했다. hook이 어떤 exit code로 끝나느냐가 Claude의 다음 행동을 결정한다.
exit 0: 성공: 계속 진행. stdout JSON이 있으면 파싱하여additionalContext로 Claude에 전달.exit 2: 차단: 해당 도구/액션을 막는다. stderr가 Claude에게 피드백으로 전달된다.- 기타: 경고만 표시하고 계속 진행 (verbose 모드에서만 보임).
stdout으로 JSON을 돌려주면 더 정교한 제어가 가능하다.
{
"hookSpecificOutput": {
"permissionDecision": "allow" | "deny" | "ask",
"permissionDecisionReason": "이유 설명",
"updatedInput": { "command": "수정된 명령어" },
"additionalContext": "Claude에게 전달할 추가 컨텍스트"
}
}여기서 모두가 가장 좋아했던 키는 updatedInput. 명령을 차단하지 않고 자동으로 안전한 형태로 갈아 끼울 수 있다. 예를 들어 rm -rf / 같은 입력을 rm -rf ./build/tmp 같은 안전한 범위로 변환하는 것도 가능하다는 뜻이다. 이건 단순 검증이 아니라 방어적 변환의 영역이다.
4. Hook의 4가지 타입
발표에선 이 네 가지를 하나씩 코드 예시와 함께 보여줬다.
① Command Hook: 로컬 shell 스크립트 실행. 가장 흔한 형태.
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/spotless-apply.sh",
"timeout": 600,
"shell": "bash",
"async": false
}② HTTP Hook: 원격 엔드포인트 호출. 중앙 권한 검사·Slack 팬아웃에 유용.
{
"type": "http",
"url": "https://dev-portal.company.com/api/claude/validate",
"headers": { "Authorization": "Bearer $DEVPORTAL_TOKEN" }
}③ Prompt Hook: LLM에게 판단을 위임. 규칙으로 표현하기 어려운 검증에.
{
"type": "prompt",
"prompt": "Does this diff break @Transactional boundaries? $ARGUMENTS",
"model": "fast-model"
}
// LLM 응답: { "ok": true, "reason": "..." }④ Agent Hook: Subagent를 띄워 본격적인 검증. Checkstyle, SpotBugs, JPA N+1 패턴 탐지 등.
{
"type": "agent",
"prompt": "Run Checkstyle + SpotBugs on $ARGUMENTS and report blocking issues",
"model": "claude-3-5-sonnet-20241022"
}4가지 타입은 위로 올라갈수록 가볍고 빠르며, 아래로 갈수록 무겁고 똑똑하다. 검증의 무게는 작업의 위험도와 비례해야 한다. 단순 grep으로 충분한 위험 명령 차단에 Agent Hook을 부르면 비용도 낭비고 응답도 느려진다.
5. 설정 구조: 이벤트 → matcher → hook 배열
settings.json의 구조는 3단 계층이다.
{
"hooks": {
"PreToolUse": [ // 이벤트
{
"matcher": "Bash", // 어떤 도구에 적용할지
"hooks": [ // 실제 실행할 hook 목록
{ "type": "command", "command": "./hooks/pre-bash.sh" },
{ "type": "command", "command": "./hooks/transform.sh" }
]
},
{
"matcher": "Edit|Write", // 정규식 가능
"hooks": [{ "type": "command", "command": "./hooks/pre-edit.sh" }]
}
]
}
}설정 파일의 위치별 우선순위도 정리해 줬다 (낮 → 높).
| 위치 | 범위 | Git 공유 |
|---|---|---|
~/.claude/settings.json | 모든 프로젝트 (전역) | No |
.claude/settings.json | 단일 프로젝트 | Yes |
.claude/settings.local.json | 단일 프로젝트 (로컬) | No |
| 조직 Managed Policy | 조직 전체 | Yes (최상위) |
발표에서 명시적으로 강조한 한 줄: "Hook은 권한을 강화할 수 있지만, 약화는 불가능하다." 조직 정책이 deny로 박은 명령을 프로젝트 hook이 allow로 풀어줄 수는 없다. Managed deny > Hook deny > Hook ask > Hook allow > 기본 동작. 이 순서가 보안 모델의 핵심이다.
6. 실전 예시 6가지: Spring Boot 운영에 바로 붙는 것들
여기가 발표의 절반이었다. 각 예시는 백엔드 운영에서 실제로 쓰는 시나리오 그대로다.
예시 ①: SessionStart: Spring Profile 자동 주입
#!/bin/bash
# .claude/hooks/session-start.sh
INPUT=$(cat)
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo "export SPRING_PROFILES_ACTIVE=local" >> "$CLAUDE_ENV_FILE"
[ -n "$JAVA_HOME" ] && echo "export JAVA_HOME=$JAVA_HOME" >> "$CLAUDE_ENV_FILE"
fi
# Gradle 빌드 캐시 없으면 Claude에게 알림
if [ -f "$CLAUDE_PROJECT_DIR/build.gradle" ] && [ ! -d "$CLAUDE_PROJECT_DIR/build/classes" ]; then
echo '{"hookSpecificOutput":{"additionalContext":"Spring Boot build 캐시 없음. ./gradlew build 먼저 필요"}}'
fi
exit 0예시 ②: PreToolUse: prod 프로파일·Flyway 차단
#!/bin/bash
# .claude/hooks/pre-tool-use-bash.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# prod 프로파일로 직접 실행하는 모든 명령 차단
if echo "$COMMAND" | grep -qiE '(bootRun|spring-boot:run|flyway.*migrate).*(prod|production)'; then
echo "prod 프로파일 실행 차단됨. staging에서 먼저 검증하세요." >&2
exit 2
fi
# clean build는 시간이 오래 걸리므로 사용자에게 확인
if echo "$COMMAND" | grep -qE '(gradlew|gradle)\s+.*clean.*build'; then
jq -n '{hookSpecificOutput:{permissionDecision:"ask",permissionDecisionReason:"clean build는 오래 걸립니다."}}'
fi
exit 0발표에서 가장 큰 반응이 나온 슬라이드였다. "prod 프로파일을 누가 실수로 띄울 수 있느냐"는 질문이 한 번도 안 나왔다. 모두가 한 번씩은 해 본 일 이라는 뜻이다.
예시 ③: PostToolUse: Spotless 자동 포맷
#!/bin/bash
# .claude/hooks/post-tool-spotless.sh
INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r '.tool_name')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ "$TOOL" = "Edit" ] || [ "$TOOL" = "Write" ]; then
cd "$CLAUDE_PROJECT_DIR" || exit 0
case "${FILE##*.}" in
java) ./gradlew spotlessApply -PspotlessFiles="$FILE" --quiet 2>/dev/null ;;
kt|kts) ./gradlew ktlintFormat --quiet 2>/dev/null ;;
sql) sqlfluff fix --dialect postgres "$FILE" 2>/dev/null ;;
xml) xmllint --format "$FILE" --output "$FILE" 2>/dev/null ;;
esac
fi
exit 0이 한 줄을 깔면 PR 리뷰에서 "포맷 좀 맞춰주세요" 코멘트가 영구히 사라진다. Spotless를 CI에서만 돌리던 팀들도 이걸 보고 즉시 적용하기로 했다.
예시 ④: Stop: 테스트 실행 강제
#!/bin/bash
# .claude/hooks/stop-verify-gradle.sh
CHANGED=$(git diff --name-only 2>/dev/null | grep -E '\.(java|kt|sql)$|pom\.xml|build\.gradle')
if [ -n "$CHANGED" ]; then
TRANSCRIPT=$(echo "$(cat)" | jq -r '.transcript_path // empty')
if [ -n "$TRANSCRIPT" ] && ! grep -qE '(gradlew|mvnw?)\s+(test|check|verify)' "$TRANSCRIPT" 2>/dev/null; then
echo "Java 소스 변경됨 but ./gradlew test 미실행!" >&2
exit 2 # Claude를 멈추지 않게 강제
fi
fi
exit 0이게 가장 강력한 패턴이다. Stop hook의 exit 2는 "Claude야, 아직 끝낼 때가 아니야. 한 가지 더 해줘" 라는 신호다. 코드 변경했는데 테스트 안 돌렸으면 자동으로 테스트를 시키게 된다. "테스트 없이 완료 금지" 정책을 시스템 차원에서 강제할 수 있게 된다.
예시 ⑤: Notification: 데스크톱 + Slack 알림
#!/bin/bash
# .claude/hooks/notification.sh (async: true 권장)
MESSAGE=$(echo "$(cat)" | jq -r '.message // "Claude Code 알림"')
# macOS 네이티브 알림
[[ "$OSTYPE" == "darwin"* ]] && osascript -e "display notification \"$MESSAGE\" with title \"Claude Code\""
# Slack Webhook
[ -n "$SLACK_WEBHOOK_URL" ] && curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' -d "{\"text\":\"$MESSAGE\"}" &>/dev/null &
exit 0긴 작업이 끝났을 때 알림을 받을 수 있다. 반드시 async: true로 두자. notification 자체가 메인 흐름을 잡으면 본말전도다.
예시 ⑥: FileChanged: application.yml / Flyway 변경 감지
#!/bin/bash
INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r '.file_path // empty')
case "$FILE" in
*/application*.yml|*/application*.yaml|*/application*.properties)
PROFILE=$(basename "$FILE" | sed -E 's/application-?([^.]*)\..*/\1/')
jq -n --arg p "${PROFILE:-default}" \
'{hookSpecificOutput:{additionalContext:("Spring 설정 변경(profile=" + $p + "). @ConfigurationProperties 리바인딩 필요")}}'
;;
*/src/main/resources/db/migration/V*__*.sql)
jq -n --arg f "$FILE" \
'{hookSpecificOutput:{additionalContext:("Flyway 마이그레이션 변경: " + $f)}}'
;;
esac
exit 0application.yml이 바뀌면 Claude에게 "이제 Spring 설정이 바뀌었으니 그걸 고려해서 일해줘"라고 자동으로 컨텍스트를 주입할 수 있다. 정적 분석이 어려운 영역을 컨텍스트 주입으로 우회하는 패턴이다.
7. 디버깅: hook이 안 도는 5가지 흔한 이유
발표 끝에 정리한 트러블슈팅 표.
| 문제 | 원인 | 해결 |
|---|---|---|
| Hook 미실행 | matcher 불일치 | /hooks로 매처 확인 |
command not found | 상대 경로 사용 | $CLAUDE_PROJECT_DIR/ prefix |
| JSON 파싱 오류 | .bashrc에서 echo 오염 | interactive 가드 추가 |
| 무한 루프 | Stop hook의 재귀 | stop_hook_active 체크 |
| 느린 실행 | 타임아웃 미설정 | timeout 명시 + async: true |
Ctrl+O로 verbose 모드 토글, claude --debug로 전체 디버그 모드 진입. 이 두 개만 알아도 hook 디버깅의 90%는 풀린다.
8. 시니어의 시선으로 정리하는 인사이트
① Hook은 Claude Code 위에 우리 팀의 빌드 시스템을 얹는 일이다
HandlerInterceptor로 모든 요청에 인증을 박는 것과 똑같다. 시스템적 강제력은 사람의 주의력보다 항상 정확하다. PR마다 "포맷 좀", "테스트 좀"이라고 댓글 다는 노력은 Hook 한 줄로 영구히 해결된다. 사내 정착의 가장 큰 자산은 결국 사람의 룰 준수를 시스템의 룰 준수로 옮겨놓는 일이다.
② "막는 것은 일이 일어나기 전에"
PreToolUse, UserPromptSubmit, Stop만이 차단 가능하다. PostToolUse 단계에서 막으려고 하지 마라. 이건 보안의 보편 원리다: 위험을 막는 것은 위험이 발생하기 전에. 발생한 위험을 사후에 처리하는 것은 mitigation일 뿐 prevention이 아니다.
③ Hook의 무게는 검증 대상의 무게에 비례해야 한다
rm -rf 검출은 5줄짜리 shell. JPA @Transactional 경계 검증은 LLM Prompt Hook. 정말 중요한 계약 검증은 Agent Hook으로 sub-agent에게 위임. 모든 검증을 가장 강력한 도구로 하면 시스템이 무거워진다. Skill 3단계 로딩의 사고와 똑같다: 비용은 가치에 맞춰서.
④ Stop hook의 exit 2는 "정책 강제 장치"다
"테스트 없이 완료 금지", "lint 안 통과하면 완료 금지" 같은 사내 정책을 시스템 레벨로 박는 가장 깔끔한 방법. CI에 의존하지 않고도 개발자의 PC에서 먼저 막을 수 있다. 시니어 입장에서 이건 PR 큐 정체를 막는 가장 강력한 한 줄이기도 하다.
⑤ updatedInput을 활용한 방어적 변환은 차단보다 우아하다
"막으면 끝"이 아니라 "안전한 형태로 갈아 끼우는" 패턴. 사용자(혹은 Claude)는 자기가 의도한 일을 계속할 수 있고, 시스템은 안전성을 지킨다. 이건 보안과 사용성을 동시에 잡는 드문 패턴이라, 알고 나면 곳곳에서 쓰게 된다.
마무리: 도구를 길들이는 가장 빠른 방법
Hook을 두세 개만 박아도 Claude Code의 인상이 바뀐다. 한 챕터원이 발표 끝에 이런 말을 했다.
"지금까지는 Claude한테 '하지 마, 하지 마'라고 매번 말로 했는데, 그걸 한 번 박아두면 영원히 안 한다는 게 진짜 큰 것 같아요."
정확히 그게 핵심이다. 사람의 주의력은 매일 새로 충전해야 하지만, settings.json의 한 줄은 한 번 박으면 잊혀도 작동한다. 사내에서 Hook을 도입한 첫 팀의 PR 리뷰 시간이 절반 가까이 줄었다는 보고가 들어왔다. 발표를 준비한 보람이 이런 데서 나온다.
다음 주는 Skills 편이다. CLAUDE.md가 "항상 깔리는 컨텍스트"였고, Hooks가 "동작 시점의 끼어들기"였다면, Skills는 "필요할 때 펼쳐 드는 표준 절차"다. 1주차에서 깔아둔 3단계 로딩 그림을 그대로 들고 가면 또 절반은 끝난 셈이다. 시리즈가 점점 머릿속에서 정리되어 간다.