G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다.
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에서 훅 사용 패턴
- 필터 훅 리스너 예시
- 훅 권한 시스템
- 관련 문서
G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다.
핵심 파일 위치: app/Extension/HookManager.php
목적: 특정 시점에 부가 작업 실행
사용 예시:
- 이메일 발송
- 활동 로그 기록 (→ 활동 로그 시스템 참조)
- 캐시 무효화
- 알림 전송
패턴:
// Service에서 훅 실행
HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);목적: 데이터 변형 및 가공
사용 예시:
- 가격 계산
- 데이터 포맷 변경
- 권한 확인
패턴:
// 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
<?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 는 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' 명시 (반환값 무시 회귀 차단) |
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);
}
}ActivityLogListener 등에서 ID 목록으로 모델을 일괄 조회 후 ID 키 맵으로 사용하는 패턴은 Repository 의 findByIdsKeyed(array $ids): Collection 으로 캡슐화한다.
// ❌ 금지
$products = Product::whereIn('id', $ids)->get()->keyBy('id');
// ✅ 위임
$products = $this->productRepository->findByIdsKeyed($ids);다음과 같은 의도된 예외는 인라인 주석으로 명시한다 (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 을 반드시 작성한다.
대부분의 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 훅은 반환값 체인이므로 항상 동기 실행됩니다 (큐 드라이버 무관).
리스너가 반드시 HTTP 요청 스레드에서 동기 실행되어야 하는 경우, 'sync' => true를 선언합니다.
public static function getSubscribedHooks(): array
{
return [
'core.user.after_create' => [
'method' => 'handleUserCreated',
'priority' => 10,
'sync' => true, // 큐 드라이버 무관하게 항상 즉시 실행
],
];
}| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
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 ✅ 자동 발견 (하위 디렉토리)
// 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 |
매 요청 부팅 시 코어(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 설정에 따라 훅 구독 대상이 동적으로 변하는 경우, 리스너에 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 으로 동기 발송 금지 |
훅 리스너에서 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);<?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,
];
}
}<?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;
}
}getSubscribedHooks() 메서드에서 type 필드를 사용하여 훅 타입을 명시해야 합니다.
| type 값 | 설명 | 기본값 |
|---|---|---|
action |
Action 훅 - 반환값 없음 | ✅ (생략 시 기본값) |
filter |
Filter 훅 - 반환값 필수 |
중요: Filter 훅 리스너는 반드시 'type' => 'filter'를 명시해야 합니다. 명시하지 않으면 Action 훅으로 처리되어 반환값이 무시됩니다.
<?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 훅을 함께 구독할 수 있습니다:
<?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);
}
}<?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() → Event::listen() 등록 + 리스너 배열 관리
addFilter() → Event::listen() 등록 + 리스너 배열 관리 (동일)
removeAction() / removeFilter() → Event::forget() + 배열에서 제거
G7은 훅 실행 시 권한 체크를 지원합니다. permission_hooks 테이블을 통해 특정 훅을 실행하려면 어떤 권한이 필요한지 매핑할 수 있습니다.
┌─────────────────────────────────────────────────────────────┐
│ 권한 체크 레이어 │
├─────────────────────────────────────────────────────────────┤
│ 1단계: permission_hooks (기능 레벨) │
│ → 'core.attachment.download' 훅 실행 권한 │
│ → 이 기능 자체를 사용할 수 있는가? │
│ → 미매핑 시 모든 사용자 허용 │
│ → 매핑 시 해당 퍼미션 보유자만 허용 │
├─────────────────────────────────────────────────────────────┤
│ 2단계: role_* (리소스 레벨) │
│ → 특정 파일/메뉴에 대한 접근 권한 │
│ → 이 리소스를 읽을/수정할/삭제할 수 있는가? │
│ → 현행 로직 유지 │
└─────────────────────────────────────────────────────────────┘
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');
});// 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();
}
}// 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();// 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. 사용자 역할의 권한 확인 │
│ → 매핑된 권한 보유 여부 반환 │
└─────────────────────────────────────────────────────────────┘
모듈의 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
- 모듈 개발 기초 - 모듈에서 훅 리스너 등록
- Service-Repository 패턴 - Service에서 훅 실행 패턴
- 활동 로그 시스템 - 활동 로그 Listener 작성 패턴, Per-Item Bulk 로깅 규칙
- 권한 시스템 - Permission, Role 체계
- 확장 시스템 인덱스 - 전체 확장 시스템 개요