Skip to content

Latest commit

 

History

History
1200 lines (936 loc) · 43.4 KB

File metadata and controls

1200 lines (936 loc) · 43.4 KB

훅 시스템 (Hook System)

G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다.


TL;DR (5초 요약)

1. Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시)
2. Filter 훅: applyFilters() - 데이터 변형 (type => 'filter' 필수!)
3. 네이밍: [vendor-module].[entity].[action]_[timing]
4. 우선순위: 1-5(높음), 10(기본), 15+(낮음)
5. Service 패턴: before_* → filter_* → 로직 → after_*

목차


개요

G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다.

핵심 파일 위치: app/Extension/HookManager.php


훅 타입

1. Action Hooks (액션 훅)

목적: 특정 시점에 부가 작업 실행

사용 예시:

패턴:

// Service에서 훅 실행
HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);

2. Filter Hooks (필터 훅)

목적: 데이터 변형 및 가공

사용 예시:

  • 가격 계산
  • 데이터 포맷 변경
  • 권한 확인

패턴:

// Service에서 필터 적용
$finalPrice = HookManager::applyFilters('sirsoft-ecommerce.product.calculate_price', $basePrice, $product);

훅 네이밍 규칙

표준 패턴

[vendor-module].[entity].[action]_[timing]

타이밍 접미사

접미사 설명
before_* 작업 실행 전
after_* 작업 실행 후
on_* 특정 이벤트 발생 시
filter_* 데이터 변형용 필터

예시

# 생성/수정/삭제 (변경 작업)
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

namespace Modules\Sirsoft\Ecommerce\Listeners;

use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class ProductCacheInvalidationListener implements HookListenerInterface
{
    /**
     * 구독할 훅 목록 반환
     *
     * @return array
     */
    public static function getSubscribedHooks(): array
    {
        return [
            // Action 훅: type 생략 시 기본값 'action'
            'sirsoft-ecommerce.product.after_create' => ['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 위임 패턴 예시 — 카운트 동기화

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 으로 캡슐화한다.

// ❌ 금지
$products = Product::whereIn('id', $ids)->get()->keyBy('id');

// ✅ 위임
$products = $this->productRepository->findByIdsKeyed($ids);

면제 (allowlist)

다음과 같은 의도된 예외는 인라인 주석으로 명시한다 (audit 가 해당 라인을 건너뜀).

// 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) 은 인터페이스 미구현이 합법이다. 이 경우 클래스 선언 직전에 다음 면제를 추가:

// 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를 선언합니다.

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 등 추가 컨텍스트를 캡처/복원하려면 코어 수정 없이 훅으로 확장 가능:

// 캡처 시 키 추가
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()에서 재귀 스캔

디렉토리 구조 예시

app/Listeners/
├── ActivityLogListener.php           ✅ 자동 발견
├── CoreActivityLogListener.php       ✅ 자동 발견
├── ExtensionCompatibilityAlertListener.php  ✅ 자동 발견
├── Dashboard/
│   ├── DashboardModuleListener.php   ✅ 자동 발견 (하위 디렉토리)
│   └── DashboardStatsListener.php    ✅ 자동 발견 (하위 디렉토리)
└── UserLogin/
    └── UpdateLastLoginListener.php   ✅ 자동 발견 (하위 디렉토리)

CoreServiceProvider 구현

// 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

정적 훅 매핑 캐시 (Static Hook Cache)

매 요청 부팅 시 코어(app/Listeners 재귀 스캔) + 모듈/플러그인(getHookListeners())의 정적 훅 리스너를 발견·리플렉션·getSubscribedHooks() 클래스 로딩하는 비용을 제거하기 위해, 사전 계산한 훅 매핑을 bootstrap/cache/hooks.php 에 캐시합니다 (오토로드 캐시 autoload-extensions.php 와 동일 위치·생명주기).

캐시는 "무엇을 등록할지 목록" 만 제공하며 등록 자체는 여전히 부팅에서 수행 되므로 등록↔발화 순서 계약은 불변입니다. 캐시 경로 등록 결과는 스캔 경로와 훅 매핑이 바이트 동일 합니다 (HookListenerRegistrar::registerFromCache()register() 와 동일한 applySubscribedHooks() 에 위임).

동작

상태 부팅 시 동작
캐시 존재 (bootstrap/cache/hooks.php) 스캔·리플렉션·클래스 로딩 없이 캐시 매핑으로 등록
캐시 부재 / 손상 / 구조 불일치 기존 스캔 경로로 안전 폴백 (항상 동작)
테스트 환경 (APP_ENV=testing) 캐시 미사용 — 매 setUp 스캔이 정확·격리 우선
  • 동적 훅(알림 등 registerDynamicHooks())은 캐시 대상이 아니며 코드 변경 없이 그대로 동작합니다. 캐시에는 각 리스너의 dynamic 플래그만 저장하여, 동적 훅 보유 코어 리스너의 boot 후반부 지연 실행 순서를 스캔 경로와 동일하게 유지합니다.
  • 동적 훅의 DB 조회(NotificationDefinitionService::getAllActive())는 이미 ['notification'] 태그로 캐시되어 있으므로 첫 요청(캐시 워밍) 이후 DB 조회가 발생하지 않습니다.

재생성 (무효화 = 재생성)

정적 훅 매핑은 확장 변경 또는 코어 리스너 코드 배포 시에만 바뀝니다. 해당 시점에 자동/수동 재생성됩니다:

사건 재생성 경로
확장 install / activate / deactivate / uninstall / update ExtensionManager::updateComposerAutoload() 가 오토로드 캐시와 나란히 재생성 (자동)
코어 업데이트 (core:update) clearAllCaches()extension:update-autoload 가 오토로드 + 훅 캐시 함께 재생성 (자동). 코어 리스너 추가/변경/삭제 반영
코어 리스너 코드 배포 php artisan hooks:cache (배포 파이프라인 — route:cache 동형)
php artisan hooks:cache   # 정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)
php artisan hooks:clear   # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전)

캐시 파일은 Git 미추적(bootstrap/cache/*) 이며 배포 환경마다 생성됩니다. 캐시가 없어도 스캔 폴백으로 정상 동작하므로 필수는 아니지만, 프로덕션 배포 시 부팅 비용 절감을 위해 config:cache/route:cache 와 함께 실행하는 것을 권장합니다.

핵심 파일: app/Extension/HookCacheManager.php, app/Extension/HookListenerRegistrar.php (registerFromCache)

동적 훅 리스너 (DB 기반)

DB 설정에 따라 훅 구독 대상이 동적으로 변하는 경우, 리스너에 registerDynamicHooks() 메서드를 구현합니다. 자동 발견 시스템이 이 메서드를 감지하여 boot() 후반부(DB 접근 가능 시점)에서 자동 호출합니다.

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 브로드캐스트를 실행할 때 사용합니다.

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 순서 유지)
// 우선순위 5 → 10 → 20 순서로 실행
HookManager::addAction('order.created', $securityCheck, 5);
HookManager::addAction('order.created', $businessLogic, 10);
HookManager::addAction('order.created', $notification, 20);

모듈에 리스너 등록

module.php에서 등록

<?php

namespace Modules\Sirsoft\Ecommerce;

use App\Contracts\Extension\ModuleInterface;
use Modules\Sirsoft\Ecommerce\Listeners\ProductCacheInvalidationListener;
use Modules\Sirsoft\Ecommerce\Listeners\OrderNotificationListener;

class Module implements ModuleInterface
{
    // ... 기타 메서드들 ...

    /**
     * 훅 리스너 목록 반환
     *
     * @return array
     */
    public function getHookListeners(): array
    {
        return [
            ProductCacheInvalidationListener::class,
            OrderNotificationListener::class,
        ];
    }
}

Service에서 훅 사용 패턴

<?php

namespace Modules\Sirsoft\Ecommerce\Services;

use App\Extension\HookManager;
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;

class ProductService
{
    public function __construct(
        private ProductRepository $productRepository
    ) {}

    public function createProduct(array $data): Product
    {
        // 1. Before 훅 - 데이터 검증, 전처리
        HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);

        // 2. 필터 훅 - 데이터 변형
        $data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);

        // 3. 비즈니스 로직 실행
        $product = $this->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

namespace Modules\Sirsoft\Ecommerce\Listeners;

use App\Contracts\Extension\HookListenerInterface;

class ProductPriceCalculationListener implements HookListenerInterface
{
    public static function getSubscribedHooks(): array
    {
        return [
            // Filter 훅은 반드시 'type' => '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

namespace Modules\Sirsoft\Board\Listeners;

use App\Contracts\Extension\HookListenerInterface;
use App\Models\User;

class UserNotificationSettingsListener implements HookListenerInterface
{
    public static function getSubscribedHooks(): array
    {
        return [
            // Filter 훅: 데이터 변형 (type 필수)
            'core.user.filter_create_data' => [
                '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

namespace Plugins\Sirsoft\Reviews\Listeners;

use App\Extension\Contracts\HookListenerInterface;

class SeoReviewMetaListener implements HookListenerInterface
{
    public static function getSubscribedHooks(): array
    {
        return [
            'core.seo.filter_meta' => [
                '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 시스템과의 하이브리드 동작에서 발생할 수 있는 무한 루프를 차단합니다.

doAction('hook.name')
  → $dispatching['hook.name'] = true  (가드 설정)
  → Event::dispatch('hook.name')
  → 리스너 실행 중 doAction('hook.name') 재호출 시
    → $dispatching 체크 → 스킵 (중복 방지)
  → unset($dispatching['hook.name'])  (가드 해제)

addAction/addFilter 내부 동작

addAction()  → Event::listen() 등록 + 리스너 배열 관리
addFilter()  → Event::listen() 등록 + 리스너 배열 관리 (동일)
removeAction() / removeFilter() → Event::forget() + 배열에서 제거

훅 권한 시스템

G7은 훅 실행 시 권한 체크를 지원합니다. permission_hooks 테이블을 통해 특정 훅을 실행하려면 어떤 권한이 필요한지 매핑할 수 있습니다.

하이브리드 권한 체계

┌─────────────────────────────────────────────────────────────┐
│                    권한 체크 레이어                          │
├─────────────────────────────────────────────────────────────┤
│  1단계: permission_hooks (기능 레벨)                        │
│         → 'core.attachment.download' 훅 실행 권한           │
│         → 이 기능 자체를 사용할 수 있는가?                   │
│         → 미매핑 시 모든 사용자 허용                         │
│         → 매핑 시 해당 퍼미션 보유자만 허용                  │
├─────────────────────────────────────────────────────────────┤
│  2단계: role_* (리소스 레벨)                                │
│         → 특정 파일/메뉴에 대한 접근 권한                    │
│         → 이 리소스를 읽을/수정할/삭제할 수 있는가?          │
│         → 현행 로직 유지                                    │
└─────────────────────────────────────────────────────────────┘

permission_hooks 테이블

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 모델

// 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의 권한 체크 메서드

// 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);
    }
}

훅에 권한 매핑하기

// 시더 또는 관리자 기능에서

// 'attachment.download' 권한을 'core.attachment.download' 훅에 매핑
$permission = Permission::where('identifier', 'attachment.download')->first();

PermissionHook::create([
    'permission_id' => $permission->id,
    'hook_name' => 'core.attachment.download',
]);

권한 매핑 해제 (모든 사용자 허용)

PermissionHook::where('hook_name', 'core.attachment.download')->delete();

Service에서 사용 예시

// 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;
}

권한 체크 흐름 요약

┌─────────────────────────────────────────────────────────────┐
│  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


관련 문서