- 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종, 버전·콘텐츠 무변경) 및 설치본 언어팩 동기
866 lines
35 KiB
Markdown
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) - 메뉴 시스템
|