공개 — admin_basic 1.0.4 가 이전 소스로 빌드된 stale dist 를 릴리즈에 실어 주문 상세 합계행에 표현식 원문이 노출되던 회귀를 1.0.5 재빌드로 재수록. - admin_basic 1.0.5: --production 재빌드 + 서빙 번들 회귀 E2E(red→green) + 시나리오 매니페스트 - 전수 점검: sourcemap 잔존 9확장+코어 3번들 --production 재빌드, untracked .map 3개 삭제 (+1 bump: gdpr 1.0.3 / tosspayments 1.0.2 / verification_kginicis 1.0.3 / verification_nhnkcp 1.0.1 / basic 1.1.1 — 미공개 워킹 5건은 CHANGELOG 누적) - 리터럴 대조 전수: 기능 drift 는 admin_basic 단독 확정(타 대상 차집합 0), 릴리즈 패리티 7/7 일치 - board/page 사문 build 스크립트 제거 (vite.config 부재 — module:build --all 실패 요인) - audit 룰 신설: extension-dist-sourcemap-reference(error), extension-dist-source-literal-sync(warn) + coverage/룰 테스트/규정 문서 정정
34 KiB
모듈 개발 기초
이 문서는 G7의 모듈 개발 기초를 다룹니다.
TL;DR (5초 요약)
1. 디렉토리: vendor-module (예: sirsoft-ecommerce)
2. 네임스페이스: Modules\Vendor\Module\ (예: Modules\Sirsoft\Ecommerce\)
3. AbstractModule 상속 권장 (ModuleInterface 직접 구현 가능)
4. 필수: module.json (메타데이터 SSoT), Module.php, composer.json, routes/admin.json
5. getName()/getVersion()/getDescription()은 module.json에서 자동 파싱 (하드코딩 불필요)
목차
모듈 네이밍 규칙
디렉토리명
vendor-module 형식 (GitHub 스타일)
- 소문자 사용
- 하이픈(-): vendor와 module 구분
- 언더스코어(_): 모듈명 내 단어 구분 (선택적)
- 예:
sirsoft-ecommerce,sirsoft-order_management,johndoe-blog
네임스페이스
Modules\Vendor\Module\ 형식
- PascalCase 사용
- 백슬래시() 구분
- 예:
Modules\Sirsoft\Ecommerce\,Modules\Sirsoft\OrderManagement\
디렉토리명 → 네임스페이스 변환 규칙
필수: 변환 규칙 이해
하이픈(-) → 네임스페이스 구분자(\)
언더스코어(_) → PascalCase 결합
예시:
sirsoft-ecommerce → Sirsoft\Ecommerce
sirsoft-order_management → Sirsoft\OrderManagement
vendor-my_module_name → Vendor\MyModuleName
Composer 패키지명
modules/vendor-module
- 예:
modules/sirsoft-ecommerce,modules/sirsoft-order_management
네이밍 요약표
| 항목 | 형식 | 예시 |
|---|---|---|
| 디렉토리명 | vendor-module |
sirsoft-ecommerce, sirsoft-order_management |
| 네임스페이스 | Modules\Vendor\Module\ |
Modules\Sirsoft\Ecommerce\, Modules\Sirsoft\OrderManagement\ |
| Composer 패키지명 | modules/vendor-module |
modules/sirsoft-ecommerce, modules/sirsoft-order_management |
식별자 검증 규칙
모듈/플러그인/템플릿 공통 식별자 검증 규칙은 extension-manager.md를 참조하세요.
AbstractModule 상속 (권장)
모듈 개발자는 AbstractModule을 상속받아 필수 메서드만 구현하면 됩니다.
getIdentifier()와 getVendor()는 디렉토리명에서 자동으로 추론됩니다.
manifest 자동 파싱 메서드 (오버라이드 불필요)
module.json에서 자동으로 값을 읽어오는 메서드입니다. 하드코딩 오버라이드가 불필요합니다.
| 메서드 | 반환 타입 | 소스 (module.json) | 기본값 |
|---|---|---|---|
getName() |
string|array |
name |
identifier |
getVersion() |
string |
version |
'0.0.0' |
getDescription() |
string|array |
description |
'' |
getRequiredCoreVersion() |
?string |
g7_version |
null |
getLicense() |
?string |
license |
null |
getGithubUrl() |
?string |
github_url |
null |
getAssets() |
array |
assets |
[] |
getAssetLoadingConfig() |
array |
loading |
strategy: global, priority: 100 |
license필드:module.json에"license": "MIT"등의 라이선스 정보를 포함합니다. 이 값은 API 리소스의license필드로 노출됩니다. 또한 각 모듈 루트에LICENSE파일을 포함하여 라이선스 전문을 제공해야 합니다.
hidden 필드 (선택)
module.json 에 "hidden": true 를 설정하면 관리자 UI 의 모듈 목록에서 기본 제외됩니다. 학습용 샘플 모듈, 내부 운영 전용 모듈을 일반 사용자에게 감출 때 사용합니다.
- 제외 대상: 관리자 UI (
GET /api/admin/modules기본 응답) - 제외 대상 아님: artisan CLI (
module:list,module:install,module:activate등), 설치/제거/업데이트 감지 - 슈퍼관리자는 "숨김 포함" 토글로 일시 조회 가능 (
?include_hidden=1) - artisan CLI 에서도 기본 목록에서는 숨기고,
php artisan module:list --hidden으로 숨김 포함 목록을 조회할 수 있습니다 - 사용 사례: 학습용 샘플 모듈(예:
gnuboard7-hello_module), 내부 전용 통합 모듈
자동 추론 메서드 (final - 오버라이드 불가)
| 메서드 | 반환 타입 | 설명 |
|---|---|---|
getIdentifier() |
string |
디렉토리명에서 자동 추론 (예: sirsoft-ecommerce) |
getVendor() |
string |
module.json 의 vendor 필드를 우선 사용. 값이 없으면 디렉토리명의 첫 단어로 폴백 (예: sirsoft) |
기본값 제공 메서드 (필요시 오버라이드)
| 메서드 | 기본값 | 설명 |
|---|---|---|
install() |
true |
설치 로직 |
uninstall() |
true |
제거 로직 |
activate() |
true |
활성화 로직 |
deactivate() |
true |
비활성화 로직 |
getDynamicTables() |
[] |
런타임 동적 생성 테이블 목록 (언인스톨 시 Manager가 삭제) |
getRoutes() |
자동 탐지 | src/routes/api.php, src/routes/web.php |
getMigrations() |
자동 탐지 | database/migrations 디렉토리 |
getViews() |
[] |
뷰 파일 |
getPermissions() |
[] |
권한 목록 (resource_route_key, owner_key, roles scope_type 지원) |
getRoles() |
[] |
역할 목록 |
getDynamicPermissionIdentifiers() |
[] |
런타임 생성 권한 식별자 — stale cleanup 보존 대상 (아래 참조) |
getDynamicRoleIdentifiers() |
[] |
런타임 생성 역할 식별자 — stale cleanup 보존 대상 |
getDynamicMenuSlugs() |
[] |
런타임 생성 메뉴 slug — stale cleanup 보존 대상 |
getConfig() |
[] |
설정 |
getAdminMenus() |
[] |
관리자 메뉴 |
getHookListeners() |
[] |
훅 리스너 |
getDependencies() |
[] |
의존성 |
getMetadata() |
[] |
메타데이터 |
getMiddleware() |
[] |
확장 미들웨어 선언 (self-gate targets) — {class, groups, timing?, targets} (middleware.md) |
getBenchmarkProfiles() |
[] |
성능 계측 대상 선언 (g7:bench 가 수집) — 목록/화면/쓰기/배치 4축 (benchmark.md) |
upgrades() |
[] |
업그레이드 스텝 (upgrades/ 디렉토리 자동 발견). g7_version >= 7.0.0-beta.5 인 모듈은 신규 step 이 AbstractUpgradeStep 상속 의무 (upgrade-step-guide §13) — 미상속 시 ModuleManager::runUpgradeSteps 가 RuntimeException throw |
동적 권한/역할/메뉴 보존 규칙
모듈이 런타임에 Permission::updateOrCreate / Role::firstOrCreate / Menu::create 등으로 동적 엔티티를 만드는 경우(예: sirsoft-board 의 게시판 slug 별 권한·역할·메뉴), 업데이트 시 cleanupStale* 로직이 정적 정의에 없다 는 이유로 전수 삭제되는 회귀가 발생한다. 이를 방지하려면 아래 3개 메서드를 override 해 현재 DB 에 존재해야 하는 동적 식별자 전체 를 반환한다.
public function getDynamicPermissionIdentifiers(): array
{
if (! Schema::hasTable('boards')) { return []; }
$actions = array_keys((array) config('sirsoft-board.board_permission_definitions', []));
$module = $this->getIdentifier();
$ids = [];
foreach (Board::query()->select('slug')->get() as $board) {
$ids[] = "{$module}.{$board->slug}"; // 카테고리
foreach ($actions as $a) {
$ids[] = "{$module}.{$board->slug}.{$a}"; // 액션
}
}
return $ids;
}
보존 원칙:
- 업데이트 경로:
updateModule()→cleanupStaleModuleEntries()는 정적 + 동적 식별자를 병합한 expected 목록을 기준으로 stale 판정. 동적 식별자가 정확히 반환되면 유실 없음. - 언인스톨 경로:
uninstallModule($deleteData=false)는 권한·메뉴·역할을 보존 (재설치 시 사용자 역할 할당 복원).deleteData=true일 때만 전수 삭제. - 설치 경로:
installModule(--force)는 cleanup 을 실행하지 않아 동적 엔티티 유실 없음.
간결한 모듈 구현 예시
참고:
getName(),getVersion(),getDescription()은module.json에서 자동 파싱되므로 오버라이드가 불필요합니다.
<?php
namespace Modules\Sirsoft\Ecommerce;
use App\Extension\AbstractModule;
class Module extends AbstractModule
{
// getName(), getVersion(), getDescription()은 module.json에서 자동 파싱
// 별도 오버라이드 불필요
/**
* 역할 목록 (필요시 오버라이드)
*/
public function getRoles(): array
{
return [
[
'identifier' => 'sirsoft-ecommerce.manager',
'name' => [
'ko' => '이커머스 관리자',
'en' => 'Ecommerce Manager',
],
'description' => [
'ko' => '이커머스 모듈 관리 권한을 가진 역할',
'en' => 'Role with ecommerce module management permissions',
],
],
];
}
/**
* 권한 목록 (필요시 오버라이드)
*
* - resource_route_key: 라우트 파라미터명 (scope 체크용, 소유자 개념 없으면 생략)
* - owner_key: 모델의 소유자 식별 컬럼명 (scope 체크용, 소유자 개념 없으면 생략)
* - roles: 문자열 배열 또는 {role, scope_type} 객체 배열
*/
public function getPermissions(): array
{
return [
[
'identifier' => 'sirsoft-ecommerce.products.view',
'name' => [
'ko' => '상품 조회',
'en' => 'View Products',
],
'description' => [
'ko' => '상품 목록 및 상세 정보를 조회할 수 있습니다',
'en' => 'Can view product list and details',
],
'resource_route_key' => 'product', // 라우트 파라미터명
'owner_key' => 'created_by', // 소유자 컬럼
// roles에 scope_type 지정 가능 (null=전체, 'self'=본인, 'role'=소유역할)
'roles' => [
['role' => 'admin', 'scope_type' => null],
['role' => 'sirsoft-ecommerce.manager', 'scope_type' => 'role'],
],
],
[
'identifier' => 'sirsoft-ecommerce.products.create',
'name' => [
'ko' => '상품 생성',
'en' => 'Create Products',
],
'description' => [
'ko' => '새로운 상품을 생성할 수 있습니다',
'en' => 'Can create new products',
],
'resource_route_key' => 'product',
'owner_key' => 'created_by',
'roles' => ['admin'], // 문자열 배열도 허용 (scope_type=null 기본값)
],
[
'identifier' => 'sirsoft-ecommerce.orders.view',
'name' => [
'ko' => '주문 조회',
'en' => 'View Orders',
],
'description' => [
'ko' => '주문 목록 및 상세 정보를 조회할 수 있습니다',
'en' => 'Can view order list and details',
],
'resource_route_key' => 'order',
'owner_key' => 'user_id',
'roles' => ['admin', 'sirsoft-ecommerce.manager'],
],
[
'identifier' => 'sirsoft-ecommerce.categories.view',
'name' => [
'ko' => '카테고리 조회',
'en' => 'View Categories',
],
'description' => [
'ko' => '카테고리 목록을 조회할 수 있습니다',
'en' => 'Can view category list',
],
// resource_route_key/owner_key 생략 = 소유자 개념 없음 (scope 체크 스킵)
'roles' => ['admin'],
],
];
}
/**
* 관리자 메뉴 정의 (필요시 오버라이드)
*/
public function getAdminMenus(): array
{
return [
[
'name' => [
'ko' => '이커머스',
'en' => 'Ecommerce',
],
'slug' => 'ecommerce',
'url' => '/admin/ecommerce',
'icon' => 'fa-shopping-cart',
'order' => 20,
'children' => [
[
'name' => [
'ko' => '상품 관리',
'en' => 'Product Management',
],
'slug' => 'sirsoft-ecommerce-products',
'url' => '/admin/ecommerce/products',
'icon' => 'fa-box',
'order' => 1,
'permission' => 'sirsoft-ecommerce.products.view',
],
[
'name' => [
'ko' => '주문 관리',
'en' => 'Order Management',
],
'slug' => 'sirsoft-ecommerce-orders',
'url' => '/admin/ecommerce/orders',
'icon' => 'fa-receipt',
'order' => 2,
'permission' => 'sirsoft-ecommerce.orders.view',
],
],
],
];
}
/**
* 훅 리스너 목록 (필요시 오버라이드)
*/
public function getHookListeners(): array
{
return [
\Modules\Sirsoft\Ecommerce\Listeners\ProductCacheInvalidationListener::class,
\Modules\Sirsoft\Ecommerce\Listeners\OrderNotificationListener::class,
];
}
/**
* 모듈 메타데이터 (필요시 오버라이드)
*/
public function getMetadata(): array
{
return [
'author' => 'sirsoft',
'license' => 'MIT',
];
}
}
ModuleInterface 직접 구현 (레거시)
참고: 특별한 경우가 아니라면
AbstractModule상속을 권장합니다.
ModuleInterface를 직접 구현할 경우, 모든 메서드를 직접 구현해야 합니다:
<?php
namespace Modules\Sirsoft\Ecommerce;
use App\Contracts\Extension\ModuleInterface;
class Module implements ModuleInterface
{
public function getIdentifier(): string
{
return 'sirsoft-ecommerce';
}
public function getVendor(): string
{
return 'sirsoft';
}
// ... 모든 메서드 직접 구현 필요
}
composer.json (모듈)
기본 구조
{
"name": "modules/sirsoft-ecommerce",
"description": "E-commerce module for Gnuboard7 platform by sirsoft",
"type": "library",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"Modules\\Sirsoft\\Ecommerce\\": "src/"
}
}
}
외부 패키지 의존성
모듈이 외부 Composer 패키지를 필요로 하는 경우, require에 추가합니다.
{
"name": "modules/sirsoft-ecommerce",
"require": {
"php": "^8.2",
"stripe/stripe-php": "^13.0",
"intervention/image": "^3.0"
},
"autoload": {
"psr-4": {
"Modules\\Sirsoft\\Ecommerce\\": "src/"
}
}
}
주의: 루트 composer.json에 모듈 패키지 추가 금지
모듈 설치 시 자동으로 composer install 실행 (Phase 4.5)
modules/{identifier}/vendor/ 디렉토리에 독립 설치
수동 설치: php artisan module:composer-install [identifier]
php와ext-*패키지는 외부 의존성으로 간주되지 않음 (composer install 트리거 안 함)- 설치된 패키지는
vendor_autoloads를 통해 런타임에 자동 로드됨 - 상세: extension-manager.md "Composer 의존성 관리" 참조
Factory/Seeder 사용 시 추가 설정
필수: database/factories/ 또는 database/seeders/ 사용 시 autoload 등록
등록하지 않으면 테스트에서 "Class not found" 오류 발생
Factory 또는 Seeder를 사용하는 모듈은 반드시 composer.json에 해당 경로를 등록해야 합니다:
{
"name": "modules/sirsoft-ecommerce",
"description": "E-commerce module for Gnuboard7 platform by sirsoft",
"type": "library",
"version": "1.0.0",
"license": "MIT",
"autoload": {
"psr-4": {
"Modules\\Sirsoft\\Ecommerce\\": "src/",
"Modules\\Sirsoft\\Ecommerce\\Database\\Seeders\\": "database/seeders/",
"Modules\\Sirsoft\\Ecommerce\\Database\\Factories\\": "database/factories/"
}
},
"require": {
"php": "^8.2"
}
}
autoload 설정 후 갱신 (필수)
composer.json 수정 후 반드시 autoload 갱신:
php artisan extension:update-autoload
모듈 디렉토리 구조
modules/_bundled/sirsoft-ecommerce/
├── module.json # 메타데이터 (이름, 버전, 설명 등 SSoT)
├── module.php # ModuleInterface 구현
├── LICENSE # 라이선스 전문 (MIT)
├── composer.json # 오토로딩 + 외부 패키지 의존성 설정 (Git 추적)
├── composer.lock # Composer 락 파일 (Git 추적 — vendor-bundle.json 의 composer_lock_sha256 가 이 파일을 검증)
├── vendor-bundle.json # 번들 메타파일: SHA256, 패키지 목록 (Git 추적)
├── vendor-bundle.zip # 압축된 vendor 디렉토리 (Git 추적)
├── package.json # npm 패키지 정의 (에셋 모듈만)
├── vite.config.ts # Vite 빌드 설정 (에셋 모듈만)
├── tsconfig.json # TypeScript 설정 (에셋 모듈만)
├── vendor/ # Composer 의존성 (자동 생성, gitignore 대상)
├── dist/ # 프론트엔드 빌드 출력 (에셋 모듈만 — _bundled 은 Git 추적, `*.map` 만 ignore)
│ ├── js/module.iife.js
│ └── css/module.css
├── upgrades/ # 버전 업그레이드 스텝 (AbstractUpgradeStep 상속 — g7_version >= 7.0.0-beta.5 모듈 의무)
│ ├── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 스텝 (extends AbstractUpgradeStep)
│ └── data/ # 버전별 데이터 스냅샷 — 카탈로그 delta / Applier / Migration 동결
│ └── 1.1.0/
│ ├── manifest.json # kind → delta JSON 매핑
│ ├── *.delta.json # added/removed/renamed 시드
│ ├── appliers/ # SnapshotApplier 구현 (버전 namespace)
│ └── migrations/ # DataMigration 구현 (변환/핫픽스, 버전 namespace)
├── config/
│ └── ecommerce.php # 모듈 설정
├── database/
│ ├── factories/ # 테스트용 Factory
│ ├── migrations/ # 마이그레이션
│ └── seeders/ # 시더
│ ├── DatabaseSeeder.php # 메인 시더 (설치 + 조건부 샘플)
│ └── Sample/ # 샘플(개발용) 시더
├── lang/ # 다국어 파일 (PHP 배열)
│ ├── en/
│ └── ko/
├── resources/ # 리소스 파일
│ ├── js/ # 프론트엔드 JS 소스 (에셋 모듈만)
│ │ ├── index.ts # 엔트리 포인트
│ │ └── handlers/ # 커스텀 핸들러
│ ├── css/ # CSS 소스 (에셋 모듈만)
│ │ └── main.css
│ ├── lang/ # 프론트엔드 다국어 (JSON)
│ │ ├── ko.json # 한국어 프론트엔드 다국어
│ │ └── en.json # 영어 프론트엔드 다국어
│ ├── layouts/ # 레이아웃 JSON
│ │ ├── admin/ # admin 템플릿 레이아웃
│ │ └── user/ # user 템플릿 레이아웃 (선택)
│ ├── routes/ # 프론트엔드 라우트 정의
│ │ ├── admin.json # admin 템플릿 전용 라우트
│ │ └── user.json # user 템플릿 전용 라우트 (선택)
│ └── views/ # 뷰 파일 (Blade 템플릿 등)
├── src/
│ ├── Contracts/ # 인터페이스
│ ├── Http/ # HTTP 계층
│ │ ├── Controllers/ # 컨트롤러
│ │ │ └── Api/
│ │ │ └── Admin/
│ │ ├── Requests/ # FormRequest 클래스
│ │ └── Resources/ # API 리소스
│ ├── Listeners/ # 훅 리스너
│ ├── Models/ # 모델
│ ├── Providers/ # 프로바이더
│ ├── Repositories/ # 리포지토리
│ ├── Seo/ # SEO Sitemap 기여자, 캐시 무효화 리스너
│ ├── routes/ # 라우트 (src 하위)
│ │ ├── api.php
│ │ └── web.php
│ └── Services/ # 서비스
└── tests/ # 테스트 파일
├── Feature/ # 기능 테스트
└── Unit/ # 단위 테스트
디렉토리 설명
| 디렉토리 | 설명 |
|---|---|
module.json |
메타데이터 SSoT (이름, 버전, 설명, 의존성, 라이선스 등) — 버전 제약 정책은 changelog-rules.md 참조 |
module.php |
ModuleInterface 구현 (진입점) |
LICENSE |
라이선스 전문 (MIT 등) — API 엔드포인트 GET /api/admin/modules/{identifier}/license로 제공 |
composer.json |
PSR-4 오토로딩 + 외부 패키지 의존성 설정 |
package.json |
npm 패키지 정의 (에셋 모듈만 해당) |
vite.config.ts |
Vite IIFE 빌드 설정 (에셋 모듈만 해당) |
vendor/ |
Composer 의존성 디렉토리 (자동 생성, gitignore 대상) |
dist/ |
프론트엔드 빌드 출력 (에셋 모듈만 해당 — _bundled 소스는 Git 추적되는 배포 산출물, *.map 만 ignore. 소스 변경 시 --production 재빌드 산출물을 함께 커밋한다) |
config/ |
모듈별 설정 파일 |
database/factories/ |
테스트용 Factory (autoload 등록 필수) |
database/migrations/ |
데이터베이스 마이그레이션 |
database/seeders/ |
데이터베이스 시더 (autoload 등록 필수). 설치 시더는 루트, 샘플 시더는 Sample/ 하위 |
lang/ |
백엔드 다국어 (PHP 배열) |
resources/js/ |
프론트엔드 JS/TS 소스 — 핸들러 정의 등 (에셋 모듈만 해당) |
resources/css/ |
CSS 소스 (에셋 모듈만 해당) |
resources/lang/ |
프론트엔드 다국어 (JSON) |
resources/layouts/ |
레이아웃 JSON (admin/, user/ 하위) |
resources/routes/admin.json |
admin 템플릿 전용 프론트엔드 라우트 |
resources/routes/user.json |
user 템플릿 전용 프론트엔드 라우트 (선택) |
src/Http/Controllers/ |
컨트롤러 |
src/Http/Requests/ |
FormRequest 클래스 |
src/Http/Resources/ |
API 리소스 |
src/Listeners/ |
훅 리스너 |
src/Models/ |
Eloquent 모델 |
src/Repositories/ |
리포지토리 |
src/Seo/ |
SEO Sitemap 기여자, 캐시 무효화 리스너 |
src/Services/ |
비즈니스 로직 서비스 |
src/routes/ |
API/Web 라우트 |
tests/ |
테스트 파일 |
번들 디렉토리 작업 규칙
필수: 모듈 수정/개발은 _bundled 디렉토리에서만 작업
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용
개발 워크플로우
1. modules/_bundled/{identifier}/ 에서 코드 수정
2. _bundled에서 직접 테스트 실행으로 검증 (활성 디렉토리 복사 불필요)
3. module.json 버전 올리기
4. php artisan module:update {identifier} 로 활성 디렉토리에 프로덕션 반영
_bundled 직접 테스트 실행
_bundled에서 바로 PHPUnit/Vitest 테스트를 실행할 수 있습니다. tests/bootstrap.php가 _bundled 코드를 활성 디렉토리보다 우선 로드합니다.
# 백엔드 테스트 (_bundled에서 직접 실행)
php vendor/bin/phpunit modules/_bundled/{identifier}/tests
php vendor/bin/phpunit --filter=TestName modules/_bundled/{identifier}/tests
# 프론트엔드 테스트
cd modules/_bundled/{identifier}
powershell -Command "npm run test:run"
상세: testing-guide.md "_bundled 확장 직접 테스트" 참조
ModuleTestCase 작성 가이드 — 훅 리스너 등록 (CRITICAL)
⚠️ module.php 가 getHookListeners() 를 선언한 모듈은 ModuleTestCase setUp 에서
훅 리스너를 수동 등록해야 한다.
왜 필요한가:
ModuleManager::loadModules()는 활성 디렉토리(modules/) 만 스캔하고_bundled/는 메타데이터만 로드 (loadBundledModules)- 테스트는
_bundled/{identifier}/tests에서 직접 실행되므로 활성 디렉토리에 모듈이 설치되지 않은 환경 - 결과:
module.php::getHookListeners()가 선언한 리스너들이 HookManager 에 자동 등록되지 않음 applyFilters()/doAction()호출 시 등록된 리스너가 없어 default 빈 결과만 반환 → 알림 미발송, 캐시 무효화 누락 등 침묵 회귀 발생
ModuleTestCase setUp 필수 패턴:
protected function setUp(): void
{
parent::setUp();
// 1. 오토로드 / ServiceProvider / 마이그레이션 / 역할 등 기존 로직
$this->registerModuleAutoload();
$this->app->register(\Modules\Vendor\YourModule\Providers\YourServiceProvider::class);
$this->runModuleMigrationIfNeeded();
$this->createDefaultRoles();
// 2. _bundled 모듈 인스턴스 + 훅 리스너 수동 등록 (CRITICAL)
$this->registerBundledModuleInstance();
// 3. HookManager 스냅샷 (위 등록도 스냅샷에 포함되어 테스트 간 격리)
$this->snapshotHookManager();
}
protected function registerBundledModuleInstance(): void
{
$moduleClass = \Modules\Vendor\YourModule\Module::class;
if (! class_exists($moduleClass)) {
require_once $this->getModuleBasePath() . '/module.php';
}
$module = new $moduleClass();
/** @var \App\Extension\ModuleManager $manager */
$manager = $this->app->make(\App\Extension\ModuleManager::class);
// ModuleManager.modules 메모리 맵에 인스턴스 주입
$reflection = new \ReflectionClass($manager);
$modulesProp = $reflection->getProperty('modules');
$modulesProp->setAccessible(true);
$current = $modulesProp->getValue($manager);
if (! isset($current['vendor-your-module'])) {
$current['vendor-your-module'] = $module;
$modulesProp->setValue($manager, $current);
}
// module.php 의 getHookListeners() 가 선언한 리스너들을 HookManager 에 등록
if (method_exists($module, 'getHookListeners')) {
foreach ($module->getHookListeners() as $listenerClass) {
if (! class_exists($listenerClass)) {
continue;
}
if (! in_array(\App\Contracts\Extension\HookListenerInterface::class, class_implements($listenerClass), true)) {
continue;
}
try {
\App\Extension\HookListenerRegistrar::register($listenerClass, 'vendor-your-module');
} catch (\Throwable $e) {
// 중복 등록 등 무해한 예외는 무시
}
}
}
}
참조 구현:
modules/_bundled/sirsoft-board/tests/ModuleTestCase.phpmodules/_bundled/sirsoft-page/tests/ModuleTestCase.php
누락 시 증상:
- 알림 정의 기반 발송 테스트에서
Notification::assertSentTo가 false 반환 extract_data필터의context.skip/related_users/data가 default 빈 결과로 받아져 정책 gate 무력화- ActivityLog / Cache 등 리스너 의존 동작이 테스트에서만 미작동
스냅샷/복원 패턴 필수:
위 setUp 에서 등록한 훅도 snapshotHookManager() 로 캡처되어 tearDown 의 restoreHookManager() 가 테스트 간 격리를 보장한다. 테스트 본문에서 추가한 훅만 정리되고 setUp 의 훅은 보존된다.
왜 활성 디렉토리 직접 수정이 금지되는가?
- 활성 디렉토리는
.gitignore대상 → Git에 변경 기록 불가 - 다음 업데이트 시
_bundled소스로 덮어쓰기 → 직접 수정 사항 유실 - 업데이트 프로세스 미수행 시 마이그레이션/권한 동기화/레이아웃 갱신 누락
예외: 초기 개발 (아직 _bundled에 미등록)
✅ 허용: 신규 모듈 초기 개발 시 활성 디렉토리에서 직접 작업
전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업
상세: extension-update-system.md "번들 디렉토리 개발 워크플로우" 참조
코드 변경 시 버전/업그레이드 필수
필수: 모듈 코드를 변경한 경우 버전을 올리고 필요 시 업그레이드 스텝을 작성해야 합니다.
버전 변경 없이 _bundled에 반영하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다.
필수 작업
module.json버전 올리기:version필드를 Semantic Versioning에 따라 증가_bundled동기화:modules/_bundled/{identifier}/디렉토리에 변경 사항 반영- 업그레이드 스텝 작성 (조건부): DB 스키마/환경설정 구조/데이터 마이그레이션이 필요한 경우
업그레이드 스텝 작성 기준
| 변경 유형 | 업그레이드 스텝 필요 | 비고 |
|---|---|---|
| DB 스키마 변경 | ✅ (+ 마이그레이션) | 컬럼/테이블 구조 변경 |
| 환경설정 구조 변경 | ✅ (SettingsMigrator) | 설정 키 이름/구조 변경 |
| 기존 데이터 변환 | ✅ | 데이터 형식 변환, 기본값 |
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | Module.php에서 정의 |
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | 동적 메뉴/권한 보존 |
| 레이아웃 JSON 변경 | ❌ (자동 갱신) | refresh-layout에서 처리 |
| PHP 코드만 변경 | ❌ | 버전만 올리면 됨 |
상세: extension-update-system.md "개발자 버전 업데이트 가이드" 참조
SEO 변수 선언 (seoVariables)
모듈이 SEO 메타 데이터(제목/설명)에 동적 변수를 제공하려면 seoVariables() 메서드를 오버라이드합니다.
오버라이드 시점
- 모듈이 SEO 대상 페이지를 제공하는 경우 (상품 상세, 카테고리 목록 등)
- 모듈 설정의 SEO 템플릿(
seo.meta_{page_type}_title)에서{key}변수 치환이 필요한 경우
구조
public function seoVariables(): array
{
return [
// _common: 모든 page_type에 공통 적용
'_common' => [
'site_name' => ['source' => 'core_setting', 'key' => 'general.site_name'],
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
],
// page_type별 변수
'product' => [
'product_name' => ['source' => 'data', 'key' => 'product.data.name'],
'product_description' => ['source' => 'data', 'key' => 'product.data.short_description'],
],
'category' => [
'category_name' => ['source' => 'data', 'key' => 'category.data.name'],
],
'search' => [
'keyword_name' => ['source' => 'query', 'key' => 'q'],
],
];
}
소스 타입
| source | 설명 | 자동 해석 |
|---|---|---|
setting |
해당 모듈의 설정 값 | ✅ |
core_setting |
코어 설정 값 | ✅ |
query |
URL 쿼리 파라미터 | ✅ |
route |
URL 라우트 파라미터 | ✅ |
data |
데이터소스 응답 데이터 (레이아웃 vars에서 매핑 필요) |
❌ |
레이아웃에서 사용
레이아웃 JSON의 meta.seo.extensions에 모듈을 선언하면 seoVariables()가 자동 호출됩니다.
{
"meta": {
"seo": {
"extensions": [{ "type": "module", "id": "sirsoft-ecommerce" }],
"page_type": "product",
"vars": {
"product_name": "{{product.data.name ?? ''}}",
"product_description": "{{product.data.short_description ?? ''}}"
}
}
}
}
setting,core_setting,query,route소스는 SeoRenderer가 자동 해석data소스 변수는vars에서 표현식으로 매핑 필요- 설치 시
ValidatesSeoVariables트레이트가 변수명 고유성 검증
상세: seo-system.md "SEO 변수 시스템" 참조
관련 문서
- index.md - 확장 시스템 개요
- hooks.md - 훅 시스템
- module-routing.md - 모듈 라우트 규칙
- module-layouts.md - 모듈 레이아웃 시스템
- module-assets.md - 모듈 프론트엔드 에셋 시스템
- module-commands.md - 모듈 Artisan 커맨드
- module-i18n.md - 모듈 다국어
- 활동 로그 시스템 - 활동 로그 Listener, DescriptionResolver, Per-Item Bulk 로깅 규칙
- extension-update-system.md - 확장 업데이트 시스템
- permissions.md - 권한 시스템
- menus.md - 메뉴 시스템