19 KiB
모듈 레이아웃 시스템
이 문서는 그누보드7 모듈의 레이아웃 시스템에 대해 설명합니다. 레이아웃 등록, 오버라이드, 상속 처리를 다룹니다.
TL;DR (5초 요약)
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 지원
목차
- 개요
- 핵심 원칙
- 레이아웃 파일 위치
- 레이아웃 네이밍 규칙
- 레이아웃 등록 메커니즘
- 모듈 활성화/비활성화 흐름
- 레이아웃 오버라이드 시스템
- 레이아웃 상속 처리
- DB 저장 규칙
- 캐시 전략
- 개발 가이드
- 문제 해결
개요
모듈은 자체 레이아웃 파일을 제공하여 관리자/사용자 페이지 UI를 구성할 수 있습니다. 모듈 레이아웃은 디렉토리 위치에 따라 활성화된 Admin 또는 User 템플릿에 자동으로 등록되며, 템플릿 오버라이드 시스템을 통해 커스터마이징이 가능합니다.
핵심 원칙
필수: 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 지정 예시
{
"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()
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)
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)
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()이 기존 활성 모듈/플러그인의 레이아웃을 새 템플릿에 자동으로 등록합니다.
// 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')
오버라이드 생성 예시
// 템플릿에서 모듈 레이아웃 오버라이드
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(),
]);
오버라이드 해석 로직
// LayoutService.resolveLayout()
if ($this->isModuleLayoutName($layoutName)) {
// LayoutResolverService를 통해 우선순위 적용
$resolved = $this->layoutResolverService->resolve($layoutName, $templateId);
// 오버라이드가 있으면 오버라이드 반환, 없으면 모듈 기본 반환
return $resolved;
}
오버라이드 버전 호환성 (version_constraint)
템플릿에서 모듈/플러그인의 레이아웃을 오버라이드할 때, 대상 모듈/플러그인의 버전이 변경되면 오버라이드가 호환되지 않을 수 있습니다. version_constraint 필드를 사용하여 오버라이드가 적용될 버전 범위를 지정할 수 있습니다.
사용 목적
- 모듈/플러그인 메이저 버전 변경 시 오버라이드 호환성 문제 방지
- 비호환 오버라이드 자동 스킵 및 관리자 경고 표시
- 템플릿 개발자가 지원하는 버전 범위 명시
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 ❌ |
동작 방식
- version_constraint 없음: 항상 오버라이드 적용 (하위 호환성 유지)
- 버전 제약 만족: 오버라이드 정상 적용
- 버전 제약 불만족: 오버라이드 스킵 + 관리자 경고 표시
- 대상 버전 정보 없음: 오버라이드 적용 (경고 로그)
관리자 경고 표시
비호환 오버라이드가 감지되면 레이아웃 상단에 Alert 컴포넌트가 자동 주입됩니다:
호환성 경고
오버라이드 "sirsoft-admin_basic"가 모듈 버전과 호환되지 않습니다 (요구: ^1.0, 현재: 2.0.0)
사용 예시
{
"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 예시:
{
"layout_name": "admin_sample_index",
"version_constraint": "^1.0",
"extends": "_admin_base",
"slots": {
"content": [...]
}
}
동작:
version_constraint없음 → 항상 오버라이드 적용- 버전 호환 → 오버라이드 적용
- 버전 비호환 → 오버라이드 스킵, 모듈 기본 레이아웃으로 폴백 +
Log::warning
레이아웃 상속 처리
extends 필드 사용
{
"layout_name": "admin_sample_index",
"extends": "_admin_base",
"slots": {
"content": [...]
}
}
병합 로직 (LayoutService.mergeLayouts)
// content에 extends가 있어야 병합 수행
if (isset($layoutData['extends'])) {
$parentLayoutName = $layoutData['extends'];
$parentLayout = $this->loadAndMergeLayout($templateId, $parentLayoutName);
$mergedLayout = $this->mergeLayouts($parentLayout, $layoutData);
} else {
$mergedLayout = $layoutData;
}
병합 결과
$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)
// 개별 필드만 추출 - 병합 실패 원인
$content = [];
if (isset($layoutData['slots'])) {
$content['slots'] = $layoutData['slots'];
}
if (isset($layoutData['meta'])) {
$content['meta'] = $layoutData['meta'];
}
// extends, layout_name 누락!
올바른 구현 (✅ DO)
// layoutData 전체 저장 - TemplateManager와 일관성
$content = $layoutData;
캐시 전략
무효화 시점
- 모듈 활성화 후
- 모듈 비활성화 후
- 레이아웃 파일 변경 시
무효화 메서드
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}");
}
}
캐시 무효화 원칙
중요: 모듈/플러그인 활성화/비활성화 시 관련 캐시 자동 무효화 필수
✅ 필수: Manager 레벨에서 캐시 무효화 구현 (Service 아님)
✅ 권장: 공통 캐시 무효화 로직은 트레이트로 분리
모듈 레이아웃 개발 가이드
1. 레이아웃 파일 생성
# 디렉토리 생성
mkdir -p modules/_bundled/sirsoft-sample/resources/layouts/admin
# 레이아웃 파일 생성
touch modules/_bundled/sirsoft-sample/resources/layouts/admin/index.json
2. 레이아웃 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)
{
"routes": [
{
"path": "/admin/sirsoft-sample",
"layout_name": "sirsoft-sample.admin_sample_index",
"permissions": ["sirsoft-sample.admin.view"]
}
]
}
4. 모듈 활성화
php artisan module:activate sirsoft-sample
문제 해결
레이아웃 병합 안 됨
원인: content에 extends 필드 누락
해결:
ModuleManager.registerLayoutToTemplate()에서$layoutData전체 저장 확인- 모듈 재활성화로 DB 업데이트:
php artisan module:deactivate sirsoft-sample php artisan module:activate sirsoft-sample
layout_name 누락 오류
원인: content에 layout_name 필드 누락
해결: 위와 동일 (ModuleManager 수정 후 재활성화)
오버라이드가 적용 안 됨
원인:
- 레이아웃 이름 불일치
source_type이 'template'이 아님- 캐시 문제
해결:
# 캐시 초기화
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/ 하위 배치 필수
해결: 적절한 하위 디렉토리로 파일 이동. 경고 로그 확인:
"레이아웃 파일이 admin/ 또는 user/ 하위에 위치하지 않아 스킵됩니다: {file}"
모듈 재활성화 후에도 레이아웃 안 보임
확인 사항:
- 레이아웃 파일 경로가 올바른지 확인
- JSON 파일 문법 오류 확인
layout_name필드가 JSON에 있는지 확인
관련 문서
- module-basics.md - 모듈 개발 기초
- module-routing.md - 모듈 라우트 규칙
- module-commands.md - 모듈 Artisan 커맨드
- module-i18n.md - 모듈 다국어
- template-caching.md - 템플릿 캐싱 전략
- index.md - 확장 시스템 인덱스