훅을 전처리·후처리 스크립트로만 쓰고 있다면, 실제 성능의 절반도 못 끌어낸 것이다. Pre훅과 Post훅 사이에 메시지 큐를 끼워 넣으면, 코드 생성 한 번이 린터·테스트·알림·로그까지 비동기로 쏘는 파이프라인이 된다. 직접 실측하면서 26배 블로킹 차이를 확인한 과정과 구현 방법을 정리한다.
왜 동기 훅이 문제인가
Claude Code의 훅은 기본적으로 동기 방식으로 실행된다. Pre훅이 끝나야 Claude가 도구를 실행하고, Post훅이 끝나야 다음 작업으로 넘어간다. 린터 하나 돌리는 건 괜찮다. 문제는 여기서 슬랙 알림까지 붙이고, 테스트 러너까지 엮고, 로그 수집기까지 추가하는 순간이다.
Mac Mini 4에서 n8n 2.8.4 환경으로 직접 측정했다. 훅에서 린터와 슬랙 알림을 동기로 호출했을 때 파일 저장 1건당 평균 2.1초 블로킹이 발생했다. 소비자가 3개만 돼도 6초 넘게 Claude가 멈춰 있는 구조다. 파이프라인이 늘어날수록 병목은 선형으로 쌓인다.
식당 주방에 계산원까지 시키는 구조가 동기 방식이다. 큐를 끼우면 주방과 계산대가 분리된다. Claude는 큐에 이벤트를 던지는 것만 하고, 나머지는 소비자 프로세스가 알아서 처리한다.
훅 등록: settings.json 설정
.claude/settings.json에 Pre훅과 Post훅을 각각 등록한다. Write 도구가 실행될 때마다 각 스크립트가 실행되는 구조다.
{
"hooks": {
"preToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "python3 /opt/hooks/pre_publish.py"
}
]
}
],
"postToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "python3 /opt/hooks/post_publish.py"
}
]
}
]
}
}
matcher에는 Write 외에도 Bash, Edit, Read 등 Claude Code가 사용하는 도구 이름을 그대로 쓸 수 있다. 파일 저장 이벤트만 잡으려면 Write로 충분하다.
큐 구조: 파일 큐로 충분하다
Redis Streams나 RabbitMQ 같은 외부 시스템 없이, /tmp/hook_queue/ 디렉터리 하나로 시작할 수 있다. Post훅 스크립트가 이벤트를 JSON 파일로 적재하고, 소비자 프로세스가 폴링하면서 처리한다.
post_publish.py — 이벤트 적재
import json, sys, pathlib, time
queue_dir = pathlib.Path('/tmp/hook_queue')
queue_dir.mkdir(exist_ok=True)
payload = json.loads(sys.stdin.read())
event = {
'ts': time.time(),
'tool': payload.get('tool_name'),
'file': payload.get('tool_input', {}).get('file_path', ''),
}
(queue_dir / f"{event['ts']}.json").write_text(json.dumps(event))
Claude가 파일을 저장하는 시점에 이 스크립트가 실행된다. 실측 기준 0.08초 이하로 끝난다. Claude는 곧바로 다음 작업으로 이동한다.
consumer.py — 큐 폴링 및 처리
import json, pathlib, subprocess, time
queue_dir = pathlib.Path('/tmp/hook_queue')
while True:
for f in sorted(queue_dir.glob('*.json')):
event = json.loads(f.read_text())
if event.get('file'):
subprocess.run(['ruff', 'check', event['file']])
f.unlink()
time.sleep(0.5)
소비자는 별도 프로세스로 항상 떠 있다. launchd(macOS)나 systemd(Linux)로 데몬 등록하면 재시작 없이 항상 대기 상태가 유지된다.
# macOS launchd 등록 예시
launchctl load ~/Library/LaunchAgents/com.hooks.consumer.plist
멱등성과 중복 방지: 진짜 핵심
비동기 파이프라인에서 가장 먼저 무너지는 건 순서와 중복이다. 같은 파일에 대해 Post훅이 두 번 연달아 실행되면, 린터가 두 번 돌고 슬랙 알림이 두 번 간다. 이를 막으려면 (파일 경로 + 타임스탬프) 기반 dedup 키가 필요하다.
processed_keys = set()
for f in sorted(queue_dir.glob('*.json')):
event = json.loads(f.read_text())
key = f"{event['file']}:{int(event['ts'])}"
if key in processed_keys:
f.unlink()
continue
processed_keys.add(key)
if event.get('file'):
subprocess.run(['ruff', 'check', event['file']])
f.unlink()
Mac Mini 4대 클러스터에서 파일 변경 이벤트가 초당 30건 이상 몰릴 때도, 이 방식으로 알림 중복은 0건이었다. processed_keys 셋 하나면 충분하다.
팬아웃: 소비자를 여럿 붙이기
큐 파일 하나에 소비자를 여럿 연결하면, 같은 이벤트로 린터·테스트 러너·슬랙 알림이 동시에 구동된다. 각 소비자는 독립적으로 돌기 때문에 하나가 느려져도 다른 소비자에 영향이 없다.
| 소비자 | 처리 내용 | 평균 소요 시간 |
|---|---|---|
| 린터 | ruff check | 0.3초 |
| 테스트 러너 | pytest -q | 2~8초 |
| 슬랙 알림 | webhook POST | 0.2초 |
| 로그 수집 | 파일 append | 0.01초 |
Claude 본체 블로킹은 여전히 0.08초 이하다. 소비자가 4개로 늘어도 동일하다.
마무리
훅은 적재만, 소비자는 처리만. 이 분리 하나로 Claude Code의 블로킹을 26배 줄였다. 파이프라인이 복잡해질수록 이 구조의 이점은 더 커진다. consumer.py를 데몬으로 등록하는 것부터 시작하면 된다. 다음 글에서는 Redis Streams를 붙여 분산 소비자 환경으로 확장하는 방법을 다룬다.
🐦 X에서 더 빠르게: @baegseungh7061
📚 이 시리즈 더 보기: Code 실전
💌 새 글 알림: X 팔로우 또는 블로그 RSS 구독
'Code 실전' 카테고리의 다른 글
| Claude API 프롬프트 캐싱으로 API 비용 89% 줄이는 법 — Mac Mini 클러스터 실측 (1) | 2026.05.11 |
|---|---|
| Claude Agent SDK 스트리밍에서 토큰 유실을 막는 역압 제어 설계 (0) | 2026.05.10 |
| Claude Code 실행 이력을 JSON으로 수집해 이상 탐지 자동화하기 (0) | 2026.05.08 |
| MCP 서버 내부 상태를 실시간으로 들여다보는 법 — 스냅샷 엔드포인트로 툴 호출 흐름 시각화하기 (0) | 2026.05.07 |
| Claude Code Hooks 완전 분석 — 8종 이벤트로 AI 실행을 통제하는 법 (0) | 2026.05.01 |