Files
Gnuboard7/docs/frontend/layout-json-inheritance.md
T
2026-05-11 11:29:41 +09:00

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 디렉토리도 자유롭게 참조 가능 (모달뿐 아니라 모든 레이아웃 컴포넌트에 적용):

  1. 호출 레이아웃 기준 상대 경로: <layout-dir>/<partial-path>
  2. 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로 평가됩니다.


목차

  1. 레이아웃 상속 (Extends & Slots)
  2. 레이아웃 Partial (파일 분할)
  3. 병합 결과 구조
  4. 상속 체인 검증 규칙

레이아웃 상속 (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}}가 작동하지 않는가?

  1. Partial 처리는 활성화 시점에 수행되어 DB에 병합된 결과가 저장됨
  2. 프론트엔드는 병합된 하나의 레이아웃만 받아서 렌더링
  3. 프론트엔드 입장에서 "partial이었던 부분"과 "원래 메인 레이아웃 부분"의 구분이 불가능
  4. props 컨텍스트는 컴포넌트 내부에서만 유효하며, partial 호출 시 전달되는 것이 아님
  5. 따라서 {{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 기준으로 병합됩니다.

병합 규칙

  1. 배열 합치기: 부모 + 자식 배열을 순서대로 합침
  2. 동일 pattern: 같은 pattern이 있으면 headers를 병합 (자식이 동일 키 덮어씀)
  3. 다른 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'));
        }
    }
}