Files
Gnuboard7/docs/extension/hooks.md
T
2026-07-01 10:30:32 +09:00

1163 lines
40 KiB
Markdown

# 훅 시스템 (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
<?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 위임 패턴 예시 — 카운트 동기화
```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
<?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
<?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
<?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
<?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
<?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 시스템과의 하이브리드 동작에서 발생할 수 있는 무한 루프를 차단합니다.
```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) - 전체 확장 시스템 개요