Claude Code로 Kotlin 파일을 고치다 보면 import 순서나 후행 콤마 같은 ktlint 규칙 위반이 종종 섞여 들어옵니다. 사람이 직접 칠 때는 IDE가 저장 시점에 바로 정리해주지만, 에이전트가 Edit/Write로 파일을 고칠 때는 이 단계가 빠집니다. 결국 PR을 올린 뒤 CI의 ktlintCheck에서야 위반이 드러나는 일이 반복돼서, Claude Code Hooks로 이 간극을 메운 과정을 정리합니다.
어떤 작업이었나
commonMain의 리포지토리 클래스 몇 개를 리팩터링하면서 Claude Code에게 여러 파일을 한 번에 고치도록 시켰습니다. 로컬에서 ./gradlew ktlintCheck를 돌리지 않고 바로 커밋했다가 CI에서 다음과 같은 실패를 받았습니다.
> Task :composeApp:ktlintCommonMainSourceSetCheck FAILED
TodoRepository.kt:3:1: Imports must be ordered in lexicographic order without any case-sensitivity (standard:import-ordering)
TodoRepository.kt:42:5: Missing trailing comma before ")" (standard:trailing-comma-on-call-site)
에이전트가 고친 파일마다 ktlintFormat을 수동으로 돌리면 되긴 하지만, 여러 파일을 연속으로 고치는 세션에서는 이 단계를 매번 잊어버렸습니다. 파일이 바뀔 때마다 자동으로 걸리게 만들기로 했습니다.
AI를 어떻게 활용했나
Claude Code Hooks는 도구 호출 전후 시점에 임의의 커맨드를 실행할 수 있게 해줍니다. Edit이나 Write가 끝난 직후(PostToolUse)에 걸리는 hook을 프로젝트 .claude/settings.json에 등록했습니다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash scripts/ktlint-format-hook.sh"
}
]
}
]
}
}
hook 커맨드는 표준입력으로 도구 호출 정보를 JSON으로 받습니다. 여기서 수정된 파일 경로를 꺼내 .kt 파일일 때만 ktlint -F를 실행하도록 스크립트를 작성했습니다.
#!/bin/bash
# scripts/ktlint-format-hook.sh
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ "$file_path" != *.kt ]]; then
exit 0
fi
if ! command -v ktlint &> /dev/null; then
exit 0
fi
ktlint -F "$file_path"
exit 0
처음에 걸렸던 부분
첫 버전에는 스크립트 맨 위에 set -e를 넣어뒀습니다. 그런데 ktlint -F는 위반 사항을 실제로 고쳤어도, 자동으로 고칠 수 없는 위반이 하나라도 남아 있으면 종료 코드 1을 반환합니다. set -e 때문에 스크립트가 그 지점에서 바로 종료됐고, Claude Code 쪽에는 매번 이런 경고가 떴습니다.
PostToolUse:command hook exited with status 1
포맷팅 자체는 정상적으로 끝났는데도 hook이 실패한 것처럼 보고되는 상황이었습니다. ktlint -F의 종료 코드가 "포맷을 수정했는지"가 아니라 "포맷 후에도 남은 위반이 있는지"를 나타낸다는 걸 확인하고, set -e를 빼고 스크립트 마지막에 항상 exit 0을 반환하도록 바꿨습니다. PostToolUse는 도구 실행 자체를 막을 수 없고 사후 알림 용도라서, 종료 코드로 세션을 멈출 필요도 없었습니다.
결과와 얻은 팁
PostToolUsehook은 도구 호출을 막지 못하고 실행 이후 시점에만 개입합니다. 특정 파일 수정 자체를 막고 싶다면PreToolUse에 등록해야 합니다.- hook 스크립트는 표준입력으로 JSON을 받으므로
jq로 필요한 필드만 꺼내 쓰고,file_path가 비어 있는 경우(파일 경로가 없는 도구 호출) 처리를 빼먹지 않아야 합니다. - 외부 CLI의 종료 코드를 그대로 hook 종료 코드로 흘려보내면, "정상 동작했지만 CLI 관례상 0이 아닌 코드를 반환하는" 경우까지 실패로 취급됩니다. hook의 의도(차단할지, 그냥 알릴지)에 맞게 종료 코드를 직접 설계하는 편이 낫습니다.
- 도입 후에는 에이전트가 파일을 고칠 때마다 포맷이 즉시 정리돼서, 커밋 시점에
ktlintFormat을 따로 돌릴 필요가 없어졌습니다. CI의ktlintCheck실패도 그만큼 줄었습니다.