본문으로 건너뛰기

Claude Code 훅으로 자동 lint 파이프라인 만들기

·19 min read

시작은 잘못된 전제였다

내가 처음 떠올린 그림은 이랬다.

깔끔해 보인다. 그리고 작동하지 않는다.

Claude Code에는 훅이 스킬을 호출하는 메커니즘이 없다. 훅은 스킬을 트리거할 수 없고, 스킬은 훅의 출력을 인자로 받지 못한다. 둘은 서로를 모른다.

훅이 결과를 바깥에 전달하는 방법은 두 가지다. exit 2 + stderr로 텍스트를 보내거나, exit 0 + stdout JSON의 hookSpecificOutput.additionalContext로 컨텍스트를 넘기거나.

어느 쪽이든 수신자는 스킬이 아니라 LLM이다. 나는 셸 스크립트에서 다루기 간단한 exit 2 + stderr를 골랐다.

"훅 → 스킬"이 아니라 "훅 → 컨텍스트 → LLM" 이다. 연결 하나가 바뀌는 것처럼 보이지만, 설계 전체가 달라진다. 스킬은 자동 루프에서 빠지고, LLM 자신이 수신자가 된다.

이 오해를 먼저 풀지 않았으면, 동작하지 않는 스킬을 만들고 왜 안 불리는지 몇 시간을 헤맸을 것이다.

--fix에 다 맡기면 안 될까

다음 질문은 자연스럽다. 어차피 eslint --fix가 있는데 LLM이 왜 필요한가?

실제로 나눠보면 경계가 선명하다.

--fix가 해주는 것--fix가 못 하는 것
semi, quotes, 들여쓰기react-hooks/exhaustive-deps — 기본 설정에선 --fix가 손대지 않음(enableDangerousAutofixThisMayCauseInfiniteLoops를 켜야 붙는다)
prefer-const, no-varreact-hooks/rules-of-hooks — 구조 변경이 필요
import 멤버 정렬(sort-imports, 한 줄 안에서만)no-unused-vars — 자동 수정 없음(에디터 suggestion만 제공)
Prettier 포맷 전부no-explicit-any — 기본 설정에선 수정 안 함(fixToUnknown을 켜야 unknown으로 바꿔준다), a11y 규칙 대부분
일부 eqeqeq, no-else-returntsc 타입 에러 전부

내가 실제로 줄이고 싶었던 에러가 전부 오른쪽 칸에 있다. React 훅 의존성 배열 실수, 타입 에러 — 정확히 기계가 못 고치는 것들.

그래서 결론은 "둘 중 하나"가 아니라 역할 분담이다.

  • --fix = 노이즈 제거기. 세미콜론 40개를 알아서 치운다.
  • LLM = 판단기. 남은 것만 본다.

이 분담이 왜 중요하냐면, 신호 대 잡음비 때문이다. exhaustive-deps 경고 하나가 포맷 경고 40개에 섞여 있을 때와, 혼자 덩그러니 있을 때, LLM의 수정 품질은 다르다. 훅이 잡음을 먼저 지워주는 것 자체가 성능 개선이다.

결국 세 개로 쪼갰다

1. PostToolUse — 파일 단위, 빠름

훅 등록 설정은 이렇게 잡았다.

matcher: Edit|Write|MultiEdit     (※ '*' 아님)
timeout: 30s
  1. stdin JSON에서 .tool_input.file_path 추출 → 그 파일 하나만
  2. eslint 없는 프로젝트면 즉시 exit 0 (가드는 아래에서 따로 다룬다)
  3. prettier --write + eslint --fix
  4. 남은 게 있으면 exit 2 + stderr로 보고
  5. 깨끗하면 무출력 exit 0토큰 소비 0

matcher*로 두면 안 된다. Bash, Read, Grep 등 모든 도구 호출마다 훅이 실행돼 JSON을 파싱하고 끝난다. 파일을 바꾸는 도구만 매칭한다.

2. Stop — 프로젝트 단위 타입 체크

tsc는 파일을 인자로 넘기는 순간 tsconfig.json을 무시한다. paths, strict, jsx, lib 설정이 통째로 빠지니 사실상 단일 파일 검사는 쓸모가 없다. 타입은 파일 경계를 넘나들기 때문에 프로젝트 전체를 컴파일해야 한다. 매 Edit마다 돌리면 편집 한 번에 수 초씩 먹는다.

그래서 아예 따로 떼어냈다. Edit마다가 아니라 턴이 끝날 때 한 번.

여기에 작은 트릭을 넣었다. .ts/.tsx 파일을 편집했을 때만 lint 훅이 마커 파일을 남기고, Stop 훅은 그 마커가 있을 때만 tsc를 돌린다.

# lint-check.sh — 편집한 파일이 TS면 마커에 프로젝트 루트를 기록
case "$FILE" in
  *.ts|*.tsx) printf '%s\n' "$ROOT" >> "$MARKDIR/$SESSION.tsdirty" ;;
esac

Stop 훅 쪽은 마커가 없으면 아무 일도 하지 않고 끝난다.

# typecheck-on-stop.sh — 마커 없으면 즉시 종료
MARK="$MARKDIR/$SESSION.tsdirty"
[ -f "$MARK" ] || exit 0
ROOTS=$(sort -u "$MARK"); rm -f "$MARK"   # 소진 먼저

CSS만 고친 턴, 문서만 고친 턴에는 tsc가 아예 돌지 않는다.

3. /lint-refactor 스킬 — 필요할 때 부르는 배치

원래 구상했던 python 스킬은 여기로 갔다. 자동 루프의 일부가 아니라, 누적된 부채를 한 번에 정리할 때 사람이 부르는 도구다. 프로젝트 전체 lint → 규칙별 그룹핑 → 위험도 3단계 분류 → 마크다운 리포트.

무거운 작업은 자동화하지 않는다. 자동화하면 언젠가 반드시 방해가 된다.

글로벌 훅은 절반이 가드였다

이건 글로벌 훅이다. 내 모든 프로젝트에서 발동한다. Rust 저장소에서도, 문서 저장소에서도, 남의 오픈소스를 클론해둔 폴더에서도.

그래서 스크립트 전반부의 절반이 가드다.

# 1) jq 없으면 포기
command -v jq >/dev/null 2>&1 || exit 0
 
# 2) 대상 확장자만
case "$FILE" in
  *.d.ts) exit 0 ;;
  *.js|*.jsx|*.ts|*.tsx|*.mjs|*.cjs) ;;
  *) exit 0 ;;
esac
 
# 3) 생성물/벤더 트리 제외
case "$FILE" in
  */node_modules/*|*/.next/*|*/dist/*|*/build/*|*/.turbo/*|*/coverage/*) exit 0 ;;
esac
 
# 4) 위로 올라가며 가장 가까운 eslint 바이너리 탐색 (monorepo 루트 포함)
ESLINT=""; ROOT=""; dir="$FILEDIR"
while :; do
  [ -z "$ROOT" ] && [ -f "$dir/package.json" ] && ROOT="$dir"
  if [ -x "$dir/node_modules/.bin/eslint" ]; then ESLINT="$dir/node_modules/.bin/eslint"; break; fi
  [ "$dir" = "/" ] && break
  dir=$(dirname "$dir")
done
[ -n "$ESLINT" ] || exit 0     # ← 설치 안 된 프로젝트는 여기서 끝

중요한 설계 결정: 훅은 글로벌이지만 룰은 각 프로젝트의 것을 쓴다. node_modules/.bin/eslint를 찾아 쓰는 이유가 이것이다. 전역 eslint를 설치해서 내 취향의 룰을 남의 프로젝트에 강요하면, 그건 도구가 아니라 사고다.

가드를 일곱 가지 경우로 테스트했고 전부 아무 출력 없이 exit 0 했다.

eslint 미설치 / 대상 확장자 아님 / 파일 없음 / file_path 없는 도구 호출
/ node_modules 내부 / .d.ts / Stop 마커 없음

실제로 밟은 지뢰들

지뢰 1 — --format unix는 ESLint 9에서 사라졌다

처음엔 파싱하기 편한 unix 포맷터를 썼다. 테스트했더니 훅이 아무 일도 안 하고 그냥 통과했다. 수동으로 돌려보니 이런 메시지가 나왔다.

The unix formatter is no longer part of core ESLint.
Install it manually with `npm install -D eslint-formatter-unix`
eslint exit=2

ESLint 9가 unix, compact, checkstyle, junit 등 7개를 core에서 제거했다. 남은 내장 포맷터는 stylish(기본), json, json-with-metadata, html 넷이다.

--format json + jq 파싱으로 교체했다. 결과적으로 더 나았다 — ruleId와 fixable 여부를 구조적으로 얻을 수 있다.

지뢰 2 — 실패를 그냥 넘기는 코드는 최악이다

지뢰 1이 아무 표시 없이 통과한 게 진짜 문제였다. 내 코드가 이랬기 때문이다.

# eslint exit: 0=clean, 1=문제 있음, 2=eslint 자체 실패
if [ "$ESTATUS" = "1" ] && [ -n "$OUT" ]; then
  ...보고...
fi
# exit 2는? → 아무 일도 안 일어남

exit 2를 무시하도록 짜놨더니, lint 검사 전체가 죽었는데 아무도 몰랐다. "에러가 없다"와 "검사가 안 돌았다"가 구분되지 않는 상태. 자동화에서 가장 위험한 실패 모드다.

그래서 eslint 자체가 실패한 경로를 따로 만들어 보고하게 고쳤다.

if [ "$ESTATUS" -ge 2 ]; then
  append "[lint-hook] eslint 실행 자체가 실패했습니다 (exit ${ESTATUS}).
lint 검사가 동작하지 않는 상태입니다: ..."
fi

출력이 없다고 성공한 게 아니었다. 표시 없이 실패하는 경로를 명시적으로 만들어둬야 했다.

지뢰 3 — 훅이 파일을 고치면 LLM이 아는 내용이 낡는다

이게 가장 미묘하다. --fix가 파일을 다시 쓰면, LLM이 기억하는 파일 내용과 디스크의 내용이 어긋난다. 다음 Edit의 old_string이 매칭에 실패한다.

포맷 하나 고쳤을 뿐인데 다음 편집이 깨지는 것이다.

해결책은 단순하다. 파일이 바뀌었다는 사실 자체를 보고한다.

BEFORE=$(shasum -a 1 "$FILE" | awk '{print $1}')
# ... prettier + eslint --fix ...
AFTER=$(shasum -a 1 "$FILE" | awk '{print $1}')
 
[ "$BEFORE" != "$AFTER" ] && REPORT="[lint-hook] ${FILE} 을 자동 수정했습니다.
→ 다시 Edit 하기 전에 반드시 Read 로 재읽기 하세요."

mtime이 아니라 해시를 쓴 이유: prettier가 내용 변화 없이 파일을 다시 쓰는 경우가 있어서, mtime은 거짓 양성을 낸다.

지뢰 4 — Stop 훅의 무한 루프

Stop 훅이 exit 2를 하면 종료가 차단되고 LLM이 다시 일한다. 그리고 다시 멈추려 하고, 훅이 또 차단하고... 무한 루프.

그래서 마커 파일을 tsc 실행 전에 삭제했다. 같은 세션에서 Stop 훅이 두 번 불려도 두 번째는 마커가 없어 그냥 통과한다.

ROOTS=$(sort -u "$MARK"); rm -f "$MARK"   # 읽자마자 삭제 — 두 번째 호출은 여기서 끝

지뢰 5 — $?가 내가 생각한 그 명령의 것이 아니다

타입 체크 결과를 판정하는 부분에서 종료 코드를 잘못 읽고 있었다.

OUT=$(eval "$CMD" 2>&1 | grep -vE '^[[:space:]]*>')
[ $? -eq 0 ] && continue

여기서 $?파이프라인 마지막 명령(grep)의 종료 코드다. set -o pipefail이 있어야 파이프 중간의 실패가 전파된다. 스크립트 맨 위에 set -uo pipefail이 있어서 동작하지만, 누군가 그 줄을 지우면 타입 에러가 그냥 무시된다.

미래의 나를 위해 주석을 박아뒀다.

# pipefail 필수: 아래 $? 는 grep 이 아니라 $CMD 의 종료코드여야 한다

테스트용 프로젝트에 위반 4개를 심어봤다

eslint를 설치한 테스트용 프로젝트에 위반 4개를 심었다 — --fix 가능 2개, 불가 2개.

let x = 1;                          // prefer-const  (fixable)
function f() {
  if (x == someUndefinedThing) {    // eqeqeq + no-undef
    return 1;
    console.log("unreachable");     // no-unreachable
  }
}

훅 실행 결과, LLM이 받는 stderr는 이렇게 나왔다.

[lint-hook] .../dirty.js 을 prettier/eslint --fix 가 자동 수정했습니다.
→ 이 파일을 다시 Edit 하기 전에 반드시 Read 로 재읽기 하세요.
[lint-hook] 자동 수정 불가 — 판단이 필요한 lint 문제 4건:
  dirty.js:3:9   Expected '===' and instead saw '=='.  [eqeqeq]
  dirty.js:3:12  'someUndefinedThing' is not defined.  [no-undef]
  dirty.js:5:5   'console' is not defined.  [no-undef]
  dirty.js:5:5   Unreachable code.  [no-unreachable]
→ 지금 이 파일을 직접 수정해서 해결하세요.

그리고 파일은 let xconst x로 이미 고쳐져 있다. 기계는 기계 몫을, 나머지는 LLM 몫으로.

이 자동화가 못 하는 것까지 선을 그었다

만들고 나서 스스로 정리한 경계다. 이걸 흐리면 나중에 "왜 안 고쳐졌지?"가 된다.

진짜 자동 (사람 개입 0)

  • prettier/eslint --fix 기계적 수정 — 편집할 때마다
  • lint 에러의 컨텍스트 주입 — 편집할 때마다
  • 턴 종료 시 tsc --noEmit
  • 설정 없는 프로젝트 스킵

자동이되 결과가 확정적이지는 않음

  • exhaustive-deps 같은 판단 항목의 실제 수정은 LLM이 한다
  • 놓치는 일은 없어진다 — 에러가 반드시 눈앞에 온다
  • 다만 고침의 품질은 보장되지 않는다 — 잘못 고칠 수도 있다

이건 sed 치환 같은 확정적 자동화가 아니다. "놓침 방지 자동화" 다. 원래 목표가 "놓쳐서 흘러가는 React 문법 에러 줄이기"였으니 정확히 맞는 도구다.

자동 아님

  • /lint-refactor 배치 스킬은 명시적으로 부를 때만

구조적 한계도 두 가지 남는다. 하나는 Claude Code 세션 안에서만 동작한다. 에디터에서 직접 타이핑한 코드에는 안 붙는다. 그건 git hook(husky)의 영역이고, 이건 별개 레이어다. 다른 하나는 PostToolUse가 사후에 돈다는 것이다. 나쁜 코드가 파일에 쓰이는 걸 막는 게 아니라, 쓰인 직후에 잡는다. 사전 차단은 PostToolUse로 불가능하다.

플랫폼이 실제로 제공하는 기능을 먼저 확인하지 않은 게 이번에 제일 크게 돌아왔다. "훅이 스킬을 부른다"는 상상 속에만 있었고, 설계 전체가 그 위에 세워질 뻔했다. 자동화의 경계를 정직하게 긋는 일도 그만큼 중요했다. "다 자동으로 됩니다"는 대개 거짓이고, 어디까지 기계가 확정적으로 처리하고 어디부터 판단이 필요한지 나누는 게 설계의 대부분이었다.

표시 없는 실패는 끝까지 남는 위험이었다. 지뢰 1이 지뢰 2 때문에 안 보였다. 검사 도구가 죽은 것과 문제가 없는 것은 반드시 구분되어야 했다. 글로벌 도구에서는 절반이 가드였다. 모든 프로젝트에서 도는 스크립트는 대부분의 프로젝트에서 아무것도 하지 않아야 했다. 그리고 신호를 정제하니 판단이 좋아졌다. LLM에게 잡음 40건 속의 문제 1건을 주는 것과 문제 1건만 주는 것은 다른 작업이다. 훅의 --fix는 수정 도구인 동시에 필터였다.

부록 — 최종 구성

훅 등록은 글로벌 설정 파일 한 곳에서 끝난다.

// ~/.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          { "type": "command",
            "command": "/Users/<me>/.claude/hooks/lint-check.sh",
            "timeout": 30 }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          { "type": "command",
            "command": "/Users/<me>/.claude/hooks/typecheck-on-stop.sh",
            "timeout": 180 }
        ]
      }
    ]
  }
}

실제 파일은 세 곳에 흩어져 있다.

~/.claude/hooks/lint-check.sh            # 파일 단위 lint
~/.claude/hooks/typecheck-on-stop.sh     # 턴 종료 시 타입 체크
~/.claude/skills/lint-refactor/          # 부채 배치 정리
    SKILL.md
    scripts/collect_lint.py

설정 파일에 직접 넣은 훅 변경은 파일 워처가 세션 중에도 자동으로 적용한다. 다만 프로젝트 스킬·서브에이전트 frontmatter에 정의한 훅은 해당 폴더의 워크스페이스 신뢰를 수락한 뒤에만 돈다.