Files
HeuJung 3945b6f1b3 feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)
공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다.

브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도
남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데
자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기
하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발
대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다.
런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다.

자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그
실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML
에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다.
편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다.

두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의
custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에
의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다.
확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을
고치면 그 변경을 감지해 재게시까지 예약된다.

FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접
넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로
나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린
스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과
분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠
화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를
함께 뒀다.

동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에
써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
2026-08-27 16:47:14 +09:00

54 KiB

훅 시스템 (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

# 스토리지 공개 URL 훅 (Filter) — StorageInterface::url() 결과 공급/수정/차단 (공개#100)
# 컨텍스트 6키는 아래 "스토리지·드라이버 확장 훅 페이로드" 표 참조
core.storage.filter_url

# 보존 기간 자동 파기 (7.0.7) — 운영자 일괄 삭제 훅과 별개로 발행한다.
# 운영자 삭제 훅에는 본인인증 같은 대화형 가드가 물려 있어 무인 예약이 탈 수 없고,
# 그렇다고 훅 없이 지우면 확장이 가장 큰 삭제 경로(첫 실행의 누적분)를 볼 수 없다.
# 인자는 (보존일) / (보존일, 삭제건수) — 대상을 ID 로 지목하지 않는다.
core.activity_log.before_prune          core.activity_log.after_prune
core.notification_log.before_prune      core.notification_log.after_prune
core.schedule.before_prune_history      core.schedule.after_prune_history

# 본문 첫 내부 이미지 썸네일 캐시 (Filter, 공개#22) — 각 모델 saving 이벤트가 발행.
# 값 = 추출된 첫 내부 이미지 URL(없으면 null), 인자 = (값, 모델, 전체 후보 src 배열).
# 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null 반환)할 수 있다.
# 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
sirsoft-board.post.filter_content_thumbnail
sirsoft-ecommerce.product.filter_content_thumbnail
sirsoft-page.page.filter_content_thumbnail

# 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴
# before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환.
# 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션)
# 포맷 변환(예: jpg → webp) 시에는 바이트만 제자리 덮어쓰지 말고, 변환된 임시 파일 경로와
# 새 원본 파일명(xxx.webp)으로 UploadedFile 을 재구성해 반환할 것 — 일부 소비처는 저장
# 확장자를 원본 파일명에서 얻으므로, 이름을 갱신하지 않으면 확장자와 내용이 어긋난다.
core.attachment.filter_upload_file                      # 코어 첨부 (아바타 포함)
core.template_layout_attachment.before_upload           # 레이아웃 편집기 첨부 (7.0.7)
core.template_layout_attachment.filter_upload_file      # 레이아웃 편집기 첨부 (7.0.7)
core.template_layout_attachment.after_upload            # 레이아웃 편집기 첨부 (7.0.7)
sirsoft-board.attachment.filter_upload_file             # 게시판 첨부
sirsoft-page.attachment.filter_upload_file              # 페이지 첨부
sirsoft-ecommerce.product-image.filter_upload_file      # 상품 이미지
sirsoft-ecommerce.category-image.filter_upload_file     # 카테고리 이미지
sirsoft-ecommerce.review-image.filter_upload_file       # 리뷰 이미지 (공개 #96)
sirsoft-ckeditor5.image.before_upload                   # 에디터 이미지
sirsoft-ckeditor5.image.filter_upload_file              # 에디터 이미지
sirsoft-ckeditor5.image.after_upload                    # 에디터 이미지

# 업로드 잔존물 회수 — 어떤 콘텐츠에서도 쓰이지 않는 에디터 이미지의 참조 판정 대상 선언 (필터)
# 자기 콘텐츠를 가진 확장이 이 훅으로 테이블/컬럼을 등록하지 않으면, 그 확장에서만 쓰이는
# 이미지가 "미참조" 로 판정돼 자동 정리 대상이 된다.
sirsoft-ckeditor5.image.filter_reference_sources        # 에디터 이미지 참조 소스

# 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

# 구 이름에서 표준 이름으로 옮긴 훅 (7.0.6) — 구 이름도 함께 발행되므로 기존 구독은 유지되나,
# 새로 구독할 때는 표준 이름을 쓴다. 구 이름에 구독자가 있으면 로그에 1회 안내가 남는다.
core.plugin_settings.update_validation_rules        # 구: core.plugin_settings.update_rules
core.auth.validate_reset_token_validation_rules     # 구: core.auth.validate_reset_token_rules
core.auth.verify_password_validation_rules          # 구: core.auth.verify_password_rules
core.extension.changelog_validation_rules           # 구: core.extension.changelog_rules
core.search.index_validation_rules                  # 구: core.search.validation_rules
core.user.upload_avatar_validation_rules            # 구: core.user.upload_avatar_rules

# Layout Extension 훅
core.layout_extension.before_apply
core.layout_extension.after_apply

# 드라이버 확장 훅 (Filter) — 플러그인이 새 드라이버를 등록
# 항목 3키 구조는 아래 "스토리지·드라이버 확장 훅 페이로드" 표 참조
core.settings.available_storage_drivers
core.settings.available_public_asset_drivers   # 공개 자산 직접 URL 서빙 디스크 (공개#100)
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

# 사용자 추가 에셋 훅 (Filter) — 운영자가 확장에 덧붙인 CSS·JS 목록을 보정/추가
core.assets.custom_assets   # applyFilters($assets, $extensionType, $identifier)

# 사용자 추가 에셋 관리 훅 (Action) — 화면에서 파일을 저장/업로드/삭제한 직후
core.custom_assets.after_change   # doAction($extensionType, $identifier, $operation, $path)

# 사용자 추가 에셋 관리 검증 훅 (Filter) — 관리 API 의 FormRequest 규칙 확장
core.extension_custom_asset.read_validation_rules
core.extension_custom_asset.save_validation_rules
core.extension_custom_asset.upload_validation_rules

# 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

# Sitemap 증분 인덱싱 훅 (Filter) — 리소스→sitemap 항목 가공/추가
sitemap.index.collect_for_resource   # SitemapIndexer 가 리소스 index 시 발화 — applyFilters($entries, $type, $id, $contributor)

# Sitemap 재생성 훅 (Action)
core.seo.sitemap.before_regenerate       # 재생성 시작 직전
core.seo.sitemap.after_regenerate        # 재생성 성공 후 ($meta)
core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'failed', 'success'=>false, ...])

sitemap.index.collect_for_resource 는 제3자 확장이 리소스 하나에 대한 sitemap 항목을 추가/보정할 때 씁니다(filter — 'type' => 'filter' 명시 필수). core.seo.sitemap.after_regenerate_failed 는 재생성 잡이 실패했을 때 확장이 알림/복구를 걸 수 있는 action 훅입니다.

스토리지·드라이버 확장 훅 페이로드

core.storage.filter_url

StorageInterface::url() 의 결과를 공급·수정·차단합니다. 첫 인자는 생성된 URL(?string)이며, 디스크 종류와 무관하게 항상 발화합니다 — 직접 URL 을 만들 수 없어 null 인 경우에도 발화하므로 확장이 서명 URL 등을 공급할 수 있습니다. 반환이 문자열이 아니거나 빈 문자열/공백이면 호출측이 스트리밍으로 폴백합니다.

두 번째 인자는 컨텍스트 배열입니다.

키 타입 값
scope string core / module / plugin — 호출한 드라이버 종류
identifier ?string 확장 식별자 (sirsoft-ecommerce 등). 코어 드라이버는 null
disk string 대상 디스크명
category string 스토리지 카테고리 (images, settings 등)
path string 카테고리 하위 상대 경로
full_path string 디스크 루트 기준 전체 경로 — Storage::temporaryUrl() 등에 그대로 사용 가능

core.settings.available_{category}_drivers

플러그인이 드라이버/디스크 선택지를 카탈로그에 추가합니다. 첫 인자인 드라이버 배열에 다음 구조의 항목을 append 합니다.

키 타입 값
id string 드라이버/디스크 식별자 — 저장값이자 config 조회 키
label array 로케일별 표시 라벨 (예: ['ko' => 'CDN', 'en' => 'CDN'])
provider string 공급 플러그인 식별자 — 비활성화 시 "사용 중 드라이버" 경고 판정에 사용 (코어 기본 항목에는 없음)

core.settings.available_public_asset_drivers 로 등록하는 디스크는 플러그인 ServiceProvider 에서 filesystems.disks.{id} 정의가 함께 있어야 하며, 그 정의에 url 키가 있어야 직접 URL 이 생성됩니다(없으면 스트리밍 폴백). 플러그인이 비활성화되어 디스크 정의가 사라지면 저장값은 보존된 채 스트리밍으로 자동 폴백합니다.

sirsoft-ckeditor5.image.filter_reference_sources

에디터 업로드 이미지가 "어디선가 쓰이고 있는지" 판정할 때 훑을 테이블/컬럼 목록을 확장합니다. 첫 인자인 소스 배열에 다음 구조의 항목을 append 합니다.

키 타입 값
table string 테이블명 — 프리픽스 제외 원시 이름 (board_posts)
columns list<string> 본문이 담긴 컬럼명 목록 (['content'])

자기 콘텐츠에 에디터를 노출하는 확장은 이 훅을 반드시 구독해야 합니다. 등록하지 않으면 그 확장에서만 쓰이는 이미지가 "미참조" 로 판정돼 자동 정리 대상이 됩니다.

리스너는 테이블명 문자열만 덧붙이고 DB 에 접근하지 않습니다 — 실재 검증(테이블·컬럼 존재)과 조회는 플러그인이 수행하며, 존재하지 않는 선언은 경고만 남기고 건너뜁니다(한 확장의 잘못된 선언이 판정 전체를 멈추지 않습니다).

로그 사본 테이블(발송 이력·신고 스냅샷 등)은 등록하지 않습니다. 이들은 자체 보존기간으로 삭제되므로 참조 소스로 삼으면 "로그가 지워지는 순간 이미지가 고아가 되는" 역전이 생깁니다.

public static function getSubscribedHooks(): array
{
    return [
        'sirsoft-ckeditor5.image.filter_reference_sources' => [
            'method' => 'addSources',
            'priority' => 10,
            'type' => 'filter',
        ],
    ];
}

public function addSources(array $sources): array
{
    $sources[] = ['table' => 'my_documents', 'columns' => ['body']];

    return $sources;
}

리스너 구현

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 가 갖는다. 다음 패턴은 정적 검사가 차단한다.

❌ 금지 ✅ 올바른 사용
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: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,  // 큐 드라이버 무관하게 항상 즉시 실행
        ],
    ];
}

호출자 트랜잭션 안에서 끝나야 하는 처리는 sync 가 필수다

기본값(큐 래핑)은 DispatchHookListenerJob 의 afterCommit 정책을 탄다. 즉 호출자 트랜잭션이 커밋된 뒤에 실행된다 — 큐 드라이버가 sync 여도 마찬가지다(같은 요청 안에서, 커밋 이후에 실행된다).

따라서 훅 안에서 실패했을 때 호출자의 작업을 되돌려야 하는 처리는 기본값으로 두면 안 된다. 되돌릴 대상이 이미 커밋된 뒤라 예외를 던져도 롤백되지 않고, 호출자는 오류 응답을 받는데 데이터는 남는다.

판정 예
sync 필수 쿠폰 차감·복원, 적립금 차감·복원 등 실패 시 호출자 트랜잭션을 되돌려야 하는 처리
기본값(큐) 유지 활동 로그, 알림 발송, 통계 갱신 등 실패해도 호출자를 되돌리지 않는 후속 처리

선언만으로는 검증되지 않는다 — 회귀 테스트는 리스너를 손으로 addAction 하지 말고 실제 등록 경로(HookListenerRegistrar::register())를 태운 뒤, 호출자 트랜잭션 안에서 반영되는지와 예외가 호출자를 롤백시키는지를 단언한다. 손으로 등록하면 프로덕션이 쓰지 않는 경로를 검증하게 되어, 커밋 이후 실행 문제를 그대로 통과시킨다.

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


관련 문서