# 레이아웃 JSON - 상속 (Extends, Partial, 병합)

> **메인 문서**: [layout-json.md](layout-json.md)
> **관련 문서**: [layout-json-features.md](layout-json-features.md) | [layout-json-components.md](layout-json-components.md)

---

## TL;DR (5초 요약)

```text
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 가 발생한다.

```text
❌ 독립 레이아웃에서 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 처리 방식

```text
필수: 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() 등을 호출하지 않습니다.
```

**병합 흐름**:

```text
[메인 레이아웃]
    ↓
[{"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 을 참조해도 정상 해석된다** (코어 공유 모달 등 재사용 케이스).

```json
// ✅ 동일 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 정의 방법**:

```json
// ✅ 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 정의)**:

```json
// ❌ _filter_section.json (partial 파일) - computed 정의해도 무시됨!
{
  "meta": { "is_partial": true },
  "computed": {
    "filterPriceType": "..."  // 이 computed는 병합되지 않음!
  },
  "type": "basic",
  "name": "Div"
}
```

위 partial의 `computed`는 **부모 레이아웃과 병합되지 않습니다**. `$computed.filterPriceType`은 `undefined`로 평가됩니다.

---

## 목차

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

---

## 레이아웃 상속 (Extends & Slots)

**목적**: 레이아웃 재사용 및 확장성 향상

### Base 레이아웃 예시

```json
{
  "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"
    }
  ]
}
```

### 상속 레이아웃 예시

```json
{
  "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 저장 전)

### 기본 문법

```json
{
  "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
```

**메인 레이아웃**:
```json
{
  "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):
```json
{
  "meta": {
    "is_partial": true,
    "description": "컨텐츠 섹션"
  },
  "type": "basic",
  "name": "Div",
  "children": [
    {
      "partial": "_info_card.json"
    }
  ]
}
```

**중첩 Partial 파일** (_info_card.json):
```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 전용 파일임을 표시하는 메타데이터 (선택사항):

```json
{
  "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 파일에서도 접근 가능

```json
// 메인 레이아웃
{
  "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):
```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):
```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}} 사용)**:

```json
// ❌ _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 직접 참조)**:

```json
// ✅ _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 메타데이터 추가 권장

**경로 표기**:
```json
// ✅ 권장 (하이브리드 구조)
{"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()`가 반환하는 결과 구조:

```json
{
  "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)**: 부모와 자식의 최상위 키만 병합, 동일 키는 자식이 덮어씀

```text
// 부모 레이아웃
"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](../backend/seo-system.md)

#### 데이터소스 initLocal의 `_merge` 옵션 (engine-v1.18.0+)

데이터소스의 `initLocal`에서 `_merge` 옵션을 사용하면 API 응답을 `_local`에 매핑할 때 병합 방식을 지정할 수 있습니다.

| `_merge` 값 | 동작 |
|-------------|------|
| `"deep"` (기본값) | 깊은 병합 — 기존 `_local` 값 보존, 매핑 결과만 덮어쓰기 |
| `"shallow"` | 얕은 병합 — 최상위 키만 병합 |
| `"replace"` | 완전 교체 — 기존 `_local` 무시, 매핑 결과만 남음 |

> **상세 문서**: [data-sources-advanced.md](data-sources-advanced.md#3-병합-전략-옵션-_merge)

### 병합 시 제거되는 필드

- `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`):

```json
{
  "globalHeaders": [
    { "pattern": "*", "headers": { "X-Template": "basic" } },
    { "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
  ]
}
```

**자식 레이아웃** (`shop/cart.json`):

```json
{
  "extends": "_user_base",
  "globalHeaders": [
    { "pattern": "*", "headers": { "X-Page": "cart" } }
  ]
}
```

**병합 결과**:

```json
{
  "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/*`: 그대로 유지

#### 병합 순서

```text
부모 레이아웃 (_base.json)
    ↓ 병합
자식 레이아웃 (shop/cart.json)
    ↓ 병합 (있을 경우)
Partial은 병합되지 않음 (컴포넌트 치환만)
```

---

## 레이아웃 상속 체인 검증 규칙

```
중요: 레이아웃 상속 시 순환 참조 및 깊이 제한 검증 필수
✅ 필수: LayoutService에서 validateInheritanceChain() 구현
```

### 핵심 원칙

- **최대 상속 깊이**: 10단계 (MAX_DEPTH 상수)
- **순환 참조 방지**: A → B → A 패턴 감지
- **검증 시점**: 레이아웃 생성/수정 시 (FormRequest)
- **예외 처리**: CircularReferenceException, MaxDepthExceededException

### 검증 구현

```php
<?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
<?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);
    }
}
```

### 검증 시점

```php
// 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'));
        }
    }
}
```
