Code 실전

Claude Code Pre/Post 훅을 이벤트 버스로 연결해 비동기 파이프라인 구성하기

seunghyeonlab 2026. 5. 9. 18:02

hero

훅을 전처리·후처리 스크립트로만 쓰고 있다면, 실제 성능의 절반도 못 끌어낸 것이다. Pre훅과 Post훅 사이에 메시지 큐를 끼워 넣으면, 코드 생성 한 번이 린터·테스트·알림·로그까지 비동기로 쏘는 파이프라인이 된다. 직접 실측하면서 26배 블로킹 차이를 확인한 과정과 구현 방법을 정리한다.

전체 이벤트 버스 파이프라인 흐름


왜 동기 훅이 문제인가

Claude Code의 훅은 기본적으로 동기 방식으로 실행된다. Pre훅이 끝나야 Claude가 도구를 실행하고, Post훅이 끝나야 다음 작업으로 넘어간다. 린터 하나 돌리는 건 괜찮다. 문제는 여기서 슬랙 알림까지 붙이고, 테스트 러너까지 엮고, 로그 수집기까지 추가하는 순간이다.

Mac Mini 4에서 n8n 2.8.4 환경으로 직접 측정했다. 훅에서 린터와 슬랙 알림을 동기로 호출했을 때 파일 저장 1건당 평균 2.1초 블로킹이 발생했다. 소비자가 3개만 돼도 6초 넘게 Claude가 멈춰 있는 구조다. 파이프라인이 늘어날수록 병목은 선형으로 쌓인다.

동기 vs 비동기 훅 블로킹 비교

식당 주방에 계산원까지 시키는 구조가 동기 방식이다. 큐를 끼우면 주방과 계산대가 분리된다. 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

멱등성과 중복 방지: 진짜 핵심

dedup 처리 흐름

비동기 파이프라인에서 가장 먼저 무너지는 건 순서와 중복이다. 같은 파일에 대해 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 구독