# 모듈 레이아웃 시스템 > 이 문서는 그누보드7 모듈의 레이아웃 시스템에 대해 설명합니다. 레이아웃 등록, 오버라이드, 상속 처리를 다룹니다. --- ## TL;DR (5초 요약) ```text 1. 위치: modules/_bundled/vendor-module/resources/layouts/admin/*.json 또는 user/*.json 2. 네이밍: vendor-module.layout-name 형식 (DOT 구분자) 3. admin/ 하위 → Admin 템플릿, user/ 하위 → User 템플릿에 등록 4. 활성화 시 자동 등록, 비활성화 시 자동 삭제 5. 오버라이드: 템플릿 layouts/overrides/vendor-module/ + version_constraint 지원 ``` --- ## 목차 1. [개요](#개요) 2. [핵심 원칙](#핵심-원칙) 3. [레이아웃 파일 위치](#레이아웃-파일-위치) 4. [레이아웃 네이밍 규칙](#레이아웃-네이밍-규칙) 5. [레이아웃 등록 메커니즘](#레이아웃-등록-메커니즘) 6. [모듈 활성화/비활성화 흐름](#모듈-활성화비활성화-흐름) 7. [레이아웃 오버라이드 시스템](#레이아웃-오버라이드-시스템) 8. [레이아웃 상속 처리](#레이아웃-상속-처리) 9. [DB 저장 규칙](#db-저장-규칙) 10. [캐시 전략](#캐시-전략) 11. [개발 가이드](#모듈-레이아웃-개발-가이드) 12. [문제 해결](#문제-해결) --- ## 개요 모듈은 자체 레이아웃 파일을 제공하여 관리자/사용자 페이지 UI를 구성할 수 있습니다. 모듈 레이아웃은 디렉토리 위치에 따라 활성화된 Admin 또는 User 템플릿에 자동으로 등록되며, 템플릿 오버라이드 시스템을 통해 커스터마이징이 가능합니다. --- ## 핵심 원칙 ```text 필수: ModuleManager와 TemplateManager의 일관성 유지 필수: admin/ 하위 레이아웃 → Admin 템플릿에만 등록 필수: user/ 하위 레이아웃 → User 템플릿에만 등록 경고: layouts/ 루트에 직접 배치된 파일은 스킵됨 (admin/ 또는 user/ 하위 필수) ✅ 레이아웃 content는 layoutData 전체를 저장 ✅ 모듈 비활성화 시 레이아웃 soft delete ✅ 모듈 재활성화 시 레이아웃 복원 ``` --- ## 레이아웃 파일 위치 모듈 레이아웃은 `resources/layouts/` 디렉토리에 위치합니다. 디렉토리 위치에 따라 등록 대상 템플릿이 결정됩니다. ``` modules/ └── _bundled/ └── vendor-module/ └── resources/ └── layouts/ ├── admin/ ← Admin 템플릿에 등록 │ ├── index.json │ └── edit.json └── user/ ← User 템플릿에 등록 ├── main.json └── detail.json ``` > **주의**: `layouts/` 루트에 직접 배치된 파일(예: `layouts/settings.json`)은 등록되지 않고 경고 로그가 출력됩니다. 반드시 `admin/` 또는 `user/` 하위에 배치하세요. ### 등록 대상 템플릿 결정 | 파일 위치 | 등록 대상 | 예시 | | ----------------------- | ------------------- | ------------------- | | `layouts/admin/*.json` | 활성 Admin 템플릿 | sirsoft-admin_basic | | `layouts/user/*.json` | 활성 User 템플릿 | sirsoft-user_theme1 | | `layouts/*.json` (루트) | 등록 안됨 (스킵) | 경고 로그 출력 | --- ## 레이아웃 네이밍 규칙 ### 자동 생성 패턴 ``` {module-identifier}.{layout_path} ``` 모듈 레이아웃은 DB에 등록될 때 `moduleIdentifier`가 점(`.`) 구분자로 접두사에 추가됩니다. ### 예시 ``` 파일: modules/_bundled/sirsoft-sample/resources/layouts/admin/index.json 파일 내 layout_name: admin_sample_index DB 등록명: sirsoft-sample.admin_sample_index 파일: modules/_bundled/sirsoft-sample/resources/layouts/admin/edit.json 파일 내 layout_name: admin_sample_edit DB 등록명: sirsoft-sample.admin_sample_edit 파일: modules/_bundled/sirsoft-sample/resources/layouts/user/main.json 파일 내 layout_name: user_sample_main DB 등록명: sirsoft-sample.user_sample_main ``` ### 중요 사항 - 레이아웃 파일 내에서는 `layout_name`을 **접두사 없이** 작성합니다 - 시스템이 자동으로 `{moduleIdentifier}.{layout_name}` 형식으로 변환합니다 - 점(`.`) 구분자는 moduleIdentifier와 layoutName의 경계를 명확히 구분합니다 ### 파일 내 layout_name 지정 예시 ```json { "layout_name": "admin_sample_index", "version": "1.0.0", "extends": "_admin_base", "meta": { "title": "$t:sirsoft-sample.admin.index.title" }, "slots": { "content": [...] } } ``` 이 레이아웃은 DB에 `sirsoft-sample.admin_sample_index`로 등록됩니다. --- ## 레이아웃 등록 메커니즘 ### ModuleManager.registerLayoutToTemplate() ```php protected function registerLayoutToTemplate( Template $template, string $layoutName, array $layoutData, string $moduleIdentifier ): void { // TemplateManager와 동일하게 layoutData 전체 저장 $content = $layoutData; TemplateLayout::updateOrCreate( [ 'template_id' => $template->id, 'name' => $layoutName, ], [ 'content' => $content, // extends, layout_name 포함 'extends' => $layoutData['extends'] ?? null, 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, 'created_by' => Auth::id(), 'updated_by' => Auth::id(), ] ); } ``` **중요**: `content`에는 `layoutData` 전체가 저장되므로 `extends`, `layout_name`, `version` 등 모든 필드가 포함됩니다. --- ## 모듈 활성화/비활성화 흐름 ### 1. 모듈 활성화 (activateModule) ```php public function activateModule(string $moduleName): array { // 1. 모듈 activate() 실행 $result = $module->activate(); if ($result) { // 2. 모듈 상태를 Active로 변경 Module::where('identifier', $module->getIdentifier()) ->update(['status' => ExtensionStatus::Active->value]); // 3. soft deleted된 레이아웃 복원 (재활성화 시) $this->restoreModuleLayouts($module->getIdentifier()); // 4. 레이아웃 등록 (새로운 레이아웃 또는 업데이트) $layoutsRegistered = $this->registerModuleLayouts($moduleName); // 5. 캐시 무효화 $this->invalidateLayoutCache($module->getIdentifier()); } return ['success' => $result, 'layouts_registered' => $layoutsRegistered]; } ``` ### 2. 모듈 비활성화 (deactivateModule) ```php public function deactivateModule(string $moduleName): array { // 1. 모듈 deactivate() 실행 $result = $module->deactivate(); if ($result) { // 2. 모듈 상태를 Inactive로 변경 Module::where('identifier', $module->getIdentifier()) ->update(['status' => ExtensionStatus::Inactive->value]); // 3. 모듈 레이아웃 soft delete $layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier()); // 4. 캐시 무효화 $this->invalidateLayoutCache($module->getIdentifier()); } return ['success' => $result, 'layouts_deleted' => $layoutsDeleted]; } ``` ### 3. 템플릿 활성화 시 레이아웃 등록 (activateTemplate) 새 템플릿이 활성화되면 `TemplateManager::activateTemplate()`이 기존 활성 모듈/플러그인의 레이아웃을 새 템플릿에 자동으로 등록합니다. ```php // TemplateManager::activateTemplate() 내부 (트랜잭션) DB::transaction(function () use ($templateName, $template, $templateRecord) { // 1. 같은 타입의 기존 활성 템플릿 비활성화 $this->deactivateTemplatesByType($template['type']); // 2. 상태 업데이트 $this->templateRepository->updateByIdentifier($templateName, [...]); // 3. 레이아웃 확장(layout_extensions) 등록 $this->layoutExtensionService->registerAllActiveExtensionsToTemplate($templateRecord->id); // 4. 활성 모듈/플러그인의 레이아웃을 새 템플릿에 등록 app(ModuleManager::class)->registerLayoutsForAllActiveModules(); app(PluginManager::class)->registerLayoutsForAllActivePlugins(); // 5. 캐시 워밍 $this->warmTemplateCache($templateName); }); ``` **중요**: `registerModuleLayouts()`는 `updateOrCreate` 패턴을 사용하므로 중복 호출에 안전합니다. ### 4. Soft Delete/Restore 이유 | 작업 | 설명 | |------|------| | **Soft Delete** | 모듈 재활성화 시 레이아웃 복원을 위해 | | **Hard Delete** | 모듈 제거(uninstall) 시에만 사용 | **장점**: 모듈 설정 및 오버라이드 데이터 보존 --- ## 레이아웃 오버라이드 시스템 ### 우선순위 (LayoutResolverService) ``` 1. 템플릿 오버라이드 (source_type = 'template') 2. 모듈 기본 레이아웃 (source_type = 'module') ``` ### 오버라이드 생성 예시 ```php // 템플릿에서 모듈 레이아웃 오버라이드 TemplateLayout::create([ 'template_id' => $templateId, 'name' => 'sirsoft-sample.admin_sample_index', // 모듈 레이아웃명과 동일 'content' => $customizedLayoutData, // 커스터마이징된 데이터 'extends' => '_admin_base', 'source_type' => LayoutSourceType::Template, // 오버라이드 표시 'source_identifier' => 'sirsoft-admin_basic', 'created_by' => Auth::id(), ]); ``` ### 오버라이드 해석 로직 ```php // LayoutService.resolveLayout() if ($this->isModuleLayoutName($layoutName)) { // LayoutResolverService를 통해 우선순위 적용 $resolved = $this->layoutResolverService->resolve($layoutName, $templateId); // 오버라이드가 있으면 오버라이드 반환, 없으면 모듈 기본 반환 return $resolved; } ``` ### 오버라이드 버전 호환성 (version_constraint) 템플릿에서 모듈/플러그인의 레이아웃을 오버라이드할 때, 대상 모듈/플러그인의 버전이 변경되면 오버라이드가 호환되지 않을 수 있습니다. `version_constraint` 필드를 사용하여 오버라이드가 적용될 버전 범위를 지정할 수 있습니다. #### 사용 목적 - 모듈/플러그인 메이저 버전 변경 시 오버라이드 호환성 문제 방지 - 비호환 오버라이드 자동 스킵 및 관리자 경고 표시 - 템플릿 개발자가 지원하는 버전 범위 명시 #### JSON 스키마 ```json { "layout_name": "admin_sample_index", "version_constraint": "^1.0", "injections": [...] } ``` #### 지원 문법 (Composer Semver) | 문법 | 의미 | 예시 | | ---- | ---- | ---- | | `^1.0` | 1.0.0 이상, 2.0.0 미만 | 1.0.0, 1.5.0, 1.9.9 ✅ / 2.0.0 ❌ | | `~1.5` | 1.5.0 이상, 2.0.0 미만 | 1.5.0, 1.9.0 ✅ / 2.0.0 ❌ | | `~1.5.0` | 1.5.0 이상, 1.6.0 미만 | 1.5.0, 1.5.9 ✅ / 1.6.0 ❌ | | `>=1.0 <2.0` | 명시적 범위 | 1.0.0, 1.9.9 ✅ / 0.9.0, 2.0.0 ❌ | | `1.0.0 - 1.9.9` | 하이픈 범위 | 1.0.0 ~ 1.9.9 ✅ | | `1.0.0 \|\| 2.0.0` | OR 조건 | 1.0.0, 2.0.0 ✅ / 1.5.0 ❌ | #### 동작 방식 1. **version_constraint 없음**: 항상 오버라이드 적용 (하위 호환성 유지) 2. **버전 제약 만족**: 오버라이드 정상 적용 3. **버전 제약 불만족**: 오버라이드 스킵 + 관리자 경고 표시 4. **대상 버전 정보 없음**: 오버라이드 적용 (경고 로그) #### 관리자 경고 표시 비호환 오버라이드가 감지되면 레이아웃 상단에 Alert 컴포넌트가 자동 주입됩니다: ``` 호환성 경고 오버라이드 "sirsoft-admin_basic"가 모듈 버전과 호환되지 않습니다 (요구: ^1.0, 현재: 2.0.0) ``` #### 사용 예시 ```json { "target_layout": "sirsoft-board.admin_board_settings", "version_constraint": "^1.0", "injections": [ { "target_id": "notification_section", "position": "after", "components": [ { "id": "custom_notification", "name": "Card", "type": "composite", "props": { "title": "$t:template.custom_notification.title" } } ] } ] } ``` 위 예시에서 `sirsoft-board` 모듈이 2.0.0으로 업데이트되면: - 오버라이드가 자동으로 스킵됨 - 관리자에게 호환성 경고 표시 - 템플릿 개발자가 오버라이드를 업데이트할 때까지 기본 레이아웃 사용 #### 구현 위치 - **LayoutResolverService**: `checkOverrideVersionCompatibility()` 메서드 (레이아웃 오버라이드) - **LayoutExtensionService**: `checkVersionCompatibility()` 메서드 (확장 오버라이드) - **ModuleManager**: `getModuleVersion()` 헬퍼 - **PluginManager**: `getPluginVersion()` 헬퍼 #### 레이아웃 오버라이드에서의 version_constraint 템플릿이 모듈 레이아웃을 오버라이드할 때도 `version_constraint`를 사용할 수 있습니다. 오버라이드 JSON 예시: ```json { "layout_name": "admin_sample_index", "version_constraint": "^1.0", "extends": "_admin_base", "slots": { "content": [...] } } ``` 동작: 1. `version_constraint` 없음 → 항상 오버라이드 적용 2. 버전 호환 → 오버라이드 적용 3. 버전 비호환 → 오버라이드 스킵, 모듈 기본 레이아웃으로 폴백 + `Log::warning` --- ## 레이아웃 상속 처리 ### extends 필드 사용 ```json { "layout_name": "admin_sample_index", "extends": "_admin_base", "slots": { "content": [...] } } ``` ### 병합 로직 (LayoutService.mergeLayouts) ```php // content에 extends가 있어야 병합 수행 if (isset($layoutData['extends'])) { $parentLayoutName = $layoutData['extends']; $parentLayout = $this->loadAndMergeLayout($templateId, $parentLayoutName); $mergedLayout = $this->mergeLayouts($parentLayout, $layoutData); } else { $mergedLayout = $layoutData; } ``` ### 병합 결과 ```php $result = [ 'version' => $childLayout['version'] ?? $parentLayout['version'] ?? '1.0.0', 'layout_name' => $childLayout['layout_name'] ?? $parentLayout['layout_name'] ?? '', 'meta' => $mergedMeta, 'data_sources' => $mergedDataSources, 'components' => $mergedComponents, ]; ``` --- ## DB 저장 규칙 ### 템플릿 레이아웃과 일관성 유지 | 구분 | TemplateManager | ModuleManager | |------|----------------|---------------| | content 저장 | `$layoutData` 전체 | `$layoutData` 전체 | | extends 포함 | ✅ Yes | ✅ Yes | | layout_name 포함 | ✅ Yes | ✅ Yes | | version 포함 | ✅ Yes | ✅ Yes | ### 잘못된 구현 (❌ DON'T) ```php // 개별 필드만 추출 - 병합 실패 원인 $content = []; if (isset($layoutData['slots'])) { $content['slots'] = $layoutData['slots']; } if (isset($layoutData['meta'])) { $content['meta'] = $layoutData['meta']; } // extends, layout_name 누락! ``` ### 올바른 구현 (✅ DO) ```php // layoutData 전체 저장 - TemplateManager와 일관성 $content = $layoutData; ``` --- ## 캐시 전략 ### 무효화 시점 1. 모듈 활성화 후 2. 모듈 비활성화 후 3. 레이아웃 파일 변경 시 ### 무효화 메서드 ```php use App\Contracts\Extension\CacheInterface; protected function invalidateLayoutCache(string $moduleIdentifier): void { $cache = app(CacheInterface::class); // 드라이버가 `g7:core:` 접두사 자동 적용 // 1. 태그 기반 캐시 삭제 (CacheInterface::flushTags) $cache->flushTags(['layouts', $moduleIdentifier]); // 2. 개별 레이아웃 캐시 삭제 $moduleLayouts = TemplateLayout::where('source_type', LayoutSourceType::Module) ->where('source_identifier', $moduleIdentifier) ->get(); foreach ($moduleLayouts as $layout) { $cache->forget("template.{$layout->template_id}.layout.{$layout->name}"); } } ``` ### 캐시 무효화 원칙 ```text 중요: 모듈/플러그인 활성화/비활성화 시 관련 캐시 자동 무효화 필수 ✅ 필수: Manager 레벨에서 캐시 무효화 구현 (Service 아님) ✅ 권장: 공통 캐시 무효화 로직은 트레이트로 분리 ``` --- ## 모듈 레이아웃 개발 가이드 ### 1. 레이아웃 파일 생성 ```bash # 디렉토리 생성 mkdir -p modules/_bundled/sirsoft-sample/resources/layouts/admin # 레이아웃 파일 생성 touch modules/_bundled/sirsoft-sample/resources/layouts/admin/index.json ``` ### 2. 레이아웃 JSON 작성 ```json { "layout_name": "admin_sample_index", "version": "1.0.0", "extends": "_admin_base", "meta": { "title": "$t:sirsoft-sample.admin.index.title", "description": "$t:sirsoft-sample.admin.index.description", "auth_required": true }, "slots": { "content": [ { "id": "page_header", "name": "PageHeader", "type": "composite", "props": { "title": "$t:sirsoft-sample.admin.index.title" } } ] }, "data_sources": [ { "id": "items", "type": "api", "method": "GET", "endpoint": "/api/modules/sirsoft-sample/admin/items", "auto_fetch": true, "auth_required": true } ] } ``` ### 3. 라우트 등록 (routes.json) ```json { "routes": [ { "path": "/admin/sirsoft-sample", "layout_name": "sirsoft-sample.admin_sample_index", "permissions": ["sirsoft-sample.admin.view"] } ] } ``` ### 4. 모듈 활성화 ```bash php artisan module:activate sirsoft-sample ``` --- ## 문제 해결 ### 레이아웃 병합 안 됨 **원인**: `content`에 `extends` 필드 누락 **해결**: 1. `ModuleManager.registerLayoutToTemplate()`에서 `$layoutData` 전체 저장 확인 2. 모듈 재활성화로 DB 업데이트: ```bash php artisan module:deactivate sirsoft-sample php artisan module:activate sirsoft-sample ``` ### layout_name 누락 오류 **원인**: `content`에 `layout_name` 필드 누락 **해결**: 위와 동일 (ModuleManager 수정 후 재활성화) ### 오버라이드가 적용 안 됨 **원인**: 1. 레이아웃 이름 불일치 2. `source_type`이 'template'이 아님 3. 캐시 문제 **해결**: ```bash # 캐시 초기화 php artisan cache:clear # 레이아웃 이름 확인 (DB 조회) SELECT name, source_type FROM template_layouts WHERE name = 'sirsoft-sample.admin_sample_index'; ``` ### user 레이아웃이 등록되지 않음 **원인**: 레이아웃 파일이 `layouts/user/` 하위에 위치하지 않음 **해결**: `resources/layouts/user/` 디렉토리로 파일 이동 ### layouts/ 루트에 배치한 레이아웃이 무시됨 **원인**: `admin/` 또는 `user/` 하위 배치 필수 **해결**: 적절한 하위 디렉토리로 파일 이동. 경고 로그 확인: ```text "레이아웃 파일이 admin/ 또는 user/ 하위에 위치하지 않아 스킵됩니다: {file}" ``` ### 모듈 재활성화 후에도 레이아웃 안 보임 **확인 사항**: 1. 레이아웃 파일 경로가 올바른지 확인 2. JSON 파일 문법 오류 확인 3. `layout_name` 필드가 JSON에 있는지 확인 --- ## 관련 문서 - [module-basics.md](module-basics.md) - 모듈 개발 기초 - [module-routing.md](module-routing.md) - 모듈 라우트 규칙 - [module-commands.md](module-commands.md) - 모듈 Artisan 커맨드 - [module-i18n.md](module-i18n.md) - 모듈 다국어 - [template-caching.md](template-caching.md) - 템플릿 캐싱 전략 - [index.md](index.md) - 확장 시스템 인덱스