-
Claude Code Hooks로 AI 에이전트의 다단계 파이프라인을 결정적으로 만들기IT 2026. 4. 6. 21:00
Claude Code로 복잡한 자동화 파이프라인을 만들면 한 가지 근본적인 문제에 부딪힌다. LLM은 비결정적(non-deterministic)이다. "이 5단계를 순서대로 해줘"라고 SKILL.md에 적어놔도, 실제로는 3단계를 건너뛰거나 4단계를 까먹을 수 있다.
이 글에서는 Claude Code의 Hooks 시스템을 활용해 AI 에이전트가 다단계 작업을 빠짐없이 수행하도록 강제하는 패턴을 소개한다.
문제: "해줘"라고 했는데 절반만 하는 AI
예를 들어 이런 파이프라인이 있다고 하자. 개인 독서 노트를 자동으로 정리하는 시스템이다:
Step 1: 독서 앱에서 하이라이트 가져오기 Step 2: 관련 기존 노트와 연결 Step 3: AI 요약 + 핵심 인사이트 추출 Step 4: 품질 검증 Step 5: git commit & push Step 6: 메신저 알림이걸 Claude Code 스킬로 만들면, 대부분의 경우 잘 동작한다. 하지만 가끔:
- Step 2를 건너뛰고 바로 요약을 시작한다
- Step 4 검증 없이 커밋한다
- 알림을 보내지 않고 "완료했습니다"라고 말한다
- Step 3를 하다 말고 커밋한다
SKILL.md에 아무리 자세히 적어놔도 LLM의 본질적 한계다. 컨텍스트가 길어지면 앞의 지시를 잊고, "이 정도면 됐겠지"라고 자체 판단한다.
해결: Hooks로 체크포인트 만들기
Claude Code Hooks는 도구 호출의 생명주기에 끼어드는 셸 스크립트다. 핵심 hook 타입:
Hook 타입 트리거 시점 차단 가능? PreToolUse도구 실행 전 Yes (exit 2) PostToolUse도구 실행 후 Yes StopClaude 응답 종료 시 Yes (exit 2) "차단 가능?"이란 해당 hook이
exit 2를 반환했을 때, Claude Code가 원래 하려던 동작을 실행하지 않고 막을 수 있는지를 뜻한다. 구체적으로:PreToolUse에서exit 2→ 도구 호출 자체가 실행되지 않는다. 예를 들어git commit을 시도했는데 hook이exit 2를 반환하면, 커밋이 아예 일어나지 않는다.PostToolUse→ 도구는 이미 실행된 후이므로 "차단"의 의미가 다르다. 여기서의 Yes는additionalContext를 통해 Claude에게 다음 행동을 유도할 수 있다는 의미다. 직접적인 실행 차단이 아니라 안내 주입이다.Stop에서exit 2→ Claude가 응답을 끝내고 대화를 종료하려는 것을 막는다. Claude는 종료하지 못하고 stderr에 출력된 피드백을 읽은 뒤 작업을 계속해야 한다.
즉,
exit 2는 Claude Code 런타임 수준의 강제다. LLM이 무시할 수 없다. LLM이 "무시하겠다"고 판단할 여지가 없이, Claude Code 하네스(harness)가 해당 동작을 물리적으로 실행하지 않는다.아이디어는 간단하다:
- 상태 파일로 "지금 어디까지 했는지" 추적
- PostToolUse로 각 단계 완료를 감지하고 다음 단계를 주입
- PreToolUse로 선행 단계 없이 뛰어넘는 걸 차단
- Stop으로 미완료 상태에서 종료하는 걸 차단
설계: 4개의 Hook
최종 설계는 이렇다:
사용자 요청: "독서 노트 정리해줘" ↓ Step 1: 하이라이트 fetch (Bash 실행) ↓ [Hook B: PostToolUse] → Step 1 완료 감지 → "Step 2 진행" 주입 ↓ Step 2: 관련 노트 연결 (Bash 실행) ↓ [Hook C: PostToolUse] → Step 2 완료 감지 → "Step 3 진행" 주입 ↓ Step 3: AI 요약 + 인사이트 (Read/Edit 반복) ↓ [Hook D: PreToolUse] → git commit 시도 시 파일 스캔 → 미처리 항목 있으면 차단 ↓ Step 4-5: 검증 + git commit ↓ [Hook E: Stop] → 파이프라인 미완료면 종료 차단왜 UserPromptSubmit은 빼는가?
처음에는
UserPromptSubmithook으로 "독서" 키워드를 감지해서 파이프라인을 초기화하려 했다. 하지만 이 hook은 모든 프롬프트에 걸린다. "안녕"이라고 쳐도, 코드 리뷰를 요청해도, 전부 실행된다.키워드 감지 로직을 넣으면?
- 오탐: "독서실 근처 맛집"에도 반응
- 미탐: "하이라이트 정리해줘"에는 미반응
- 매 프롬프트마다 불필요한 오버헤드
더 나은 방법: 파이프라인의 실제 진입점에서 상태를 초기화한다. 이 파이프라인의 Step 1은 항상 특정 Python 명령을 실행한다. 그 명령의 PostToolUse에서 상태 파일을 생성하면, "독서 노트를 정리해줘"든 "하이라이트 가져와"든, 실제로 Step 1이 실행될 때만 파이프라인이 시작된다.
이것이 의도 감지(intent detection)보다 행위 감지(action detection)가 더 정확한 이유다.
좀 더 풀어보자. 의도 감지는 사용자의 자연어 입력에서 "무엇을 하려는지" 추론하는 것이다.
UserPromptSubmithook에서 "독서", "하이라이트", "정리" 같은 키워드를 grep하는 방식이다. 문제는 자연어가 모호하다는 것이다. "독서실 예약"에도 "독서"가 포함되고, "킨들 싱크해줘"에는 관련 키워드가 없다. NLP 분류기를 붙이면 정확도가 올라가지만, 매 프롬프트마다 추론 오버헤드가 생기고, 결국 또 다른 LLM을 호출하는 비용이 발생한다.행위 감지는 완전히 다르다. Claude가 실제로 실행한 도구 호출의 내용을 본다.
PostToolUse에서reading_sync.main fetch라는 명령이 실행되었는지 확인하는 것은 문자열 비교 한 번이면 된다고 했는데, 이게 어떻게 가능할까?Claude Code 하네스는 도구가 실행될 때마다, 해당 도구 호출의 정보를 JSON으로 직렬화해서 hook 스크립트의 stdin으로 전달한다. 예를 들어 Claude가
python3 -m reading_sync.main fetch를 Bash로 실행했다면, PostToolUse hook은 stdin에서 다음과 같은 JSON을 받는다:{ "tool_name": "Bash", "tool_input": { "command": "python3 -m reading_sync.main fetch" }, "tool_output": "..." }hook 스크립트는 이 JSON을 파싱하고,
tool_input.command문자열에"reading_sync.main fetch"가 포함되어 있는지 Python의in연산자로 확인한다. 자연어 해석이 아니라 프로그래밍적 문자열 매칭이므로 오탐이 원리적으로 불가능하다. 사용자가 어떤 표현을 쓰든, 어떤 언어로 요청하든, 최종적으로 Claude가 Step 1 명령을 실행하는 순간에만 파이프라인이 활성화된다.여기서 핵심은 이 JSON을 만드는 주체가 하네스라는 점이다. Bash 도구 자체가 JSON을 산출하는 것이 아니다. 하네스가 도구 호출의 이름, 입력 파라미터, 출력 결과를 하나의 JSON 객체로 조립해서 hook의 stdin으로 파이프한다. Hook 스크립트 입장에서는 "하네스가 정해진 스키마의 JSON을 줄 테니 파싱만 하면 된다"는 계약이다.
구현: 상태 파일 + 3개의 Hook 스크립트
상태 파일
단순한 JSON 파일 하나가 전체 파이프라인을 추적한다:
// /tmp/.reading_pipeline_state { "step1_fetch": true, "step2_link": false, "step3_summarize": false, "step5_git": false, "step6_notify": false }이 파일이 없으면 파이프라인 밖이므로, 모든 hook이 즉시
sys.exit(0)한다. 오버헤드: 파일 존재 확인 1회.Hook B+C: PostToolUse(Bash) — 단계 완료 감지
#!/usr/bin/env python3 """PostToolUse(Bash) — 파이프라인 단계 추적.""" import json, sys from pathlib import Path STATE = Path("/tmp/.reading_pipeline_state") # 하네스가 stdin으로 전달한 도구 호출 정보(JSON)를 파싱 data = json.loads(sys.stdin.read()) cmd = data.get("tool_input", {}).get("command", "") # 빠른 필터: 파이프라인과 무관한 명령이면 즉시 종료 (오버헤드 최소화) if "reading_sync" not in cmd: sys.exit(0) # Step 1 감지: fetch 명령이 실행된 후 → 파이프라인 시작 if "reading_sync.main fetch" in cmd: # 상태 파일 초기 생성: step1만 true, 나머지는 false state = {"step1_fetch": True, "step2_link": False, "step3_summarize": False, "step5_git": False, "step6_notify": False} STATE.write_text(json.dumps(state)) # stdout에 JSON 출력 → 하네스가 파싱 → additionalContext를 Claude에 주입 json.dump({ "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "✅ Step 1 완료. 다음 → Step 2: 관련 노트 연결" } }, sys.stdout) sys.exit(0) # Step 2 감지: linker 명령이 실행된 후 if "reading_sync.linker" in cmd: state = json.loads(STATE.read_text()) # 기존 상태 읽기 state["step2_link"] = True # step2 완료 마킹 STATE.write_text(json.dumps(state)) # 상태 파일에 반영 # 다음 단계 안내를 Claude 컨텍스트에 주입 json.dump({ "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "✅ Step 2 완료. 다음 → Step 3: AI 요약 + 인사이트 추출" } }, sys.stdout) sys.exit(0)핵심 패턴:
additionalContext로 Claude에 다음 단계를 주입한다. Claude는 이 메시지를 읽고 다음 행동을 결정한다.- Step 1 감지 시 상태 파일을 생성한다. 이것이 UserPromptSubmit 대신 파이프라인을 시작하는 지점이다.
- Step 2 감지 시 상태 파일을 업데이트한다. 각 단계가 완료될 때마다 해당 플래그가
true로 마킹된다.
PostToolUse는 왜 JSON으로 반환하는가?
PostToolUse hook의 stdout 출력은 JSON 형식이어야 한다. Claude Code 하네스가 이 JSON을 파싱해서
hookSpecificOutput.additionalContext값을 추출한 뒤, Claude의 대화 컨텍스트에 삽입한다. Claude(LLM)가 직접 JSON을 파싱하는 것이 아니라, 하네스가 JSON을 해석하고, 그 안의 텍스트 메시지만 Claude에게 전달하는 구조다.JSON을 쓰는 이유는 LLM 친화성보다는 기계 파싱의 정확성 때문이다. 하네스는 프로그램이므로, 구조화된 형식이 필요하다. 자유 형식 텍스트로 반환하면 하네스가 "이 부분이 컨텍스트 메시지인지, 에러 로그인지" 구분할 수 없다. JSON의 키-값 구조가 hook 출력의 의미를 명확하게 전달한다.
Hook이 stdin을 읽어버리면 LLM은 정보를 못 받나?
이 부분이 처음에 혼란스러울 수 있다. 결론부터 말하면, hook과 LLM은 stdin을 공유하지 않는다.
하네스는 hook 스크립트를 별도의 서브프로세스로 실행하면서, 도구 호출 정보를 그 프로세스의 stdin으로 파이프한다. 이것은 LLM이 보는 도구 호출 결과와는 완전히 별개의 채널이다. LLM은 하네스 내부의 메시지 스트림을 통해 도구 결과를 받고, hook은 자기만의 stdin 파이프를 통해 같은 정보를 받는다.
마찬가지로, hook이 관련 없는 명령을 보고
sys.exit(0)으로 빠져나가도, 다른 hook이나 LLM의 동작에 영향을 주지 않는다. 각 hook 실행은 독립적인 프로세스이며, 하네스가 매번 새로운 stdin 파이프를 만들어 전달한다.상태 파일은 언제 업데이트되는가?
상태 파일(
.reading_pipeline_state)의 업데이트는 전적으로 PostToolUse hook 안에서 일어난다:- Step 1 완료 → hook이 상태 파일을 생성하면서
step1_fetch: true로 기록 - Step 2 완료 → hook이 상태 파일을 읽고
step2_link: true로 업데이트 - 이후 단계도 동일한 패턴으로, 각 단계의 PostToolUse에서 해당 플래그를 마킹한다
Claude(LLM)는 상태 파일을 직접 수정하지 않는다. Hook 스크립트가 도구 호출 결과를 관찰하고 자동으로 기록한다. 이것이 중요하다 — LLM에게 "상태 파일을 업데이트해줘"라고 맡기면, 바로 그 비결정성 문제가 다시 발생하기 때문이다.
Hook D: PreToolUse(Bash) — 선행 조건 검증
#!/usr/bin/env python3 """PreToolUse(Bash) — git commit 전 파이프라인 검증.""" import json, sys, re from pathlib import Path STATE = Path("/tmp/.reading_pipeline_state") # 하네스가 stdin으로 전달한 도구 호출 정보 파싱 data = json.loads(sys.stdin.read()) cmd = data.get("tool_input", {}).get("command", "") # git commit이 아닌 명령은 무조건 통과 if "git commit" not in cmd: sys.exit(0) # 활성 파이프라인이 아니면 통과 (일반 git commit은 방해하지 않음) state_path = STATE if not state_path.exists(): sys.exit(0) # --- 핵심: 실제 산출물을 스캔해서 미처리 항목 검증 --- # 상태 파일의 플래그가 아닌, 실제 노트 파일의 내용으로 판단한다. # Claude가 계획 단계에서 노트에 @TODO 마커를 심고, 요약 단계에서 처리 후 [done]을 붙인다. # [done]이 없는 @TODO가 남아있다 = 요약 작업이 아직 끝나지 않았다. issues = [] notes_dir = Path.home() / "reading-vault" / "notes" for p in notes_dir.rglob("*.md"): text = p.read_text() if "@TODO" in text and "[done]" not in text: issues.append(f"미처리 항목: {p.name}") if issues: # stderr 메시지가 Claude의 컨텍스트에 피드백으로 삽입된다 msg = "⛔ 파이프라인 미완료:\n" + "\n".join(f" - {i}" for i in issues) print(msg, file=sys.stderr) sys.exit(2) # exit 2 = 도구 실행 차단 (git commit이 실행되지 않음) sys.exit(0) # 검증 통과 → git commit 허용exit 2가 핵심이다. 이 종료 코드는 Claude Code에게 "이 도구 호출을 차단합니다"라고 알린다. stderr 메시지가 Claude에게 피드백으로 전달되어, Claude는 누락된 작업을 먼저 수행한다.exit 2의 강제 차단은 어떻게 작동하는가?
"강제"라는 표현이 다소 모호할 수 있다. 정확히 설명하면 이렇다:
- Claude(LLM)가
git commit을 실행하려고 도구 호출을 요청한다. - Claude Code 하네스(harness)가 실제 실행 전에
PreToolUsehook을 먼저 실행한다. - Hook 스크립트가
exit 2를 반환하면, 하네스는git commit을 실행하지 않는다. LLM에게 "실행해도 될까요?"라고 묻는 것이 아니라, 하네스 레벨에서 실행 자체를 차단한다. - 동시에, hook이 stderr에 출력한 메시지 (예: "⛔ 파이프라인 미완료: 미처리 항목 3건")가 Claude의 대화 컨텍스트에 피드백으로 삽입된다.
- Claude는 "도구 호출이 차단되었다"는 사실과 그 이유를 읽고, 미처리 항목을 먼저 해결한 뒤 다시 커밋을 시도한다.
핵심은 3번이다. 이것은 LLM의 판단이 아니라 런타임의 물리적 차단이다. LLM이 "무시하겠다"고 결정할 수 있는 여지가 없다.
@TODO와 [done] — hook은 Claude의 계획을 어떻게 아는가?
hook이 노트 파일을 전수 스캔해서
@TODO와[done]마커를 확인한다고 했는데, 의문이 생길 수 있다. "hook이 Claude가 어떤 파일로 작업하고 있는지 어떻게 아는가? Claude의 계획을 읽을 수 있는 건가?"답은, hook은 Claude의 계획을 모른다. 대신, 파이프라인의 규약(convention)이 있다:
- Step 2 (노트 연결) 단계에서 Claude가 기존 노트를 분석하고 처리 계획을 세울 때, 정해진 디렉토리(
~/reading-vault/notes/)의 노트 파일에@TODO마커를 심는다. 이것은 Claude가 "이 항목은 요약이 필요하다"고 판단한 지점을 표시하는 것이다. - Step 3 (AI 요약)에서 Claude가 각
@TODO항목을 실제로 처리한 뒤[done]을 붙인다. - hook은 정해진 디렉토리를 스캔해서
@TODO가 있으면서[done]이 없는 항목이 남아있는지만 확인한다.
즉, hook이 스캔하는 범위는 하드코딩된 디렉토리이다. Claude가 어떤 파일을 열었는지, 전체 작업 계획이 어떤지는 모른다. "이 디렉토리의 모든 .md 파일에 미처리 항목이 없어야 커밋을 허용한다"는 단순한 규칙이다.
그렇다면 Claude가 반드시 [done]을 붙이는 보장이 있는가? 보장은 없다. 이것이 바로 PreToolUse hook이 존재하는 이유다. Claude가
[done]을 까먹고 커밋을 시도하면, hook이 "미처리 항목: chapter3-highlights.md"라는 구체적 피드백과 함께 차단한다. Claude는 이 피드백을 읽고 해당 파일로 돌아가 작업을 완료하게 된다.미처리 항목 목록은 stderr를 통해 Claude에게 전달되므로, Claude는 어떤 파일의 어떤 항목이 미처리인지 구체적으로 알 수 있다.
Hook E: Stop — 미완료 종료 차단
#!/usr/bin/env python3 """Stop hook — 파이프라인 완료 여부 확인.""" import json, sys from pathlib import Path STATE = Path("/tmp/.reading_pipeline_state") # Stop hook은 stdin 입력을 사용하지 않으므로 소비만 함 try: sys.stdin.read() except: pass # 활성 파이프라인이 없으면 정상 종료 허용 if not STATE.exists(): sys.exit(0) # 상태 파일에서 미완료 단계 수집 state = json.loads(STATE.read_text()) missing = [] for key, label in { "step1_fetch": "하이라이트 가져오기", "step2_link": "노트 연결", "step5_git": "git commit", "step6_notify": "알림 발송", }.items(): if not state.get(key): missing.append(label) # 미완료 단계가 있으면 종료를 차단하고, 남은 목록을 피드백으로 전달 if missing: msg = f"⛔ 파이프라인 미완료. 남은 단계:\n" msg += "\n".join(f" - {m}" for m in missing) print(msg, file=sys.stderr) sys.exit(2) # 종료 차단 → Claude가 남은 작업을 계속 수행 # 모든 단계 완료 → 상태 파일 정리 후 정상 종료 STATE.unlink() sys.exit(0)이것이 마지막 방어선이다. Claude가 "다 했습니다!"라고 말하려는 순간, Stop hook이 "아직 알림 안 보냈는데요?"라고 막는다.
settings.json 설정
{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "python3 ~/reading-vault/hooks/post_bash.py", "timeout": 10 }] } ], "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "python3 ~/reading-vault/hooks/pre_git.py", "timeout": 10 }] } ], "Stop": [ { "hooks": [{ "type": "command", "command": "python3 ~/reading-vault/hooks/stop_check.py", "timeout": 10 }] } ] } }matcher: "Bash"는 모든 Bash 호출에 걸리지만, 스크립트 첫 줄에서 관련 없는 명령을 걸러내므로 실질적 오버헤드는 Python 기동 시간(~30ms) 정도다.실제 동작 흐름
사용자가 "오늘 읽은 책 하이라이트 정리해줘"라고 요청하면:
1. Claude가 SKILL.md를 읽고 Step 1 실행 2. [PostToolUse] 상태 파일 생성 + "Step 2 진행" 주입 3. Claude가 주입된 안내를 읽고 Step 2 실행 4. [PostToolUse] step2=true 마킹 + "Step 3 진행" 주입 5. Claude가 Step 3 수행 (Read/Edit 반복) 6. Claude가 git commit 시도 7. [PreToolUse] 파일 스캔 → @TODO 미처리 발견 → exit 2 → 차단! 8. Claude가 피드백을 읽고 미처리 항목 처리 9. Claude가 다시 git commit → 이번엔 통과 10. Claude가 알림 발송 → 종료 시도 11. [Stop] 모든 단계 완료 확인 → 상태 파일 삭제 → 정상 종료7번이 핵심이다. Hook이 없었다면 Claude는 미처리 항목을 남긴 채 커밋했을 것이다.
설계 판단: 의도 감지 vs 행위 감지
접근 구현 문제 의도 감지 UserPromptSubmit에서 키워드 매칭오탐/미탐, 모든 프롬프트에 오버헤드 행위 감지 PostToolUse에서 실제 명령 감지정확, 관련 명령 시에만 동작 UserPromptSubmit은 "사용자가 뭘 하려는지" 추측한다.PostToolUse는 "실제로 뭘 했는지" 관찰한다. 후자가 압도적으로 정확하다.비유하면, 의도 감지는 "레스토랑에 들어온 사람의 옷차림을 보고 뭘 주문할지 예측"하는 것이고, 행위 감지는 "주문서에 적힌 내용을 읽는" 것이다. 전자는 틀릴 수 있지만, 후자는 사실 확인이다.
게다가 UserPromptSubmit은 파이프라인과 무관한 대화에서도 매번 실행된다. "오늘 날씨 어때?"라는 질문에도 키워드 매칭 로직이 돌아간다. PostToolUse는 실제로 도구가 호출될 때만 실행되고, 스크립트 첫 줄의 빠른 필터에서 관련 없는 명령은 즉시
sys.exit(0)하므로 실질적 오버헤드가 거의 없다.한계와 고려사항
Claude가 주입된 안내를 따르지 않으면?
additionalContext는 Claude의 대화 컨텍스트에 텍스트를 삽입할 뿐, 특정 도구 호출을 강제하지는 않는다. 이론적으로 Claude가 "Step 2 진행"이라는 안내를 무시하고 다른 행동을 할 수 있다. 하지만 이 파이프라인에서는 두 겹의 안전망이 있다:- PreToolUse hook: Claude가 Step 2를 건너뛰고 바로 git commit을 시도하면, 파일 스캔에서 미처리 항목이 발견되어
exit 2로 차단된다. 차단 메시지에 구체적인 미처리 목록이 포함되므로, Claude는 결국 해당 작업을 수행하게 된다. - Stop hook: Claude가 모든 것을 건너뛰고 종료하려 해도, 상태 파일에서 미완료 단계가 확인되면 종료가 차단된다. Claude는 종료할 수 없으므로 남은 작업을 계속해야 한다.
즉,
additionalContext자체는 "권고"이지만, 그 권고를 무시했을 때 PreToolUse와 Stop hook이 물리적으로 막는 구조다. 안내를 따르면 순탄하게 진행되고, 따르지 않으면 차단과 피드백을 반복하다가 결국 올바른 경로로 돌아오게 된다.exit 2로 차단되면 이전 단계로 롤백해야 하지 않나?
좋은 질문이다. PreToolUse에서
exit 2가 발생하면, 도구 호출 자체가 실행되지 않으므로 상태가 변하지 않는다. 즉, 롤백할 것이 없다. git commit이 차단되었을 뿐, 커밋이 실제로 일어난 것이 아니기 때문이다.하지만 더 복잡한 시나리오를 생각해보자. Step 3 (AI 요약)이 절반만 완료된 상태에서 Claude가 commit을 시도하고 차단된 경우, Step 3의 상태를 "미완료"로 되돌려야 할까? 이 파이프라인에서는 상태 파일이 아니라 실제 파일의 내용으로 검증한다는 점이 핵심이다. PreToolUse hook의 파일 스캔 로직을 보면, 상태 파일의 플래그가 아닌 실제 노트 파일에
@TODO가 남아있는지를 확인한다. 따라서 상태 파일을 롤백할 필요 없이, 미처리 항목이 실제로 해결되었는지가 차단/통과의 기준이 된다.만약 상태 파일의 플래그만으로 검증하는 구조였다면, 차단 시 해당 플래그를
false로 되돌리는 롤백 로직이 필요했을 것이다. 예를 들어:# 롤백이 필요한 경우의 패턴 (이 파이프라인에서는 사용하지 않음) if issues: state["step3_summarize"] = False # 미완료로 되돌림 STATE.write_text(json.dumps(state)) print(msg, file=sys.stderr) sys.exit(2)이런 상태 기반 롤백보다 실제 산출물 기반 검증이 더 견고하다. 상태 파일은 "했다고 기록됨"이고, 파일 스캔은 "실제로 됐는지" 확인이다.
기타 고려사항
- Stop hook의 무한 루프 주의. Claude가 미완료 단계를 해결하지 못하면 계속 차단된다. 상태 파일에 재시도 횟수를 기록하고, 임계치를 넘으면 강제 종료를 허용하는 것이 좋다.
- Python 기동 오버헤드가 있다. 매 Bash 호출마다 ~30ms. 성능이 중요하면 bash 스크립트 + jq 조합으로 대체할 수 있다.
정리
Claude Code Hooks를 사용한 파이프라인 강제 패턴:
- 상태 파일: 파이프라인 진행 상황을
/tmp/에 JSON으로 추적 - PostToolUse: 단계 완료 감지 + 다음 단계 안내 주입 (행위 감지)
- PreToolUse: 선행 조건 미충족 시 도구 호출 차단 (
exit 2) - Stop: 미완료 상태에서 종료 차단 (최후 방어선)
UserPromptSubmit은 쓰지 않는다. 의도 감지보다 행위 감지가 정확하고 효율적이다.
이 패턴은 독서 노트뿐 아니라, CI/CD 파이프라인, 데이터 ETL, 코드 리뷰 자동화 등 다단계 작업이면 어디든 적용할 수 있다. 핵심은 "LLM에게 절차를 설명하는 것"에서 "절차를 시스템적으로 강제하는 것"으로 패러다임을 바꾸는 것이다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
AI 코딩 에이전트의 자기 진화 학습 시스템 — 실수를 기억하고 성장하는 에이전트 만들기 (1) 2026.04.11 Qdrant 벡터 DB, 임베디드 모드에서 Docker 서버로 전환한 이유 — 로컬 RAG 시스템 구축 삽질기 (0) 2026.04.10 캘린더 싱크의 중복 지옥, event_id로 탈출하기 — Google Calendar → Obsidian 자동화 삽질기 (1) 2026.04.09 Claude Code가 플랜을 짜는 방법 - Plan Mode 내부 동작 원리 (0) 2026.04.08 axios에 악성코드가 심어졌다 - 북한 해커의 npm 공급망 공격 분석 (2) 2026.04.07 Qwen3.5-122B 양자화 비교: Q4_K_M vs Unsloth UD-Q3_K_XL 실측 (1) 2026.04.05 텔레그램 AI 어시스턴트에 기억을 심다 — 단기·에피소드·장기 메모리 설계기 (1) 2026.04.04 로컬 VLM으로 가족사진 3만 장 분석하기 — 열흘간의 대장정 (0) 2026.04.03 Context7 분석 (5) 다층 품질 스코어링 (1) 2026.04.02 Context7 분석 (4) 5단계 품질 파이프라인 (0) 2026.04.01