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는 도구 실행 자체를 막을 수 없고 사후 알림 용도라서, 종료 코드로 세션을 멈출 필요도 없었습니다.

결과와 얻은 팁

  • PostToolUse hook은 도구 호출을 막지 못하고 실행 이후 시점에만 개입합니다. 특정 파일 수정 자체를 막고 싶다면 PreToolUse에 등록해야 합니다.
  • hook 스크립트는 표준입력으로 JSON을 받으므로 jq로 필요한 필드만 꺼내 쓰고, file_path가 비어 있는 경우(파일 경로가 없는 도구 호출) 처리를 빼먹지 않아야 합니다.
  • 외부 CLI의 종료 코드를 그대로 hook 종료 코드로 흘려보내면, "정상 동작했지만 CLI 관례상 0이 아닌 코드를 반환하는" 경우까지 실패로 취급됩니다. hook의 의도(차단할지, 그냥 알릴지)에 맞게 종료 코드를 직접 설계하는 편이 낫습니다.
  • 도입 후에는 에이전트가 파일을 고칠 때마다 포맷이 즉시 정리돼서, 커밋 시점에 ktlintFormat을 따로 돌릴 필요가 없어졌습니다. CI의 ktlintCheck 실패도 그만큼 줄었습니다.