30 KiB
레이아웃 JSON - 상속 (Extends, Partial, 병합)
메인 문서: layout-json.md 관련 문서: layout-json-features.md | layout-json-components.md
TL;DR (5초 요약)
1. extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입)
2. slots: 상속 시 슬롯별 컴포넌트 배열 정의
3. partials: 파일 분할 (컴포넌트 내용만 치환)
4. 병합 규칙: extends 상속 시 data_sources, modals, init_actions, computed, globalHeaders 자동 병합
5. globalHeaders 병합: pattern 기준으로 병합, 동일 pattern은 headers 병합 (자식 우선)
6. Partial은 컴포넌트 치환만! computed/data_sources 등은 부모 레이아웃에 정의!
7. 독립 레이아웃(extends 없음)은 Toast/Modal 등 글로벌 호스트 컴포넌트를 직접 마운트해야 함 (engine-v1.47.1 명문화)
독립 레이아웃의 글로벌 호스트 컴포넌트 (CRITICAL)
extends 가 없는 독립 레이아웃(예: admin_login.json)은 베이스의 Toast, ModalRoot 등 글로벌 호스트 컴포넌트를 자동으로 마운트하지 않는다. toast / openModal 같은 핸들러는 _global.toasts / _global.modal 에 데이터를 push 만 하고 화면 렌더는 호스트 컴포넌트의 책임이므로, 호스트 미마운트 상태에서 핸들러를 호출하면 success 로 기록되지만 사용자에게 보이지 않는 silent failure 가 발생한다.
❌ 독립 레이아웃에서 init_actions 의 toast / openModal 발화 + 호스트 컴포넌트 누락
→ 핸들러 success / 화면 미노출 → 회귀 (시각적으로 무동작)
✅ 독립 레이아웃 components 최상단에 호스트 컴포넌트 직접 추가
- Toast: { id, type: "composite", name: "Toast",
props: { toasts: "{{_global.toasts}}", position, duration } }
- 베이스 레이아웃의 호스트 컴포넌트 정의를 그대로 복사하는 것을 권장
베이스 레이아웃의 호스트 컴포넌트 위치
| 템플릿 | 베이스 레이아웃 | Toast 컴포넌트 위치 |
|---|---|---|
| sirsoft-admin_basic | layouts/_admin_base.json |
components[0] (id: global_toast) |
| sirsoft-basic | layouts/_user_base.json |
components 내 Toast 항목 |
독립 레이아웃을 작성할 때는 위 베이스의 global_toast (또는 동등 항목) 블록을 그대로 복사한다. 사용자 정의 변형 없이 정의를 통일하면 회귀가 발생하지 않는다.
Partial 처리 방식
필수: Partial 처리는 "단순 컴포넌트 치환"입니다!
Partial 처리 시 {"partial": "path"} 객체가 해당 파일의 컴포넌트 내용으로 치환됩니다.
최상위 속성(computed, data_sources, modals, init_actions 등)은 병합되지 않습니다!
✅ Partial에서 사용 가능:
- 컴포넌트 정의 (type, name, children, props 등)
- 데이터 바인딩 표현식 ({{...}})
- $computed 참조 (부모 레이아웃에 정의된 computed 사용)
- 조건부 렌더링 (if), 반복 (iteration) 등
❌ Partial에서 정의해도 무시되는 속성 (전체 목록):
- computed: 부모 레이아웃과 병합되지 않음
- data_sources: 부모 레이아웃과 병합되지 않음
- modals: 부모 레이아웃과 병합되지 않음
- init_actions: 부모 레이아웃과 병합되지 않음
- state: 부모 레이아웃과 병합되지 않음
- version: 부모 레이아웃 버전이 사용됨
- layout_name: 부모 레이아웃 이름이 사용됨
- extends: partial에서 extends 사용 불가
- slots: partial은 슬롯 정의 불가
- classMap: 부모 레이아웃과 병합되지 않음
- errorHandling: 부모 레이아웃과 병합되지 않음
- scripts: 부모 레이아웃과 병합되지 않음
최상위 속성은 반드시 부모 레이아웃(메인 레이아웃)에 정의해야 합니다!
💡 기술적 이유: Partial 처리는 {"partial": "path"} 객체를 파일 내용으로 치환합니다.
치환된 내용은 컴포넌트 배열 내에 삽입되므로, 레이아웃 최상위 속성은 의미가 없습니다.
extends 상속과 달리 partial은 mergeComputed(), mergeDataSources() 등을 호출하지 않습니다.
병합 흐름:
[메인 레이아웃]
↓
[{"partial": "path"} 객체 탐색]
↓
[해당 파일 로드 후 컴포넌트 내용으로 치환]
↓
[DB에 병합된 단일 JSON 저장]
↓
[프론트엔드는 병합된 JSON만 수신]
Partial 경로 해석 (cross-namespace 지원)
Partial 경로는 다음 순서로 해석된다 — 다른 레이아웃의 partials 디렉토리도 자유롭게 참조 가능 (모달뿐 아니라 모든 레이아웃 컴포넌트에 적용):
- 호출 레이아웃 기준 상대 경로:
<layout-dir>/<partial-path> - Layouts 루트 기준 fallback:
<layouts-root>/<partial-path>—partials/로 시작하는 경로일 때만
따라서 admin_template_list.json 에서 "partial": "partials/admin_settings/_modal_core_update_guide.json" 처럼 다른 layout namespace 의 partial 을 참조해도 정상 해석된다 (코어 공유 모달 등 재사용 케이스).
// ✅ 동일 namespace
{ "partial": "partials/admin_template_list/_modal_update.json" }
// ✅ 다른 namespace (cross-namespace) — 코어 공유 모달/컴포넌트 재사용
{ "partial": "partials/admin_settings/_modal_core_update_guide.json" }
// ❌ layouts 디렉토리 외부 — 보안상 LayoutIncludeException
{ "partial": "../../some-other-template/partials/x.json" }
보안: 해석된 절대 경로가 layouts 루트 디렉토리 밖이면 LayoutIncludeException 으로 차단 (path traversal 방지).
구현: app/Extension/Traits/ValidatesLayoutFiles.php::resolvePartialPath() 가 위 두 단계 해석을 수행. 같은 로직을 PluginManager 도 공유.
올바른 computed 정의 방법:
// ✅ admin_product_list.json (부모 레이아웃) - computed 정의
{
"layout_name": "admin_product_list",
"computed": {
"filterPriceType": "{{_local.filter.priceType ?? query.price_type ?? 'selling_price'}}",
"filterDateType": "{{_local.filter.dateType ?? query.date_type ?? 'created_at'}}"
},
"slots": {
"content": [
{ "partial": "partials/_filter_section.json" }
]
}
}
// ✅ _filter_section.json (partial 파일) - $computed 참조만 가능
{
"meta": { "is_partial": true },
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "가격 유형: {{$computed.filterPriceType}}"
}
]
}
잘못된 예시 (partial에서 computed 정의):
// ❌ _filter_section.json (partial 파일) - computed 정의해도 무시됨!
{
"meta": { "is_partial": true },
"computed": {
"filterPriceType": "..." // 이 computed는 병합되지 않음!
},
"type": "basic",
"name": "Div"
}
위 partial의 computed는 부모 레이아웃과 병합되지 않습니다. $computed.filterPriceType은 undefined로 평가됩니다.
목차
레이아웃 상속 (Extends & Slots)
목적: 레이아웃 재사용 및 확장성 향상
Base 레이아웃 예시
{
"version": "1.0.0",
"layout_name": "base_admin",
"components": [
{
"id": "header",
"type": "composite",
"name": "AdminHeader"
},
{
"id": "main-content",
"type": "slot",
"name": "content",
"default": []
},
{
"id": "footer",
"type": "composite",
"name": "AdminFooter"
}
]
}
상속 레이아웃 예시
{
"version": "1.0.0",
"layout_name": "dashboard",
"extends": "base_admin",
"meta": {
"title": "$t:dashboard.title",
"description": "$t:dashboard.description"
},
"data_sources": [],
"slots": {
"content": [
{
"id": "dashboard-content",
"type": "composite",
"name": "DashboardStats"
}
]
}
}
레이아웃 Partial (파일 분할)
목적: 거대한 레이아웃 JSON 파일을 작은 파일들로 분할하여 가독성 및 유지보수성 향상
처리 시점: 템플릿/모듈 활성화 시점 (DB 저장 전)
기본 문법
{
"partial": "경로"
}
핵심 원칙:
- ✅
partial키워드 사용 ($ 접두사 없음) - ✅ 상대 경로만 허용
- ✅ 어디든 사용 가능 (객체, 배열 항목 등)
- ✅ 무제한 중첩 깊이 (최대 10단계)
- ✅ data_sources 전파 (partial 파일에서도 접근 가능)
사용 예시
1. 기본 예시 (템플릿)
디렉토리 구조:
templates/sirsoft-admin_basic/layouts/
├── template_partial_test.json # 메인 레이아웃
└── partials/ # Partial 파일 폴더
└── template_partial_test/ # 메인 레이아웃명으로 그룹핑
├── _content_section.json # Level 1 partial
└── _info_card.json # Level 2 partial
메인 레이아웃:
{
"version": "1.0.0",
"layout_name": "template_partial_test",
"extends": "_admin_base",
"slots": {
"content": [
{
"type": "basic",
"name": "Div",
"children": [
{
"partial": "partials/template_partial_test/_content_section.json"
}
]
}
]
}
}
Partial 파일 (_content_section.json):
{
"meta": {
"is_partial": true,
"description": "컨텐츠 섹션"
},
"type": "basic",
"name": "Div",
"children": [
{
"partial": "_info_card.json"
}
]
}
중첩 Partial 파일 (_info_card.json):
{
"meta": {
"is_partial": true,
"description": "정보 카드"
},
"type": "composite",
"name": "Card",
"props": {
"title": "Partial 테스트",
"content": "이 Card는 partial로 포함되었습니다.\n\n특징:\n• 무제한 중첩 깊이\n• data_sources 전파"
}
}
is_partial 메타데이터
Partial 전용 파일임을 표시하는 메타데이터 (선택사항):
{
"meta": {
"is_partial": true,
"description": "Partial 전용 파일 설명"
}
}
용도:
- Partial 전용 파일임을 명시적으로 표시
- DB 저장 방지 (활성화 시 자동 스킵)
- 문서화 및 가독성 향상
경로 해석 규칙
기본 경로: 현재 레이아웃 파일이 있는 디렉토리
| 경로 형식 | 설명 | 예시 |
|---|---|---|
folder/file.json |
하위 디렉토리의 파일 | partials/template_partial_test/_content_section.json |
file.json |
같은 디렉토리의 파일 | _section_header.json |
../file.json |
상위 디렉토리 | ../common/header.json |
보안 제약:
- ✅ 상대 경로만 허용
- 절대 경로 사용 금지
- ❌ 크로스 템플릿/모듈 참조 금지
- ✅
resources/layouts/{category}/디렉토리 내부로 제한
data_sources 전파
핵심 기능: 메인 레이아웃의 data_sources가 partial 파일에서도 접근 가능
// 메인 레이아웃
{
"data_sources": [
{"id": "items", "endpoint": "/api/admin/sample/test-data"}
],
"slots": {
"content": [
{"partial": "partials/sample_content/_tab_content.json"}
]
}
}
// _tab_content.json (partial 파일)
{
"type": "composite",
"name": "Card",
"props": {
"content": "Items 총 개수: {{items.data.pagination.total}}"
}
}
제약사항
- 최대 깊이: 10단계 (
config('template.layout.max_inheritance_depth')) - 순환 참조: 자동 감지 및 로그 기록 (에러 컴포넌트 반환)
- 보안: layouts 디렉토리 외부 참조 금지
- 에러 처리: 파일 없음, JSON 파싱 실패 시 로그 기록 + 에러 컴포넌트 반환 (개발 환경에서만)
에러 처리
개발 환경 (APP_DEBUG=true):
- 에러 메시지를 화면에 빨간색 박스로 표시
- 로그에 상세 정보 기록
프로덕션 환경 (APP_DEBUG=false):
- 에러 컴포넌트를 빈 배열로 반환 (아무것도 렌더링 안 함)
- 로그에만 기록
2. 하이브리드 폴더 구조 예시 (권장)
디렉토리 구조:
modules/sirsoft-board/resources/layouts/admin/
├── admin_board_form.json # 메인 레이아웃
└── partials/
├── admin_board_form/ # 레이아웃 전용 Partial 파일
│ ├── _tab_basic.json
│ ├── _tab_permissions.json
│ ├── _tab_list.json
│ ├── _tab_post.json
│ └── _tab_notification.json
└── common/ # 재사용 가능한 공통 Partial (향후)
└── _common_section.json
메인 레이아웃 (admin_board_form.json):
{
"version": "1.0.0",
"layout_name": "admin_board_form",
"extends": "_admin_base",
"slots": {
"content": [
{
"id": "board_form",
"type": "basic",
"name": "Form",
"children": [
{
"partial": "partials/admin_board_form/_tab_basic.json"
},
{
"partial": "partials/admin_board_form/_tab_permissions.json"
}
]
}
]
}
}
Partial 파일 (partials/admin_board_form/_tab_basic.json):
{
"meta": {
"is_partial": true,
"description": "Board Form - Basic Tab Content"
},
"id": "tab_content_basic",
"type": "basic",
"name": "Div",
"condition": "{{(_local.activeTab ?? 'basic') === 'basic'}}",
"props": {
"className": "space-y-6 max-w-2xl pt-6"
},
"children": [
{
"id": "board_id_field",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "board_id",
"placeholder": "$t:sirsoft-board.admin.form.board_id_placeholder"
}
}
]
}
하이브리드 구조의 장점:
- ✅ 레이아웃별 격리:
admin_board_form/폴더로 해당 레이아웃 전용 파일 관리 - ✅ 확장성:
common/폴더에 향후 재사용 가능한 컴포넌트 추가 가능 - ✅ 명확한 구조: 파일 경로만 봐도 용도를 알 수 있음
- ✅ 유지보수성: 특정 레이아웃 수정 시 해당 폴더만 작업
Partial 내부 데이터 바인딩 규칙
Partial은 PHP의 include와 동일하게 "단순 문자열 삽입"입니다.
병합 후 하나의 완전한 레이아웃이 되므로, 프론트엔드는 partial 여부를 알 수 없습니다.
주의: partial 내부에서 {{props.xxx}} 사용 불가
- props는 partial 호출 시 전달되는 것이 아님
- 병합 후 프론트엔드에서 props 컨텍스트가 없어 평가 불가
✅ 필수: partial 내부에서 직접 data_sources ID로 참조
- {{stats?.data?.users ?? 0}} (O)
- {{props.value}} (X)
잘못된 예시 ({{props.xxx}} 사용):
// ❌ _stat_card.json (partial 파일)
{
"meta": { "is_partial": true },
"type": "basic",
"name": "Div",
"children": [
{ "type": "basic", "name": "Span", "text": "{{props.value}}" },
{ "type": "basic", "name": "P", "text": "{{props.label}}" }
]
}
// ❌ home.json (메인 레이아웃) - props 전달 시도
{
"partial": "partials/home/_stat_card.json",
"props": { "value": "{{stats?.data?.users ?? 0}}", "label": "회원수" }
}
올바른 예시 (data_sources 직접 참조):
// ✅ _stat_card.json (partial 파일) - data_sources ID 직접 참조
{
"meta": { "is_partial": true },
"type": "basic",
"name": "Div",
"children": [
{ "type": "basic", "name": "Span", "text": "{{stats?.data?.users ?? 0}}" },
{ "type": "basic", "name": "P", "text": "$t:home.stats.members" }
]
}
// ✅ home.json (메인 레이아웃) - partial만 참조
{
"partial": "partials/home/_stat_card.json"
}
왜 {{props.xxx}}가 작동하지 않는가?
- Partial 처리는 활성화 시점에 수행되어 DB에 병합된 결과가 저장됨
- 프론트엔드는 병합된 하나의 레이아웃만 받아서 렌더링
- 프론트엔드 입장에서 "partial이었던 부분"과 "원래 메인 레이아웃 부분"의 구분이 불가능
props컨텍스트는 컴포넌트 내부에서만 유효하며, partial 호출 시 전달되는 것이 아님- 따라서
{{props.xxx}}는 평가 시점에undefined로 해석됨
체크리스트:
□ partial 파일 내부에서 {{props.xxx}} 패턴 사용하지 않았는가?
□ data_sources ID를 직접 참조하고 있는가? (예: {{stats?.data?.xxx}})
□ 메인 레이아웃의 data_sources가 partial에서 접근하는 ID를 포함하는가?
□ Optional chaining (?.) 과 nullish coalescing (??) 을 사용하여 안전하게 접근하는가?
베스트 프랙티스
분할 기준:
- ✅ 반복되는 큰 구조 (탭, 섹션)
- ✅ 재사용 가능한 컴포넌트 조합
- ✅ 독립적으로 관리하고 싶은 부분
- ❌ meta, state (짧고 맥락 필요)
- ❌ data_sources (관계 파악 필요)
폴더 구조 규칙:
- ✅ 하이브리드 구조 (권장):
partials/{레이아웃명}/+partials/common/ - ✅ 레이아웃 전용:
partials/{레이아웃명}/(특정 레이아웃에만 사용) - ✅ 공통 폴더:
partials/common/(여러 레이아웃에서 재사용) - ❌ 단일 폴더:
partials/(파일이 많아질 경우 관리 어려움)
네이밍 규칙:
- 파일:
_{파일명}.json(언더스코어 접두사 권장) - is_partial 메타데이터 추가 권장
경로 표기:
// ✅ 권장 (하이브리드 구조)
{"partial": "partials/admin_board_form/_tab_basic.json"}
{"partial": "partials/common/_section_header.json"}
// ✅ 허용 (같은 디렉토리)
{"partial": "_section_header.json"}
// ❌ 금지 (절대 경로, 크로스 참조)
{"partial": "/modules/sirsoft-sample/resources/layouts/..."}
{"partial": "./partials/_tab_content.json"}
extends와의 차이
| 항목 | extends | partial |
|---|---|---|
| 목적 | 레이아웃 상속 | 파일 분할 |
| 구조 | 선형 체인 | 트리 |
| 개수 | 1개만 | 제한 없음 |
| 깊이 | 10단계 | 10단계 |
| 병합 | 슬롯 교체 | 직접 치환 |
| 처리 시점 | Runtime | 활성화 시점 |
| data_sources | 상속됨 | 전파됨 |
처리 흐름
[템플릿/모듈 활성화]
↓
[syncLayouts() 실행]
↓
[레이아웃 JSON 파일 읽기]
↓
[resolveAllPartials() 실행] ← Partial 처리
- partial 키 탐색 (DFS)
- 파일 로드 및 JSON 파싱
- data_sources 전파
- 순환 참조 감지
- 치환된 데이터로 교체
↓
[partial이 모두 병합된 JSON]
↓
[DB 저장 (template_layouts 테이블)]
중요: Partial 처리는 활성화 시점에 한 번만 실행되며, DB에는 병합된 결과가 저장됩니다. Runtime에서는 DB에서 병합된 JSON을 직접 사용합니다.
병합 결과 구조
레이아웃 병합 시 LayoutService::mergeLayouts()가 반환하는 결과 구조:
{
"version": "1.0.0",
"layout_name": "dashboard",
"meta": { ... },
"data_sources": [ ... ],
"components": [ ... ],
"modals": [ ... ],
"init_actions": [ ... ]
}
필수 필드
| 필드 | 설명 | 병합 우선순위 |
|---|---|---|
version |
레이아웃 스키마 버전 | 자식 → 부모 → "1.0.0" |
layout_name |
레이아웃 식별자 | 자식 → 부모 → "" |
meta |
메타 정보 | 자식이 부모를 덮어씀 |
data_sources |
데이터 소스 | 부모 + 자식 (ID 중복 불가) |
components |
컴포넌트 트리 | 슬롯 교체 병합 |
modals |
모달 컴포넌트 | ID 기반 병합 (자식 우선) |
initActions / init_actions |
초기화 액션 | 부모 먼저 + 자식 나중에 (실행 순서 보장) |
initLocal / state |
로컬 상태 초기값 | 얕은 병합 (자식이 동일 키 덮어씀) |
initGlobal |
전역 상태 초기값 | 얕은 병합 (자식이 동일 키 덮어씀) |
initIsolated |
격리 상태 초기값 | 얕은 병합 (자식이 동일 키 덮어씀) |
scripts |
외부 스크립트 | ID 기반 병합 (자식 우선) |
상태 초기화 속성 병합 규칙 (engine-v1.11.0+)
얕은 병합 (Shallow Merge): 부모와 자식의 최상위 키만 병합, 동일 키는 자식이 덮어씀
// 부모 레이아웃
"initLocal": { "activeTab": "basic", "isLoading": false }
// 자식 레이아웃
"initLocal": { "activeTab": "advanced", "filter": {} }
// 병합 결과 (자식이 동일 키 덮어씀)
"initLocal": { "activeTab": "advanced", "isLoading": false, "filter": {} }
| 속성 | 병합 방식 | 하위 호환 |
|---|---|---|
initLocal |
얕은 병합 (array_merge) | state 속성도 동일 처리. 데이터소스 initLocal은 _merge 옵션으로 병합 방식 지정 가능 |
initGlobal |
얕은 병합 (array_merge) | - |
initIsolated |
얕은 병합 (array_merge) | - |
initActions |
배열 연결 (부모 + 자식 순서) | init_actions 속성도 동일 처리 |
scripts |
ID 기반 병합 (자식 우선) | - |
globalHeaders |
pattern 기준 병합 (자식 우선) | 동일 pattern은 headers 병합 (engine-v1.16.0+) |
meta.seo 병합 규칙
부모의 meta.seo 기본값을 자식이 부분적으로 오버라이드 가능:
| 키 유형 | 병합 전략 |
|---|---|
| 스칼라 (enabled, priority 등) | 자식 우선 오버라이드 |
| 연관 배열 (og, vars, structured_data) | deep merge (array_replace_recursive) |
| data_sources (숫자 배열) | 합집합 + 중복 제거 (permissions와 동일) |
- 부모:
{ enabled: true, priority: 0.5 } - 자식:
{ priority: 0.8, og: {...} } - 결과:
{ enabled: true, priority: 0.8, og: {...} }← 부모 enabled 보존
자식이 enabled: false 지정 시 → SEO 비활성화 (자식 우선)
상세: seo-system.md
데이터소스 initLocal의 _merge 옵션 (engine-v1.18.0+)
데이터소스의 initLocal에서 _merge 옵션을 사용하면 API 응답을 _local에 매핑할 때 병합 방식을 지정할 수 있습니다.
_merge 값 |
동작 |
|---|---|
"deep" (기본값) |
깊은 병합 — 기존 _local 값 보존, 매핑 결과만 덮어쓰기 |
"shallow" |
얕은 병합 — 최상위 키만 병합 |
"replace" |
완전 교체 — 기존 _local 무시, 매핑 결과만 남음 |
상세 문서: data-sources-advanced.md
병합 시 제거되는 필드
extends: 상속 관계 정보slots: 슬롯 정의slot: 컴포넌트의 슬롯 속성
프론트엔드 검증
LayoutLoader.ts에서version,layout_name필드 필수 검증- 필드 누락 시
VALIDATION_FAILED오류 발생
검증 규칙
extends필드가 있으면components또는slots중 하나 필수extends가 없으면components필드 필수 (base 레이아웃)- 순환 참조 방지 (A → B → A)
- 최대 상속 깊이: 10단계
슬롯 병합 규칙
- 부모 레이아웃의 슬롯을 자식 레이아웃의 컴포넌트로 대체
- 슬롯 이름이 일치하지 않으면 부모 레이아웃의
default컴포넌트 사용 - 상속 체인은 재귀적으로 병합 (최대 10단계)
globalHeaders 병합
버전: engine-v1.16.0+
부모와 자식 레이아웃의 globalHeaders는 pattern 기준으로 병합됩니다.
병합 규칙
- 배열 합치기: 부모 + 자식 배열을 순서대로 합침
- 동일 pattern: 같은 pattern이 있으면 headers를 병합 (자식이 동일 키 덮어씀)
- 다른 pattern: 별도로 유지
예시
부모 레이아웃 (_user_base.json):
{
"globalHeaders": [
{ "pattern": "*", "headers": { "X-Template": "basic" } },
{ "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
]
}
자식 레이아웃 (shop/cart.json):
{
"extends": "_user_base",
"globalHeaders": [
{ "pattern": "*", "headers": { "X-Page": "cart" } }
]
}
병합 결과:
{
"globalHeaders": [
{ "pattern": "*", "headers": { "X-Template": "basic", "X-Page": "cart" } },
{ "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
]
}
- 동일 pattern
*: headers가 병합됨 (자식의X-Page추가) - 부모에만 있는 pattern
/api/modules/sirsoft-ecommerce/*: 그대로 유지
병합 순서
부모 레이아웃 (_base.json)
↓ 병합
자식 레이아웃 (shop/cart.json)
↓ 병합 (있을 경우)
Partial은 병합되지 않음 (컴포넌트 치환만)
레이아웃 상속 체인 검증 규칙
중요: 레이아웃 상속 시 순환 참조 및 깊이 제한 검증 필수
✅ 필수: LayoutService에서 validateInheritanceChain() 구현
핵심 원칙
- 최대 상속 깊이: 10단계 (MAX_DEPTH 상수)
- 순환 참조 방지: A → B → A 패턴 감지
- 검증 시점: 레이아웃 생성/수정 시 (FormRequest)
- 예외 처리: CircularReferenceException, MaxDepthExceededException
검증 구현
<?php
namespace App\Services\Template;
use App\Exceptions\Template\CircularReferenceException;
use App\Exceptions\Template\MaxDepthExceededException;
use App\Models\TemplateLayout;
class LayoutService
{
private const MAX_DEPTH = 10;
/**
* 레이아웃 상속 체인 검증
*
* @param string $layoutName 검증할 레이아웃명
* @param array $stack 상속 체인 스택 (재귀 추적용)
* @param int $depth 현재 깊이
* @throws CircularReferenceException 순환 참조 발생 시
* @throws MaxDepthExceededException 깊이 초과 시
*/
public function validateInheritanceChain(
string $layoutName,
array $stack = [],
int $depth = 0
): void {
// 1. 순환 참조 검사
if (in_array($layoutName, $stack)) {
throw new CircularReferenceException($stack, $layoutName);
}
// 2. 깊이 검사
if ($depth > self::MAX_DEPTH) {
throw new MaxDepthExceededException($depth, self::MAX_DEPTH);
}
// 3. 레이아웃 조회
$layout = TemplateLayout::where('name', $layoutName)->first();
if (!$layout) {
return; // 레이아웃이 없으면 더 이상 검증 불필요
}
// 4. extends가 있으면 재귀적으로 검증
if ($layout->extends) {
$this->validateInheritanceChain(
$layout->extends,
array_merge($stack, [$layoutName]),
$depth + 1
);
}
}
/**
* 레이아웃 생성 전 검증
*/
public function createLayout(array $data): TemplateLayout
{
// extends가 있으면 상속 체인 검증
if (isset($data['extends'])) {
$this->validateInheritanceChain($data['extends']);
}
$layout = TemplateLayout::create($data);
// 캐시 무효화
$this->cacheService->invalidateLayout($layout->name);
return $layout;
}
/**
* 레이아웃 수정 전 검증
*/
public function updateLayout(int $id, array $data): TemplateLayout
{
$layout = TemplateLayout::findOrFail($id);
$oldExtends = $layout->extends;
// extends 변경 시 새로운 상속 체인 검증
if (isset($data['extends']) && $data['extends'] !== $oldExtends) {
// 자기 자신을 extends하는지 검사
if ($data['extends'] === $layout->name) {
throw new CircularReferenceException([], $layout->name);
}
// 새 상속 체인 검증
$this->validateInheritanceChain($data['extends'], [$layout->name]);
}
$layout->update($data);
// 캐시 무효화 (상속 체인 전체)
$this->cacheService->invalidateLayoutChain($layout->name);
return $layout->fresh();
}
}
Custom Exception 예시
<?php
namespace App\Exceptions\Template;
use Exception;
/**
* 레이아웃 순환 참조 예외
*/
class CircularReferenceException extends Exception
{
private array $stack;
public function __construct(array $stack, string $currentLayout)
{
$this->stack = $stack;
$stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
$message = __('exceptions.template.circular_reference', ['trace' => $stackTrace]);
parent::__construct($message);
}
public function getStack(): array
{
return $this->stack;
}
}
/**
* 레이아웃 최대 깊이 초과 예외
*/
class MaxDepthExceededException extends Exception
{
public function __construct(int $currentDepth, int $maxDepth = 10)
{
$message = __('exceptions.template.max_depth_exceeded', [
'current' => $currentDepth,
'max' => $maxDepth
]);
parent::__construct($message);
}
}
검증 시점
// StoreLayoutRequest.php - 레이아웃 생성 시
public function rules(): array
{
return [
'extends' => ['nullable', 'string', new ValidLayoutInheritance],
// ... 기타 필드
];
}
// ValidLayoutInheritance Custom Rule
class ValidLayoutInheritance implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
try {
$layoutService = app(LayoutService::class);
$layoutService->validateInheritanceChain($value);
} catch (CircularReferenceException $e) {
$fail(__('validation.layout.circular_reference', ['trace' => $e->getMessage()]));
} catch (MaxDepthExceededException $e) {
$fail(__('validation.layout.max_depth_exceeded'));
}
}
}