Recent Posts
Recent Comments
반응형
«   2025/11   »
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
Archives
Today
Total
관리 메뉴

오늘도 공부

Claude Code 훅(Hook) 완벽 가이드: 개발 워크플로우 자동화 마스터하기 본문

AI/Claude code

Claude Code 훅(Hook) 완벽 가이드: 개발 워크플로우 자동화 마스터하기

행복한 수지아빠 2025. 11. 25. 11:08
반응형

클로드 코드 훅에 대한 기본 설명은 아래 링크에서 꼭 확인하고 오세요.

 

Claude Code 훅(Hook) 완벽 가이드: 개발 워크플로우를 자동화하는 11가지 방법

Claude Code는 AI 기반 코딩 어시스턴트로, 다양한 훅(Hook) 이벤트를 통해 개발 프로세스를 세밀하게 제어할 수 있습니다. 이 글에서는 각 훅의 용도와 실제 활용 예제를 상세히 살펴보겠습니다.훅(Ho

javaexpert.tistory.com

 


AI 코딩 어시스턴트의 모든 동작을 제어하고, 팀 표준을 강제하며, 보안을 자동화하는 방법

 

들어가며

Claude Code를 사용하면서 이런 고민을 해본 적 있으신가요?

  • "매번 프로젝트 컨텍스트를 설명하기 귀찮다"
  • "실수로 프로덕션 환경에 영향을 줄까봐 불안하다"
  • "코드 스타일이 일관되지 않아서 리뷰할 때 힘들다"
  • "민감한 파일에 접근하는 걸 막고 싶다"

훅(Hook) 시스템이 이 모든 문제를 해결해줍니다.

이 글에서는 Claude Code의 11가지 훅 이벤트를 실제 프로젝트에 바로 적용할 수 있는 수준으로 상세히 다룹니다. 모든 예제는 복사해서 바로 사용할 수 있습니다.


훅 시스템 개요

훅이란?

훅(Hook)은 Claude Code 실행 흐름의 특정 시점에서 발생하는 이벤트입니다. 각 훅에 커스텀 로직을 연결하면 다음과 같은 자동화가 가능합니다:

목적 활용 훅

프로젝트 컨텍스트 자동 로드 SessionStart
위험한 명령어 차단 PreToolUse
코드 자동 포맷팅 PostToolUse
민감 파일 접근 제어 PermissionRequest
작업 완료 검증 Stop
세션 통계 리포트 SessionEnd

훅 실행 흐름


환경 설정

디렉토리 구조 생성

# 프로젝트 루트에서 실행
mkdir -p .claude/hooks
mkdir -p .claude/logs
mkdir -p .claude/reports
mkdir -p .claude/backups

# 기본 설정 파일 생성
touch .claude/config.yaml
touch .claude/hooks/__init__.py

기본 config.yaml

# .claude/config.yaml
version: "1.0"

# 훅 활성화 설정
hooks:
  enabled: true
  directory: ".claude/hooks"
  
  # 각 훅별 설정
  session_start:
    enabled: true
    timeout: 5000  # ms
    
  user_prompt_submit:
    enabled: true
    max_enhancement_length: 2000
    
  permission_request:
    enabled: true
    auto_approve_reads: true
    
  pre_tool_use:
    enabled: true
    block_dangerous: true
    
  post_tool_use:
    enabled: true
    auto_format: true
    auto_lint: true
    
  notification:
    enabled: true
    slack_webhook: "${SLACK_WEBHOOK_URL}"
    
  stop:
    enabled: true
    require_tests: true
    
  session_end:
    enabled: true
    generate_report: true
    auto_backup: true

# 프로젝트 설정
project:
  name: "My Project"
  language: "dart"
  framework: "flutter"
  
# 보안 설정
security:
  sensitive_paths:
    - ".env"
    - ".env.local"
    - "secrets/"
    - "private_keys/"
    - "**/credentials*"
    
  dangerous_commands:
    - "rm -rf"
    - "sudo"
    - "chmod 777"
    - "DROP TABLE"
    - "DELETE FROM"
    
  allowed_extensions:
    - ".dart"
    - ".yaml"
    - ".json"
    - ".md"
    - ".txt"

공통 유틸리티 모듈

# .claude/hooks/utils.py
"""
훅에서 공통으로 사용하는 유틸리티 함수들
"""

import os
import json
import subprocess
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any, Optional

# 로깅 설정
logging.basicConfig(
    filename='.claude/logs/hooks.log',
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('claude_hooks')


def load_config() -> Dict[str, Any]:
    """config.yaml 파일 로드"""
    import yaml
    config_path = Path('.claude/config.yaml')
    if config_path.exists():
        with open(config_path, 'r', encoding='utf-8') as f:
            return yaml.safe_load(f)
    return {}


def save_json(filepath: str, data: Any) -> None:
    """JSON 파일 저장"""
    path = Path(filepath)
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2, default=str)


def load_json(filepath: str) -> Optional[Dict]:
    """JSON 파일 로드"""
    path = Path(filepath)
    if path.exists():
        with open(path, 'r', encoding='utf-8') as f:
            return json.load(f)
    return None


def get_git_info() -> Dict[str, str]:
    """현재 Git 정보 조회"""
    try:
        branch = subprocess.check_output(
            ['git', 'branch', '--show-current'],
            stderr=subprocess.DEVNULL
        ).decode().strip()
        
        commit = subprocess.check_output(
            ['git', 'rev-parse', 'HEAD'],
            stderr=subprocess.DEVNULL
        ).decode().strip()[:8]
        
        status = subprocess.check_output(
            ['git', 'status', '--porcelain'],
            stderr=subprocess.DEVNULL
        ).decode().strip()
        
        return {
            'branch': branch,
            'commit': commit,
            'has_changes': len(status) > 0,
            'changed_files': status.split('\n') if status else []
        }
    except subprocess.CalledProcessError:
        return {'branch': 'unknown', 'commit': 'unknown', 'has_changes': False}


def run_command(command: List[str], timeout: int = 30) -> Dict[str, Any]:
    """명령어 실행 및 결과 반환"""
    try:
        result = subprocess.run(
            command,
            capture_output=True,
            timeout=timeout,
            text=True
        )
        return {
            'success': result.returncode == 0,
            'stdout': result.stdout,
            'stderr': result.stderr,
            'returncode': result.returncode
        }
    except subprocess.TimeoutExpired:
        return {'success': False, 'error': 'timeout'}
    except Exception as e:
        return {'success': False, 'error': str(e)}


def backup_file(filepath: str) -> Optional[str]:
    """파일 백업 생성"""
    path = Path(filepath)
    if not path.exists():
        return None
    
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    backup_dir = Path('.claude/backups')
    backup_dir.mkdir(parents=True, exist_ok=True)
    
    backup_path = backup_dir / f"{path.stem}_{timestamp}{path.suffix}"
    
    import shutil
    shutil.copy2(path, backup_path)
    logger.info(f"Backup created: {backup_path}")
    
    return str(backup_path)


def is_sensitive_path(filepath: str) -> bool:
    """민감한 경로인지 확인"""
    config = load_config()
    sensitive_paths = config.get('security', {}).get('sensitive_paths', [])
    
    from fnmatch import fnmatch
    for pattern in sensitive_paths:
        if fnmatch(filepath, pattern) or pattern in filepath:
            return True
    return False


def is_dangerous_command(command: str) -> bool:
    """위험한 명령어인지 확인"""
    config = load_config()
    dangerous_commands = config.get('security', {}).get('dangerous_commands', [])
    
    command_lower = command.lower()
    return any(danger in command_lower for danger in dangerous_commands)


def send_slack_notification(message: str, channel: str = '#dev-alerts') -> bool:
    """Slack 알림 전송"""
    config = load_config()
    webhook_url = config.get('hooks', {}).get('notification', {}).get('slack_webhook')
    
    if not webhook_url:
        logger.warning("Slack webhook URL not configured")
        return False
    
    import urllib.request
    import urllib.error
    
    try:
        data = json.dumps({
            'channel': channel,
            'text': message,
            'username': 'Claude Code Bot',
            'icon_emoji': ':robot_face:'
        }).encode('utf-8')
        
        req = urllib.request.Request(
            webhook_url,
            data=data,
            headers={'Content-Type': 'application/json'}
        )
        urllib.request.urlopen(req, timeout=5)
        return True
    except Exception as e:
        logger.error(f"Slack notification failed: {e}")
        return False

1. SessionStart - 세션 초기화

핵심 질문

"초기 컨텍스트나 환경 설정이 필요한가?"

활용 시나리오

  • 프로젝트 README, 아키텍처 문서 자동 로드
  • 환경 변수 및 개발 환경 설정
  • 이전 세션 상태 복원
  • 팀 코딩 스타일 가이드 주입

실전 예제: Flutter 프로젝트용

# .claude/hooks/session_start.py
"""
세션 시작 시 프로젝트 컨텍스트와 환경을 자동 설정합니다.
"""

import os
import subprocess
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional
from .utils import (
    load_config, load_json, save_json, 
    get_git_info, logger
)


def load_project_readme() -> Optional[str]:
    """프로젝트 README 로드"""
    readme_paths = ['README.md', 'readme.md', 'README.rst']
    
    for readme in readme_paths:
        path = Path(readme)
        if path.exists():
            with open(path, 'r', encoding='utf-8') as f:
                content = f.read()
                # 너무 길면 요약
                if len(content) > 3000:
                    return content[:3000] + "\n\n... (README 요약됨)"
                return content
    return None


def load_architecture_docs() -> Dict[str, str]:
    """아키텍처 문서 로드"""
    docs = {}
    doc_paths = [
        'docs/ARCHITECTURE.md',
        'docs/architecture.md',
        '.claude/ARCHITECTURE.md',
        'ARCHITECTURE.md'
    ]
    
    for doc_path in doc_paths:
        path = Path(doc_path)
        if path.exists():
            with open(path, 'r', encoding='utf-8') as f:
                docs['architecture'] = f.read()[:2000]
                break
    
    return docs


def load_coding_standards() -> str:
    """코딩 스타일 가이드 로드"""
    
    # 커스텀 스타일 가이드가 있으면 로드
    style_path = Path('.claude/STYLE_GUIDE.md')
    if style_path.exists():
        with open(style_path, 'r', encoding='utf-8') as f:
            return f.read()
    
    # 기본 Flutter 프로젝트 스타일 가이드
    return """
## 프로젝트 코딩 규칙

### 아키텍처
- Clean Architecture 패턴 적용
- Feature-first 디렉토리 구조
- 각 Feature는 data/domain/presentation 레이어 분리

### 상태관리
- Riverpod 2.5+ 사용
- Provider는 `providers/` 디렉토리에 모음
- AsyncNotifier 패턴 선호

### 코드 스타일
- 한국어 주석 작성 (public API는 영문 dartdoc)
- const 생성자 적극 활용
- 매직 넘버 금지 (상수로 정의)
- 단일 파일 300줄 이하 유지

### 네이밍 규칙
- 파일명: snake_case (user_repository.dart)
- 클래스명: PascalCase (UserRepository)
- 변수/함수: camelCase (getUserById)
- 상수: SCREAMING_SNAKE_CASE 또는 camelCase

### 테스트
- 모든 public 메서드에 단위 테스트
- 테스트 커버리지 80% 이상 유지
- 테스트 파일명: *_test.dart
"""


def restore_previous_session() -> Optional[Dict]:
    """이전 세션 상태 복원"""
    state_path = '.claude/state_snapshot.json'
    return load_json(state_path)


def detect_project_type() -> Dict[str, Any]:
    """프로젝트 타입 자동 감지"""
    project_info = {
        'type': 'unknown',
        'language': 'unknown',
        'framework': None,
        'package_manager': None
    }
    
    # Flutter/Dart 프로젝트
    if Path('pubspec.yaml').exists():
        project_info.update({
            'type': 'mobile',
            'language': 'dart',
            'framework': 'flutter',
            'package_manager': 'pub'
        })
        
        # pubspec.yaml 분석
        import yaml
        with open('pubspec.yaml', 'r') as f:
            pubspec = yaml.safe_load(f)
            project_info['name'] = pubspec.get('name', 'unknown')
            project_info['dependencies'] = list(pubspec.get('dependencies', {}).keys())
    
    # Node.js 프로젝트
    elif Path('package.json').exists():
        project_info.update({
            'type': 'web',
            'language': 'javascript',
            'package_manager': 'npm'
        })
        
        # React/Next.js/Vue 등 감지
        with open('package.json', 'r') as f:
            package = load_json('package.json')
            deps = package.get('dependencies', {})
            if 'next' in deps:
                project_info['framework'] = 'nextjs'
            elif 'react' in deps:
                project_info['framework'] = 'react'
            elif 'vue' in deps:
                project_info['framework'] = 'vue'
    
    # Python 프로젝트
    elif Path('requirements.txt').exists() or Path('pyproject.toml').exists():
        project_info.update({
            'type': 'python',
            'language': 'python',
            'package_manager': 'pip'
        })
        
        if Path('pyproject.toml').exists():
            project_info['package_manager'] = 'poetry'
    
    return project_info


def get_pending_tasks() -> list:
    """TODO/FIXME 항목 수집"""
    pending = []
    
    # Git 기반 TODO 추적
    try:
        result = subprocess.run(
            ['git', 'grep', '-n', '-E', '(TODO|FIXME|HACK|XXX):'],
            capture_output=True,
            text=True
        )
        if result.stdout:
            lines = result.stdout.strip().split('\n')[:10]  # 상위 10개만
            pending = [{'type': 'code', 'item': line} for line in lines]
    except:
        pass
    
    return pending


def on_session_start() -> Dict[str, Any]:
    """
    세션 시작 훅 메인 함수
    
    Returns:
        Dict containing session initialization data
    """
    logger.info("Session starting...")
    
    session_data = {
        'timestamp': datetime.now().isoformat(),
        'session_id': datetime.now().strftime('%Y%m%d_%H%M%S'),
    }
    
    # 1. 프로젝트 타입 감지
    project_info = detect_project_type()
    session_data['project'] = project_info
    
    # 2. Git 정보 로드
    git_info = get_git_info()
    session_data['git'] = git_info
    
    # 3. README 로드
    readme = load_project_readme()
    if readme:
        session_data['readme'] = readme
    
    # 4. 아키텍처 문서 로드
    arch_docs = load_architecture_docs()
    session_data['architecture'] = arch_docs
    
    # 5. 코딩 스타일 가이드 로드
    session_data['coding_standards'] = load_coding_standards()
    
    # 6. 이전 세션 상태 복원
    previous_state = restore_previous_session()
    if previous_state:
        session_data['previous_session'] = {
            'last_task': previous_state.get('current_task'),
            'completed_features': previous_state.get('completed_features', []),
            'pending_tasks': previous_state.get('pending_tasks', [])
        }
    
    # 7. 현재 TODO 항목 수집
    session_data['pending_todos'] = get_pending_tasks()
    
    # 8. 환경 정보
    session_data['environment'] = {
        'cwd': os.getcwd(),
        'user': os.environ.get('USER', 'unknown'),
        'shell': os.environ.get('SHELL', 'unknown')
    }
    
    # 세션 상태 저장
    save_json('.claude/current_session.json', session_data)
    
    logger.info(f"Session initialized: {session_data['session_id']}")
    
    # Claude에게 전달할 컨텍스트 구성
    context_for_claude = f"""
## 프로젝트 정보
- 이름: {project_info.get('name', 'Unknown')}
- 프레임워크: {project_info.get('framework', 'Unknown')}
- 현재 브랜치: {git_info.get('branch', 'Unknown')}
- 최근 커밋: {git_info.get('commit', 'Unknown')}

## 코딩 규칙
{session_data['coding_standards']}

## 이전 세션 정보
{f"마지막 작업: {previous_state.get('current_task')}" if previous_state else "새 세션"}
"""
    
    return {
        'session_data': session_data,
        'context_for_claude': context_for_claude,
        'project_type': project_info.get('framework'),
        'git_branch': git_info.get('branch')
    }


# 훅 실행 시 호출
if __name__ == '__main__':
    result = on_session_start()
    print(f"Session initialized: {result['session_data']['session_id']}")

2. UserPromptSubmit - 프롬프트 전처리

핵심 질문

"사용자 프롬프트에 컨텍스트를 추가하거나 검증이 필요한가?"

활용 시나리오

  • 금지어/민감한 요청 차단
  • 프로젝트 컨텍스트 자동 추가
  • 프롬프트 로깅 및 감사
  • 언어/용어 자동 번역

실전 예제

# .claude/hooks/user_prompt_submit.py
"""
사용자 프롬프트 제출 전 검증 및 강화
"""

import re
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List
from .utils import (
    load_config, load_json, save_json,
    is_sensitive_path, logger
)


class PromptValidator:
    """프롬프트 검증 클래스"""
    
    def __init__(self):
        self.config = load_config()
        self.forbidden_patterns = [
            r'api[_-]?key\s*[:=]',
            r'password\s*[:=]',
            r'secret\s*[:=]',
            r'token\s*[:=]\s*["\']?\w{20,}',
            r'private[_-]?key',
            r'delete\s+from\s+\w+\s*;',  # SQL DELETE
            r'drop\s+(table|database)',   # SQL DROP
            r'rm\s+-rf\s+/',              # 위험한 삭제
        ]
        
        self.warning_patterns = [
            r'production',
            r'prod\s+environment',
            r'live\s+server',
            r'deploy\s+to\s+main',
        ]
    
    def check_forbidden(self, prompt: str) -> Optional[Dict]:
        """금지된 패턴 검사"""
        prompt_lower = prompt.lower()
        
        for pattern in self.forbidden_patterns:
            if re.search(pattern, prompt_lower, re.IGNORECASE):
                return {
                    'blocked': True,
                    'reason': f'보안상 민감한 패턴이 감지되었습니다: {pattern}',
                    'pattern': pattern
                }
        return None
    
    def check_warnings(self, prompt: str) -> List[str]:
        """경고 패턴 검사"""
        warnings = []
        prompt_lower = prompt.lower()
        
        for pattern in self.warning_patterns:
            if re.search(pattern, prompt_lower, re.IGNORECASE):
                warnings.append(f"'{pattern}' 관련 작업 - 주의가 필요합니다")
        
        return warnings


class PromptEnhancer:
    """프롬프트 강화 클래스"""
    
    def __init__(self):
        self.config = load_config()
        self.current_session = load_json('.claude/current_session.json') or {}
    
    def add_project_context(self, prompt: str) -> str:
        """프로젝트 컨텍스트 추가"""
        project = self.current_session.get('project', {})
        git = self.current_session.get('git', {})
        
        context = f"""
[현재 프로젝트 컨텍스트]
- 프레임워크: {project.get('framework', 'Unknown')}
- 브랜치: {git.get('branch', 'Unknown')}
- 의존성: {', '.join(project.get('dependencies', [])[:5])}
"""
        return prompt + context
    
    def add_coding_standards(self, prompt: str) -> str:
        """코딩 작업 시 스타일 가이드 추가"""
        coding_keywords = ['생성', '작성', 'create', 'write', '코드', 'code', '함수', 'function', '클래스', 'class']
        
        if any(keyword in prompt.lower() for keyword in coding_keywords):
            standards = self.current_session.get('coding_standards', '')
            if standards:
                return prompt + f"\n\n[적용할 코딩 규칙]\n{standards[:500]}"
        
        return prompt
    
    def add_test_reminder(self, prompt: str) -> str:
        """테스트 작성 리마인더 추가"""
        creation_keywords = ['만들', '생성', '작성', 'create', 'implement', 'add']
        
        if any(keyword in prompt.lower() for keyword in creation_keywords):
            return prompt + "\n\n[참고: 새로운 기능 구현 시 단위 테스트도 함께 작성해주세요]"
        
        return prompt
    
    def detect_and_add_file_context(self, prompt: str) -> str:
        """언급된 파일의 컨텍스트 자동 추가"""
        # 파일 경로 패턴 감지
        file_patterns = [
            r'[\w/]+\.dart',
            r'[\w/]+\.py',
            r'[\w/]+\.ts',
            r'[\w/]+\.js',
        ]
        
        mentioned_files = []
        for pattern in file_patterns:
            matches = re.findall(pattern, prompt)
            mentioned_files.extend(matches)
        
        if not mentioned_files:
            return prompt
        
        file_contexts = []
        for filepath in mentioned_files[:3]:  # 최대 3개 파일
            path = Path(filepath)
            if path.exists() and path.stat().st_size < 5000:  # 5KB 이하만
                with open(path, 'r', encoding='utf-8') as f:
                    content = f.read()
                    file_contexts.append(f"### {filepath}\n```\n{content[:1000]}\n```")
        
        if file_contexts:
            context = "\n\n[참조된 파일 내용]\n" + "\n".join(file_contexts)
            return prompt + context
        
        return prompt


class PromptLogger:
    """프롬프트 로깅 클래스"""
    
    def __init__(self):
        self.log_dir = Path('.claude/logs/prompts')
        self.log_dir.mkdir(parents=True, exist_ok=True)
    
    def log(self, original: str, enhanced: str, metadata: Dict) -> None:
        """프롬프트 로그 저장"""
        timestamp = datetime.now()
        log_entry = {
            'timestamp': timestamp.isoformat(),
            'original_prompt': original,
            'enhanced_prompt': enhanced,
            'enhancement_applied': original != enhanced,
            'metadata': metadata
        }
        
        # 일별 로그 파일
        log_file = self.log_dir / f"prompts_{timestamp.strftime('%Y%m%d')}.jsonl"
        with open(log_file, 'a', encoding='utf-8') as f:
            import json
            f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')


def on_user_prompt_submit(prompt: str, metadata: Optional[Dict] = None) -> Dict[str, Any]:
    """
    프롬프트 제출 훅 메인 함수
    
    Args:
        prompt: 사용자가 입력한 프롬프트
        metadata: 추가 메타데이터
        
    Returns:
        Dict containing processed prompt or block information
    """
    logger.info(f"Processing prompt: {prompt[:100]}...")
    metadata = metadata or {}
    
    # 1. 검증
    validator = PromptValidator()
    
    # 금지 패턴 체크
    forbidden_check = validator.check_forbidden(prompt)
    if forbidden_check:
        logger.warning(f"Prompt blocked: {forbidden_check['reason']}")
        return {
            'block': True,
            'reason': forbidden_check['reason'],
            'original_prompt': prompt
        }
    
    # 경고 패턴 체크
    warnings = validator.check_warnings(prompt)
    
    # 2. 강화
    enhancer = PromptEnhancer()
    enhanced_prompt = prompt
    
    # 프로젝트 컨텍스트 추가
    enhanced_prompt = enhancer.add_project_context(enhanced_prompt)
    
    # 코딩 스타일 가이드 추가
    enhanced_prompt = enhancer.add_coding_standards(enhanced_prompt)
    
    # 테스트 리마인더 추가
    enhanced_prompt = enhancer.add_test_reminder(enhanced_prompt)
    
    # 파일 컨텍스트 추가
    enhanced_prompt = enhancer.detect_and_add_file_context(enhanced_prompt)
    
    # 3. 로깅
    prompt_logger = PromptLogger()
    prompt_logger.log(prompt, enhanced_prompt, {
        **metadata,
        'warnings': warnings
    })
    
    result = {
        'enhanced_prompt': enhanced_prompt,
        'original_prompt': prompt,
        'warnings': warnings,
        'enhancements_applied': prompt != enhanced_prompt
    }
    
    logger.info(f"Prompt processed. Enhancements: {result['enhancements_applied']}")
    
    return result


# 사용 예시
if __name__ == '__main__':
    # 테스트 프롬프트
    test_prompts = [
        "UserRepository 클래스를 생성해주세요",
        "api_key = 'sk-xxxx' 형태로 설정 파일 만들어줘",
        "production 서버에 배포하는 스크립트 작성",
    ]
    
    for prompt in test_prompts:
        print(f"\n원본: {prompt}")
        result = on_user_prompt_submit(prompt)
        if result.get('block'):
            print(f"❌ 차단됨: {result['reason']}")
        else:
            print(f"✅ 처리됨 (경고: {result.get('warnings', [])})")

3. PermissionRequest - 권한 자동화

핵심 질문

"이 권한 요청을 자동으로 승인/거부해야 하나?"

활용 시나리오

  • 읽기 전용 파일 자동 승인
  • 민감한 파일 자동 거부
  • 파일 수정 전 백업 생성
  • 권한 감사 로그 작성

실전 예제

# .claude/hooks/permission_request.py
"""
파일/리소스 접근 권한 자동 처리
"""

from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional
from .utils import (
    load_config, save_json, backup_file,
    is_sensitive_path, logger
)


class PermissionPolicy:
    """권한 정책 관리"""
    
    def __init__(self):
        self.config = load_config()
        self.security_config = self.config.get('security', {})
        
        # 자동 승인 파일 확장자
        self.auto_approve_extensions = {
            'read': ['.dart', '.py', '.js', '.ts', '.json', '.yaml', '.yml', 
                     '.md', '.txt', '.html', '.css', '.xml', '.toml'],
            'write': ['.dart', '.py', '.js', '.ts', '.md', '.txt']
        }
        
        # 자동 거부 경로 패턴
        self.auto_deny_patterns = [
            '.env', '.env.local', '.env.production',
            'secrets/', 'private/', 'credentials/',
            '*.pem', '*.key', '*.p12', '*.pfx',
            '.git/config', '.git/credentials',
            'id_rsa', 'id_ed25519',
        ]
        
        # 특별 보호 파일
        self.protected_files = [
            'pubspec.lock',  # 의존성 잠금
            'package-lock.json',
            'poetry.lock',
        ]
    
    def is_auto_approve(self, request_type: str, filepath: str) -> bool:
        """자동 승인 가능 여부"""
        path = Path(filepath)
        extension = path.suffix.lower()
        
        # 민감한 경로는 절대 자동 승인 안함
        if is_sensitive_path(filepath):
            return False
        
        # 확장자 기반 자동 승인
        allowed_extensions = self.auto_approve_extensions.get(request_type, [])
        return extension in allowed_extensions
    
    def is_auto_deny(self, filepath: str) -> Optional[str]:
        """자동 거부 여부 및 사유"""
        from fnmatch import fnmatch
        
        for pattern in self.auto_deny_patterns:
            if fnmatch(filepath, f'*{pattern}*') or pattern in filepath:
                return f"보안 정책에 의해 '{pattern}' 패턴 접근이 차단되었습니다"
        
        return None
    
    def is_protected(self, filepath: str) -> bool:
        """보호된 파일 여부"""
        return Path(filepath).name in self.protected_files


class PermissionAuditor:
    """권한 감사 로깅"""
    
    def __init__(self):
        self.audit_dir = Path('.claude/logs/audit')
        self.audit_dir.mkdir(parents=True, exist_ok=True)
    
    def log(self, request: Dict, decision: str, reason: str) -> None:
        """감사 로그 기록"""
        timestamp = datetime.now()
        audit_entry = {
            'timestamp': timestamp.isoformat(),
            'request_type': request.get('type'),
            'path': request.get('path'),
            'decision': decision,  # 'approved', 'denied', 'user_confirm'
            'reason': reason,
            'request_details': request
        }
        
        # 일별 감사 로그
        audit_file = self.audit_dir / f"audit_{timestamp.strftime('%Y%m%d')}.jsonl"
        with open(audit_file, 'a', encoding='utf-8') as f:
            import json
            f.write(json.dumps(audit_entry, ensure_ascii=False) + '\n')
        
        logger.info(f"Permission {decision}: {request.get('path')} - {reason}")


def on_permission_request(request: Dict[str, Any]) -> Dict[str, Any]:
    """
    권한 요청 훅 메인 함수
    
    Args:
        request: 권한 요청 정보
            - type: 'file_read', 'file_write', 'file_delete', 'execute', etc.
            - path: 대상 파일/리소스 경로
            - operation: 구체적인 작업 내용
            
    Returns:
        Dict containing permission decision
    """
    request_type = request.get('type', 'unknown')
    filepath = request.get('path', '')
    
    logger.info(f"Permission request: {request_type} - {filepath}")
    
    policy = PermissionPolicy()
    auditor = PermissionAuditor()
    
    # 1. 자동 거부 체크
    deny_reason = policy.is_auto_deny(filepath)
    if deny_reason:
        auditor.log(request, 'denied', deny_reason)
        return {
            'deny': True,
            'reason': deny_reason,
            'notify_user': True,
            'suggest_alternative': "민감한 정보가 포함된 파일입니다. 다른 방법을 사용해주세요."
        }
    
    # 2. 파일 읽기 요청
    if request_type == 'file_read':
        if policy.is_auto_approve('read', filepath):
            auditor.log(request, 'approved', '안전한 읽기 작업')
            return {'approve': True, 'log': True}
        else:
            auditor.log(request, 'user_confirm', '수동 확인 필요')
            return {
                'require_user_confirmation': True,
                'message': f"'{filepath}' 파일 읽기를 승인하시겠습니까?"
            }
    
    # 3. 파일 쓰기 요청
    if request_type == 'file_write':
        path = Path(filepath)
        
        # 보호된 파일 체크
        if policy.is_protected(filepath):
            auditor.log(request, 'denied', '보호된 파일')
            return {
                'deny': True,
                'reason': f"'{path.name}'은(는) 보호된 파일입니다. 직접 수정할 수 없습니다.",
                'notify_user': True
            }
        
        # 기존 파일이면 백업 생성
        backup_path = None
        if path.exists():
            backup_path = backup_file(filepath)
        
        if policy.is_auto_approve('write', filepath):
            auditor.log(request, 'approved', f'자동 승인 (백업: {backup_path})')
            return {
                'approve': True,
                'backup_created': backup_path is not None,
                'backup_path': backup_path
            }
        else:
            auditor.log(request, 'user_confirm', '수동 확인 필요')
            return {
                'require_user_confirmation': True,
                'message': f"'{filepath}' 파일 쓰기를 승인하시겠습니까?",
                'backup_will_be_created': path.exists()
            }
    
    # 4. 파일 삭제 요청
    if request_type == 'file_delete':
        # 삭제 전 항상 백업
        backup_path = backup_file(filepath) if Path(filepath).exists() else None
        
        auditor.log(request, 'user_confirm', '삭제 작업은 항상 확인 필요')
        return {
            'require_user_confirmation': True,
            'message': f"⚠️ '{filepath}' 파일을 삭제하시겠습니까?",
            'backup_created': backup_path is not None,
            'backup_path': backup_path,
            'warning': "삭제 작업은 되돌릴 수 없습니다."
        }
    
    # 5. 실행 권한 요청
    if request_type == 'execute':
        command = request.get('command', '')
        
        # 위험한 명령어 체크
        from .utils import is_dangerous_command
        if is_dangerous_command(command):
            auditor.log(request, 'denied', '위험한 명령어')
            return {
                'deny': True,
                'reason': f"위험한 명령어가 감지되었습니다: {command[:50]}",
                'notify_user': True
            }
        
        auditor.log(request, 'user_confirm', '실행 명령 확인 필요')
        return {
            'require_user_confirmation': True,
            'message': f"다음 명령을 실행하시겠습니까?\n`{command}`"
        }
    
    # 6. 알 수 없는 요청 타입
    auditor.log(request, 'user_confirm', '알 수 없는 요청 타입')
    return {
        'require_user_confirmation': True,
        'message': f"'{request_type}' 작업을 승인하시겠습니까?"
    }


# 테스트
if __name__ == '__main__':
    test_requests = [
        {'type': 'file_read', 'path': 'lib/main.dart'},
        {'type': 'file_read', 'path': '.env'},
        {'type': 'file_write', 'path': 'lib/new_feature.dart'},
        {'type': 'file_delete', 'path': 'temp_file.dart'},
        {'type': 'execute', 'command': 'flutter build apk'},
        {'type': 'execute', 'command': 'rm -rf /'},
    ]
    
    for req in test_requests:
        print(f"\n요청: {req}")
        result = on_permission_request(req)
        print(f"결과: {result}")

4. PreToolUse - 도구 실행 전 제어

핵심 질문

"도구 실행을 허용, 수정 또는 차단해야 하나?"

활용 시나리오

  • 위험한 셸 명령어 차단
  • API 호출 rate limit 체크
  • 파라미터 자동 수정
  • 실행 전 검증

실전 예제

# .claude/hooks/pre_tool_use.py
"""
도구 사용 전 보안 검증 및 파라미터 조정
"""

import re
import time
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List, Tuple
from collections import defaultdict
from .utils import (
    load_config, load_json, save_json,
    is_sensitive_path, is_dangerous_command, logger
)


class CommandSecurityChecker:
    """명령어 보안 검사"""
    
    # 위험 등급별 명령어 패턴
    CRITICAL_PATTERNS = [
        (r'rm\s+-rf\s+/', "루트 디렉토리 삭제 시도"),
        (r'rm\s+-rf\s+~', "홈 디렉토리 삭제 시도"),
        (r'rm\s+-rf\s+\*', "와일드카드 삭제"),
        (r':(){ :\|:& };:', "Fork bomb 감지"),
        (r'dd\s+if=/dev/zero', "디스크 초기화 시도"),
        (r'mkfs\.', "파일시스템 포맷 시도"),
        (r'chmod\s+-R\s+777', "전체 권한 변경"),
        (r'>\s*/dev/sd', "디스크 직접 쓰기"),
    ]
    
    HIGH_RISK_PATTERNS = [
        (r'\bsudo\b', "관리자 권한 사용"),
        (r'\bsu\s+-', "사용자 전환"),
        (r'curl.*\|\s*(ba)?sh', "원격 스크립트 실행"),
        (r'wget.*\|\s*(ba)?sh', "원격 스크립트 실행"),
        (r'eval\s+', "동적 코드 실행"),
        (r'>\s*/etc/', "/etc 파일 수정"),
    ]
    
    MEDIUM_RISK_PATTERNS = [
        (r'npm\s+publish', "패키지 배포"),
        (r'pip\s+.*--user', "사용자 패키지 설치"),
        (r'git\s+push.*--force', "강제 푸시"),
        (r'docker\s+rm', "컨테이너 삭제"),
        (r'kubectl\s+delete', "K8s 리소스 삭제"),
    ]
    
    def check(self, command: str) -> Tuple[str, Optional[str]]:
        """
        명령어 위험도 검사
        
        Returns:
            Tuple[risk_level, reason]: ('critical', 'high', 'medium', 'low'), 사유
        """
        for pattern, reason in self.CRITICAL_PATTERNS:
            if re.search(pattern, command, re.IGNORECASE):
                return ('critical', reason)
        
        for pattern, reason in self.HIGH_RISK_PATTERNS:
            if re.search(pattern, command, re.IGNORECASE):
                return ('high', reason)
        
        for pattern, reason in self.MEDIUM_RISK_PATTERNS:
            if re.search(pattern, command, re.IGNORECASE):
                return ('medium', reason)
        
        return ('low', None)


class RateLimiter:
    """API 호출 Rate Limit 관리"""
    
    def __init__(self):
        self.state_file = Path('.claude/rate_limit_state.json')
        self.limits = {
            'default': {'calls': 100, 'window': 60},  # 분당 100회
            'api_call': {'calls': 30, 'window': 60},   # 분당 30회
            'web_search': {'calls': 10, 'window': 60}, # 분당 10회
            'file_write': {'calls': 50, 'window': 60}, # 분당 50회
        }
        self._load_state()
    
    def _load_state(self):
        """상태 로드"""
        if self.state_file.exists():
            self.state = load_json(str(self.state_file)) or {}
        else:
            self.state = {}
    
    def _save_state(self):
        """상태 저장"""
        save_json(str(self.state_file), self.state)
    
    def check(self, operation: str) -> Tuple[bool, Optional[int]]:
        """
        Rate limit 체크
        
        Returns:
            Tuple[allowed, retry_after_seconds]
        """
        limit_config = self.limits.get(operation, self.limits['default'])
        max_calls = limit_config['calls']
        window = limit_config['window']
        
        now = time.time()
        key = f"rate_{operation}"
        
        if key not in self.state:
            self.state[key] = {'calls': [], 'window_start': now}
        
        # 윈도우 내 호출 필터링
        self.state[key]['calls'] = [
            t for t in self.state[key]['calls']
            if now - t < window
        ]
        
        current_calls = len(self.state[key]['calls'])
        
        if current_calls >= max_calls:
            oldest_call = min(self.state[key]['calls'])
            retry_after = int(window - (now - oldest_call))
            return (False, retry_after)
        
        # 호출 기록
        self.state[key]['calls'].append(now)
        self._save_state()
        
        return (True, None)


class ParameterSanitizer:
    """파라미터 정제"""
    
    def __init__(self):
        self.config = load_config()
    
    def sanitize_file_path(self, path: str) -> str:
        """파일 경로 정제"""
        # 상대 경로로 변환
        if path.startswith('/'):
            path = path.lstrip('/')
        
        # .. 제거
        path = re.sub(r'\.\./', '', path)
        
        # 위험한 문자 제거
        path = re.sub(r'[;&|`$]', '', path)
        
        return path
    
    def sanitize_command(self, command: str) -> str:
        """명령어 정제"""
        # 명령어 체이닝 감지 및 분리
        if '&&' in command or '||' in command or ';' in command:
            # 첫 번째 명령만 허용
            command = re.split(r'[;&|]{1,2}', command)[0].strip()
            logger.warning(f"Command chaining detected, using only first command: {command}")
        
        return command


def on_pre_tool_use(tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
    """
    도구 실행 전 훅 메인 함수
    
    Args:
        tool_name: 사용할 도구 이름
        parameters: 도구 파라미터
        
    Returns:
        Dict containing allow/block/modify decision
    """
    logger.info(f"Pre-tool check: {tool_name} with params: {parameters}")
    
    security_checker = CommandSecurityChecker()
    rate_limiter = RateLimiter()
    sanitizer = ParameterSanitizer()
    
    # 1. Bash 명령어 검사
    if tool_name == 'bash':
        command = parameters.get('command', '')
        
        # 보안 검사
        risk_level, reason = security_checker.check(command)
        
        if risk_level == 'critical':
            logger.error(f"Critical command blocked: {command}")
            return {
                'block': True,
                'reason': f"🚫 위험한 명령어 차단: {reason}",
                'risk_level': risk_level,
                'notify_user': True
            }
        
        if risk_level == 'high':
            logger.warning(f"High-risk command requires confirmation: {command}")
            return {
                'require_confirmation': True,
                'reason': f"⚠️ 고위험 명령어: {reason}",
                'risk_level': risk_level,
                'command': command
            }
        
        if risk_level == 'medium':
            # 명령어 정제 후 진행
            sanitized_command = sanitizer.sanitize_command(command)
            if sanitized_command != command:
                return {
                    'modify': True,
                    'modified_parameters': {'command': sanitized_command},
                    'reason': f"명령어가 정제되었습니다 (위험도: {risk_level})",
                    'original_command': command
                }
        
        return {'allow': True}
    
    # 2. 파일 생성/쓰기 검사
    if tool_name in ['create_file', 'file_write', 'str_replace']:
        filepath = parameters.get('path', '')
        
        # 민감한 경로 체크
        if is_sensitive_path(filepath):
            return {
                'block': True,
                'reason': f"🔒 보안 정책: 민감한 경로에 대한 쓰기가 차단되었습니다",
                'path': filepath
            }
        
        # 경로 정제
        sanitized_path = sanitizer.sanitize_file_path(filepath)
        if sanitized_path != filepath:
            parameters['path'] = sanitized_path
            return {
                'modify': True,
                'modified_parameters': parameters,
                'reason': f"파일 경로가 정제되었습니다: {filepath} -> {sanitized_path}"
            }
        
        # Rate limit 체크
        allowed, retry_after = rate_limiter.check('file_write')
        if not allowed:
            return {
                'block': True,
                'reason': f"파일 쓰기 Rate limit 초과",
                'retry_after': retry_after
            }
        
        return {'allow': True}
    
    # 3. API 호출 검사
    if tool_name == 'api_call':
        endpoint = parameters.get('endpoint', '')
        method = parameters.get('method', 'GET')
        
        # Rate limit 체크
        allowed, retry_after = rate_limiter.check('api_call')
        if not allowed:
            return {
                'block': True,
                'reason': f"API Rate limit 초과",
                'retry_after': retry_after
            }
        
        # 위험한 엔드포인트 체크
        dangerous_endpoints = ['/admin', '/delete', '/drop', '/truncate']
        if any(ep in endpoint.lower() for ep in dangerous_endpoints):
            return {
                'require_confirmation': True,
                'reason': f"⚠️ 민감한 API 엔드포인트: {endpoint}",
                'method': method
            }
        
        return {'allow': True}
    
    # 4. 웹 검색 검사
    if tool_name == 'web_search':
        query = parameters.get('query', '')
        
        # Rate limit 체크
        allowed, retry_after = rate_limiter.check('web_search')
        if not allowed:
            return {
                'block': True,
                'reason': f"웹 검색 Rate limit 초과",
                'retry_after': retry_after
            }
        
        return {'allow': True}
    
    # 5. 기타 도구는 기본 허용
    return {'allow': True}


# 테스트
if __name__ == '__main__':
    test_cases = [
        ('bash', {'command': 'ls -la'}),
        ('bash', {'command': 'rm -rf /'}),
        ('bash', {'command': 'sudo apt update'}),
        ('create_file', {'path': 'lib/new_file.dart', 'content': '...'}),
        ('create_file', {'path': '.env', 'content': '...'}),
        ('api_call', {'endpoint': '/api/users', 'method': 'GET'}),
        ('api_call', {'endpoint': '/admin/delete', 'method': 'DELETE'}),
    ]
    
    for tool, params in test_cases:
        print(f"\n도구: {tool}, 파라미터: {params}")
        result = on_pre_tool_use(tool, params)
        print(f"결과: {result}")

5. PostToolUse - 도구 실행 후 처리

핵심 질문

"결과를 검증하거나 후처리가 필요한가?"

활용 시나리오

  • 코드 자동 포맷팅
  • 린트 체크 실행
  • 테스트 자동 실행
  • Git 스테이징

실전 예제

# .claude/hooks/post_tool_use.py
"""
도구 실행 후 자동 처리 및 검증
"""

import subprocess
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional, List
from .utils import (
    load_config, run_command, logger, save_json
)


class CodeFormatter:
    """코드 자동 포맷터"""
    
    FORMATTERS = {
        '.dart': {
            'command': ['dart', 'format'],
            'check_command': ['dart', 'format', '--output=none', '--set-exit-if-changed']
        },
        '.py': {
            'command': ['black'],
            'check_command': ['black', '--check']
        },
        '.js': {
            'command': ['prettier', '--write'],
            'check_command': ['prettier', '--check']
        },
        '.ts': {
            'command': ['prettier', '--write'],
            'check_command': ['prettier', '--check']
        },
        '.json': {
            'command': ['prettier', '--write'],
            'check_command': ['prettier', '--check']
        }
    }
    
    def format(self, filepath: str) -> Dict[str, Any]:
        """파일 포맷팅"""
        path = Path(filepath)
        extension = path.suffix.lower()
        
        if extension not in self.FORMATTERS:
            return {'formatted': False, 'reason': f'No formatter for {extension}'}
        
        formatter = self.FORMATTERS[extension]
        result = run_command(formatter['command'] + [filepath])
        
        return {
            'formatted': result['success'],
            'output': result.get('stdout', ''),
            'error': result.get('stderr', '')
        }
    
    def check(self, filepath: str) -> Dict[str, Any]:
        """포맷팅 체크 (수정 없이)"""
        path = Path(filepath)
        extension = path.suffix.lower()
        
        if extension not in self.FORMATTERS:
            return {'needs_formatting': False}
        
        formatter = self.FORMATTERS[extension]
        result = run_command(formatter['check_command'] + [filepath])
        
        return {
            'needs_formatting': not result['success'],
            'output': result.get('stdout', '')
        }


class CodeLinter:
    """코드 린터"""
    
    LINTERS = {
        '.dart': {
            'command': ['dart', 'analyze'],
            'error_pattern': r'error\s+•|Error:',
            'warning_pattern': r'warning\s+•|Warning:'
        },
        '.py': {
            'command': ['flake8'],
            'error_pattern': r'E\d{3}',
            'warning_pattern': r'W\d{3}'
        },
        '.js': {
            'command': ['eslint'],
            'error_pattern': r'error',
            'warning_pattern': r'warning'
        },
        '.ts': {
            'command': ['eslint'],
            'error_pattern': r'error',
            'warning_pattern': r'warning'
        }
    }
    
    def lint(self, filepath: str) -> Dict[str, Any]:
        """린트 실행"""
        import re
        
        path = Path(filepath)
        extension = path.suffix.lower()
        
        if extension not in self.LINTERS:
            return {'linted': False, 'reason': f'No linter for {extension}'}
        
        linter = self.LINTERS[extension]
        result = run_command(linter['command'] + [filepath])
        
        output = result.get('stdout', '') + result.get('stderr', '')
        
        errors = len(re.findall(linter['error_pattern'], output, re.IGNORECASE))
        warnings = len(re.findall(linter['warning_pattern'], output, re.IGNORECASE))
        
        return {
            'linted': True,
            'passed': errors == 0,
            'errors': errors,
            'warnings': warnings,
            'output': output
        }


class TestRunner:
    """테스트 실행기"""
    
    def __init__(self):
        self.config = load_config()
    
    def find_test_file(self, source_file: str) -> Optional[str]:
        """소스 파일에 대응하는 테스트 파일 찾기"""
        path = Path(source_file)
        
        # Dart/Flutter 패턴
        if path.suffix == '.dart':
            # lib/feature.dart -> test/feature_test.dart
            if 'lib/' in str(path):
                test_path = str(path).replace('lib/', 'test/').replace('.dart', '_test.dart')
                if Path(test_path).exists():
                    return test_path
        
        # Python 패턴
        elif path.suffix == '.py':
            # src/module.py -> tests/test_module.py
            test_path = str(path).replace('src/', 'tests/').replace(path.stem, f'test_{path.stem}')
            if Path(test_path).exists():
                return test_path
        
        return None
    
    def run_tests(self, test_file: str) -> Dict[str, Any]:
        """테스트 실행"""
        path = Path(test_file)
        
        if path.suffix == '.dart':
            result = run_command(['flutter', 'test', test_file], timeout=120)
        elif path.suffix == '.py':
            result = run_command(['pytest', test_file, '-v'], timeout=120)
        elif path.suffix in ['.js', '.ts']:
            result = run_command(['npm', 'test', '--', test_file], timeout=120)
        else:
            return {'ran': False, 'reason': f'Unknown test file type: {path.suffix}'}
        
        return {
            'ran': True,
            'passed': result['success'],
            'output': result.get('stdout', ''),
            'error': result.get('stderr', '')
        }


class GitStager:
    """Git 스테이징 관리"""
    
    def stage_file(self, filepath: str) -> Dict[str, Any]:
        """파일 스테이징"""
        result = run_command(['git', 'add', filepath])
        return {
            'staged': result['success'],
            'error': result.get('stderr', '')
        }
    
    def get_diff(self, filepath: str) -> str:
        """파일 변경 내용"""
        result = run_command(['git', 'diff', filepath])
        return result.get('stdout', '')


def on_post_tool_use(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
    """
    도구 실행 후 훅 메인 함수
    
    Args:
        tool_name: 실행된 도구 이름
        result: 도구 실행 결과
            - path: 생성/수정된 파일 경로
            - content: 파일 내용
            - success: 성공 여부
            
    Returns:
        Dict containing post-processing results
    """
    logger.info(f"Post-tool processing: {tool_name}")
    
    config = load_config()
    post_config = config.get('hooks', {}).get('post_tool_use', {})
    
    response = {
        'tool_name': tool_name,
        'original_result': result,
        'post_processing': {}
    }
    
    # 파일 생성/수정 후 처리
    if tool_name in ['create_file', 'file_write', 'str_replace']:
        filepath = result.get('path', '')
        path = Path(filepath)
        
        if not path.exists():
            return response
        
        # 1. 코드 포맷팅
        if post_config.get('auto_format', True):
            formatter = CodeFormatter()
            format_result = formatter.format(filepath)
            response['post_processing']['formatting'] = format_result
            
            if format_result.get('formatted'):
                logger.info(f"File formatted: {filepath}")
        
        # 2. 린트 체크
        if post_config.get('auto_lint', True):
            linter = CodeLinter()
            lint_result = linter.lint(filepath)
            response['post_processing']['linting'] = lint_result
            
            if lint_result.get('errors', 0) > 0:
                response['warnings'] = response.get('warnings', [])
                response['warnings'].append({
                    'type': 'lint_errors',
                    'message': f"린트 오류 {lint_result['errors']}개 발견",
                    'suggest_fix': True
                })
                logger.warning(f"Lint errors in {filepath}: {lint_result['errors']}")
        
        # 3. 관련 테스트 실행
        if post_config.get('auto_test', False):
            test_runner = TestRunner()
            test_file = test_runner.find_test_file(filepath)
            
            if test_file:
                test_result = test_runner.run_tests(test_file)
                response['post_processing']['testing'] = test_result
                
                if not test_result.get('passed', True):
                    response['warnings'] = response.get('warnings', [])
                    response['warnings'].append({
                        'type': 'test_failure',
                        'message': f"테스트 실패: {test_file}",
                        'output': test_result.get('output', '')
                    })
        
        # 4. Git 스테이징
        if post_config.get('auto_git_stage', False):
            stager = GitStager()
            stage_result = stager.stage_file(filepath)
            response['post_processing']['git'] = stage_result
        
        # 5. 테스트 파일 생성 제안 (테스트 파일이 없을 경우)
        test_runner = TestRunner()
        if (path.suffix in ['.dart', '.py', '.js', '.ts'] 
            and '_test' not in str(path) 
            and 'test_' not in str(path)
            and not test_runner.find_test_file(filepath)):
            
            response['suggestions'] = response.get('suggestions', [])
            response['suggestions'].append({
                'type': 'missing_test',
                'message': f"'{path.name}'에 대한 테스트 파일이 없습니다. 테스트를 작성해주세요."
            })
    
    # Bash 명령어 실행 후
    if tool_name == 'bash':
        command = result.get('command', '')
        output = result.get('stdout', '') + result.get('stderr', '')
        
        # 에러 패턴 감지
        error_patterns = [
            r'error:', r'Error:', r'ERROR:',
            r'failed', r'Failed', r'FAILED',
            r'exception', r'Exception',
            r'not found', r'No such file'
        ]
        
        import re
        for pattern in error_patterns:
            if re.search(pattern, output):
                response['warnings'] = response.get('warnings', [])
                response['warnings'].append({
                    'type': 'command_error',
                    'message': f"명령어 실행 중 오류 패턴 감지: {pattern}",
                    'output': output[:500]
                })
                break
    
    # 세션 상태 업데이트
    _update_session_state(tool_name, result, response)
    
    return response


def _update_session_state(tool_name: str, result: Dict, response: Dict):
    """세션 상태 업데이트"""
    from .utils import load_json, save_json
    
    state = load_json('.claude/current_session.json') or {}
    
    if 'tool_usage' not in state:
        state['tool_usage'] = []
    
    state['tool_usage'].append({
        'tool': tool_name,
        'timestamp': datetime.now().isoformat(),
        'path': result.get('path'),
        'success': result.get('success', True),
        'warnings': len(response.get('warnings', []))
    })
    
    # 생성된 파일 추적
    if tool_name == 'create_file' and result.get('path'):
        if 'created_files' not in state:
            state['created_files'] = []
        state['created_files'].append(result['path'])
    
    # 수정된 파일 추적
    if tool_name in ['file_write', 'str_replace'] and result.get('path'):
        if 'modified_files' not in state:
            state['modified_files'] = []
        if result['path'] not in state['modified_files']:
            state['modified_files'].append(result['path'])
    
    save_json('.claude/current_session.json', state)


# 테스트
if __name__ == '__main__':
    # 테스트용 파일 생성
    test_result = {
        'path': 'lib/test_feature.dart',
        'success': True,
        'content': '''
class TestFeature {
  void doSomething() {
    print("Hello");
  }
}
'''
    }
    
    result = on_post_tool_use('create_file', test_result)
    print(f"결과: {result}")

6. Notification - 알림 커스터마이징

핵심 질문

"사용자에게 어떤 방식으로 알림을 보낼까?"

실전 예제

# .claude/hooks/notification.py
"""
알림 시스템 커스터마이징
"""

import os
import json
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional
from enum import Enum
from .utils import load_config, logger


class NotificationType(Enum):
    INFO = 'info'
    SUCCESS = 'success'
    WARNING = 'warning'
    ERROR = 'error'
    SECURITY_ALERT = 'security_alert'
    TASK_COMPLETE = 'task_complete'
    TASK_START = 'task_start'


class NotificationChannel:
    """알림 채널 기본 클래스"""
    
    def send(self, notification_type: NotificationType, message: str, 
             metadata: Optional[Dict] = None) -> bool:
        raise NotImplementedError


class SlackNotification(NotificationChannel):
    """Slack 알림"""
    
    ICONS = {
        NotificationType.INFO: ':information_source:',
        NotificationType.SUCCESS: ':white_check_mark:',
        NotificationType.WARNING: ':warning:',
        NotificationType.ERROR: ':x:',
        NotificationType.SECURITY_ALERT: ':rotating_light:',
        NotificationType.TASK_COMPLETE: ':tada:',
        NotificationType.TASK_START: ':rocket:',
    }
    
    COLORS = {
        NotificationType.INFO: '#36a64f',
        NotificationType.SUCCESS: '#2eb886',
        NotificationType.WARNING: '#daa038',
        NotificationType.ERROR: '#cc0000',
        NotificationType.SECURITY_ALERT: '#ff0000',
        NotificationType.TASK_COMPLETE: '#36a64f',
        NotificationType.TASK_START: '#439fe0',
    }
    
    def __init__(self, webhook_url: str, channel: str = '#dev-alerts'):
        self.webhook_url = webhook_url
        self.channel = channel
    
    def send(self, notification_type: NotificationType, message: str,
             metadata: Optional[Dict] = None) -> bool:
        if not self.webhook_url:
            return False
        
        icon = self.ICONS.get(notification_type, ':robot_face:')
        color = self.COLORS.get(notification_type, '#36a64f')
        
        payload = {
            'channel': self.channel,
            'username': 'Claude Code Bot',
            'icon_emoji': icon,
            'attachments': [{
                'color': color,
                'title': f'{icon} {notification_type.value.upper()}',
                'text': message,
                'footer': 'Claude Code',
                'ts': int(datetime.now().timestamp())
            }]
        }
        
        if metadata:
            fields = [
                {'title': k, 'value': str(v), 'short': True}
                for k, v in metadata.items()
            ]
            payload['attachments'][0]['fields'] = fields[:10]  # 최대 10개
        
        try:
            data = json.dumps(payload).encode('utf-8')
            req = urllib.request.Request(
                self.webhook_url,
                data=data,
                headers={'Content-Type': 'application/json'}
            )
            urllib.request.urlopen(req, timeout=5)
            return True
        except Exception as e:
            logger.error(f"Slack notification failed: {e}")
            return False


class DiscordNotification(NotificationChannel):
    """Discord 알림"""
    
    COLORS = {
        NotificationType.INFO: 0x3498db,
        NotificationType.SUCCESS: 0x2ecc71,
        NotificationType.WARNING: 0xf39c12,
        NotificationType.ERROR: 0xe74c3c,
        NotificationType.SECURITY_ALERT: 0xe74c3c,
        NotificationType.TASK_COMPLETE: 0x2ecc71,
        NotificationType.TASK_START: 0x3498db,
    }
    
    def __init__(self, webhook_url: str):
        self.webhook_url = webhook_url
    
    def send(self, notification_type: NotificationType, message: str,
             metadata: Optional[Dict] = None) -> bool:
        if not self.webhook_url:
            return False
        
        color = self.COLORS.get(notification_type, 0x3498db)
        
        embed = {
            'title': f'{notification_type.value.upper()}',
            'description': message,
            'color': color,
            'timestamp': datetime.utcnow().isoformat(),
            'footer': {'text': 'Claude Code'}
        }
        
        if metadata:
            embed['fields'] = [
                {'name': k, 'value': str(v)[:100], 'inline': True}
                for k, v in list(metadata.items())[:10]
            ]
        
        payload = {'embeds': [embed]}
        
        try:
            data = json.dumps(payload).encode('utf-8')
            req = urllib.request.Request(
                self.webhook_url,
                data=data,
                headers={'Content-Type': 'application/json'}
            )
            urllib.request.urlopen(req, timeout=5)
            return True
        except Exception as e:
            logger.error(f"Discord notification failed: {e}")
            return False


class DesktopNotification(NotificationChannel):
    """데스크톱 알림"""
    
    def send(self, notification_type: NotificationType, message: str,
             metadata: Optional[Dict] = None) -> bool:
        try:
            from plyer import notification as plyer_notify
            
            plyer_notify.notify(
                title=f'Claude Code - {notification_type.value.upper()}',
                message=message[:256],  # 길이 제한
                app_name='Claude Code',
                timeout=10
            )
            return True
        except ImportError:
            logger.warning("plyer not installed, skipping desktop notification")
            return False
        except Exception as e:
            logger.error(f"Desktop notification failed: {e}")
            return False


class FileLogger(NotificationChannel):
    """파일 로깅"""
    
    def __init__(self, log_dir: str = '.claude/logs'):
        self.log_dir = Path(log_dir)
        self.log_dir.mkdir(parents=True, exist_ok=True)
    
    def send(self, notification_type: NotificationType, message: str,
             metadata: Optional[Dict] = None) -> bool:
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'type': notification_type.value,
            'message': message,
            'metadata': metadata or {}
        }
        
        log_file = self.log_dir / f"notifications_{datetime.now().strftime('%Y%m%d')}.jsonl"
        
        try:
            with open(log_file, 'a', encoding='utf-8') as f:
                f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
            return True
        except Exception as e:
            logger.error(f"File logging failed: {e}")
            return False


class NotificationManager:
    """알림 관리자"""
    
    def __init__(self):
        self.config = load_config()
        self.channels: list[NotificationChannel] = []
        self._setup_channels()
    
    def _setup_channels(self):
        """채널 설정"""
        notification_config = self.config.get('hooks', {}).get('notification', {})
        
        # Slack
        slack_webhook = notification_config.get('slack_webhook') or os.environ.get('SLACK_WEBHOOK_URL')
        if slack_webhook:
            channel = notification_config.get('slack_channel', '#dev-alerts')
            self.channels.append(SlackNotification(slack_webhook, channel))
        
        # Discord
        discord_webhook = notification_config.get('discord_webhook') or os.environ.get('DISCORD_WEBHOOK_URL')
        if discord_webhook:
            self.channels.append(DiscordNotification(discord_webhook))
        
        # 데스크톱
        if notification_config.get('desktop_enabled', True):
            self.channels.append(DesktopNotification())
        
        # 파일 로깅 (항상 활성화)
        self.channels.append(FileLogger())
    
    def notify(self, notification_type: NotificationType, message: str,
               metadata: Optional[Dict] = None) -> Dict[str, bool]:
        """모든 채널에 알림 전송"""
        results = {}
        
        for channel in self.channels:
            channel_name = channel.__class__.__name__
            results[channel_name] = channel.send(notification_type, message, metadata)
        
        return results


# 전역 매니저
_manager: Optional[NotificationManager] = None


def get_manager() -> NotificationManager:
    global _manager
    if _manager is None:
        _manager = NotificationManager()
    return _manager


def on_notification(notification_type: str, message: str, 
                   metadata: Optional[Dict] = None) -> Dict[str, Any]:
    """
    알림 훅 메인 함수
    
    Args:
        notification_type: 알림 타입 문자열
        message: 알림 메시지
        metadata: 추가 메타데이터
        
    Returns:
        Dict containing notification results
    """
    try:
        notif_type = NotificationType(notification_type.lower())
    except ValueError:
        notif_type = NotificationType.INFO
    
    manager = get_manager()
    results = manager.notify(notif_type, message, metadata)
    
    logger.info(f"Notification sent: {notif_type.value} - {message[:50]}...")
    
    return {
        'notification_type': notif_type.value,
        'message': message,
        'channel_results': results
    }


# 편의 함수들
def notify_info(message: str, **metadata):
    return on_notification('info', message, metadata)

def notify_success(message: str, **metadata):
    return on_notification('success', message, metadata)

def notify_warning(message: str, **metadata):
    return on_notification('warning', message, metadata)

def notify_error(message: str, **metadata):
    return on_notification('error', message, metadata)

def notify_security_alert(message: str, **metadata):
    return on_notification('security_alert', message, metadata)

def notify_task_complete(message: str, **metadata):
    return on_notification('task_complete', message, metadata)


# 테스트
if __name__ == '__main__':
    notify_info("테스트 알림입니다", project="모바일프로젝트", task="테스트")
    notify_success("작업이 완료되었습니다")
    notify_warning("주의가 필요합니다")
    notify_error("오류가 발생했습니다")

7. Stop - 작업 완료 검증

핵심 질문

"Claude가 작업을 완료했나, 아니면 계속해야 하나?"

실전 예제

# .claude/hooks/stop.py
"""
작업 완료 검증 및 품질 게이트
"""

import subprocess
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional
from .utils import (
    load_config, load_json, save_json, run_command, logger
)


class CompletionChecker:
    """작업 완료 검증"""
    
    def __init__(self, task_context: Dict):
        self.context = task_context
        self.config = load_config()
        self.checks_passed = []
        self.checks_failed = []
    
    def check_required_files(self, required_files: List[str]) -> bool:
        """필수 파일 존재 확인"""
        missing = []
        for filepath in required_files:
            if not Path(filepath).exists():
                missing.append(filepath)
        
        if missing:
            self.checks_failed.append({
                'check': 'required_files',
                'missing': missing,
                'message': f"필수 파일 누락: {', '.join(missing)}"
            })
            return False
        
        self.checks_passed.append('required_files')
        return True
    
    def check_tests_pass(self) -> bool:
        """테스트 통과 확인"""
        project = self.config.get('project', {})
        framework = project.get('framework')
        
        if framework == 'flutter':
            result = run_command(['flutter', 'test'], timeout=300)
        elif project.get('language') == 'python':
            result = run_command(['pytest'], timeout=300)
        elif project.get('language') in ['javascript', 'typescript']:
            result = run_command(['npm', 'test'], timeout=300)
        else:
            self.checks_passed.append('tests_skipped')
            return True
        
        if result['success']:
            self.checks_passed.append('tests')
            return True
        else:
            self.checks_failed.append({
                'check': 'tests',
                'output': result.get('stderr', result.get('stdout', '')),
                'message': '테스트 실패'
            })
            return False
    
    def check_lint_clean(self) -> bool:
        """린트 오류 없음 확인"""
        project = self.config.get('project', {})
        framework = project.get('framework')
        
        if framework == 'flutter':
            result = run_command(['dart', 'analyze'], timeout=60)
            has_errors = 'error' in result.get('stdout', '').lower()
        elif project.get('language') == 'python':
            result = run_command(['flake8', '--count'], timeout=60)
            has_errors = not result['success']
        else:
            self.checks_passed.append('lint_skipped')
            return True
        
        if not has_errors:
            self.checks_passed.append('lint')
            return True
        else:
            self.checks_failed.append({
                'check': 'lint',
                'output': result.get('stdout', ''),
                'message': '린트 오류 발견'
            })
            return False
    
    def check_documentation(self) -> bool:
        """문서화 확인"""
        created_files = self.context.get('created_files', [])
        
        # 새 파일이 생성되었는데 README나 문서가 없으면 경고
        code_files = [f for f in created_files if Path(f).suffix in ['.dart', '.py', '.js', '.ts']]
        doc_files = [f for f in created_files if Path(f).suffix in ['.md', '.txt']]
        
        if len(code_files) >= 3 and len(doc_files) == 0:
            self.checks_failed.append({
                'check': 'documentation',
                'message': '여러 코드 파일이 생성되었지만 문서가 없습니다'
            })
            return False
        
        self.checks_passed.append('documentation')
        return True
    
    def check_no_todos_in_code(self) -> bool:
        """새 코드에 TODO 남아있지 않은지 확인"""
        created_files = self.context.get('created_files', [])
        
        files_with_todos = []
        for filepath in created_files:
            if Path(filepath).exists():
                try:
                    with open(filepath, 'r', encoding='utf-8') as f:
                        content = f.read()
                        if 'TODO:' in content or 'FIXME:' in content:
                            files_with_todos.append(filepath)
                except:
                    pass
        
        if files_with_todos:
            self.checks_failed.append({
                'check': 'todos',
                'files': files_with_todos,
                'message': f"미완료 TODO 발견: {', '.join(files_with_todos)}"
            })
            return False
        
        self.checks_passed.append('todos')
        return True


def generate_task_summary(task_context: Dict) -> str:
    """작업 요약 생성"""
    summary_parts = []
    
    # 생성된 파일
    created = task_context.get('created_files', [])
    if created:
        summary_parts.append(f"생성된 파일: {len(created)}개")
        for f in created[:5]:
            summary_parts.append(f"  - {f}")
        if len(created) > 5:
            summary_parts.append(f"  ... 외 {len(created) - 5}개")
    
    # 수정된 파일
    modified = task_context.get('modified_files', [])
    if modified:
        summary_parts.append(f"수정된 파일: {len(modified)}개")
    
    # 실행된 명령어
    commands = task_context.get('commands_executed', [])
    if commands:
        summary_parts.append(f"실행된 명령어: {len(commands)}개")
    
    return '\n'.join(summary_parts)


def on_stop(task_context: Dict[str, Any]) -> Dict[str, Any]:
    """
    작업 완료 훅 메인 함수
    
    Args:
        task_context: 작업 컨텍스트
            - created_files: 생성된 파일 목록
            - modified_files: 수정된 파일 목록
            - current_task: 현재 작업 설명
            - start_time: 시작 시간
            
    Returns:
        Dict containing completion status and next steps
    """
    logger.info("Checking task completion...")
    
    config = load_config()
    stop_config = config.get('hooks', {}).get('stop', {})
    
    checker = CompletionChecker(task_context)
    all_passed = True
    
    # 1. 필수 파일 확인 (작업 타입에 따라 다름)
    # 실제로는 task_context에서 required_files를 가져와야 함
    required_files = task_context.get('required_files', [])
    if required_files:
        if not checker.check_required_files(required_files):
            all_passed = False
    
    # 2. 테스트 통과 확인
    if stop_config.get('require_tests', True):
        if not checker.check_tests_pass():
            all_passed = False
    
    # 3. 린트 확인
    if stop_config.get('require_lint', True):
        if not checker.check_lint_clean():
            all_passed = False
    
    # 4. 문서화 확인
    if stop_config.get('require_docs', False):
        if not checker.check_documentation():
            all_passed = False
    
    # 5. TODO 확인
    if stop_config.get('require_no_todos', False):
        if not checker.check_no_todos_in_code():
            all_passed = False
    
    # 결과 처리
    if not all_passed:
        # 가장 중요한 실패 이유
        primary_failure = checker.checks_failed[0] if checker.checks_failed else {}
        
        logger.warning(f"Task not complete: {primary_failure.get('message', 'Unknown')}")
        
        return {
            'complete': False,
            'continue': True,
            'reason': primary_failure.get('message', '검증 실패'),
            'failed_checks': checker.checks_failed,
            'passed_checks': checker.checks_passed,
            'additional_prompt': _generate_fix_prompt(checker.checks_failed),
            'retry_count': task_context.get('retry_count', 0) + 1
        }
    
    # 작업 완료
    duration = None
    if task_context.get('start_time'):
        start = datetime.fromisoformat(task_context['start_time'])
        duration = str(datetime.now() - start)
    
    summary = generate_task_summary(task_context)
    
    logger.info("Task completed successfully")
    
    # 세션 상태 업데이트
    _mark_task_complete(task_context)
    
    return {
        'complete': True,
        'summary': summary,
        'passed_checks': checker.checks_passed,
        'metrics': {
            'files_created': len(task_context.get('created_files', [])),
            'files_modified': len(task_context.get('modified_files', [])),
            'duration': duration,
            'checks_passed': len(checker.checks_passed)
        }
    }


def _generate_fix_prompt(failed_checks: List[Dict]) -> str:
    """수정 프롬프트 생성"""
    prompts = []
    
    for check in failed_checks:
        check_type = check.get('check')
        
        if check_type == 'required_files':
            missing = check.get('missing', [])
            prompts.append(f"다음 파일들을 생성해주세요: {', '.join(missing)}")
        
        elif check_type == 'tests':
            prompts.append("테스트가 실패했습니다. 테스트를 수정하거나 관련 코드를 수정해주세요.")
            if check.get('output'):
                prompts.append(f"테스트 출력:\n{check['output'][:500]}")
        
        elif check_type == 'lint':
            prompts.append("린트 오류를 수정해주세요.")
            if check.get('output'):
                prompts.append(f"린트 출력:\n{check['output'][:500]}")
        
        elif check_type == 'documentation':
            prompts.append("새로 생성된 코드에 대한 문서(README.md 등)를 작성해주세요.")
        
        elif check_type == 'todos':
            files = check.get('files', [])
            prompts.append(f"다음 파일들의 TODO를 완료해주세요: {', '.join(files)}")
    
    return '\n'.join(prompts)


def _mark_task_complete(task_context: Dict):
    """작업 완료 표시"""
    state = load_json('.claude/current_session.json') or {}
    
    if 'completed_tasks' not in state:
        state['completed_tasks'] = []
    
    state['completed_tasks'].append({
        'task': task_context.get('current_task', 'Unknown'),
        'completed_at': datetime.now().isoformat(),
        'files_created': task_context.get('created_files', []),
        'files_modified': task_context.get('modified_files', [])
    })
    
    save_json('.claude/current_session.json', state)


# 테스트
if __name__ == '__main__':
    test_context = {
        'current_task': '사용자 인증 기능 구현',
        'start_time': datetime.now().isoformat(),
        'created_files': ['lib/auth/auth_service.dart', 'test/auth/auth_service_test.dart'],
        'modified_files': ['lib/main.dart']
    }
    
    result = on_stop(test_context)
    print(f"결과: {result}")

8-10. 나머지 훅 (요약)

지면 관계상 SubagentStop, PreCompact, SessionEnd는 핵심 코드만 제공합니다.

SubagentStop

# .claude/hooks/subagent_stop.py
def on_subagent_stop(subagent_id: str, task_result: Dict) -> Dict[str, Any]:
    """서브에이전트 작업 검증"""
    
    # 서브에이전트별 검증 로직
    validators = {
        'ui_builder': validate_ui_output,
        'api_integrator': validate_api_integration,
        'test_writer': validate_test_coverage,
        'docs_writer': validate_documentation
    }
    
    validator = validators.get(subagent_id)
    if validator:
        return validator(task_result)
    
    return {'complete': True}

PreCompact

# .claude/hooks/pre_compact.py
def on_pre_compact(context: Dict) -> Dict[str, Any]:
    """컨텍스트 압축 전 중요 정보 보존"""
    
    # 핵심 정보 추출 및 저장
    important_data = {
        'decisions': extract_decisions(context),
        'current_task': context.get('current_task'),
        'files_state': context.get('created_files', []),
        'timestamp': datetime.now().isoformat()
    }
    
    save_json('.claude/pre_compact_state.json', important_data)
    
    # 압축 후에도 유지할 정보 반환
    return {
        'preserve': {
            'project_context': context.get('readme'),
            'coding_standards': context.get('style_guide'),
            'current_task': context.get('current_task')
        }
    }

SessionEnd

# .claude/hooks/session_end.py
def on_session_end(session_data: Dict) -> Dict[str, Any]:
    """세션 종료 시 정리 및 리포트 생성"""
    
    # 1. 세션 통계 계산
    statistics = calculate_session_statistics(session_data)
    
    # 2. 마크다운 리포트 생성
    report = generate_session_report(session_data, statistics)
    report_path = save_report(report, session_data['session_id'])
    
    # 3. Git 자동 커밋 (옵션)
    if session_data.get('auto_commit'):
        auto_commit(session_data)
    
    # 4. 백업 생성
    create_session_backup(session_data)
    
    # 5. 임시 파일 정리
    cleanup_temp_files()
    
    # 6. 알림 전송
    notify_session_end(statistics)
    
    return {
        'report_path': report_path,
        'statistics': statistics,
        'cleanup_done': True
    }

실전 프로젝트 설정 예시

Flutter 프로젝트 (앱)

# .claude/config.yaml
version: "1.0"

project:
  name: "프로젝트"
  description: "프로젝트"
  language: "dart"
  framework: "flutter"
  
hooks:
  enabled: true
  
  session_start:
    enabled: true
    load_readme: true
    load_architecture: true
    
  user_prompt_submit:
    enabled: true
    add_context: true
    validate_prompts: true
    blocked_keywords:
      - "production_api_key"
      - "mainnet_private_key"
      - "real_wallet"
    
  permission_request:
    enabled: true
    auto_approve_extensions:
      - ".dart"
      - ".yaml"
      - ".json"
      - ".md"
    auto_deny_patterns:
      - ".env"
      - "secrets/"
      - "*.key"
      
  pre_tool_use:
    enabled: true
    block_dangerous: true
    rate_limits:
      api_call: 30  # per minute
      file_write: 50
      
  post_tool_use:
    enabled: true
    auto_format: true
    auto_lint: true
    auto_test: false  # 수동 트리거
    auto_git_stage: true
    
  stop:
    enabled: true
    require_tests: true
    require_lint: true
    require_no_todos: false
    
  session_end:
    enabled: true
    generate_report: true
    auto_backup: true
    auto_commit: false

security:
  sensitive_paths:
    - ".env"
    - ".env.*"
    - "secrets/"
    - "private_keys/"
    - "*.pem"
    - "*.key"
    
  dangerous_commands:
    - "rm -rf"
    - "sudo"
    - "chmod 777"
    - "DROP"
    - "DELETE FROM"
    
coding_standards:
  architecture: "Clean Architecture"
  state_management: "Riverpod 2.5+"
  language: "한국어 주석, 영문 dartdoc"
  test_coverage: 80
  max_file_lines: 300

디렉토리 구조

your_project/
├── .claude/
│   ├── config.yaml              # 메인 설정
│   ├── STYLE_GUIDE.md          # 코딩 스타일 가이드
│   ├── ARCHITECTURE.md         # 아키텍처 문서
│   ├── hooks/
│   │   ├── __init__.py
│   │   ├── utils.py            # 공통 유틸리티
│   │   ├── session_start.py
│   │   ├── user_prompt_submit.py
│   │   ├── permission_request.py
│   │   ├── pre_tool_use.py
│   │   ├── post_tool_use.py
│   │   ├── notification.py
│   │   ├── stop.py
│   │   ├── subagent_stop.py
│   │   ├── pre_compact.py
│   │   └── session_end.py
│   ├── logs/
│   │   ├── hooks.log
│   │   ├── audit/
│   │   └── prompts/
│   ├── reports/
│   └── backups/
├── lib/
├── test/
└── pubspec.yaml

마무리

핵심 정리

훅 핵심 질문 주요 용도

SessionStart 초기 컨텍스트가 필요한가? 프로젝트 설정 로드
UserPromptSubmit 프롬프트 검증/강화가 필요한가? 보안 검증, 컨텍스트 추가
PermissionRequest 자동 승인/거부할 것인가? 권한 자동화
PreToolUse 도구 실행을 제어할 것인가? 보안 정책 적용
PostToolUse 후처리가 필요한가? 자동 포맷팅, 린트
Notification 어떻게 알릴 것인가? Slack/Desktop 알림
Stop 작업이 완료되었는가? 품질 게이트
SubagentStop 서브태스크가 완료되었는가? 병렬 작업 검증
PreCompact 보존할 정보가 있는가? 컨텍스트 백업
SessionEnd 정리할 것이 있는가? 리포트 생성, 백업

다음 단계

  1. .claude/ 디렉토리 생성
  2. config.yaml 설정
  3. 필요한 훅부터 순차적으로 구현
  4. 팀 전체에 설정 공유 (Git 커밋)

주의사항

  • 훅이 너무 많은 작업을 하면 응답 속도가 느려질 수 있습니다
  • 민감한 정보(API 키 등)는 환경 변수로 관리하세요
  • 훅 오류가 전체 워크플로우를 막지 않도록 예외 처리를 철저히 하세요

질문이나 피드백이 있으시면 댓글로 남겨주세요!

이 글이 도움이 되셨다면 공유해주세요. 🚀

반응형