로드 중
로드 중

Hooks

CLAUDE.md 파일에서 Claude에게 무언가를 하라고 지시하는 것에는 이런 문제가 있습니다: 그것은 요청이지 보장이 아니라는 점입니다. "편집 후에는 항상 포맷을 적용하라"고 작성하면 Claude는 대체로 그 말을 따릅니다. 대체로요. 하지만 여러분이 지켜보고 있지 않은 긴 실행 과정에서는 "대체로"로는 충분하지 않습니다. 훅(hook)은 이 문제를 해결합니다. 훅은 루프 내의 고정된 지점에서 실행되는 결정론적 코드이므로, 동작을 바라는 것이 아니라 보장할 수 있습니다. 이는 "Claude가 대체로 따른다"는 규칙을 "Claude가 건너뛸 수 없다"로 바꿔줍니다.

이것이 전체 요지입니다. 이제 실제로 어떻게 작동하는지 살펴보겠습니다.

훅 이벤트

Claude Code는 세션이 진행되는 동안 약 30개의 훅 이벤트를 발생시킵니다. 이 모든 것을 알 필요는 없습니다. 여러분이 반복해서 사용하게 될 소수의 이벤트가 있으며, 이들은 에이전틱 루프에서 개입하고 싶은 지점들과 일치합니다.

이들이 루프 안에서 어떻게 자리 잡고 있는지 살펴보겠습니다. 세션이 시작되고, 프롬프트가 들어오고, 도구가 호출되고, 결국 턴이 끝납니다. 이러한 각 순간마다 코드를 걸 수 있는 훅이 있습니다.

알아둘 만한 것들:

  • PreToolUse는 도구 호출 전에 발생합니다. 이것이 여러분의 강제 수단(enforcement primitive)입니다. 어떤 일이 일어나기 전에 막을 수 있는 유일한 것입니다.
  • PostToolUse는 도구 호출이 성공한 후에 발생합니다. 보통 자동 포맷팅이나 자동 린트가 여기에 들어갑니다.
  • Stop은 Claude가 턴을 끝내려 할 때 발생합니다. 어떤 조건이 충족되지 않았다면 "아니요, 아직 끝나지 않았습니다"라고 거부할 수 있습니다. 서브 에이전트가 끝날 때를 위한 대응 이벤트인 SubagentStop도 있습니다.
  • PreCompact와 PostCompact는 압축(compaction) 전후에 발생합니다.
  • InstructionsLoaded는 CLAUDE.md나 규칙 파일이 로드될 때 발생합니다. 실제로 컨텍스트에 반영된 내용을 감사하는 데 유용합니다.
  • SessionStart는 시작 시점에 발생하며 환경을 준비합니다. 새로 시작할 때만 원한다면 startup 소스를 사용하세요.

사람들이 흔히 헷갈리는 부분: 압축 후 컨텍스트를 다시 주입하려면 PostCompact를 사용하지 마세요. compact 매처와 함께 SessionStart를 사용하세요. 그것이 실제로 출력을 대화로 다시 가져오는 이벤트입니다.

PreToolUse: 결정을 JSON으로 반환하기

PreToolUse는 실제 힘이 있는 곳입니다. 도구 호출이 실행되기 전에 막을 수 있기 때문입니다. Claude에게 응답하는 방법은 JSON을 출력하고 0으로 종료하는 것입니다. 핵심 필드는 permissionDecision이며, 다음 세 가지 값 중 하나를 가집니다:

  • allow — 호출을 통과시킵니다
  • deny — 호출을 막습니다
  • ask — 사용자에게 결정을 넘깁니다

기술적으로는 네 번째 값인 defer도 있지만, 이는 호출 프로세스가 도구를 일시 중지했다가 나중에 재개하는 비대화형 -p 실행에만 적용됩니다. 이 값을 사용할 일은 거의 없을 것입니다.

형태는 다음과 같습니다:

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "...",
    "updatedInput": {
      "command": "..."
    }
  }
}

updatedInput에 주목하세요. 호출을 막는 대신, 이를 다시 작성할 수 있습니다. 이것이 bash 명령에서 비밀 정보를 삭제하면서도 여전히 실행되게 하는 방법입니다. 한 가지 주의할 점: updatedInput은 입력 객체 전체를 대체하므로, 변경하지 않는 필드들도 다시 돌려줘야 합니다. 그렇지 않으면 그 필드들을 잃게 됩니다.

종료 코드: JSON을 반환하지 않는 훅을 위해

모든 훅이 JSON으로 말할 필요는 없습니다. 더 단순한 훅의 경우 종료 코드로 충분합니다. 중요한 숫자는 세 가지입니다.

  • 0은 성공입니다. 표준 출력이 JSON이면 Claude가 이를 파싱합니다. 일반 텍스트는 대부분의 이벤트에서 무시되지만, SessionStart, UserPromptSubmit, UserPromptExpansion에서는 일반 텍스트가 컨텍스트에 추가됩니다. 이것이 바로 상태 보존 훅(state-preserver hook)이 작동하게 만드는 요소입니다.
  • 2는 차단 오류입니다. 표준 오류가 컨텍스트로서 Claude에게 다시 전달됩니다. 이것이 거의 모든 곳에서 사용되는 차단용 종료 코드입니다.
  • 그 외의 값은 차단하지 않습니다. 표준 오류는 기록되고, Claude는 계속 진행합니다.

사람들을 헷갈리게 하는 것은 종료 코드 1입니다. 오류처럼 느껴지지만, 실제로는 차단하지 않습니다. Claude는 어쨌든 명령을 실행합니다. 따라서 무언가를 멈추려는 의도라면 1이 아니라 2로 종료해야 합니다.

몇 가지 더 까다로운 점이 있습니다. 종료 코드 2는 Stop조차 막을 수 있으며, 이것이 Claude에게 아직 끝나지 않았다고 알리는 방법입니다. 하지만 PostToolUse는 도구가 이미 실행된 후에 발생하므로, 거기서 차단하는 것은 호출을 막기에는 너무 늦습니다. 다만 여전히 Claude에게 텍스트를 다시 전달할 수는 있습니다. 그리고 Notification과 SessionStart 같은 일부 이벤트는 차단을 완전히 무시합니다. SessionStart는 표준 오류를 여러분에게만 보여주고, Notification은 이를 무시하며, 둘 다 어쨌든 계속 진행합니다.

실제 가드레일: 차단 대신 삭제하기

실용적인 예시로 이를 종합해 보겠습니다. Bash 도구에 PreToolUse 가드레일을 두고 싶다고 가정해 봅시다. 매처는 감시할 도구를 선택하며, 선택적인 if 절로 특정 명령으로 범위를 좁힐 수 있습니다.

가장 뻔한 방법은 deny를 반환해서 위험한 호출을 막는 것입니다. 그것도 좋습니다. 하지만 덜 알려져 있으면서 더 흥미로운 방법은 updatedInput을 반환해서 호출을 다시 작성하는 것입니다. 이것이 명령에서 비밀 정보를 제거하면서도 단순히 거부하는 대신 여전히 실행되게 하는 방법입니다.

실제로는 이렇게 작동합니다. Claude가 실제처럼 보이는 비밀 정보가 포함된 명령을 실행하라는 요청을 받습니다. 훅이 이를 가로채서 sk_live_ 패턴을 발견하고, 명령이 실행되기 전에 이를 플레이스홀더로 바꿔치기합니다.

명령은 여전히 실행되었습니다. 작업도 여전히 완료되었습니다. 하지만 비밀 정보는 결코 통과하지 못했습니다. 이것이 차단과 삭제의 차이이며, 훅이 매번 강제할 수 있는 종류의 일입니다.

압축 전반에 걸쳐 상태 보존하기

설정해 둘 만한 패턴이 하나 더 있습니다. Claude가 긴 대화를 압축할 때, 많은 세부 사항이 사라집니다. compact 매처가 있는 SessionStart 훅은 압축 직후에 실행됩니다. 이 훅이 작업 중이던 파일들에 대한 짧은 요약을 출력하게 하세요. 그 요약은 다시 컨텍스트로 들어가므로, Claude는 처음부터 다시 시작하는 대신 멈췄던 지점에서 이어갈 수 있습니다.

마무리

훅은 Claude가 대체로 따르는 규칙을 항상 따르는 규칙으로 바꿔줍니다. 자동 포맷팅을 넘어서, PreToolUse로 도구를 보호하고, Stop으로 턴을 통제하고, 압축 전반에 걸쳐 상태를 보존하세요. 설정에는 처음에 약간의 노력이 들지만, 여러분이 지켜보고 있지도 않던 실행 중에 무언가를 잡아내는 첫 순간에 그 노력은 충분히 보상받습니다.