본문 바로가기

Coding, Testing, Challenge

CLAUDE.md에 써도 안 지켜지는 이유, Claude Code 훅으로 반드시 실행시키는 법

CLAUDE.md 지침과 Claude Code 훅의 차이

AI 에이전트에게 규칙을 알려 주는 가장 쉬운 방법은 CLAUDE.md에 한 줄 적어 두는 것입니다.

그래서 저도 처음에는 무엇이든 그 파일에 적었습니다. "세션이 끝나면 기록을 서버에 올려 줄 것" 같은 문장까지 그랬습니다.

그런데 그 문장은 처음부터 지켜질 수가 없는 문장이었습니다.

이번 포스팅에서는 CLAUDE.md와 훅이 어떻게 다른지, 그리고 세션 종료 훅을 걸면서 멀쩡한 훅을 두 번이나 고장으로 진단했던 이야기를 풀어 보도록 하겠습니다.

⚠️ 이 글은 훅 설정법 전체를 다루지 않습니다. 어떤 일을 지침에 맡기고 어떤 일을 훅에 거는지, 그 경계를 어떻게 그었는지가 중심입니다.

그러면 CLAUDE.md가 어떤 파일인지부터 보겠습니다.

CLAUDE.md는 모델이 읽는 안내문입니다

CLAUDE.md는 세션이 열릴 때 모델이 읽어 들이는 문서입니다.

모델은 여기 적힌 내용을 참고해서 움직입니다. 하지만 참고와 실행은 다릅니다.

안내문은 판단의 재료로 들어갈 뿐이라, 모델은 그 줄을 따를 수도 있고 그냥 지나칠 수도 있기 때문입니다.

그렇다고 쓸모가 없다는 뜻은 아닙니다. 말투, 저장할 곳을 고르는 기준, 승인을 사람에게 넘기는 절차처럼 대화 중에 모델이 판단해야 하는 일은 이 파일이 제자리입니다.

문제는 판단이 필요 없는 일, 빠지면 안 되는 일까지 이 파일에 적을 때 생깁니다.

세션이 끝나는 순간에는 읽을 모델이 없습니다

9월 18일 저녁, 세션을 닫을 때마다 대화 본문을 서버에 올리는 장치를 만들다가 이 질문에 부딪혔습니다.

CLAUDE.md에 "끝날 때 올려 줘"라고 적으면 되지 않을까?

답은 안 된다였고, 이유는 두 가지였습니다.

첫째는 앞에서 말한 그대로입니다. 지침은 지켜질 수도, 안 지켜질 수도 있습니다. 하루 일이 통째로 남는 기록을 확률에 맡길 수는 없었습니다.

둘째가 더 근본적이었습니다. 세션이 끝나는 시점에는 지침을 읽고 움직일 모델이 이미 사라진 뒤입니다.

세션 종료는 대화 안에서 벌어지는 일이 아닙니다. Claude Code 프로그램, 이른바 하네스 쪽에서 일어나는 사건입니다.

그 순간에 반드시 실행된다고 믿을 수 있는 건 SessionEnd 훅뿐이었습니다.

훅은 모델을 거치지 않고 하네스가 돌립니다

훅은 정해 둔 사건이 일어날 때 Claude Code가 지정한 명령을 실행하게 하는 설정입니다.

모델에게 물어보지 않습니다. 사건이 생기면 그냥 돕니다.

전역 설정 파일에 한 번 걸어 두면 어느 프로젝트 폴더에서 세션을 열든 똑같이 적용됩니다. 프로젝트마다 따로 챙길 필요가 없어서 빠뜨릴 구멍도 줄어듭니다.

모양은 대략 이렇습니다.

{
  "hooks": {
    "SessionEnd": [
      { "hooks": [ { "type": "command",
                     "command": "python <훅 경로>/session_end.py" } ] }
    ]
  }
}

제 훅이 세션 종료 때 하는 일은 두 가지입니다. 정제한 대화 본문을 세션 표에 넣거나 갱신하고, 실행 기록 한 건을 남깁니다.

같은 세션이 두 번 끝나도 행은 하나만 남게 했습니다. 몇 번을 다시 돌려도 결과가 같아야 마음 놓고 재시도할 수 있기 때문입니다.

어느 저장 공간에 넣을지는 작업 폴더가 아니라 대화 내용을 보고 가릅니다. 폴더만 보면 세션 대부분이 같은 폴더에서 열려 한쪽으로 쏠리기 때문입니다.

정해 둔 폴더 목록 밖에서 열린 세션은 조용히 건너뜁니다.

훅은 실패해도 0으로 끝냅니다

훅을 짜면서 가장 오래 고민한 대목입니다.

서버가 응답하지 않거나 네트워크가 끊기면 업로드는 실패합니다. 이때 훅이 오류를 내며 끝나면 어떻게 될까요?

종료 시점에 찍힌 오류 메시지는 사람이 볼 수 없습니다. 사람은 이미 창을 닫는 중이기 때문입니다.

그래서 실패는 로그 파일에 남기고, 훅 자체는 무슨 일이 있어도 exit 0 으로 끝나게 했습니다. 기록 한 번 못 올렸다고 세션 종료를 붙잡을 이유는 없기 때문입니다.

흐름만 줄여서 보이면 이런 구조입니다.

import json, sys, traceback

def main():
    data = json.loads(sys.stdin.read() or "{}")
    if not data.get("session_id"):
        return              # 입력이 비면 조용히 넘어간다
    upload(data)            # 본문 upsert + 실행 기록

try:
    main()
except Exception:
    with open(LOG_PATH, "a", encoding="utf-8") as f:
        f.write(traceback.format_exc())
sys.exit(0)                 # 세션을 막지 않는다

대신 로그는 가끔 열어 봐야 합니다. 조용히 실패하도록 만든 장치는 누군가 들여다보지 않으면 실패한 줄도 모르고 지나가기 때문입니다.

멀쩡한 훅을 두 번이나 고장으로 진단했습니다

훅을 다 만들고 시험 삼아 돌렸는데 서버에 아무것도 남지 않았습니다.

저는 훅 코드를 의심했습니다. 다시 돌려도 결과는 비어 있었고, 그렇게 두 번을 "훅이 고장 났다"고 판단했습니다.

그다음에는 단계를 나눠 하나씩 돌려 봤습니다. 훅은 단계마다 전부 통과였습니다.

틀린 건 시험하는 쪽이었습니다.

시험 입력을 셸의 echo로 만들어 넘겼는데, 셸이 JSON 안의 역슬래시와 경로를 망가뜨렸습니다. 훅이 받은 건 빈 {} 였습니다.

세션 정보가 없는 입력은 건너뛰도록 짰으니, 훅은 정확히 설계대로 움직인 셈입니다.

세션을 막지 않으려고 넣은 "조용히 넘어가기"가 시험할 때는 함정이 됐습니다. 잘 돈 것인지 아무 일도 안 한 것인지 겉으로는 구별이 안 되기 때문입니다.

그 뒤로 시험 입력은 셸이 아니라 파이썬으로 만듭니다.

python -c "import json; print(json.dumps({'session_id': 'test-1', 'cwd': r'<작업 폴더>'}))" | python session_end.py

json.dumps 가 따옴표와 역슬래시를 알아서 처리해 주기 때문에, 셸을 거쳐도 모양이 바뀌지 않습니다.

그래서 일을 두 갈래로 나눴습니다

이 일을 겪고 나서 "어디에 적을까"의 기준이 분명해졌습니다.

  • 세션 도중, 판단이 필요한 일 → CLAUDE.md. "허브에 저장"이라고 말하면 서버로 보내는 규칙이 여기 있습니다. 대화 중에는 모델이 살아 있고, 무엇을 어떤 종류로 올릴지는 맥락을 아는 모델이 고르는 편이 낫기 때문입니다.
  • 반드시 일어나야 하는 일 → 훅. 세션 종료 때의 본문과 실행 기록, 코드 리뷰를 돌릴 때의 검토 기록이 여기 들어갑니다.
  • 사람이 손으로 하는 일 → 결정의 제안과 승인, 할 일 등록. 승인은 사람 몫이라 자동으로 돌리지 않았습니다.

나누는 질문은 하나입니다. 빠지면 곤란한 일인가, 아니면 판단이 필요한 일인가.

지침과 훅을 고르는 다섯 가지 기준

1. 모델이 대화 중에 판단해야 하는 일은 CLAUDE.md에 적습니다.

2. 빠지면 안 되는 일, 특히 세션 바깥에서 일어나는 일은 훅에 겁니다.

3. 훅은 실패해도 0으로 끝내고, 실패는 로그 파일로 남깁니다.

4. 같은 사건이 두 번 와도 결과가 하나가 되게 만듭니다.

5. 훅이 조용하면 훅보다 시험 입력을 먼저 봅니다. 입력은 json.dumps 로 만듭니다.

지침은 부탁이고, 훅은 장치입니다. 둘을 섞어 쓰던 동안에는 왜 어떤 규칙은 지켜지고 어떤 규칙은 안 지켜지는지 설명할 수가 없었습니다.

혹시 CLAUDE.md에 "항상"이나 "반드시"로 시작하는 줄이 있다면, 그 줄이 정말 모델의 판단에 맡겨도 되는 일인지 한 번 살펴보시는 건 어떨까요?

그러면 오늘도 모두 스테이블 하세요.


만든 것들