20줄짜리 bash 함수에 대한 26가지 테스트 케이스와 그 경험을 통해 배운 것들 (Claude Code 관련)
요약
작성자는 Claude Code 후크 팩을 출시하고, 특히 `PostToolUse` 스크립트를 통해 실제 디스크 변경 여부를 포착하는 방법을 공유합니다. 이 과정에서 단순한 코드 작성보다 테스트 케이스를 만드는 데 더 많은 시간이 걸렸으며, 이를 통해 '진짜 변경'의 정의가 중요하다는 점을 강조합니다.
핵심 포인트
- 후크 자체는 쉽지만, 무엇이 '실제 변경'인지 정의하는 것이 핵심입니다.
- 줄바꿈 문자(CRLF/LF) 정규화로 인한 의도치 않은 diff 처리가 필요합니다.
- 단순히 줄바꿈 문자만 포함된 diff는 no-op으로 간주하여 훅의 오작동을 방지해야 합니다.
- 공백 변경은 원칙적으로 작업으로 간주하되, 설정 플래그를 통해 유연하게 관리할 수 있습니다.
지난주 저는 Did-It-Actually라는 이름의 Claude Code 후크 팩을 출시했습니다. 이 후크의 핵심 기능은 PostToolUse 스크립트 하나입니다. 이 스크립트는 Claude가 편집이 발생했다고 주장하지만 실제 디스크에는 아무것도 변경되지 않았을 때 이를 포착합니다.
후크 자체는 20줄짜리 bash로 작성되었습니다. 작성하는 데는 오후 시간이 걸렸지만, 테스트하는 데는 이틀이 걸렸습니다.
이 간극은 저에게 충분히 놀라움을 주었고, 저는 이를 공유하고 싶습니다. 왜냐하면 이는 문서에서 명확하게 드러나지 않는 후크 제작자(hook-authoring)의 작업에 대해 무언가를 말한다고 생각하기 때문입니다. 만약 여러분이 직접 후크를 작성한다면, 배울 점은 이렇습니다: 후크 자체는 쉬운 부분입니다. 무엇을 '진짜 변경'으로 간주할지 결정하는 곳이야말로 실제 사고가 필요한 영역입니다.
제가 결국 만들게 된 26가지 테스트 케이스를 각 케이스가 어떤 질문에 답하는지에 따라 그룹화하여 소개합니다.
카테고리 1: '전혀 아무것도 변경되지 않았는가' 케이스 (6개 테스트)
이것들은 여러분이 작성할 것이라고 예상되는 유형입니다. 이들은 기준선(baseline)을 설정합니다: Edit에 대한 PostToolUse 이벤트가 발생했을 때, git은 차이점(diff)을 감지했는가?
test-01-basic-edit-produces-diff-passes
test-02-identical-content-write-fails
test-03-empty-file-to-empty-file-fails
...
여기에는 놀라운 점이 없습니다. 주목할 만한 것은 test 06입니다: Claude Code는 때때로 쓰기 과정에서 줄 바꿈 문자(line endings)를 정규화하기 때문에, CRLF 파일이 LF로 다시 작성되는 경우 차이점(diff)으로 간주됩니다. 하지만 이는 아무도 의도하지 않은 차이점입니다. 실제로는 WSL 환경의 Windows 사용자들에게서 발생하므로, 저는 git diff --ignore-cr-at-eol 폴백을 사용하여 처리합니다.
카테고리 2: 공백(whitespace) 케이스 (5개 테스트)
여기서부터 재미가 시작됩니다.
test-07-trailing-newline-only-fails
test-08-leading-whitespace-only-passes
test-09-trailing-whitespace-only-passes
...
판단적 판단: 줄바꿈 문자만 포함된 diff는 거의 항상 의미 있는 작업이 아닌 POSIX 규격 준수 편집입니다. 거의 모든 최신 에디터가 이를 추가합니다. Claude의 모델은 이를 내재화하여 요청받지 않아도 종종 줄바꿈 문자를 추가합니다. 만약 제가 이것을 '편집 내용이 변경되었다'고 간주한다면, 실제 no-op(작업 없음)인 경우 훅(hook)이 작동하지 않습니다. 왜냐하면 모든 저장 작업은 적어도 그 하나의 바이트의 diff를 생성하기 때문입니다.
그래서 저는 최종 줄바꿈 문자만 포함된 경우는 명시적으로 제거하고, 이를 no-op으로 처리합니다.
하지만 나머지 네 가지 경우—내용 라인의 선행/후행 공백, 들여쓰기, 빈 줄 삽입—는 실제 diff로 간주합니다. 만약 Claude가 들여쓰기를 수정할 의도였다면, 그것은 작업입니다. 훅은 의도를 추측해서는 안 되며, 작업이 발생했는지 여부를 확인해야 합니다.
저는 이를 설정 플래그로 노출하는데, 공백에 대해 pre-commit을 사용하는 팀들은 이 부분을 더 엄격하게 관리하기를 원할 것입니다. 기본값은 '후행 줄바꿈 문자만 포함된 경우는 no-op이며, 나머지는 모두 실제 작업'입니다.
카테고리 3: Claude 관련 사례 (6개 테스트)
이것들은 제가 직접 겪어본 후에야 작성하게 된 것들입니다.
test-12-old-string-identical-to-new-string-fails
test-13-old-string-already-not-in-file-fails-silently
test-14-write-of-identical-content-after-read-fails
...
테스트 12는 고전적인 사례입니다. Claude의 Edit 도구는 old_string과 new_string을 받습니다. 만약 이들이 동일하다면(모델이 이미 올바른 것을 '수정'할 때 가끔 발생함), 파일은 바이트 단위로 재작성되고 git은 변경 사항을 감지하지 못합니다. 이 테스트가 없으면, 훅은 조용히 통과합니다.
테스트 13은 제가 반나절을 날렸던 사례입니다. Edit 도구는 old_string이 발견되지 않으면 오류를 발생시켜야 합니다. 일부 경로(특히 파일이 최근에 형제 도구 호출에 의해 재작성된 경우)에서는 이 오류가 무시되고, 편집이 적용된 것으로 보고되며, 파일은 변경되지 않습니다. 훅은 도구가 성공했다고 보고하든 안 하든 관계없이—왜냐하면 도구의 보고는 거짓말을 하기 때문입니다—파일 자체가 변경되지 않았음을 포착해야 합니다.
테스트 14는 로그에서 가장 많이 발생하는 케이스입니다. Claude가 Read를 수행한 다음, 파일에 무언가를 "확보(ensure)"하기로 결정하고, 방금 읽은 정확한 바이트로 다시 Write하는 것입니다. 쓰기는 성공하지만, 차이점(diff)은 없습니다.
테스트 15와 테스트 16은 MultiEdit 버전입니다. MultiEdit은 하나의 호출에서 일련의 편집을 적용합니다. 만약 어느 한 청크라도 diff를 생성하면, 저는 후크(hook)가 작동했다고 간주합니다 (모델이 실제로 작업을 수행했더라도, 그 작업 전체가 아닐지라도). 모든 부분이 아무런 변경도 없는 MultiEdit만 실패합니다.
테스트 17은 나중에 추가된 케이스입니다. 일부 프로젝트는 dotfiles(.pre-commit-config.yaml, .github/workflows/*)를 생성하는데, Claude가 이를 건드린 다음 재(re)생성합니다. 이 재생성은 no-op이지만 파일은 추적됩니다. 저는 이를 기록하지만 exit 2로 종료하지 않습니다. 왜냐하면 exit 2는 루프를 만들 것이기 때문입니다 (Claude가 수정에 대응하여 다시 편집하고, 이것 역시 no-op을 생성하며, 또다시 후크를 작동시키기 때문입니다).
카테고리 4: git 상태 케이스 (5개 테스트)
이것들은 Claude와는 아무 상관이 없고, 오로지 git의 특이한 동작 방식과 관련된 것입니다.
test-18-edit-of-untracked-file-passes-if-content-differs
test-19-edit-of-ignored-file-passes-via-fallback-diff
test-20-edit-during-merge-conflict-skips-hook
...
git diff HEAD는 이 중 여러 케이스에서 놀라울 정도로 다르게 동작합니다. untracked 파일의 경우, git diff HEAD -- path는 빈 값을 반환합니다. .gitignore로 지정된 파일도 마찬가지입니다. 커밋이 전혀 없는 완전히 새로운 리포지토리의 경우, 비교할 HEAD 자체가 없습니다.
대체(fallback) 방식은 이전 형제(sibling) PreToolUse 후크가 캡처한 파일 상태와 직접적으로 cmp를 수행하는 것입니다. 이 사전 후크는 편집 전 바이트를 파일 경로와 도구 호출 ID로 키를 지정하여 임시 파일에 저장합니다. 사후 후크(post-hook)는 현재 파일을 해당 임시 파일과 cmp -s로 비교합니다. 만약 두 값이 다르다면 변경이 있었고, 그렇지 않다면 no-op입니다.
병합 충돌(merge conflict) 케이스는 특별합니다: 병합 중에는 파일이 불안정한 상태에 있습니다. 따라서 후크가 완전히 건너뛰어지고 기록만 남습니다.
카테고리 5: 통합 케이스 (4개 테스트)
test-23-hook-under-concurrent-edits
test-24-hook-with-large-file-10mb
test-25-hook-with-binary-file
...
동시성 테스트는 동일한 파일에 대해 두 개의 Edit 호출을 연속으로 실행합니다. 이 경우 훅(hook)은 도구 호출 ID를 기준으로 상태를 키(key)해야 하며, 그렇지 않으면 두 번째 편집본이 첫 번째 편집본의 사전 상태(pre-state)를 보고 '변경 없음(no-op)'으로 보고하게 됩니다.
10MB 테스트는 순수한 성능 문제입니다. 훅이 100ms 이내에 완료되어야 모든 Edit 작업이 느리게 느껴지지 않을 것입니다. 현재 10MB 텍스트 파일에서 약 40ms가 소요되며, cmp 명령어는 빠릅니다.
바이너리(binary) 케이스는 예상치 못했습니다: Claude가 가끔 바이너리 파일(컴파일된 더미 파일, PNG 등)을 편집합니다. 따라서 file --mime-type이 텍스트가 아닌 것을 반환하면 저는 초기에 중단 처리합니다. 왜냐하면 바이트를 비교하는 것은 낭비이며, 바이너리의 경우 거짓 양성(false-positives)은 유용하지 않기 때문입니다.
심볼릭 링크(symlink) 케이스는 가장 마지막에 작성되었습니다. 편집본의 사전 상태와 사후 상태 모두에서 심볼릭 링크를 해결합니다. 그렇지 않으면 심볼릭 링크 대상에 대한 편집이 심볼릭 링크 파일 자체에서는 '변경 없음(no-op)'으로 읽히기 때문입니다.
이것이 저에게 비용이 된 것 (What this cost me)
테스트 케이스 작성에는 훅을 작성하는 시간보다 약 두 배의 시간이 걸렸습니다. 지금 돌이켜보면, 저는 다음과 같이 말하고 싶습니다:
- 26개 중 6개는 제가 고난 없이(Category 1 기준선) 작성했을 테스트입니다.
- 12개는 실제 세션에서 특정 실패를 경험한 후 추가한 테스트입니다.
- 8개는 훅이 처리하지 못하는 모서리 케이스(edge-case)를 찾기 위해 git/파일 시스템의 예외 목록을 체계적으로 검토하면서 작성한 테스트입니다.
'실패 이후 추가된' 12개의 테스트가 가장 중요하며, 이 때문에 이 훅은 실행할 가치가 있습니다. 만약 제가 Category 1만 가지고 배포했다면, 실제 경험에서 보는 '변경 없음(no-op)' 케이스의 약 3분의 1밖에 잡아내지 못했을 것입니다.
훅을 작성한다면 (If you're writing hooks)
이 과정을 거치고 나니 제가 전하고 싶은 세 가지가 있습니다:
-
훅(hook)은 필터가 아니라 분류기(classifier)입니다. '이 호출이 실제 편집인지 아니면 아무 작업도 하지 않은 것(no-op)인지?'에 답하는 무언가를 구축하고 있습니다. 이를 둘러싼 훅 인프라 자체는 전체 작업의 10% 정도일 수 있습니다. 나머지 90%가 바로 분류기이며, 모든 예외 케이스(edge case)는 이 분류기에 존재합니다.
-
실제 세션에서 발견하는 모든 실패에 대한 테스트를 작성하세요. '어떤 종류의 실패'가 아니라, 구체적인 순서(specific sequence)를 기록해야 합니다. Edit JSON을 유지하고, 파일-이전(file-before), 파일-이후(file-after)를 모두 보존하세요. 이를 재현(Replay)하십시오. 이러한 재현은 나중에 정규화 로직을 변경할 때 발생하는 회귀(regression)를 잡아냅니다.
-
종료 코드 2(Exit 2)는 처벌이 아니라 피드백 채널입니다. 여러분의
stderr는 Claude에게 수정 프롬프트로 전달됩니다. 이를 특정 청중을 위해 작성하는 오류 메시지라고 생각하세요. 이 청중은 다음 차례에 우회하여 작업을 수행하려고 할 것입니다. 'foo.py의 편집 결과 diff가 생성되지 않았습니다'라는 문구는 정보적이지만, '파일이 이미 의도했던 내용을 포함하고 있다면 명시적으로 그렇게 말해달라'는 요청은 실행 가능합니다(actionable). 후자는 에이전트가 재시도 시 다른 동작을 하도록 유도합니다.
전체 테스트 스위트와 훅 소스 코드는 공개 GitHub 저장소에 있습니다. 패키지 설치본(다른 세 개의 훅과 감사 CLI 포함)은 Gumroad의 Did-It-Actually에서 구매할 수 있으며, 10월 21일까지 SHOWDEV50 코드를 사용하면 9.50달러입니다.
댓글에서 특정 'X를 어떻게 처리하는가'에 대한 질문에는 기꺼이 답변하겠습니다. no-op 감지기에는 제가 아직 보지 못한 예외 케이스들이 있을 수 있습니다.
본 게시물은 AI의 도움을 받아 초안 작성되었으며 정확성을 위해 검토되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기