Claude Code가 스스로 테스트하게 하기: Stop 훅까지 19번 돌려 보니

게시 읽는 시간 약 9분 자동화 워크플로

테스트 파일을 지운 작은 프로젝트에서 Claude에게 “slugify에서 제목에 공백이 두 칸 이상 있으면 하이픈이 두 개 이상 생기는 버그가 있어. 고쳐 줘”라고 했습니다. Claude는 코드를 고치고, 직접 고른 예시 네 개를 돌려 본 결과까지 붙여서 끝냈어요. 세 번 맡겨서 세 번 다 그랬습니다.

그런데 지워 뒀던 테스트를 다시 돌려 보니 세 번 모두 3개 중 1개가 실패하더라고요. 요청하지 않은 두 번째 버그가 같은 함수에 있었는데, Claude가 고른 예시에는 그 경우가 없었거든요.

테스트 파일을 그대로 두면 이야기가 달랐습니다. 테스트 파일만 두었을 때도 시키지 않았는데 세 번 모두 고치기 전후로 테스트를 돌렸어요. 그래서 저는 Claude에게 확인을 시키는 것보다 확인할 수단을 프로젝트 안에 두는 것이 먼저라고 봅니다. Stop 훅은 그다음 보험인데, 잘못 걸면 오히려 일을 키웁니다.

2026년 10월 23일 Ubuntu 24.04 테스트 환경에서 Claude Code 2.1.308과 Haiku 5.5로 직접 돌린 결과이고, 공식 문서도 같은 날 확인했습니다.

확인한 버전·환경

  • Claude Code 2.1.308 · Haiku 5.5 · Ubuntu 24.04 · Node.js 22 · 확인일 2026-10-23

실험에 쓴 프로젝트

글 제목을 URL용 slug로 바꾸는 함수 하나와 테스트 세 개짜리 프로젝트입니다. 함수에는 버그를 두 개 넣었어요. 공백이 여러 칸이면 하이픈이 겹치는 것(요청한 버그)과, 끝에 기호가 있으면 하이픈이 남는 것(요청하지 않은 버그)입니다.

// slugify.js
export function slugify(title) {
  return title
    .trim()
    .toLowerCase()
    .replace(/ /g, '-')
    .replace(/[^a-z0-9가-힣-]/g, '');
}

테스트는 Node.js 내장 테스트 러너로 썼고 npm test가 node --test를 부릅니다. 처음 상태에서는 기본 경우만 통과합니다.

test('공백이 여러 칸', () => {
  assert.equal(slugify('두  칸   공백'), '두-칸-공백');
});

test('기호 뒤 공백', () => {
  assert.equal(slugify('FAQ: 자주 묻는 질문 ?'), 'faq-자주-묻는-질문');
});

물음표 앞 공백이 먼저 하이픈이 되고 물음표는 나중에 지워집니다. 그래서 결과가 faq-자주-묻는-질문-으로 끝나는 게 두 번째 버그예요. 요청은 매번 같은 한 문장이었고, 조건만 바꿔 가며 세 번씩 맡겼습니다.

테스트가 없으면 “확인했다”의 기준이 Claude 몫이 된다

테스트 파일이 없는 프로젝트에서는 세 번 모두 요청한 버그만 고쳤습니다. 셋 다 node로 함수를 직접 불러 결과를 보여 줬는데, 예시가 전부 공백에 관한 것이었어요.

- "Hello  World" → "hello-world"
- "a   b c" → "a-b-c"
- "  foo bar  " → "foo-bar"
- "한글  제목 test" → "한글-제목-test"

이 예시만 보면 일은 끝난 것처럼 보입니다. 실제로 요청한 버그는 고쳐졌고요. 다만 확인의 범위를 Claude가 정했기 때문에, 요청 밖의 경우는 아무도 보지 않았습니다.

Claude는 일이 끝나 보이면 멈추는데, 돌려 볼 검사가 없으면 “끝나 보인다”가 유일한 신호라는 거죠. 그러면 사람이 검증 루프가 돼서 모든 실수가 사람 눈에 띌 때까지 남게 됩니다.

테스트 파일만 있어도 알아서 돌린다

테스트 파일을 두자 행동이 바로 바뀌었습니다. 아무 말도 하지 않았는데 세 번 모두 고치기 전에 한 번, 고친 뒤에 한 번 테스트를 돌렸어요.

네 조건에서 같은 요청을 세 번씩 맡긴 뒤 테스트 3개가 모두 통과한 횟수 막대그래프. 테스트 파일이 없으면 0번, 테스트 파일이 있으면 2번, 테스트에 CLAUDE.md 한 줄을 더하면 3번, 테스트에 Stop 훅을 더하면 3번이었다. 테스트 파일만 둔 조건에서는 Claude가 시키지 않아도 세 번 모두 테스트를 돌렸다.
가장 큰 차이는 테스트 파일이 있느냐 없느냐에서 났습니다.

그래프에서 먼저 볼 곳은 맨 위 막대예요. 0에서 2로 뛰는 차이가 나머지 조건 사이의 차이보다 훨씬 큽니다. 무엇을 더 얹을지 고민하기 전에 돌릴 수 있는 테스트가 있는지부터 보세요.

테스트만 있던 세 번 중 한 번은 끝까지 실패를 남겼습니다. 다만 숨기지는 않았어요. 기호 뒤 공백 테스트는 수정 전에도 실패하던 것이고, 요청 범위 밖이라 건드리지 않았다고 보고했거든요. 판단으로는 틀리지 않았고, 이런 보고가 나오는 것 자체가 테스트가 있어서 생긴 차이입니다.

CLAUDE.md에 “코드를 바꾼 뒤에는 npm test를 실행하고, 모두 통과한 것을 확인한 다음 끝낸다” 한 줄을 넣자 세 번 모두 두 버그를 같이 고쳤습니다. 한 번은 프로젝트 규칙상 모든 테스트가 통과해야 해서 같이 고쳤다고 이유를 직접 적었어요. 세 번씩이라 큰 결론은 못 내지만, “통과할 때 끝낸다”는 완료 기준을 적어 둔 효과는 보였습니다.

“그 버그만 고쳐 줘”와 Stop 훅이 부딪히면

Stop 훅은 Claude가 응답을 끝내려 할 때마다 도는 훅입니다. 여기서 테스트를 돌려 실패하면 종료 코드 2로 막는 스크립트를 걸었어요. 훅 기본 사용법은 Claude Code 훅으로 편집 후 포맷·커밋 전 테스트·폴더 보호를 거는 법에 따로 정리해 두었습니다.

#!/bin/bash
cat > /dev/null
if ! OUT=$(npm test 2>&1); then
  echo "npm test가 실패했습니다. 고친 뒤 끝내세요:" >&2
  echo "$OUT" | grep -E '^not ok|^# (pass|fail)' >&2
  exit 2
fi
exit 0
{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh" }] }
    ]
  }
}

평범한 요청에서는 이 훅이 한 번도 막지 않았습니다. Claude가 이미 테스트를 돌려 통과시킨 뒤에 끝냈으니까요. 일이 생긴 건 요청에 “그 버그만 고쳐 줘. 다른 동작은 건드리지 마”를 붙였을 때였습니다.

훅이 없을 때는 두 번 모두 요청한 버그만 고치고, 남은 실패를 범위 밖이라고 보고하고 끝냈어요. 훅을 걸자 두 번이 서로 정반대로 갔습니다.

  • 한 번은 훅에 한 번 막힌 뒤 두 번째 버그까지 고쳤습니다. 끝에는 요청 범위를 넘는 변경이지만 훅이 전체 통과를 요구해서 넣었다고 밝혔어요.
  • 다른 한 번은 범위를 지키겠다며 버텼고, 훅은 그때마다 다시 막았습니다.

첫 번째는 훅이 사람의 지시를 이긴 경우예요. 저는 이쪽이 더 조심할 일이라고 봅니다. 테스트 전체 통과를 훅으로 걸어 두면, 프롬프트로 범위를 좁혀도 Claude가 그 범위를 넘을 수 있습니다.

24번 막히고 나서야 끝났다

버틴 쪽 실행은 이렇게 흘러갔습니다. Claude는 막힐 때마다 끝 하이픈을 지울지, 테스트 기대값을 고칠지 골라 달라는 답을 조금씩 바꿔 내놓았고, 훅은 같은 실패를 계속 돌려줬어요.

범위를 제한한 요청에 Stop 훅이 계속 막은 한 번의 실행을 차단 순서대로 늘어놓은 그림. 1번 막힌 뒤 Claude가 도구를 한 번 쓰고, 8번 연속 막힌 뒤 다시 테스트를 돌리고, 6번 막힌 뒤 또 테스트를 돌렸다. 도구를 쓸 때마다 연속 횟수가 처음부터 다시 세어졌고, 마지막 묶음의 9번째 차단에서 Claude Code가 훅을 무시하고 턴을 끝냈다. 훅은 모두 24번 실패를 돌려줬다.
도구를 쓸 때마다 연속 횟수가 초기화돼서 상한에 늦게 닿았습니다.

Claude Code에는 Stop 훅이 끝없이 막는 것을 끊는 상한이 있습니다. 8번 연속으로 이어 가게 한 뒤 그다음 차단은 무시하고 턴을 끝내요. 실제로 마지막 묶음의 9번째 차단에서 이런 메시지가 남고 끝났습니다.

A hook blocked the turn from ending 9 consecutive times — overriding and ending turn.

그런데 상한에 닿기까지 24번이 걸렸어요. 연속 횟수는 Claude가 도구를 쓸 때마다 처음부터 다시 셉니다. 중간에 Claude가 훅 스크립트를 한 번 열어 보고 테스트도 두 번 다시 돌렸는데, 그때마다 0으로 돌아갔죠.

시간은 85.7초였습니다. 같은 요청을 훅 없이 맡겼을 때는 18.8~19.3초였으니 4배 넘게 걸렸고, API 단가로 환산한 비용도 4배쯤이었어요. Haiku라서 2센트도 안 되는 차이였지만, 무거운 모델과 큰 테스트였다면 이 반복이 그대로 돈과 시간입니다.

stop_hook_active 한 줄로 바뀐 것

Stop 훅의 입력에는 stop_hook_active라는 값이 들어옵니다. 이미 Stop 훅 때문에 이어 가는 중이면 true예요. 이 값이 true면 그냥 끝내게 두는 줄을 스크립트 맨 앞에 넣었습니다.

INPUT=$(cat)
case "$INPUT" in *'"stop_hook_active":true'*|*'"stop_hook_active": true'*) exit 0 ;; esac

입력 JSON을 문자열로만 보는 방식이라 jq를 따로 설치하지 않아도 됩니다. 같은 범위 제한 요청을 세 번 맡겨 보니 세 번 모두 딱 한 번만 막혔어요.

  • 두 번은 한 번 막힌 뒤에도 범위를 지켰습니다. 남은 실패가 왜 생겼는지와 고치는 방법 두 가지를 설명하고, 어느 쪽으로 할지 물으며 끝냈어요.
  • 한 번은 막힌 뒤 두 번째 버그까지 고쳤습니다.

무한 반복은 사라졌지만, 이렇게 하면 Stop 훅은 “끝내기 전에 한 번 더 보게 하는 장치”가 됩니다. 반드시 통과해야만 끝나는 관문은 아니에요. 저는 이쪽이 맞다고 봅니다. 통과시킬 수 없는 조건을 관문으로 걸면, 결국 상한이 풀어 줄 때까지 돈만 쓰거든요.

그래도 관문으로 쓰려면 먼저 이미 실패하던 테스트를 정리해서 “전체 통과”가 지금 이룰 수 있는 조건이 되게 하세요. 범위를 좁히는 요청을 할 때는 훅이 그 범위를 넘게 만들 수 있다는 것도 염두에 두고요.

exit 2로 막을 때마다 Stop hook error occurred 알림이 함께 떴습니다. 훅이 고장 난 게 아니라 설계대로 막은 것이니 놀라지 않아도 됩니다.

오류처럼 보이는 게 싫다면 종료 코드 대신 JSON의 additionalContext로 안내를 돌려주는 방법도 있습니다. 대화는 똑같이 이어지고 같은 반복 보호(stop_hook_active와 연속 상한)를 받지만, 훅 오류 알림은 뜨지 않아요. 상한 8은 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 환경 변수로 바꿀 수 있고 0이면 상한이 꺼집니다. 0은 위 같은 경우를 끝없이 돌게 만들 수 있어서 저는 권하지 않습니다.

훅이 아예 돌지 않거나 몇 번 돌다 무시될 때 어디부터 볼지는 Claude Code 훅이 실행되지 않을 때 원인 찾는 순서에 있습니다.

어떤 수단을 고를까

수단 설정하는 곳 이번에 본 것 맞는 상황
테스트 파일 프로젝트 안 시키지 않아도 3번 모두 실행 모든 프로젝트의 출발점
CLAUDE.md 한 줄 저장소 규칙 파일 3번 모두 전체 통과까지 감 완료 기준을 정해 두고 싶을 때
Stop 훅 .claude/settings.json 범위 지시를 이긴 적 있음 자리를 비우고 맡길 때의 보험

세션 하나에만 완료 조건을 걸고 싶다면 /goal이 있습니다. 매 턴이 끝날 때 작은 모델이 대화에 드러난 결과로 조건이 이뤄졌는지 판정합니다. 판정하는 모델이 명령을 직접 돌리지는 않으니 “테스트가 통과한다”처럼 Claude의 출력으로 보여 줄 수 있는 조건으로 쓰는 게 좋아요.

일을 한 에이전트가 자기 결과를 채점하지 않게 하려면 검증용 서브에이전트를 둘 수도 있습니다. 새 모델이 결과를 반박해 보게 하는 방식이에요. 어느 쪽이든 Claude에게 “통과했다”는 말 대신 실행한 명령과 출력을 증거로 보여 달라고 하면 사람이 확인하는 시간이 줄어듭니다.

CLAUDE.md를 어디에 두고 무엇을 적을지는 CLAUDE.md 작성 가이드에, 이번에 쓴 Haiku 5.5를 고른 이유는 Opus·Sonnet·Haiku 5.5를 effort별로 108번 돌려 본 비교에 있습니다.

확인 수단은 Claude가 찾을 수 있는 자리에

돌려 보고 가장 크게 남은 건, Claude가 확인을 게을리해서 버그를 놓친 게 아니었다는 점입니다. 테스트가 없던 세 번도 나름대로 확인을 했어요. 다만 무엇을 확인할지를 Claude가 골랐고, 그 범위가 제가 원한 범위와 달랐을 뿐입니다.

그래서 프롬프트를 다듬기 전에 한 줄 명령으로 돌릴 수 있는 테스트부터 만들어 두길 권합니다. 완료 기준은 CLAUDE.md에 한 줄로 적어 두고요. Stop 훅은 자리를 비우고 맡길 때만, stop_hook_active 확인을 넣어서 거는 게 좋겠습니다.

참고한 공식 자료

2026년 10월 23일 확인.