Files
HeuJung ca4834bbb9 fix(release): 7.0.8 사전점검 전수조사 — 릴리즈 차단 2건 포함 결함 7건 수정 및 출시일 정렬
- pay_nhnkcp 1.0.3 분리: 관리자 API 권한 게이트 변경분이 이미 배포된 1.0.2
 섹션에 소급 누적돼 있어 배포본과 어긋나고 업데이트 감지도 불가하던 것을
 manifest/package/composer/lock 동기와 함께 정정
- 공개 시나리오 매니페스트의 내부 경로 참조 제거 — 릴리즈 파일 누출 검사가
 error 로 발행을 차단하던 상태 해소 (내부 정적 검사 연계는 도구 비귀속 서술로 이관)
- 모듈/플러그인/템플릿 수동 설치에서 깨진 zip·manifest 누락 같은 사용자 입력
 오류가 500 으로 위장되던 422 계약 회귀 복원 — 서비스 경계에서 도메인 예외로
 승격해 상태코드와 실패 사유를 함께 보존 (회귀 테스트 동반)
- 본인인증 정책 검색 버튼이 입력 중인 검색어를 반영하지 않고, 초기화 후에도
 입력창에 이전 검색어가 남던 문제 수정 — 폼 자동바인딩 옵트아웃 + 명시 상태
 전환, E2E 회귀 스펙과 트러블슈팅 사례 동반
- 프록시 URL 검증 비문자열 분기의 :schemes 치환 누락 보정 (회귀 테스트 동반)
- 7.0.8 사이클 출시일자를 2026-08-22 로 정렬 (코어·엔진·확장 7종·언어팩 3종,
 버전·콘텐츠 무변경) 및 설치본 언어팩 동기
2026-08-22 10:16:08 +09:00

866 lines
35 KiB
Markdown

# 모듈 개발 기초
> 이 문서는 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
<?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
<?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 (모듈)
### 기본 구조
```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`에 추가합니다.
```json
{
"name": "modules/sirsoft-ecommerce",
"require": {
"php": "^8.2",
"stripe/stripe-php": "^13.0",
"intervention/image": "^3.0"
},
"autoload": {
"psr-4": {
"Modules\\Sirsoft\\Ecommerce\\": "src/"
}
}
}
```
```text
주의: 루트 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](extension-manager.md) "Composer 의존성 관리" 참조
### Factory/Seeder 사용 시 추가 설정
```
필수: database/factories/ 또는 database/seeders/ 사용 시 autoload 등록
등록하지 않으면 테스트에서 "Class not found" 오류 발생
```
Factory 또는 Seeder를 사용하는 모듈은 **반드시** composer.json에 해당 경로를 등록해야 합니다:
```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 갱신:
```bash
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](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) - 메뉴 시스템