hook 하나 붙였을 뿐인데, 어떤 프로젝트에서는 멀쩡하고 다른 프로젝트에서는 갑자기 터진다.
처음엔 권한 문제 같고, 그다음엔 쉘 문제 같고, 마지막엔 모델이 삐졌나 싶은데, 대부분은 그냥 경로 문제다.
경로는 참 성실하게 배신한다.
한 줄 결론: Claude Code hooks는 “현재 디렉토리”에 기대서 쓰면 언젠가 깨지고, 프로젝트 루트가 필요한 스크립트는
"$CLAUDE_PROJECT_DIR"기준으로 고정해야 덜 망가진다.
이 글은 공식 문서와 1차 출처를 기준으로, 왜 이런 문제가 자주 터지는지와 어떻게 안 깨지게 만드는지 운영자 시각으로 정리한 메모다.
왜 이게 자꾸 깨지나
경험상 제일 많이 망가지는 지점은 세 가지다.
- hook 실행 시점의
pwd를 프로젝트 루트로 착각하는 경우 - 경로에 공백이 있는데 따옴표를 안 붙이는 경우
- SessionStart, CwdChanged, FileChanged, plugin script, worktree 같은 실행 맥락 차이를 한 줄로 우겨 넣는 경우
이 셋이 한 번만 겹쳐도 자동화는 아주 예쁘게 깨진다.
그리고 더 무서운 건, 깨질 때마다 에러 메시지가 전부 다르게 보인다는 점이다.
같은 원인이 다른 얼굴을 하고 나온다.
그래서 사람은 로그를 의심하고, 권한을 의심하고, 도구를 의심하고, 결국 밤을 의심한다.
공식 문서가 먼저 말하는 것
Anthropic의 Claude Code hooks 문서는 아주 중요한 전제를 깔아둔다.
- hook handler는 Claude Code의 환경을 가진 상태에서 현재 디렉토리 기준으로 실행된다
- 프로젝트 안의 스크립트는
$CLAUDE_PROJECT_DIR같은 환경 변수를 써서 참조하라고 안내한다 - plugin에 묶인 스크립트는
${CLAUDE_PLUGIN_ROOT}를 쓰라고 적어둔다 - SessionStart에서는 이후 bash 명령에 넘길 환경 변수를
CLAUDE_ENV_FILE에 적을 수 있다
즉, “어디서 실행되느냐”와 “어디를 기준으로 삼느냐”는 같은 말이 아니다.
이 차이를 놓치면 hook은 금방 변심한다.
공식 문서 링크는 여기다.
루트가 하나가 아닐 때부터 일이 꼬인다
사람은 보통 프로젝트 루트를 하나라고 생각한다.
근데 실제 운영에서는 루트가 하나가 아니다.
- 저장소 루트
- 현재 작업 디렉토리
- hook이 시작된 위치
- worktree 루트
- plugin 설치 루트
- plugin 데이터 루트
이걸 한 덩어리로 보면 안 된다.
특히 Claude Code hooks는 “내가 지금 어디에서 돌고 있나”를 꽤 중요하게 본다.
반면 너의 스크립트는 “나는 저장소 루트에서만 살아야 한다”고 가정할 수 있다.
이 둘이 어긋나는 순간이 바로 사고 지점이다.
원인표
| 원인 | 현장에서 보이는 증상 | 왜 그런가 | 안정화 방법 |
|---|---|---|---|
./script.sh 상대경로 사용 |
어떤 폴더에서는 되고 어떤 폴더에서는 안 됨 | hook 실행 위치가 항상 repo root라는 보장이 없음 | "$CLAUDE_PROJECT_DIR" 기준으로 고정 |
| 경로에 공백 존재 | No such file or directory 또는 인자 분리 오류 |
따옴표 없이 경로를 넘겨 쉘이 쪼갬 | 경로 전체를 감싸기 |
| nested 디렉토리에서 세션 시작 | 같은 스크립트가 서브폴더에서만 실패 | pwd가 프로젝트 루트가 아닐 수 있음 |
cd "$CLAUDE_PROJECT_DIR" 후 실행 |
| worktree 사용 | 원래 repo 기준 파일을 못 찾음 | worktree는 별도 작업 복사본 | worktree 기준으로 재해석하거나 절대 경로 사용 |
| plugin 스크립트인데 project 경로로 씀 | 업데이트 후 경로가 흔들림 | plugin 설치 위치는 프로젝트와 생명주기가 다름 | ${CLAUDE_PLUGIN_ROOT} 사용 |
| SessionStart에서 env를 안 넘김 | 이후 bash 명령이 환경을 못 봄 | 초기 hook과 후속 shell 사이 상태 공유가 없음 | CLAUDE_ENV_FILE에 export 기록 |
| JSON 설정에 shell quoting이 없음 | 로컬에선 되고 팀 환경에서 깨짐 | 쉘/OS/공백 처리 차이 | command string을 명시적으로 quoting |
이 표만 제대로 읽어도 절반은 잡는다.
제일 흔한 실패 패턴
1. pwd가 프로젝트 루트일 거라는 믿음
이 믿음이 참 단단하다.
문제는 hook이 항상 네가 IDE에서 바라보는 그 폴더에서 시작하지 않는다는 점이다.
세션이 서브디렉토리에서 열리거나, worktree를 타거나, 다른 컨텍스트에서 재진입하면 pwd는 금방 달라진다.
그런데 스크립트는 그걸 모른다.
그래서 pwd를 절대 진실처럼 쓰는 순간 자동화가 흔들린다.
2. 경로를 따옴표 없이 쓴다
이건 거의 고전이다.
/path/to/My Project 같은 경로는 쉘 입장에서 두 개의 인자로 쪼개질 수 있다.
그냥 따옴표 하나면 끝날 걸, 안 붙여서 하루를 태운다.
3. 상대경로를 설정 파일 안에 박아둔다
설정 파일에 ./.claude/hooks/check.sh를 적어두면 보기엔 예쁘다.
근데 예쁜 게 오래 가는 건 아니다.
hook이 호출되는 위치가 바뀌면 상대경로는 바로 흔들린다.
운영에서 예쁨은 부가 기능이고, 안정성은 필수다.
4. 스크립트와 설정을 같은 것처럼 생각한다
설정은 “무엇을 실행할지”를 말하고, 스크립트는 “어디서 어떻게 실행할지”를 말한다.
둘이 분리되지 않으면 나중에 원인 추적이 너무 어려워진다.
한 번 꼬이면, 설정 파일을 고쳐야 하는지 스크립트를 고쳐야 하는지 헷갈린다.
5. plugin과 project를 한 경로로 본다
plugin은 설치 단위고, project는 작업 단위다.
둘은 비슷해 보여도 수명이 다르다.
문서가 ${CLAUDE_PLUGIN_ROOT}를 따로 주는 이유가 있다.
업데이트 한 번에 경로가 바뀔 수 있으니까.
바로 고치는 패턴
프로젝트 루트가 필요한 경우
"$CLAUDE_PROJECT_DIR"/.claude/hooks/check-style.sh
이게 가장 단순하다.
루트 기준 경로를 명시해두면, hook이 어디서 시작하든 스크립트 위치는 흔들리지 않는다.
루트로 이동한 뒤 실행하는 경우
cd "$CLAUDE_PROJECT_DIR" && ./.claude/hooks/check-style.sh
이 방식은 디버깅할 때 편하다.
실행 맥락을 눈으로 확인하기 쉽고, 이후 상대경로도 예측 가능하다.
plugin에 넣은 스크립트를 부르는 경우
"${CLAUDE_PLUGIN_ROOT}"/scripts/audit.sh
plugin 쪽은 project 쪽보다 더 자주 바뀔 수 있다.
그래서 plugin 전용 루트를 쓰는 편이 맞다.
후속 bash 명령에 환경을 넘기고 싶은 경우
SessionStart hook에서 CLAUDE_ENV_FILE에 export를 적는다.
그럼 이후 bash 명령들이 같은 세션 안에서 그 환경을 이어받는다.
이건 진짜 실무에서 꽤 유용하다.
특히 API 토큰이 아니라, 작업 경로와 운영 플래그를 이어 붙일 때 좋다.
hook 실행 컨텍스트를 이렇게 이해하면 편하다
hook은 그냥 shell script가 아니다.
Claude Code 런타임 안에서 호출되는 “운영 단위”다.
그래서 네 스크립트가 기대하는 환경과, Claude Code가 제공하는 환경이 만나는 지점을 봐야 한다.
여기서 확인할 포인트는 네 개다.
- 현재 디렉토리
- hook input JSON의
cwd - 프로젝트 루트 변수
- plugin 또는 persistent data 루트
cwd는 진단에는 좋지만, 루트의 기준이 되진 않는다.
프로젝트 루트가 따로 필요한 스크립트라면, cwd보다 CLAUDE_PROJECT_DIR를 믿는 편이 덜 흔들린다.
내 해석으로는 이게 핵심이다.
헷갈리기 쉬운 구분
pwd는 “지금 여기”
cwd는 “hook이 관찰한 지금 여기”
CLAUDE_PROJECT_DIR는 “프로젝트 기준 여기”
이 셋이 같은 날도 있지만, 같은 규칙은 아니다.
운영은 보통 같은 날보다 다른 날이 더 많다.
실패 사례를 조금 더 구체적으로 보자
사례 1. 서브폴더에서 실행했더니 스타일 체크가 사라짐
설정은 이랬다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/check-style.sh"
}
]
}
]
}
}
repo root에서 시작하면 운 좋게 돌아간다.
하지만 세션이 하위 디렉토리에서 붙으면 스크립트를 못 찾는다.
고친 뒤에는 이렇게 바뀐다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-style.sh"
}
]
}
]
}
}
핵심은 fancy한 로직이 아니라 기준점이다.
사례 2. 경로에 공백이 있어서 쉘이 두 토막 냄
"$CLAUDE_PROJECT_DIR"/scripts/run-audit.sh
이렇게 쓰면 된다.
반대로 따옴표를 빼면 path가 쪼개질 수 있다.
이건 작은 차이 같지만, 운영에서는 큰 차이다.
사례 3. plugin 업데이트 후 스크립트 경로가 흔들림
plugin에 넣은 도구를 project 루트로 착각하면 꼬인다.
문서가 ${CLAUDE_PLUGIN_ROOT}를 따로 주는 이유를 잊으면 안 된다.
plugin은 “설치된 위치”를 봐야 한다.
project는 “작업 중인 저장소”를 봐야 한다.
사례 4. CLAUDE_ENV_FILE을 안 써서 다음 명령이 맥락을 잃음
SessionStart에서 뭔가를 읽고, 다음 bash 명령으로 넘기고 싶다.
그런데 export를 어디에도 저장하지 않으면 이후 명령은 그걸 모른다.
그럴 때 문서가 CLAUDE_ENV_FILE를 제공한다.
여기서 상태를 이어 붙이면 된다.
이건 경로 문제와 직접 동일하진 않지만, 자동화가 “왜 중간에 기억을 잃는지”를 잡는 데 같이 중요하다.
안정화 체크리스트
pwd가 아니라"$CLAUDE_PROJECT_DIR"를 기준으로 삼았는지 확인한다- 스크립트 호출부의 경로를 전부 따옴표로 감쌌는지 확인한다
- hook 설정과 shell script의 책임을 분리했는지 본다
- project script와 plugin script를 같은 변수로 처리하지 않았는지 본다
- SessionStart에서 필요한 환경은
CLAUDE_ENV_FILE로 넘기는지 본다 - worktree에서 실행될 수 있으면 그 경로도 테스트한다
- 공백이 들어간 디렉토리에서 한 번 실제로 돌려본다
- nested 디렉토리에서 한 번 실제로 돌려본다
cwd,CLAUDE_PROJECT_DIR,dirname "$0"를 로그로 남겨 원인 추적이 가능하게 한다- 스크립트는
set -euo pipefail같은 실패 조기 감지를 쓰도록 한다 - hook command 안에서 너무 많은 로직을 한 번에 하지 않는다
- 경로가 중요한 자동화는 설정 예시만 보지 말고 실제 실행 위치까지 검증한다
이 체크리스트를 안 지키면, 자동화는 언젠가 “왜 나한테만 이러냐” 모드로 들어간다.
운영자용 점검 루틴
문제가 났을 때는 아래 순서로 보면 빨리 잡힌다.
- hook command가 실제로 어떤 문자열로 실행되는지 확인한다
"$CLAUDE_PROJECT_DIR"값이 기대한 저장소를 가리키는지 확인한다- 현재 세션의
cwd가 어디인지 본다 - path에 공백이나 특수문자가 있는지 본다
- plugin 경로인데 project 경로로 잘못 썼는지 본다
- worktree나 nested repo인지 확인한다
- 같은 스크립트를 터미널에서 직접 실행해 재현해본다
운영은 대개 복잡한 구조보다 재현 순서가 더 중요하다.
실수 TOP
1. pwd가 진실이라고 믿기
진실이 아니라 현재값이다.
현재값은 바뀐다.
2. 상대경로를 “간단함”으로 착각하기
간단한 건 처음뿐이다.
나중엔 디버깅 난이도만 남는다.
3. 따옴표를 아끼기
따옴표는 비용이 아니라 보험이다.
4. project와 plugin의 루트를 혼동하기
이건 자주 보는데, 고치기 전까지 계속 비슷한 버그만 반복된다.
5. SessionStart와 나머지 hook을 같은 것으로 보기
문서가 일부 hook에만 CLAUDE_ENV_FILE을 주는 건 이유가 있다.
6. 로컬에서만 성공한 걸 팀 표준으로 올리기
내 컴퓨터는 늘 친절하다.
운영은 친절하지 않다.
7. 스크립트 실패를 조용히 넘기기
hook 실패가 로그 없이 묻히면, 다음엔 더 큰 시간 낭비가 온다.
FAQ
Q1. CLAUDE_PROJECT_DIR는 왜 꼭 써야 해?
프로젝트 루트가 필요한 스크립트를 현재 디렉토리에 묶어두면 실행 맥락이 바뀔 때 깨지기 쉽다.
공식 문서도 프로젝트 기준 경로 참조를 권한다.
Q2. 그냥 절대경로를 하드코딩하면 안 되나?
가끔은 된다.
근데 팀/장비/워크트리/플러그인 수명이 바뀌면 관리가 금방 어려워진다.
그래서 프로젝트 루트 변수로 묶는 편이 낫다.
Q3. pwd랑 CLAUDE_PROJECT_DIR는 뭐가 달라?
pwd는 현재 셸 위치고, CLAUDE_PROJECT_DIR는 프로젝트 루트 기준이다.
문제는 둘이 같지 않을 때다.
Q4. plugin 스크립트는 왜 별도 변수로 봐야 해?
plugin은 설치 위치와 데이터 위치가 project와 다른 생명주기를 갖기 때문이다.
문서가 ${CLAUDE_PLUGIN_ROOT}와 ${CLAUDE_PLUGIN_DATA}를 따로 둔 이유다.
Q5. SessionStart에서 설정한 값이 왜 다음 명령에 안 보일 때가 있지?
지속 저장을 안 했기 때문이다.
문서 기준으로는 CLAUDE_ENV_FILE에 export를 써야 이후 bash 명령에 이어진다.
Q6. hook이 특정 폴더에서만 실패하면 어디부터 봐야 해?
첫 번째는 현재 디렉토리다.
두 번째는 따옴표다.
세 번째는 루트 변수다.
Q7. 내 스크립트가 충분히 단순한데도 왜 자꾸 흔들려?
대부분 스크립트가 아니라 경로 해석이 흔들리는 거다.
코드보다 환경이 먼저 문제를 일으키는 케이스가 생각보다 많다.
운영 팁
hook 스크립트는 가능한 한 짧게 유지하는 게 좋다.
길어질수록 경로, 셸, 상태 공유, 예외 처리까지 한꺼번에 섞인다.
그다음부터는 “왜 안 되지”가 아니라 “어디서 망가졌지”를 찾는 일이 된다.
그래서 나는 보통 이렇게 나눈다.
- 설정 파일은 트리거만 둔다
- 스크립트는 진입점만 둔다
- 핵심 로직은 별도 파일로 뺀다
- 경로는 루트 변수로만 받는다
이렇게 하면 문제 생겨도 범위를 빠르게 좁힐 수 있다.
공식 문서에서 특히 챙길 문장
문서를 읽을 때 눈여겨볼 포인트는 세 가지다.
- handlers가 current directory와 Claude Code 환경에서 돈다는 점
- project root script는
$CLAUDE_PROJECT_DIR를 쓰라는 점 - plugin script는
${CLAUDE_PLUGIN_ROOT}와${CLAUDE_PLUGIN_DATA}를 쓰라는 점
이 세 개만 잡아도, 경로 관련 사고의 상당수가 줄어든다.
관련 글
- Claude Code hooks 실전 2026 — 승인 루프·로그·알림 자동화 어디까지 붙여도 되나
- How Claude Code works 2026 — 터미널 기반 코딩 에이전트는 왜 live UI와 continuous loop를 쓰나
- Claude Code 권한 설계 체크리스트 2026 — hooks·subagents·세션 분리 어디서부터 막아야 하나
마무리 메모
경로 문제는 늘 사소해 보인다.
근데 자동화에서 사소한 건 대개 가장 자주 터지는 부분이다.
hook은 “돌아가면 끝”이 아니라 “어디서 돌든 같은 결과가 나와야” 운영된다.
그래서 프로젝트 루트, 현재 디렉토리, plugin 루트, 상태 전달 경로를 분리해서 보는 습관이 중요하다.
이걸 습관으로 만들면, Claude Code 자동화는 훨씬 덜 짜증난다.
그리고 덜 짜증나는 자동화는 꽤 훌륭한 자동화다.