# 모듈 개발 기초 > 이 문서는 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` 파일을 포함하여 라이선스 전문을 제공해야 합니다. ### 자동 추론 메서드 (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()` | `[]` | 메타데이터 | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) | #### 동적 권한/역할/메뉴 보존 규칙 모듈이 런타임에 `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 상세: [testing-guide.md](../testing-guide.md) "_bundled 확장 직접 테스트" 참조 ### 왜 활성 디렉토리 직접 수정이 금지되는가? - 활성 디렉토리는 `.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 명시 호출) | #135: 동적 메뉴/권한 보존 | | 레이아웃 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) - 메뉴 시스템