# 모듈 개발 기초 > 이 문서는 G7의 모듈 개발 기초를 다룹니다. --- ## TL;DR (5초 요약) ```text 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에서 자동 파싱 (하드코딩 불필요) ``` --- ## 목차 - [모듈 네이밍 규칙](#모듈-네이밍-규칙) - [AbstractModule 상속 (권장)](#abstractmodule-상속-권장) - [ModuleInterface 직접 구현 (레거시)](#moduleinterface-직접-구현-레거시) - [composer.json 작성법](#composerjson-모듈) - [모듈 디렉토리 구조](#모듈-디렉토리-구조) - [관련 문서](#관련-문서) --- ## 모듈 네이밍 규칙 ### 디렉토리명 `vendor-module` 형식 (GitHub 스타일) - 소문자 사용 - 하이픈(-): vendor와 module 구분 - 언더스코어(_): 모듈명 내 단어 구분 (선택적) - 예: `sirsoft-ecommerce`, `sirsoft-order_management`, `johndoe-blog` ### 네임스페이스 `Modules\Vendor\Module\` 형식 - PascalCase 사용 - 백슬래시(\) 구분 - 예: `Modules\Sirsoft\Ecommerce\`, `Modules\Sirsoft\OrderManagement\` ### 디렉토리명 → 네임스페이스 변환 규칙 ```text 필수: 변환 규칙 이해 하이픈(-) → 네임스페이스 구분자(\) 언더스코어(_) → 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](./extension-manager.md#식별자-검증-규칙-validextensionidentifier)를 참조하세요. --- ## 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](../backend/middleware.md)) | | `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw | #### 수명주기 훅이 실패를 알리는 방법 `install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로, 그냥 `false` 를 반환하면 **왜 거부했는지가 코어에 전달되지 않는다.** 그 결과 운영자는 원인이 빠진 실패 문구만 본다. 사유를 남기려면 `failWith()` 로 반환한다. 코어가 그 사유를 응답 문구의 원인 자리에 싣는다. ```php public function activate(): bool { if (! extension_loaded('gd')) { return $this->failWith(__('my-module::messages.gd_required')); } return true; } ``` - 사유는 **이미 번역된 문장**이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다. - 사유를 남기지 않고 `false` 만 돌려주면 코어가 일반 문구로 대체한다(동작은 그대로). - 같은 규칙이 플러그인(`AbstractPlugin`)에도 동일하게 적용된다. #### 동적 권한/역할/메뉴 보존 규칙 모듈이 런타임에 `Permission::updateOrCreate` / `Role::firstOrCreate` / `Menu::create` 등으로 동적 엔티티를 만드는 경우(예: sirsoft-board 의 게시판 slug 별 권한·역할·메뉴), 업데이트 시 `cleanupStale*` 로직이 **정적 정의에 없다** 는 이유로 전수 삭제되는 회귀가 발생한다. 이를 방지하려면 아래 3개 메서드를 override 해 **현재 DB 에 존재해야 하는 동적 식별자 전체** 를 반환한다. ```php 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 '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 = 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](changelog-rules.md#8-코어-버전-제약-정책) 참조 | | `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/` | 테스트 파일 | --- ## 번들 디렉토리 작업 규칙 ```text 필수: 모듈 수정/개발은 _bundled 디렉토리에서만 작업 필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용 ``` ### 개발 워크플로우 ```text 1. modules/_bundled/{identifier}/ 에서 코드 수정 2. _bundled에서 직접 테스트 실행으로 검증 (활성 디렉토리 복사 불필요) 3. module.json 버전 올리기 4. php artisan module:update {identifier} 로 활성 디렉토리에 프로덕션 반영 ``` ### _bundled 직접 테스트 실행 _bundled에서 바로 PHPUnit/Vitest 테스트를 실행할 수 있습니다. `tests/bootstrap.php`가 _bundled 코드를 활성 디렉토리보다 우선 로드합니다. ```bash # 백엔드 테스트 (_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](../testing-guide.md) "_bundled 확장 직접 테스트" 참조 ### ModuleTestCase 작성 가이드 — 훅 리스너 등록 (CRITICAL) ```text ⚠️ module.php 가 getHookListeners() 를 선언한 모듈은 ModuleTestCase setUp 에서 훅 리스너를 수동 등록해야 한다. ``` **왜 필요한가**: - `ModuleManager::loadModules()` 는 활성 디렉토리(`modules/`) 만 스캔하고 `_bundled/` 는 메타데이터만 로드 (`loadBundledModules`) - 테스트는 `_bundled/{identifier}/tests` 에서 직접 실행되므로 활성 디렉토리에 모듈이 설치되지 않은 환경 - 결과: `module.php::getHookListeners()` 가 선언한 리스너들이 HookManager 에 자동 등록되지 않음 - `applyFilters()` / `doAction()` 호출 시 등록된 리스너가 없어 default 빈 결과만 반환 → 알림 미발송, 캐시 무효화 누락 등 침묵 회귀 발생 **ModuleTestCase setUp 필수 패턴**: ```php 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.php` - `modules/_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에 미등록) ```text ✅ 허용: 신규 모듈 초기 개발 시 활성 디렉토리에서 직접 작업 전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업 ``` > 상세: [extension-update-system.md](./extension-update-system.md) "번들 디렉토리 개발 워크플로우" 참조 --- ## 코드 변경 시 버전/업그레이드 필수 ```text 필수: 모듈 코드를 변경한 경우 버전을 올리고 필요 시 업그레이드 스텝을 작성해야 합니다. 버전 변경 없이 _bundled에 반영하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다. ``` ### 필수 작업 1. **`module.json` 버전 올리기**: `version` 필드를 Semantic Versioning에 따라 증가 2. **`_bundled` 동기화**: `modules/_bundled/{identifier}/` 디렉토리에 변경 사항 반영 3. **업그레이드 스텝 작성** (조건부): DB 스키마/환경설정 구조/데이터 마이그레이션이 필요한 경우 ### 업그레이드 스텝 작성 기준 | 변경 유형 | 업그레이드 스텝 필요 | 비고 | |----------|-------------------|------| | DB 스키마 변경 | ✅ (+ 마이그레이션) | 컬럼/테이블 구조 변경 | | 환경설정 구조 변경 | ✅ (SettingsMigrator) | 설정 키 이름/구조 변경 | | 기존 데이터 변환 | ✅ | 데이터 형식 변환, 기본값 | | 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | Module.php에서 정의 | | 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | 동적 메뉴/권한 보존 | | 레이아웃 JSON 변경 | ❌ (자동 갱신) | refresh-layout에서 처리 | | PHP 코드만 변경 | ❌ | 버전만 올리면 됨 | > 상세: [extension-update-system.md](./extension-update-system.md) "개발자 버전 업데이트 가이드" 참조 --- ## SEO 변수 선언 (seoVariables) 모듈이 SEO 메타 데이터(제목/설명)에 동적 변수를 제공하려면 `seoVariables()` 메서드를 오버라이드합니다. ### 오버라이드 시점 - 모듈이 SEO 대상 페이지를 제공하는 경우 (상품 상세, 카테고리 목록 등) - 모듈 설정의 SEO 템플릿(`seo.meta_{page_type}_title`)에서 `{key}` 변수 치환이 필요한 경우 ### 구조 ```php 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()`가 자동 호출됩니다. ```json { "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](../backend/seo-system.md) "SEO 변수 시스템" 참조 --- ## 관련 문서 - [index.md](./index.md) - 확장 시스템 개요 - [hooks.md](./hooks.md) - 훅 시스템 - [module-routing.md](./module-routing.md) - 모듈 라우트 규칙 - [module-layouts.md](./module-layouts.md) - 모듈 레이아웃 시스템 - [module-assets.md](./module-assets.md) - 모듈 프론트엔드 에셋 시스템 - [module-commands.md](./module-commands.md) - 모듈 Artisan 커맨드 - [module-i18n.md](./module-i18n.md) - 모듈 다국어 - [활동 로그 시스템](../backend/activity-log.md) - 활동 로그 Listener, DescriptionResolver, Per-Item Bulk 로깅 규칙 - [extension-update-system.md](./extension-update-system.md) - 확장 업데이트 시스템 - [permissions.md](./permissions.md) - 권한 시스템 - [menus.md](./menus.md) - 메뉴 시스템