# 훅 시스템 (Hook System) > G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다. --- ## TL;DR (5초 요약) ```text 1. Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) 2. Filter 훅: applyFilters() - 데이터 변형 (type => 'filter' 필수!) 3. 네이밍: [vendor-module].[entity].[action]_[timing] 4. 우선순위: 1-5(높음), 10(기본), 15+(낮음) 5. Service 패턴: before_* → filter_* → 로직 → after_* ``` --- ## 목차 - [개요](#개요) - [훅 타입](#훅-타입) - [훅 네이밍 규칙](#훅-네이밍-규칙) - [리스너 구현](#리스너-구현) - [코어 리스너 자동 발견](#코어-리스너-자동-발견) - [우선순위 가이드라인](#우선순위-가이드라인) - [모듈에 리스너 등록](#모듈에-리스너-등록) - [Service에서 훅 사용 패턴](#service에서-훅-사용-패턴) - [필터 훅 리스너 예시](#필터-훅-리스너-예시) - [훅 권한 시스템](#훅-권한-시스템) - [관련 문서](#관련-문서) --- ## 개요 G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다. **핵심 파일 위치**: `app/Extension/HookManager.php` --- ## 훅 타입 ### 1. Action Hooks (액션 훅) **목적**: 특정 시점에 부가 작업 실행 **사용 예시**: - 이메일 발송 - 활동 로그 기록 (→ [활동 로그 시스템](../backend/activity-log.md) 참조) - 캐시 무효화 - 알림 전송 **패턴**: ```php // Service에서 훅 실행 HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data); ``` ### 2. Filter Hooks (필터 훅) **목적**: 데이터 변형 및 가공 **사용 예시**: - 가격 계산 - 데이터 포맷 변경 - 권한 확인 **패턴**: ```php // Service에서 필터 적용 $finalPrice = HookManager::applyFilters('sirsoft-ecommerce.product.calculate_price', $basePrice, $product); ``` --- ## 훅 네이밍 규칙 ### 표준 패턴 ```text [vendor-module].[entity].[action]_[timing] ``` ### 타이밍 접미사 | 접미사 | 설명 | | ---------- | ------------------ | | `before_*` | 작업 실행 전 | | `after_*` | 작업 실행 후 | | `on_*` | 특정 이벤트 발생 시 | | `filter_*` | 데이터 변형용 필터 | ### 예시 ```text # 생성/수정/삭제 (변경 작업) sirsoft-ecommerce.product.before_create sirsoft-ecommerce.product.after_create sirsoft-ecommerce.product.after_update sirsoft-ecommerce.product.filter_create_data # 조회 작업 sirsoft-ecommerce.product.before_list sirsoft-ecommerce.product.after_list sirsoft-ecommerce.product.filter_list_query sirsoft-ecommerce.product.filter_list_result sirsoft-ecommerce.product.before_show sirsoft-ecommerce.product.after_show sirsoft-ecommerce.product.filter_show_result # 이벤트/기타 sirsoft-ecommerce.order.on_payment_success sirsoft-ecommerce.product.calculate_price # 코어 훅 core.attachment.download core.attachment.update core.attachment.delete # FormRequest Validation Rules 훅 (Filter) core.user.create_validation_rules core.user.update_validation_rules core.role.store_validation_rules core.role.update_validation_rules core.permission.store_validation_rules core.permission.update_validation_rules core.menu.store_validation_rules core.menu.update_validation_rules # Layout Extension 훅 core.layout_extension.before_apply core.layout_extension.after_apply # 드라이버 확장 훅 (Filter) — 플러그인이 새 드라이버를 등록 core.settings.available_storage_drivers core.settings.available_cache_drivers core.settings.available_session_drivers core.settings.available_queue_drivers core.settings.available_log_drivers core.settings.available_websocket_drivers core.settings.available_mail_drivers # 드라이버 확장 훅 (Action) — 플러그인 드라이버 선택 시 Config 적용 core.settings.apply_driver_config # SEO 렌더링 훅 (Filter) core.seo.filter_context # DataSource 결합 후 컨텍스트 보강 ($context 배열) core.seo.filter_og_data # OG 태그 분기별 hook ($og 배열) — image_width/site_name/extra 주입 core.seo.filter_twitter_data # Twitter 카드 분기별 hook ($twitter 배열) core.seo.filter_structured_data # JSON-LD 분기별 hook ($structuredData 배열) core.seo.filter_meta # 통합 hook (모든 분기 결합 후 $meta 전체) core.seo.filter_view_data # View 직전 ($viewData) — extraHeadTags / extraBodyEnd # SEO 봇 감지 훅 (Filter) — null 반환 시 라이브러리 평가로 fallthrough core.seo.resolve_is_bot ``` --- ## 리스너 구현 ### HookListenerInterface 사용 ```php ['method' => 'handleProductChange', 'priority' => 10], 'sirsoft-ecommerce.product.after_update' => ['method' => 'handleProductChange', 'priority' => 10], 'sirsoft-ecommerce.product.after_delete' => ['method' => 'handleProductChange', 'priority' => 10], ]; } /** * 상품 변경 시 캐시 무효화 * * @param mixed ...$args * @return void */ public function handleProductChange(...$args): void { // CacheInterface 를 컨테이너에서 lazy resolve (모듈 리스너면 ModuleCacheDriver) $cache = app(\App\Contracts\Extension\CacheInterface::class); // 모든 상품 목록 캐시 삭제 $cache->forget('products.all'); $cache->forget('products.active'); // 특정 상품 캐시 삭제 if (isset($args[0]->id)) { $cache->forget("product.{$args[0]->id}"); } Log::info('상품 캐시가 무효화되었습니다.', [ 'product_id' => $args[0]->id ?? null, ]); } } ``` ### Listener 데이터 접근 규정 Listener 는 thin orchestrator 로 동작하며 영속/도메인 책임은 Service / Repository 가 갖는다. 다음 패턴은 audit 가 차단한다. | ❌ 금지 | ✅ 올바른 사용 | |--------|---------------| | `Model::query()->where(...)->get()` | `$this->modelRepository->findByXxx()` 위임 | | `Model::find($id)`, `Model::create($data)` | Repository 의 `findById/create` 메서드 | | `DB::table('xxx')->update(...)` | Repository 의 도메인 의도 메서드 (`recalculateXxxCount`, `anonymizeUser` 등) | | `$row->save()`, `$row->saveQuietly()`, `$row->delete()` | Repository 의 `update/save/delete` | | 생성자에 구체 Repository 클래스 (`UserRepository $r`) | Repository Interface (`UserRepositoryInterface $r`) | | `request()->input(...)`, `$_POST` | Service 가 검증 후 도메인 객체로 전달 — Listener 는 받기만 | | Filter 훅 `'method' => '...'` 만 (`type` 누락) | `'type' => 'filter'` 명시 (반환값 무시 회귀 차단) | #### Service-Repository 위임 패턴 예시 — 카운트 동기화 ```php class BoardCommentsCountSyncListener implements HookListenerInterface { public function __construct( protected BoardRepositoryInterface $boardRepository, ) {} public static function getSubscribedHooks(): array { return [ 'sirsoft-board.comment.after_create' => ['method' => 'syncCommentsCount', 'priority' => 10, 'sync' => true], 'sirsoft-board.comment.after_delete' => ['method' => 'syncCommentsCount', 'priority' => 10, 'sync' => true], ]; } public function syncCommentsCount(Comment $comment, string $slug): void { // ❌ 금지: DB::table('board_comments')->where(...)->count() + DB::table('boards')->update(...) // ✅ 위임: 카운트 재계산은 Repository 의 단일 진입점 $this->boardRepository->recalculateCommentsCount((int) $comment->board_id); } } ``` #### Bulk lookup 패턴 — `findByIdsKeyed` ActivityLogListener 등에서 ID 목록으로 모델을 일괄 조회 후 ID 키 맵으로 사용하는 패턴은 Repository 의 `findByIdsKeyed(array $ids): Collection` 으로 캡슐화한다. ```php // ❌ 금지 $products = Product::whereIn('id', $ids)->get()->keyBy('id'); // ✅ 위임 $products = $this->productRepository->findByIdsKeyed($ids); ``` #### 면제 (allowlist) 다음과 같은 의도된 예외는 인라인 주석으로 명시한다 (audit 가 해당 라인을 건너뜀). ```php // audit:allow listener-direct-db-facade reason: 동적 modelClass dispatch (description resolver 의 unified ID→name 변환) $entity = $modelClass::find($id); ``` 면제는 *실제로 Repository 추상화가 부적절한* 경우에만 (예: 동적 `$modelClass::find` dispatcher, boot 컨텍스트 정적 메서드) 사용하며, reason 을 반드시 작성한다. #### 명시 등록 패턴 (HookListenerInterface 면제) 대부분의 Listener 는 `HookListenerInterface` 구현 + auto-discovery 로 등록되지만, ServiceProvider 가 직접 `HookManager::addAction/addFilter` 로 등록하는 명시 등록 패턴 (예: LanguagePack 라이프사이클 listeners) 은 인터페이스 미구현이 합법이다. 이 경우 클래스 선언 직전에 다음 면제를 추가: ```php // audit:allow listener-must-implement-hooklistenerinterface reason: ServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 class SyncDatabaseTranslations { public function __construct( private readonly LanguagePackTranslationRepositoryInterface $translationRepository, ) {} public function handleActivated(LanguagePack $pack): void { // Service / Repository 위임만 수행 $audit = DB::transaction(fn () => $this->translationRepository->applySeedFromPack($pack, $seedBundle)); } } ``` --- ## 큐 자동 실행 Action 훅 리스너는 환경설정의 **큐 드라이버** 설정에 따라 자동으로 동기/비동기 실행됩니다. 리스너 개발자가 별도로 설정할 필요 없습니다. ### 큐 드라이버별 동작 | 큐 드라이버 | 리스너 실행 방식 | 큐 워커 필요 | |------------|-----------------|-------------| | `sync` | 즉시 실행 (동기) | 불필요 | | `database` | jobs 테이블 저장 → 워커 처리 | 필요 (`php artisan queue:work`) | | `redis` | Redis 큐 저장 → 워커 처리 | 필요 (`php artisan queue:work`) | | 커스텀 (플러그인 추가) | 해당 드라이버 큐 → 워커 처리 | 필요 | > **Filter 훅**은 반환값 체인이므로 **항상 동기 실행**됩니다 (큐 드라이버 무관). ### 동기 실행 강제 (opt-out) 리스너가 반드시 HTTP 요청 스레드에서 동기 실행되어야 하는 경우, `'sync' => true`를 선언합니다. ```php public static function getSubscribedHooks(): array { return [ 'core.user.after_create' => [ 'method' => 'handleUserCreated', 'priority' => 10, 'sync' => true, // 큐 드라이버 무관하게 항상 즉시 실행 ], ]; } ``` ### getSubscribedHooks() 옵션 요약 | 옵션 | 타입 | 기본값 | 설명 | |------|------|--------|------| | `method` | string | `'handle'` | 실행할 메서드명 | | `priority` | int | `10` | 실행 우선순위 (낮을수록 먼저) | | `type` | string | `'action'` | `'action'` 또는 `'filter'` | | `sync` | bool | `false` | `true`: 큐 드라이버 무관하게 동기 실행 | ### 내부 구현 `HookListenerRegistrar`가 리스너 등록 시 큐/동기 분기를 처리합니다: - Action + `sync: false` (기본) → `DispatchHookListenerJob`으로 래핑하여 `dispatch()` - Action + `sync: true` → 기존 방식 동기 실행 - Filter → 항상 동기 실행 `DispatchHookListenerJob`은 리스너 클래스명과 메서드명을 직렬화하고, 큐 워커에서 DI 컨테이너로 리스너를 재생성하여 호출합니다. ### 사용자 컨텍스트 자동 복원 큐 워커는 별도 프로세스이므로 `Auth::user()`, `request()->ip()`, `App::getLocale()` 등이 모두 리셋됩니다. 이를 보완하기 위해 `HookContextCapture`가 디스패치 시점에 다음 항목을 자동 캡처하고, 워커에서 복원합니다: | 항목 | 캡처 출처 | 복원 효과 | | ---- | --------- | --------- | | `user_id` | `Auth::id()` | 워커에서 `Auth::user()` 사용 가능 (활동로그 actor 정상 기록) | | `ip_address` | `request()->ip()` | 워커에서 `request()->ip()` 사용 가능 | | `user_agent` | `request()->userAgent()` | 워커에서 `request()->userAgent()` 사용 가능 | | `locale` | `App::getLocale()` | 워커에서 다국어 메시지가 원래 요청 로케일로 발송됨 | | `path` | `request()->path()` | `ResolvesActivityLogType`이 워커에서 정상 동작 | 리스너 코드는 변경 불필요 — 평소처럼 `Auth::user()`, `request()->ip()` 호출하면 됩니다. #### 확장 컨텍스트 추가 (플러그인) 플러그인이 `tenant_id`, `trace_id` 등 추가 컨텍스트를 캡처/복원하려면 코어 수정 없이 훅으로 확장 가능: ```php // 캡처 시 키 추가 HookManager::addFilter('hook.context.capture', function (array $context) { $context['tenant_id'] = app('tenant')->id; return $context; }); // 복원 시 처리 HookManager::addAction('hook.context.restore', function (array $context) { if (! empty($context['tenant_id'])) { app('tenant')->setId($context['tenant_id']); } }); ``` ### 주의사항 - 큐 디스패치 시 인자는 `HookArgumentSerializer`로 직렬화됩니다. Eloquent Model은 PK로 변환 후 워커에서 DB 재조회합니다. - Closure 등 직렬화 불가능한 인자는 null로 대체되므로, 훅 인자로 Closure를 전달하지 마세요. - 큐 드라이버가 `database`/`redis`일 때 큐 워커가 미실행이면 작업이 적체됩니다. - 사용자 컨텍스트는 자동 복원되지만, `request()->session()` 등 세션 의존 정보는 복원되지 않습니다 (큐 컨텍스트는 단발 실행). --- ## 코어 리스너 자동 발견 코어 애플리케이션의 훅 리스너는 `app/Listeners/` 디렉토리에서 **자동 발견**됩니다. `HookListenerInterface`를 구현한 클래스는 별도 등록 없이 자동으로 HookManager에 등록됩니다. ### 자동 발견 조건 | 조건 | 설명 | |------|------| | 위치 | `app/Listeners/` 및 모든 하위 디렉토리 | | 인터페이스 | `HookListenerInterface` 구현 필수 | | 등록 방식 | `CoreServiceProvider::boot()`에서 재귀 스캔 | ### 디렉토리 구조 예시 ```text app/Listeners/ ├── ActivityLogListener.php ✅ 자동 발견 ├── CoreActivityLogListener.php ✅ 자동 발견 ├── ExtensionCompatibilityAlertListener.php ✅ 자동 발견 ├── Dashboard/ │ ├── DashboardModuleListener.php ✅ 자동 발견 (하위 디렉토리) │ └── DashboardStatsListener.php ✅ 자동 발견 (하위 디렉토리) └── UserLogin/ └── UpdateLastLoginListener.php ✅ 자동 발견 (하위 디렉토리) ``` ### CoreServiceProvider 구현 ```php // app/Providers/CoreServiceProvider.php private function registerCoreHookListeners(): void { $listenersPath = app_path('Listeners'); if (! is_dir($listenersPath)) { return; } // 재귀적으로 모든 PHP 파일 스캔 $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if ($file->getExtension() !== 'php') { continue; } // 파일 경로에서 클래스명 추출 $relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname()); $relativePath = str_replace('.php', '', $relativePath); $relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath); $listenerClass = 'App\\Listeners\\'.$relativePath; // 클래스 존재 여부 확인 if (! class_exists($listenerClass)) { Log::warning("코어 훅 리스너 클래스를 찾을 수 없습니다: {$listenerClass}"); continue; } // HookListenerInterface 구현 여부 확인 if (! in_array(HookListenerInterface::class, class_implements($listenerClass))) { continue; } $this->registerCoreHookListener($listenerClass); } } ``` ### 장점 | 장점 | 설명 | |------|------| | 자동 활성화 | 리스너 파일 생성만으로 훅 구독 시작 | | ServiceProvider 수정 불필요 | 새 리스너 추가 시 코드 변경 없음 | | 모듈/플러그인과 동일 패턴 | 일관된 아키텍처 | | 메모리 효율 | 클로저 래퍼로 지연 인스턴스 생성 | ### 모듈/플러그인과의 차이점 | 항목 | 코어 리스너 | 모듈/플러그인 리스너 | |------|------------|-------------------| | 발견 방식 | 디렉토리 재귀 스캔 | `getHookListeners()` 메서드 | | 위치 | `app/Listeners/**/*.php` | `modules/**/Listeners/`, `plugins/**/Listeners/` | | 등록 주체 | `CoreServiceProvider` | `ModuleServiceProvider`, `PluginServiceProvider` | ### 동적 훅 리스너 (DB 기반) DB 설정에 따라 훅 구독 대상이 동적으로 변하는 경우, 리스너에 `registerDynamicHooks()` 메서드를 구현합니다. 자동 발견 시스템이 이 메서드를 감지하여 `boot()` 후반부(DB 접근 가능 시점)에서 자동 호출합니다. ```php class NotificationHookListener implements HookListenerInterface { // 정적 훅은 빈 배열 (DB 기반이므로) public static function getSubscribedHooks(): array { return []; } public function handle(...$args): void {} /** * DB 기반 동적 훅을 등록합니다. * CoreServiceProvider 자동 발견 시스템이 boot 후반부에서 자동 호출합니다. */ public function registerDynamicHooks(): void { // notification_definitions 테이블에서 훅 목록 조회 후 // 각 훅을 HookListenerRegistrar::registerDynamicAction() 으로 등록 foreach ($definitions as $definition) { foreach ($definition->hooks as $hook) { HookListenerRegistrar::registerDynamicAction( hookName: $hook, listenerClass: self::class, method: 'dispatchForDefinition', boundArgs: [$definition], // 워커가 dispatchForDefinition($definition, ...$hookArgs) 로 복원 호출 priority: 30, ); } } } } ``` 동적 훅도 **큐 디스패치가 기본**이다. 직접 `HookManager::addAction($hook, fn() => $this->send(...))` 로 등록하면 콜백이 훅 발화 시점(HTTP 요청 스레드)에서 동기 실행되어, 발송 대상이 많을 때 요청이 전체 처리 완료까지 막힌다 (정적 `getSubscribedHooks` 리스너는 `HookListenerRegistrar` 가 자동으로 큐 래핑하지만, 동적 등록은 Registrar 를 거치지 않으므로 이 누락이 발생하기 쉽다). 동적 등록은 반드시 `HookListenerRegistrar::registerDynamicAction()` 에 위임해 정적 등록과 동일한 큐/동기 정책을 적용한다. 직접 `dispatch(new DispatchHookListenerJob(...))` 를 리스너에 작성하지 않는다 — 큐 래핑 로직의 소유권은 Registrar 한 곳에 둔다. | `registerDynamicAction` 인자 | 설명 | |------|------| | `hookName` | 구독할 훅 이름 | | `listenerClass` / `method` | 큐 워커가 복원해 호출할 리스너 FQCN·public 메서드 | | `boundArgs` | 훅 발화 인자 앞에 고정으로 붙일 인자 (예: DB 정의 모델). 직렬화 가능해야 함 | | `priority` | 실행 우선순위 (기본 10) | | `sync` | `true` 면 큐 래핑 없이 즉시 동기 실행 (큐 드라이버 무관). IDV 가드 등 요청 스레드 동기 실행이 필수일 때만 | | 조건 | 설명 | |------|------| | 메서드명 | `registerDynamicHooks()` (덕 타이핑) | | 호출 시점 | `CoreServiceProvider::boot()` 후반부 (DB 유효성 검증 후) | | 별도 인터페이스 | 불필요 — `method_exists()` 체크 | | 안전성 | 테이블 미존재 시 `Schema::hasTable()` 체크 필수 | | 큐 정책 | `HookListenerRegistrar::registerDynamicAction()` 위임 (기본 큐, `sync: true` opt-out) — 직접 `addAction` 으로 동기 발송 금지 | --- ### HookManager::broadcast() — WebSocket 브로드캐스트 훅 리스너에서 WebSocket 브로드캐스트를 실행할 때 사용합니다. ```php use App\Extension\HookManager; HookManager::broadcast( 'user.notifications.123', // 채널 'notification.received', // 이벤트명 ['subject' => '새 알림'] // 데이터 ); ``` 개별 Event 클래스(`app/Events/`)를 직접 생성하지 않습니다. `HookManager::broadcast()`가 내부적으로 `GenericBroadcastEvent`를 사용합니다. --- ## 우선순위 가이드라인 **우선순위 범위**: 1 ~ 100 (낮을수록 먼저 실행) | 우선순위 | 용도 | 예시 | | -------- | ---------- | ------------------ | | 1-5 | 매우 높음 | 데이터 검증, 보안 체크 | | 10 | 기본값 | 일반 리스너 | | 15-20 | 낮음 | 알림, 로깅 | | 25+ | 매우 낮음 | 분석, 통계 | ### 정렬 메커니즘 HookManager는 `ksort()`를 사용하여 우선순위별 정렬 후 실행합니다: | 규칙 | 설명 | | ---- | ---- | | 정렬 방식 | `ksort()` — 숫자 오름차순 (낮을수록 먼저 실행) | | 기본값 | `$priority = 10` (`addAction`, `addFilter` 공통) | | 동일 우선순위 | 등록 순서대로 실행 (FIFO — 배열 push 순서 유지) | ```php // 우선순위 5 → 10 → 20 순서로 실행 HookManager::addAction('order.created', $securityCheck, 5); HookManager::addAction('order.created', $businessLogic, 10); HookManager::addAction('order.created', $notification, 20); ``` --- ## 모듈에 리스너 등록 ### module.php에서 등록 ```php productRepository->create($data); // 4. After 훅 - 후처리, 알림, 캐시 등 HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data); return $product; } public function calculateProductPrice(Product $product): float { $basePrice = $product->price; // 필터 훅으로 가격 계산 (할인, 세금 등) $finalPrice = HookManager::applyFilters( 'sirsoft-ecommerce.product.calculate_price', $basePrice, $product ); return $finalPrice; } } ``` --- ## 필터 훅 리스너 예시 ### `type` 필드 사용법 `getSubscribedHooks()` 메서드에서 `type` 필드를 사용하여 훅 타입을 명시해야 합니다. | type 값 | 설명 | 기본값 | |---------|------|--------| | `action` | Action 훅 - 반환값 없음 | ✅ (생략 시 기본값) | | `filter` | Filter 훅 - 반환값 필수 | | **중요**: Filter 훅 리스너는 반드시 `'type' => 'filter'`를 명시해야 합니다. 명시하지 않으면 Action 훅으로 처리되어 **반환값이 무시**됩니다. ### Filter 훅 리스너 구현 ```php 'filter' 명시 'sirsoft-ecommerce.product.calculate_price' => [ 'method' => 'applyDiscounts', 'priority' => 10, 'type' => 'filter', // 필수! ], ]; } /** * 할인 적용 * * @param float $price 기본 가격 * @param Product $product 상품 객체 * @return float 할인 적용된 가격 */ public function applyDiscounts(float $price, $product): float { // 10% 할인 적용 (예시) if ($product->category->name === 'Special') { $price = $price * 0.9; } return $price; // 반드시 값을 반환해야 함 } } ``` ### Action과 Filter 혼합 사용 하나의 리스너에서 Action 훅과 Filter 훅을 함께 구독할 수 있습니다: ```php [ 'method' => 'filterCreateData', 'priority' => 10, 'type' => 'filter', ], 'core.user.filter_update_data' => [ 'method' => 'filterUpdateData', 'priority' => 10, 'type' => 'filter', ], 'core.user.filter_resource_data' => [ 'method' => 'filterResourceData', 'priority' => 10, 'type' => 'filter', ], // Action 훅: 부가 작업 (type 생략 가능) 'core.user.after_create' => [ 'method' => 'afterCreate', 'priority' => 10, // type 생략 = 'action' ], ]; } /** * 생성 데이터 필터: 모듈 필드 추출 후 제거 * * @param array $data 요청 데이터 * @return array 모듈 필드가 제거된 데이터 */ public function filterCreateData(array $data): array { // 모듈 필드 추출 및 임시 저장 $moduleData = $this->extractModuleData($data); session(['module_data' => $moduleData]); // 모듈 필드 제거 후 반환 return $this->removeModuleFields($data); } /** * 생성 후 액션: 임시 저장된 데이터로 모듈 데이터 저장 * * @param User $user 생성된 사용자 * @param array $originalData 원본 요청 데이터 * @return void */ public function afterCreate(User $user, array $originalData): void { $moduleData = session('module_data', []); session()->forget('module_data'); if (!empty($moduleData)) { $this->saveModuleData($user->id, $moduleData); } } /** * API 응답 필터: 모듈 데이터 병합 * * @param array $data API 응답 데이터 * @param User $user 조회 대상 사용자 * @return array 모듈 데이터가 병합된 응답 */ public function filterResourceData(array $data, User $user): array { $moduleData = $this->getModuleData($user->id); return array_merge($data, $moduleData); } } ``` ### SEO 훅 활용 예시 — 리뷰 플러그인이 JSON-LD에 리뷰 데이터 주입 ```php [ 'method' => 'injectReviewJsonLd', 'priority' => 10, 'type' => 'filter', // 필수! ], ]; } /** * SEO 메타에 리뷰 JSON-LD를 주입합니다. * * @param array $meta 메타 태그 배열 * @param array $hookMeta 훅 메타 정보 (layoutName, moduleIdentifier 등) * @return array 수정된 메타 배열 */ public function injectReviewJsonLd(array $meta, array $hookMeta): array { // 상품 상세 페이지에서만 동작 if ($hookMeta['moduleIdentifier'] !== 'sirsoft-ecommerce') { return $meta; } $context = $hookMeta['context'] ?? []; $productId = $context['route']['id'] ?? null; if (! $productId) { return $meta; } // 리뷰 데이터 조회 및 JSON-LD 주입 $reviews = $this->getReviewAggregate($productId); if ($reviews && $meta['jsonLd']) { $jsonLd = json_decode($meta['jsonLd'], true); $jsonLd['aggregateRating'] = [ '@type' => 'AggregateRating', 'ratingValue' => $reviews['average'], 'reviewCount' => $reviews['count'], ]; $meta['jsonLd'] = json_encode($jsonLd, JSON_UNESCAPED_UNICODE); } return $meta; } } ``` ### 훅 타입별 동작 비교 | 구분 | Action 훅 | Filter 훅 | |------|----------|-----------| | `type` 필드 | 생략 가능 (`'action'` 기본값) | **필수** (`'type' => 'filter'`) | | 반환값 | 무시됨 | **필수** (다음 필터로 전달) | | HookManager 메서드 | `doAction()` | `applyFilters()` | | 사용 목적 | 부가 작업 (로그, 알림, 캐시 등) | 데이터 변형 (필터링, 병합, 제거 등) | --- ## 훅 내부 메커니즘 ### 중복 실행 방지 (가드 플래그) HookManager는 `$dispatching` 배열을 사용하여 동일 훅의 중복 실행을 방지합니다. Laravel Event 시스템과의 하이브리드 동작에서 발생할 수 있는 무한 루프를 차단합니다. ```text doAction('hook.name') → $dispatching['hook.name'] = true (가드 설정) → Event::dispatch('hook.name') → 리스너 실행 중 doAction('hook.name') 재호출 시 → $dispatching 체크 → 스킵 (중복 방지) → unset($dispatching['hook.name']) (가드 해제) ``` ### addAction/addFilter 내부 동작 ```text addAction() → Event::listen() 등록 + 리스너 배열 관리 addFilter() → Event::listen() 등록 + 리스너 배열 관리 (동일) removeAction() / removeFilter() → Event::forget() + 배열에서 제거 ``` --- ## 훅 권한 시스템 G7은 훅 실행 시 **권한 체크**를 지원합니다. `permission_hooks` 테이블을 통해 특정 훅을 실행하려면 어떤 권한이 필요한지 매핑할 수 있습니다. ### 하이브리드 권한 체계 ```text ┌─────────────────────────────────────────────────────────────┐ │ 권한 체크 레이어 │ ├─────────────────────────────────────────────────────────────┤ │ 1단계: permission_hooks (기능 레벨) │ │ → 'core.attachment.download' 훅 실행 권한 │ │ → 이 기능 자체를 사용할 수 있는가? │ │ → 미매핑 시 모든 사용자 허용 │ │ → 매핑 시 해당 퍼미션 보유자만 허용 │ ├─────────────────────────────────────────────────────────────┤ │ 2단계: role_* (리소스 레벨) │ │ → 특정 파일/메뉴에 대한 접근 권한 │ │ → 이 리소스를 읽을/수정할/삭제할 수 있는가? │ │ → 현행 로직 유지 │ └─────────────────────────────────────────────────────────────┘ ``` ### permission_hooks 테이블 ```php Schema::create('permission_hooks', function (Blueprint $table) { $table->id(); $table->foreignId('permission_id')->constrained()->cascadeOnDelete(); $table->string('hook_name')->comment('훅 이름 (예: core.attachment.download)'); $table->timestamps(); $table->unique(['permission_id', 'hook_name']); $table->index('hook_name'); }); ``` ### PermissionHook 모델 ```php // app/Models/PermissionHook.php class PermissionHook extends Model { protected $fillable = ['permission_id', 'hook_name']; public function permission(): BelongsTo { return $this->belongsTo(Permission::class); } /** * 특정 훅에 매핑된 권한 ID 목록 조회 */ public static function getPermissionsForHook(string $hookName): array { return static::where('hook_name', $hookName) ->pluck('permission_id') ->toArray(); } /** * 훅에 권한이 매핑되어 있는지 확인 */ public static function hasPermissionMapping(string $hookName): bool { return static::where('hook_name', $hookName)->exists(); } } ``` ### HookManager의 권한 체크 메서드 ```php // app/Extension/HookManager.php class HookManager { /** * 훅 실행 전 권한 체크 * * @param string $hookName 훅 이름 * @param User|null $user 사용자 (null이면 비로그인) * @return bool 실행 허용 여부 */ public static function checkHookPermission(string $hookName, ?User $user): bool { // 1. 훅에 매핑된 권한이 없으면 모든 사용자 허용 if (!PermissionHook::hasPermissionMapping($hookName)) { return true; } // 2. 비로그인 사용자는 권한 매핑된 훅 실행 불가 if ($user === null) { return false; } // 3. 관리자는 모든 훅 실행 가능 if ($user->hasRole('admin')) { return true; } // 4. 사용자의 역할에 해당 권한이 있는지 확인 $requiredPermissionIds = PermissionHook::getPermissionsForHook($hookName); return $user->roles() ->whereHas('permissions', function ($query) use ($requiredPermissionIds) { $query->whereIn('permissions.id', $requiredPermissionIds); }) ->exists(); } /** * 권한 체크 후 Action 실행 * * @param string $hookName 훅 이름 * @param User|null $user 사용자 * @param mixed ...$args 훅 인자 * @return bool 실행 성공 여부 */ public static function doActionWithPermission(string $hookName, ?User $user, ...$args): bool { if (!static::checkHookPermission($hookName, $user)) { return false; } static::doAction($hookName, ...$args); return true; } /** * 권한 체크 후 Filter 적용 * * @param string $hookName 훅 이름 * @param User|null $user 사용자 * @param mixed $value 필터링할 값 * @param mixed ...$args 추가 인자 * @return mixed 필터링된 값 (권한 없으면 원본 반환) */ public static function applyFiltersWithPermission(string $hookName, ?User $user, $value, ...$args) { if (!static::checkHookPermission($hookName, $user)) { return $value; } return static::applyFilters($hookName, $value, ...$args); } } ``` ### 훅에 권한 매핑하기 ```php // 시더 또는 관리자 기능에서 // 'attachment.download' 권한을 'core.attachment.download' 훅에 매핑 $permission = Permission::where('identifier', 'attachment.download')->first(); PermissionHook::create([ 'permission_id' => $permission->id, 'hook_name' => 'core.attachment.download', ]); ``` ### 권한 매핑 해제 (모든 사용자 허용) ```php PermissionHook::where('hook_name', 'core.attachment.download')->delete(); ``` ### Service에서 사용 예시 ```php // app/Services/AttachmentService.php public function download(int $id, ?User $user): ?Attachment { // 1단계: 기능 레벨 권한 체크 (permission_hooks) if (!HookManager::checkHookPermission('core.attachment.download', $user)) { throw new AuthorizationException(__('attachment.download_permission_denied')); } $attachment = $this->repository->findById($id); // 2단계: 리소스 레벨 권한 체크 (role_attachments) if (!$this->checkResourcePermission($user, $attachment, AttachmentPermissionType::Read)) { throw new AuthorizationException(__('attachment.resource_access_denied')); } return $attachment; } ``` ### 권한 체크 흐름 요약 ```text ┌─────────────────────────────────────────────────────────────┐ │ checkHookPermission() 흐름 │ ├─────────────────────────────────────────────────────────────┤ │ 1. permission_hooks에 훅 매핑 확인 │ │ → 매핑 없음: 모든 사용자 허용 (return true) │ ├─────────────────────────────────────────────────────────────┤ │ 2. 비로그인 사용자 │ │ → 매핑 있으면 거부 (return false) │ ├─────────────────────────────────────────────────────────────┤ │ 3. 관리자(admin) 역할 │ │ → 모든 훅 실행 허용 (return true) │ ├─────────────────────────────────────────────────────────────┤ │ 4. 사용자 역할의 권한 확인 │ │ → 매핑된 권한 보유 여부 반환 │ └─────────────────────────────────────────────────────────────┘ ``` --- ### SEO 캐시 무효화 리스너 모듈의 CRUD 훅에서 SEO 캐시를 무효화하는 패턴: | 훅 | 리스너 메서드 | 무효화 대상 | |----|-------------|-----------| | `[module].product.after_create` | `onProductChange` | 해당 URL + 목록/카테고리 | | `[module].product.after_update` | `onProductUpdate` | 해당 URL + 목록 | | `[module].*.after_delete` | `onProductChange` | 전체 관련 캐시 | 캐시 무효화 시 `app(CacheInterface::class)->forget('seo.sitemap')` 도 함께 호출 (드라이버가 `g7:core:` 접두사 자동 적용) > 구현 상세: [seo-system.md](../backend/seo-system.md) --- ## 관련 문서 - [모듈 개발 기초](module-basics.md) - 모듈에서 훅 리스너 등록 - [Service-Repository 패턴](../backend/service-repository.md) - Service에서 훅 실행 패턴 - [활동 로그 시스템](../backend/activity-log.md) - 활동 로그 Listener 작성 패턴, Per-Item Bulk 로깅 규칙 - [권한 시스템](permissions.md) - Permission, Role 체계 - [확장 시스템 인덱스](index.md) - 전체 확장 시스템 개요