1163 lines
40 KiB
Markdown
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) - 전체 확장 시스템 개요
|