Recent Posts
Recent Comments
반응형
«   2025/10   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

오늘도 공부

Claude Code Hooks 고급 가이드: Sub-Agent 동작 제어하기 본문

AI/Claude code

Claude Code Hooks 고급 가이드: Sub-Agent 동작 제어하기

행복한 수지아빠 2025. 10. 26. 09:48
반응형
 

Advanced Claude Code Hooks: Controlling Sub-Agent Behavior | LTSCommerce - Bespoke PHP Development

Advanced Claude Code Hooks: Controlling Sub-Agent Behavior 24 October 2025 • 8 min read • AI Claude Code hooks are powerful automation tools that execute at specific points during AI coding sessions. While basic hooks can validate prompts or add contex

ltscommerce.dev

원문을 번역한 내용입니다.

개요

Claude Code의 훅(hooks)은 AI 코딩 세션의 특정 시점에 자동으로 실행되는 강력한 자동화 도구입니다. 기본적인 훅으로 프롬프트를 검증하거나 컨텍스트를 추가할 수 있지만, 고급 훅을 사용하면 "데이터베이스 연결을 공유하는 테스트 스위트를 병렬로 실행하는 것을 방지"하는 것과 같은 정교한 규칙을 적용할 수 있습니다.

Claude Code Hooks 이해하기

Hook이란?

Claude Code의 Hook은 AI의 도구 사용을 가로채고 제어하는 자동화된 스크립트입니다. 특정 생명주기 이벤트에서 임의의 셸 명령을 실행하여 다음을 수행할 수 있습니다:

  • PreToolUse: 도구 실행 전 유효성 검사
  • UserPromptSubmit: 사용자 프롬프트에 컨텍스트 추가
  • SessionEnd: 세션 종료 시 리소스 정리
  • SessionStart: 세션 시작 시 환경 데이터 주입
  • 파일 작업 권한 제어

가장 강력한 훅 타입은 PreToolUse로, 모든 도구가 실행되기 전에 작동하며 작업을 승인, 거부하거나 사용자 확인을 요청할 수 있습니다.

Hook의 종류와 실행 시점

SessionStart
    ↓
UserPromptSubmit → [사용자 입력 전처리]
    ↓
PreToolUse → [도구 실행 전 검증]
    ↓
[도구 실행: bash, file_create, view 등]
    ↓
PostToolUse → [도구 실행 후 처리]
    ↓
SessionEnd → [세션 종료 시 정리]

문제 상황: 병렬 실행과 공유 리소스

Sub-Agent 시스템

Claude Code의 sub-agent 시스템은 병렬 작업 실행을 가능하게 합니다. 여러 에이전트가 코드베이스의 다양한 측면에서 동시에 작업할 수 있습니다. 이는 생산성에는 좋지만, 작업들이 리소스를 공유할 때 문제가 발생합니다.

실제 사례: SQLite 데이터베이스를 사용하는 테스트

PHP 프로젝트에서 PHPUnit 테스트가 SQLite 데이터베이스를 사용한다고 가정해봅시다. 테스트 스위트가 병렬 실행에 최적화되지 않은 이유:

  1. 데이터베이스 락: SQLite는 한 번에 하나의 writer만 허용
  2. 공유 상태: 테스트들이 동일한 fixture를 생성하거나 수정할 수 있음
  3. 레이스 컨디션: 병렬 실행이 예측 불가능한 실패를 야기

Claude가 복잡한 리팩토링 작업을 처리하기 위해 여러 sub-agent를 생성하면, 각각이 독립적으로 테스트 스위트를 실행하려고 시도할 수 있습니다. 결과는? 데이터베이스 락 충돌, 실패한 테스트, 혼란스러운 AI 에이전트들입니다.

문제 시나리오 다이어그램

Main Claude Agent
    │
    ├─→ Sub-Agent 1: 리팩토링 작업
    │       └─→ PHPUnit 실행 시도 ❌
    │           └─→ SQLite DB 락 획득
    │
    ├─→ Sub-Agent 2: 코드 정리 작업
    │       └─→ PHPUnit 실행 시도 ❌
    │           └─→ DB 락 대기 → 타임아웃
    │
    └─→ Sub-Agent 3: 문서화 작업
            └─→ PHPUnit 실행 시도 ❌
                └─→ DB 락 대기 → 실패

해결책: Sub-Agent 감지 및 제어

핵심 아이디어

Sub-agent는 메인 claude 프로세스의 자식 프로세스로 실행됩니다. 부모 프로세스 ID(PPID)를 조사하여 메인 세션에서 실행 중인지 sub-agent에서 실행 중인지 판단할 수 있습니다.

프로세스 계층 구조

# 메인 에이전트
$ echo $$ && ps -p $PPID -o comm=
12345
bash

# Sub-agent
$ echo $$ && ps -p $PPID -o comm=
12350
claude  ← 부모가 claude 프로세스!

구현: PreToolUse Hook

완전한 Python 구현

#!/usr/bin/env python3
"""
PreToolUse hook - subagent에서 테스트 실행 방지

Sub-agent는 allCS와 allStatic은 실행 가능하지만,
unit test, PHPUnit, Infection은 실행 불가.
이유: 데이터베이스 락 충돌로 인해 테스트는 병렬 실행 불가.

감지 방법: Sub-agent는 메인 'claude' 프로세스를 부모(PPID)로 가짐.
"""
import json
import os
import re
import subprocess
import sys

def is_subagent() -> bool:
    """PPID를 조사하여 subagent 컨텍스트에서 실행 중인지 확인"""
    try:
        ppid = os.getppid()
        # 부모 프로세스의 명령 이름 가져오기
        result = subprocess.run(
            ['ps', '-o', 'comm=', '-p', str(ppid)],
            capture_output=True,
            text=True,
            timeout=2
        )
        parent_cmd = result.stdout.strip()
        return parent_cmd == 'claude'
    except Exception:
        # 판단할 수 없으면 subagent가 아니라고 가정 (fail open)
        return False

def is_test_command(command: str) -> bool:
    """명령이 테스트 실행인지 확인 (subagent에서 불허)"""
    test_patterns = [
        r'\bphpunit\b',
        r'\bbin/qa\s+.*-t\s+unit\b',
        r'\bbin/qa\s+.*--type\s+unit\b',
        r'\binfection\b',
        r'\bvendor/bin/phpunit\b',
        r'\bphp\s+vendor/bin/phpunit\b',
        r'\.\/bin\/qa\s+.*-t\s+unit\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in test_patterns)

def is_allowed_qa_command(command: str) -> bool:
    """허용된 QA 명령인지 확인 (allCS 또는 allStatic)"""
    allowed_patterns = [
        r'\bbin/qa\s+.*-t\s+allCs\b',
        r'\bbin/qa\s+.*--type\s+allCs\b',
        r'\bbin/qa\s+.*-t\s+allStatic\b',
        r'\bbin/qa\s+.*--type\s+allStatic\b',
        r'\.\/bin\/qa\s+.*-t\s+allCs\b',
        r'\.\/bin\/qa\s+.*-t\s+allStatic\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in allowed_patterns)

def main() -> int:
    """메인 hook 로직"""
    try:
        # stdin에서 hook payload 읽기
        payload = json.loads(sys.stdin.read())
        
        # Bash 도구 호출만 확인
        if payload.get('tool') != 'Bash':
            return 0
        
        # subagent인지 확인
        if not is_subagent():
            return 0  # subagent가 아니면 모든 명령 허용
        
        # 실행 중인 명령 가져오기
        command = payload.get('parameters', {}).get('command', '')
        
        # 명시적으로 허용된 QA 명령은 허용
        if is_allowed_qa_command(command):
            return 0
        
        # subagent에서 테스트 명령 차단
        if is_test_command(command):
            error_msg = {
                'error': 'Subagent 컨텍스트에서 테스트 실행 차단됨',
                'reason': '데이터베이스 락 충돌로 인해 테스트는 병렬 실행 불가',
                'command': command,
                'allowed': 'Subagent에서 실행 가능: bin/qa -t allCs, bin/qa -t allStatic',
                'blocked': '차단된 명령: phpunit, bin/qa -t unit, infection'
            }
            print(json.dumps(error_msg, ensure_ascii=False), file=sys.stderr)
            return 1  # 명령 차단
        
        return 0  # 다른 모든 명령 허용
        
    except Exception as e:
        # 에러 로그를 남기되 차단하지 않음 (안전을 위해 fail open)
        print(f"Hook 에러: {e}", file=sys.stderr)
        return 0

if __name__ == '__main__':
    sys.exit(main())

동작 원리

1. Sub-Agent 감지

is_subagent() 함수는 프로세스 조사를 사용하여 컨텍스트를 판단합니다:

  • os.getppid()를 사용하여 부모 프로세스 ID 가져오기
  • ps 명령으로 부모의 명령 이름 조회
  • 부모가 claude 프로세스면 True 반환
  • 에러 발생 시 정상 작업을 차단하지 않도록 False 반환 (fail open)

2. 명령 패턴 매칭

Hook은 정규표현식 패턴을 사용하여 명령을 분류합니다:

  • 테스트 명령: PHPUnit, Infection, bin/qa -t unit
  • 허용된 QA 명령: bin/qa -t allCs, bin/qa -t allStatic
  • 기타 모든 명령: 제한 없이 허용

3. 선택적 차단

Hook은 화이트리스트/블랙리스트 전략을 구현합니다:

  • 메인 에이전트: 모든 명령 허용
  • Sub-agent: 정적 분석은 허용, 테스트는 차단
  • 에러 응답: 차단 이유를 설명하는 구조화된 JSON

설정 방법

Hook을 활성화하려면 Claude Code 설정 파일(~/.claude/settings.json 또는 .claude/settings.json)에 추가합니다:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/prevent-subagent-tests.py",
            "timeout": 2000
          }
        ]
      }
    ]
  }
}

스크립트를 실행 가능하게 만듭니다:

chmod +x prevent-subagent-tests.py

실전 예제

예제 1: Node.js 프로젝트 - Jest 테스트 제어

Node.js 프로젝트에서 Jest 테스트가 MongoDB를 사용하는 경우:

#!/usr/bin/env python3
"""
Node.js 프로젝트용 PreToolUse hook
Sub-agent에서 Jest 테스트 실행 방지
"""
import json
import os
import re
import subprocess
import sys

def is_subagent() -> bool:
    """Sub-agent 컨텍스트 확인"""
    try:
        ppid = os.getppid()
        result = subprocess.run(
            ['ps', '-o', 'comm=', '-p', str(ppid)],
            capture_output=True,
            text=True,
            timeout=2
        )
        parent_cmd = result.stdout.strip()
        return parent_cmd == 'claude'
    except Exception:
        return False

def is_test_command(command: str) -> bool:
    """Jest 테스트 명령 감지"""
    test_patterns = [
        r'\bnpm\s+test\b',
        r'\bnpm\s+run\s+test\b',
        r'\byarn\s+test\b',
        r'\bjest\b',
        r'\bpnpm\s+test\b',
        r'\.\/node_modules/\.bin/jest\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in test_patterns)

def is_allowed_command(command: str) -> bool:
    """허용된 명령 (ESLint, Prettier, TypeScript 컴파일)"""
    allowed_patterns = [
        r'\bnpm\s+run\s+lint\b',
        r'\beslint\b',
        r'\bprettier\b',
        r'\btsc\b',
        r'\btsc\s+--noEmit\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in allowed_patterns)

def main() -> int:
    try:
        payload = json.loads(sys.stdin.read())
        
        if payload.get('tool') != 'Bash':
            return 0
        
        if not is_subagent():
            return 0
        
        command = payload.get('parameters', {}).get('command', '')
        
        if is_allowed_command(command):
            return 0
        
        if is_test_command(command):
            error_msg = {
                'error': 'Sub-agent에서 Jest 테스트 실행 차단',
                'reason': 'MongoDB 연결 공유로 인한 충돌 방지',
                'command': command,
                'allowed': 'Sub-agent 허용 명령: npm run lint, prettier, tsc',
                'blocked': '차단된 명령: npm test, jest'
            }
            print(json.dumps(error_msg, ensure_ascii=False), file=sys.stderr)
            return 1
        
        return 0
        
    except Exception as e:
        print(f"Hook 에러: {e}", file=sys.stderr)
        return 0

if __name__ == '__main__':
    sys.exit(main())

예제 2: Python 프로젝트 - Pytest 제어

#!/usr/bin/env python3
"""
Python 프로젝트용 PreToolUse hook
Sub-agent에서 pytest 실행 방지, mypy/ruff는 허용
"""
import json
import os
import re
import subprocess
import sys

def is_subagent() -> bool:
    """Sub-agent 컨텍스트 확인"""
    try:
        ppid = os.getppid()
        result = subprocess.run(
            ['ps', '-o', 'comm=', '-p', str(ppid)],
            capture_output=True,
            text=True,
            timeout=2
        )
        parent_cmd = result.stdout.strip()
        return parent_cmd == 'claude'
    except Exception:
        return False

def is_test_command(command: str) -> bool:
    """Pytest 테스트 명령 감지"""
    test_patterns = [
        r'\bpytest\b',
        r'\bpython\s+-m\s+pytest\b',
        r'\bpoetry\s+run\s+pytest\b',
        r'\btox\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in test_patterns)

def is_allowed_command(command: str) -> bool:
    """허용된 명령 (타입 체크, 린터)"""
    allowed_patterns = [
        r'\bmypy\b',
        r'\bruff\b',
        r'\bblack\b',
        r'\bisort\b',
        r'\bflake8\b',
        r'\bpylint\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in allowed_patterns)

def main() -> int:
    try:
        payload = json.loads(sys.stdin.read())
        
        if payload.get('tool') != 'Bash':
            return 0
        
        if not is_subagent():
            return 0
        
        command = payload.get('parameters', {}).get('command', '')
        
        if is_allowed_command(command):
            return 0
        
        if is_test_command(command):
            error_msg = {
                'error': 'Sub-agent에서 pytest 실행 차단',
                'reason': '테스트 DB 격리 문제로 병렬 실행 불가',
                'command': command,
                'allowed': 'Sub-agent 허용: mypy, ruff, black, isort',
                'blocked': '차단: pytest, tox'
            }
            print(json.dumps(error_msg, ensure_ascii=False), file=sys.stderr)
            return 1
        
        return 0
        
    except Exception as e:
        print(f"Hook 에러: {e}", file=sys.stderr)
        return 0

if __name__ == '__main__':
    sys.exit(main())

예제 3: 데이터베이스 마이그레이션 보호

#!/usr/bin/env python3
"""
데이터베이스 마이그레이션 보호 hook
Sub-agent에서 마이그레이션 명령 차단
"""
import json
import os
import re
import subprocess
import sys

def is_subagent() -> bool:
    """Sub-agent 컨텍스트 확인"""
    try:
        ppid = os.getppid()
        result = subprocess.run(
            ['ps', '-o', 'comm=', '-p', str(ppid)],
            capture_output=True,
            text=True,
            timeout=2
        )
        parent_cmd = result.stdout.strip()
        return parent_cmd == 'claude'
    except Exception:
        return False

def is_migration_command(command: str) -> bool:
    """마이그레이션 명령 감지"""
    migration_patterns = [
        # Rails
        r'\brails\s+db:migrate\b',
        r'\brake\s+db:migrate\b',
        # Django
        r'\bpython\s+manage\.py\s+migrate\b',
        r'\bdjango-admin\s+migrate\b',
        # Node.js (Prisma, Knex, etc)
        r'\bprisma\s+migrate\b',
        r'\bknex\s+migrate\b',
        r'\bsequelize\s+db:migrate\b',
        # PHP (Laravel, Doctrine, etc)
        r'\bphp\s+artisan\s+migrate\b',
        r'\bbin/console\s+doctrine:migrations:migrate\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in migration_patterns)

def is_destructive_db_command(command: str) -> bool:
    """위험한 DB 명령 감지"""
    destructive_patterns = [
        r'\bdb:reset\b',
        r'\bdb:drop\b',
        r'\bdb:seed\b',
        r'\bmigrate:fresh\b',
        r'\bmigrate:reset\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in destructive_patterns)

def main() -> int:
    try:
        payload = json.loads(sys.stdin.read())
        
        if payload.get('tool') != 'Bash':
            return 0
        
        if not is_subagent():
            return 0
        
        command = payload.get('parameters', {}).get('command', '')
        
        if is_migration_command(command) or is_destructive_db_command(command):
            error_msg = {
                'error': 'Sub-agent에서 데이터베이스 마이그레이션 차단',
                'reason': '병렬 마이그레이션은 스키마 충돌을 일으킬 수 있음',
                'command': command,
                'solution': '메인 에이전트에만 마이그레이션 작업 허용',
                'blocked': '차단: migrate, db:reset, db:drop, db:seed'
            }
            print(json.dumps(error_msg, ensure_ascii=False), file=sys.stderr)
            return 1
        
        return 0
        
    except Exception as e:
        print(f"Hook 에러: {e}", file=sys.stderr)
        return 0

if __name__ == '__main__':
    sys.exit(main())

예제 4: 외부 API 호출 제한

#!/usr/bin/env python3
"""
외부 API 호출 제한 hook
Sub-agent의 비용 발생 API 호출 차단
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta
import fcntl
import hashlib

RATE_LIMIT_FILE = '/tmp/claude_api_rate_limit.lock'
MAX_CALLS_PER_HOUR = 100

def is_subagent() -> bool:
    """Sub-agent 컨텍스트 확인"""
    try:
        ppid = os.getppid()
        result = subprocess.run(
            ['ps', '-o', 'comm=', '-p', str(ppid)],
            capture_output=True,
            text=True,
            timeout=2
        )
        parent_cmd = result.stdout.strip()
        return parent_cmd == 'claude'
    except Exception:
        return False

def is_api_call_command(command: str) -> bool:
    """외부 API 호출 명령 감지"""
    api_patterns = [
        # OpenAI API
        r'\bcurl.*api\.openai\.com\b',
        r'\bopenai\s+api\b',
        # AWS 서비스
        r'\baws\s+\w+\b',
        # Google Cloud
        r'\bgcloud\s+\w+\b',
        # Generic API calls with cost implications
        r'\bcurl.*-X\s+POST.*api\b',
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in api_patterns)

def check_rate_limit(command: str) -> bool:
    """레이트 리밋 확인 (시간당 최대 호출 수)"""
    try:
        # 명령의 해시를 생성하여 고유 식별자로 사용
        cmd_hash = hashlib.md5(command.encode()).hexdigest()[:8]
        lock_file_path = f"{RATE_LIMIT_FILE}.{cmd_hash}"
        
        with open(lock_file_path, 'a+') as f:
            fcntl.flock(f, fcntl.LOCK_EX)
            f.seek(0)
            content = f.read()
            
            # 현재 시각과 기록된 호출 시각 파싱
            now = datetime.now()
            calls = []
            
            for line in content.strip().split('\n'):
                if line:
                    try:
                        timestamp = datetime.fromisoformat(line)
                        # 1시간 이내의 호출만 유지
                        if now - timestamp < timedelta(hours=1):
                            calls.append(timestamp)
                    except ValueError:
                        pass
            
            # 레이트 리밋 확인
            if len(calls) >= MAX_CALLS_PER_HOUR:
                fcntl.flock(f, fcntl.LOCK_UN)
                return False
            
            # 현재 호출 기록
            calls.append(now)
            f.seek(0)
            f.truncate()
            f.write('\n'.join(t.isoformat() for t in calls))
            
            fcntl.flock(f, fcntl.LOCK_UN)
            return True
            
    except Exception:
        # 에러 시 허용 (fail open)
        return True

def main() -> int:
    try:
        payload = json.loads(sys.stdin.read())
        
        if payload.get('tool') != 'Bash':
            return 0
        
        command = payload.get('parameters', {}).get('command', '')
        
        # API 호출 명령 감지
        if is_api_call_command(command):
            # Sub-agent에서는 완전 차단
            if is_subagent():
                error_msg = {
                    'error': 'Sub-agent에서 외부 API 호출 차단',
                    'reason': '비용 관리 및 레이트 리밋 보호',
                    'command': command,
                    'solution': '메인 에이전트를 통해 API 호출 수행'
                }
                print(json.dumps(error_msg, ensure_ascii=False), file=sys.stderr)
                return 1
            
            # 메인 에이전트에서도 레이트 리밋 확인
            if not check_rate_limit(command):
                error_msg = {
                    'error': 'API 호출 레이트 리밋 초과',
                    'reason': f'시간당 최대 {MAX_CALLS_PER_HOUR}회 호출 제한',
                    'command': command,
                    'solution': '잠시 후 다시 시도하거나 레이트 리밋 설정 조정'
                }
                print(json.dumps(error_msg, ensure_ascii=False), file=sys.stderr)
                return 1
        
        return 0
        
    except Exception as e:
        print(f"Hook 에러: {e}", file=sys.stderr)
        return 0

if __name__ == '__main__':
    sys.exit(main())

실전 활용 사례

1. 데이터베이스 락 충돌 방지

SQLite를 사용하는 테스트에서 병렬 테스트 실행을 차단하여 데이터베이스 락 에러를 제거합니다.

✅ 메인 에이전트: PHPUnit 테스트 실행 가능
✅ Sub-agent 1: allCS (코드 스타일 검사) 병렬 실행
✅ Sub-agent 2: allStatic (정적 분석) 병렬 실행
❌ Sub-agent 3: PHPUnit 테스트 차단됨

2. 병렬 정적 분석 활성화

리소스를 공유하지 않는 도구들은 여전히 병렬로 실행할 수 있습니다:

  • 코드 스타일 체커 (allCs)
  • 정적 분석 도구 (allStatic)
  • 린터 (ESLint, Pylint 등)

3. 명확한 에러 메시지

Sub-agent가 테스트를 실행하려고 하면 구조화된 JSON 응답을 받습니다:

{
  "error": "Subagent 컨텍스트에서 테스트 실행 차단됨",
  "reason": "데이터베이스 락 충돌로 인해 테스트는 병렬 실행 불가",
  "command": "phpunit tests/",
  "allowed": "Subagent에서 실행 가능: bin/qa -t allCs, bin/qa -t allStatic",
  "blocked": "차단된 명령: phpunit, bin/qa -t unit, infection"
}

4. Fail-Safe 설계

Hook은 "fail open" 전략을 사용합니다. Sub-agent인지 판단할 수 없으면 명령을 허용합니다. 이는 훅 에러로 인해 정상 작업이 차단되는 것을 방지합니다.

패턴 확장하기

이 기법은 모든 공유 리소스 시나리오에 적용할 수 있습니다:

1. 데이터베이스 마이그레이션

# 병렬 스키마 변경 방지
if is_migration_command(command) and is_subagent():
    return block("스키마 충돌 방지")

2. 파일 시스템 작업

# 락 파일에 대한 동시 쓰기 차단
if writes_to_lock_file(command) and is_subagent():
    return block("파일 락 충돌 방지")

3. 외부 서비스

# Sub-agent 전체에서 API 호출 레이트 리밋
if calls_external_api(command) and exceeds_rate_limit():
    return block("API 레이트 리밋 보호")

4. 빌드 아티팩트

# 디렉토리를 공유하는 동시 빌드 방지
if is_build_command(command) and is_subagent():
    return block("빌드 충돌 방지")

모범 사례

1. 구체적인 패턴 사용

정규표현식 패턴을 가능한 한 구체적으로 만들어 false positive를 피합니다. 적절한 경우 단어 경계(\b)와 전체 명령 경로를 사용합니다.

# ❌ 나쁜 예: 너무 광범위
r'test'  # "protest", "attest" 등도 매칭됨

# ✅ 좋은 예: 구체적
r'\bphpunit\b'  # 정확히 "phpunit"만 매칭

2. 안전을 위해 Fail Open

에러 처리 시 작업을 차단하는 것보다 허용하는 것을 선호합니다. 정당한 작업이 차단되는 것이 드문 레이스 컨디션보다 더 좌절스럽습니다.

except Exception as e:
    print(f"Hook 에러: {e}", file=sys.stderr)
    return 0  # 에러 시 허용 (차단하지 않음)

3. 명확한 피드백 제공

에러 메시지를 JSON으로 구조화하여 무엇이 차단되었는지, 왜 차단되었는지, 어떤 대안이 있는지 설명합니다.

error_msg = {
    'error': '무엇이 잘못되었는지',
    'reason': '왜 차단되었는지',
    'command': '시도한 명령',
    'allowed': '대신 사용할 수 있는 것',
    'blocked': '차단된 것들'
}

4. 양쪽 컨텍스트 테스트

메인 에이전트와 sub-agent 컨텍스트 모두에서 훅이 올바르게 작동하는지 확인합니다.

# 메인 에이전트에서 테스트
$ echo $$ && ps -p $PPID -o comm=
12345
bash

# Sub-agent 시뮬레이션 테스트
$ ./test-hook-as-subagent.sh

5. Hook을 빠르게 유지

Hook은 모든 도구 사용 시 실행됩니다. 가볍게 유지하세요. 이 구현은 밀리초 단위로 완료됩니다.

# ✅ 빠름: 간단한 프로세스 조사
def is_subagent() -> bool:
    result = subprocess.run(['ps', '-o', 'comm=', '-p', str(ppid)], timeout=2)
    return result.stdout.strip() == 'claude'

# ❌ 느림: 복잡한 파일 시스템 작업
def is_subagent() -> bool:
    # 전체 프로세스 트리 순회하지 마세요
    # 외부 API 호출하지 마세요
    # 대용량 파일 읽지 마세요

고급 디버깅

Hook 디버그 모드

#!/usr/bin/env python3
import json
import os
import sys
from datetime import datetime

DEBUG = os.environ.get('CLAUDE_HOOK_DEBUG', '0') == '1'
DEBUG_LOG = '/tmp/claude-hook-debug.log'

def debug_log(message: str):
    """디버그 모드에서 로그 기록"""
    if DEBUG:
        with open(DEBUG_LOG, 'a') as f:
            timestamp = datetime.now().isoformat()
            f.write(f"[{timestamp}] {message}\n")

def main() -> int:
    try:
        payload = json.loads(sys.stdin.read())
        
        debug_log(f"Tool: {payload.get('tool')}")
        debug_log(f"PPID: {os.getppid()}")
        debug_log(f"Command: {payload.get('parameters', {}).get('command', '')}")
        
        # ... 나머지 로직
        
    except Exception as e:
        debug_log(f"ERROR: {e}")
        return 0

if __name__ == '__main__':
    sys.exit(main())

디버그 모드 활성화:

export CLAUDE_HOOK_DEBUG=1
tail -f /tmp/claude-hook-debug.log

프로세스 계층 확인

#!/bin/bash
# process-tree.sh - 프로세스 트리 시각화

echo "현재 프로세스: $$"
echo "부모 프로세스: $PPID"
echo ""
echo "프로세스 트리:"
pstree -p $PPID

설정 예제 모음

전체 프로젝트 설정 (.claude/settings.json)

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/home/user/.claude/hooks/prevent-subagent-tests.py",
            "timeout": 2000,
            "description": "Sub-agent 테스트 실행 방지"
          },
          {
            "type": "command",
            "command": "/home/user/.claude/hooks/rate-limit-api.py",
            "timeout": 2000,
            "description": "API 호출 레이트 리밋"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/home/user/.claude/hooks/setup-environment.sh",
            "timeout": 5000,
            "description": "개발 환경 초기화"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/home/user/.claude/hooks/cleanup.sh",
            "timeout": 5000,
            "description": "리소스 정리"
          }
        ]
      }
    ]
  }
}

다중 환경 설정

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/home/user/.claude/hooks/env-specific.py",
            "timeout": 2000,
            "env": {
              "PROJECT_ENV": "production",
              "ALLOWED_COMMANDS": "lint,test,build"
            }
          }
        ]
      }
    ]
  }
}

트러블슈팅

문제 1: Hook이 실행되지 않음

증상: Hook 스크립트가 전혀 실행되지 않는 것 같습니다.

해결 방법:

# 1. 실행 권한 확인
chmod +x /path/to/hook.py

# 2. Shebang 확인
head -n 1 /path/to/hook.py
# 출력: #!/usr/bin/env python3

# 3. Python 경로 확인
which python3

# 4. 수동 테스트
echo '{"tool":"Bash","parameters":{"command":"phpunit"}}' | /path/to/hook.py

문제 2: Sub-agent 감지 실패

증상: Sub-agent에서도 테스트가 실행됩니다.

해결 방법:

# 디버그 정보 추가
def is_subagent() -> bool:
    try:
        ppid = os.getppid()
        result = subprocess.run(
            ['ps', '-o', 'comm=', '-p', str(ppid)],
            capture_output=True,
            text=True,
            timeout=2
        )
        parent_cmd = result.stdout.strip()
        
        # 디버그 출력
        print(f"DEBUG: PID={os.getpid()}, PPID={ppid}, Parent={parent_cmd}", 
              file=sys.stderr)
        
        return parent_cmd == 'claude'
    except Exception as e:
        print(f"DEBUG: Error in is_subagent: {e}", file=sys.stderr)
        return False

문제 3: 정당한 명령이 차단됨

증상: Sub-agent에서 실행해야 하는 명령이 차단됩니다.

해결 방법:

# 패턴을 더 구체적으로 만들기
def is_test_command(command: str) -> bool:
    # 너무 광범위한 패턴 피하기
    test_patterns = [
        r'\bphpunit\s+tests/',  # 더 구체적
        r'\bbin/qa\s+-t\s+unit\b',  # 정확한 플래그
    ]
    return any(re.search(pattern, command, re.IGNORECASE) for pattern in test_patterns)

성능 최적화

1. 캐싱 활용

import functools
import time

@functools.lru_cache(maxsize=1)
def is_subagent_cached() -> tuple:
    """결과를 1초간 캐시"""
    is_sub = is_subagent()
    timestamp = time.time()
    return (is_sub, timestamp)

def is_subagent_fast() -> bool:
    """캐시된 결과 사용 (1초 유효)"""
    is_sub, timestamp = is_subagent_cached()
    if time.time() - timestamp > 1.0:
        is_subagent_cached.cache_clear()
        is_sub, _ = is_subagent_cached()
    return is_sub

2. 조기 반환

def main() -> int:
    try:
        payload = json.loads(sys.stdin.read())
        
        # 가장 빠른 체크를 먼저
        if payload.get('tool') != 'Bash':
            return 0  # 즉시 반환
        
        # 다음으로 빠른 체크
        if not is_subagent():
            return 0
        
        # 가장 비용이 큰 작업을 마지막에
        command = payload.get('parameters', {}).get('command', '')
        # ...

결론

Claude Code Hook은 단순한 검증을 넘어 강력한 자동화 기능을 제공합니다. 프로세스 조사와 패턴 매칭을 활용하여 컨텍스트에 적응하는 정교한 실행 정책을 적용할 수 있습니다.

이 sub-agent 제어 패턴은 레이스 컨디션과 락 충돌의 잠재적 원인을 잘 조율된 병렬 실행 시스템으로 변환합니다. 메인 에이전트가 테스트 실행을 조정하는 동안, sub-agent들은 정적 분석을 병렬로 처리하여 안정성을 희생하지 않으면서 생산성을 극대화합니다.

데이터베이스 락 관리, 동시 마이그레이션 방지, 외부 API 호출 레이트 리밋 등 어떤 작업이든, 이 패턴은 리소스 인식 병렬 실행 제어를 위한 견고한 기반을 제공합니다.

핵심 요점

  1. Sub-agent는 감지 가능: PPID 검사로 실행 컨텍스트 파악
  2. 선택적 제어: 안전한 작업은 병렬 실행, 위험한 작업은 직렬 실행
  3. Fail-Safe 설계: 에러 시 허용하여 정상 작업 보호
  4. 명확한 피드백: 구조화된 에러 메시지로 이유와 대안 제공
  5. 확장 가능: 다양한 공유 리소스 시나리오에 적용 가능

다음 단계

  1. 프로젝트에 맞는 Hook 스크립트 작성
  2. .claude/settings.json에 설정 추가
  3. 메인 에이전트와 sub-agent 양쪽에서 테스트
  4. 프로젝트의 특정 요구사항에 맞게 패턴 조정
  5. 팀과 공유하여 일관된 개발 환경 유지

이제 Claude Code를 더 안전하고 효율적으로 사용할 준비가 되었습니다! 🚀

반응형