Files
Gnuboard7/docs/extension/layout-extensions.md
T
HeuJung 42eb21320b fix(ecommerce,core): 현금영수증 UI 보완 + 하네스 결함 6건 + 확장점 룰 사각 제거
현금영수증/환불계좌 기능(S3)의 감사 보완과, 그 과정에서 발견한 하네스 결함을
같은 세션에서 일괄 처리한다. 지시에 따라 무관 산출물(namuwiki 문서 초안 포함)도
단일 커밋으로 묶는다.

- 현금영수증 발급 이력 접기/펼치기(W-3) + 결제행별 독립 토글 + aria 상태
- vendor-bundle manifest 해시 재생성 — composer.json version bump 미반영분 정합
- 시나리오 매니페스트 거짓 경로 정정(A/B) + 미작성 부채 :test-files 정직 표기(C)
- layout-* 룰의 resources/extensions 검사 사각 제거 + 확장점 전용 root 스키마 분기
- validate-test-scenario.cjs: 맵형 test_files 크래시 + fallback 파서 covers 오검출 수정
- tosspayments 확장점 setState target 규약 위반 수정
2026-08-06 12:36:31 +09:00

891 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 레이아웃 확장 시스템 (Layout Extensions)
> 모듈/플러그인이 기존 레이아웃에 동적으로 UI를 주입하는 시스템입니다.
## 목차
- [개요](#개요)
- [핵심 원칙](#핵심-원칙)
- [확장 타입](#확장-타입)
- [Extension 파일 위치](#extension-파일-위치)
- [Overlay 확장](#overlay-확장)
- [Extension Point 확장](#extension-point-확장)
- [템플릿 오버라이드](#템플릿-오버라이드)
- [모듈 활성화/비활성화 동작](#모듈-활성화비활성화-동작)
- [우선순위](#우선순위)
- [데이터 소스 병합](#데이터-소스-병합)
- [모달 병합](#모달-병합)
- [Overlay 레이아웃 섹션 병합](#overlay-레이아웃-섹션-병합)
- [관련 파일](#관련-파일)
- [문제 해결](#문제-해결)
---
## 개요
Layout Extension 시스템은 모듈/플러그인이 코어 또는 다른 확장의 레이아웃에 UI 컴포넌트를 동적으로 주입할 수 있게 해줍니다.
**주요 특징**:
- 코어 레이아웃 수정 없이 UI 확장
- 모듈 비활성화 시 자동으로 확장 UI 숨김
- 템플릿에서 모듈 확장 오버라이드 가능
- 우선순위 기반 렌더링 순서 제어
- 관리자가 레이아웃 시각 편집기로 확장 조각을 직접 편집 (아래 "시각 편집" 참조)
### 시각 편집 (레이아웃 편집기)
레이아웃 시각 편집기(`/admin/layout-editor/{templateId}`)의 라우트 트리에 `[확장 주입]`
그룹(출처별 하위그룹)이 표시되며, 확장 항목을 선택하면 확장 편집 모드로 진입해 확장 조각
(extension_point=`content.components`, overlay=각 injection 의 `components`)을 일반 레이아웃과
동등하게 시각 편집한다. 저장은 해당 확장 행(layout-extensions API)으로 적재되어 버전 관리·수정
감지·`--layout-strategy=keep` 보존이 동작한다.
`inject_props` 주입은 편집할 컴포넌트 트리가 없으므로, 그 호스트 노드를 라우트/공통 레이아웃
편집 중 선택해 **속성 모달의 "확장이 주입한 속성" 섹션**(출처 배지)에서 편집한다 — 편집 결과는
호스트 레이아웃이 아니라 그 확장 행으로 교차 저장된다. 라우트/공통 레이아웃 편집 중 확장이 주입한
영역은 잠금 표시되며 "확장 편집" 어포던스로 확장 편집 모드에 진입할 수 있다.
#### 주입 kind × 편집기 대응
확장이 호스트에 주입하는 모든 kind 와 편집기의 대응 방식은 아래 표가 기준이다. 새 주입 방법을
추가할 때는 이 표에 편집기 대응을 함께 정의해야 한다.
| 주입 kind | 주입 경로 | 편집기 대응 |
| --- | --- | --- |
| extension_point `components` (mode: replace/append/prepend) | 호스트 EP 노드 자리 (components·modals 트리 모두 순회) | 확장 편집 모드 시각 편집. 게이트(`if`) 뒤면 editor-spec `states` 로 상태 시뮬레이션, 미정의 시 디그레이드 안내 |
| overlay `injections[].components` (position: prepend/append/prepend_child/append_child/replace) | 호스트 `target_id` 노드 기준 | 동일 — 확장 편집 모드 시각 편집 |
| overlay `injections[].position: inject_props` | 호스트 `target_id` 노드 props 병합 | 라우트/공통 편집 중 속성 모달 "확장이 주입한 속성" 섹션에서 편집 (확장 행으로 교차 저장) |
| 호스트 **모달 내부** EP 주입 (예: 회원가입 약관 모달의 `html_content`) | 호스트 `modals[]` 안 EP 노드 | 확장 편집 모드가 그 모달 노드를 표시용 `isOpen=true` 로 캔버스에 함께 렌더해 시각 편집 (모달 편집 모드와 동형) |
| `content.modals` (확장이 모달을 통째로 기여) | 호스트 `modals[]` 끝에 병합 | 시각 편집 비대상 — 트리에는 노출되고, 진입 시 "코드 편집 이용" 디그레이드 안내 |
| `content.data_sources` | 호스트 `data_sources` 병합 | 데이터 소스 모달에 출처 배지로 표시 (시각 편집 비대상) |
| `content.scripts` | 호스트 `scripts` 병합 (중복 제거) | 캔버스에 로드만 (CKEditor CDN 등) — 편집은 코드 편집 |
| `content.init_actions` (overlay) | 호스트 `init_actions` 끝에 병합 | 시각 편집 비대상 — 진입 시 "코드 편집 이용" 디그레이드 안내 |
트리 노출 판별(백엔드 `getExtensionHostLayouts`)은 "주입이 성립하는가"(overlay: 실재 `target_id`
injection 또는 modals/data_sources/scripts/init_actions 보유, EP: 병합할 payload 보유 + 확장점 실재)만 본다.
시각 편집 가능 여부의 정밀 판정은 진입 시 프론트(editability 판정 + 렌더 검증 + 폴백 안내)가
수행한다 — 시각 조각이 없는 확장도 숨기지 않고 디그레이드 안내로 코드 편집을 유도한다.
---
## 핵심 원칙
```text
필수: layout_extensions 통한 동적 주입 사용 (코어 레이아웃에 모듈 UI 하드코딩 금지)
필수: Layout Extension 시스템을 통한 동적 UI 주입
필수: 모듈 비활성화 시 관련 UI 자동 숨김
✅ 필수: 확장 컴포넌트에 ExtensionBadge 표시 (관리자 UI)
필수: 플러그인이 자기 화면을 가질 때 라우트는 plugins/{identifier}/ 네임스페이스 안에 선언
(다른 확장이 소유한 경로를 선언하면 설치 순서에 따라 화면이 조용히 바뀐다)
필수: 다른 확장의 화면에 UI 를 끼워 넣을 때는 확장 지점(layout_extensions) 사용
```
---
## 확장 타입
| 타입 | 키 | 설명 | 사용 시점 |
|------|-----|------|----------|
| **Overlay** | `target_layout` | 기존 레이아웃의 특정 컴포넌트 ID를 찾아 주입 | 특정 위치에 삽입/추가 |
| **Extension Point** | `extension_point` | 레이아웃에 사전 정의된 확장 포인트에 주입 | 확장용 영역이 미리 정의된 경우 |
---
## Extension 파일 위치
### 모듈
```
modules/
└── vendor-module/
└── resources/
└── extensions/ ← 확장 정의 디렉토리
└── *.json ← 확장 JSON 파일
```
### 플러그인
```
plugins/
└── vendor-plugin/
└── resources/
└── extensions/
└── *.json
```
### 템플릿 오버라이드
```
templates/
└── vendor-template/
└── extensions/
└── {module-identifier}/ ← 오버라이드 대상 모듈
└── *.json ← 오버라이드 JSON
```
---
## Overlay 확장
기존 레이아웃의 특정 컴포넌트를 찾아 UI를 주입합니다.
### JSON 스키마
```json
{
"target_layout": "admin_user_form",
"injections": [
{
"target_id": "section_marketing_consent",
"position": "append",
"components": [...]
}
],
"data_sources": [...],
"priority": 100
}
```
### 필드 설명
| 필드 | 필수 | 타입 | 설명 |
|------|------|------|------|
| `target_layout` | ✅ | string | 확장할 대상 레이아웃 이름 |
| `injections` | ✅ | array | 주입 정의 배열 |
| `injections[].target_id` | ✅ | string | 주입 대상 컴포넌트 ID |
| `injections[].position` | ✅ | string | 주입 위치 (아래 참조) |
| `injections[].components` | ✅ | array | 주입할 컴포넌트 배열 |
| `data_sources` | ❌ | array | 추가 데이터 소스 (선택) |
| `init_actions` | ❌ | array | 호스트 레이아웃 `init_actions` 뒤에 추가할 초기화 액션 배열 (선택). 모듈이 호스트가 모르는 `_global` 초기화(예: 모듈별 표시 통화 복원)를 기여할 때 사용. 호스트는 추가 단계의 존재/모듈을 모른다 |
| `priority` | ❌ | number | 우선순위 (기본값: 100, 낮을수록 먼저) |
> `init_actions` 병합은 **overlay(`target_layout`)** 확장에서 지원한다. 호스트 `init_actions` 배열 끝에 확장의 `init_actions` 가 순서대로 추가되므로, 호스트 초기화가 모두 끝난 뒤 확장 초기화가 실행된다. 모듈 비활성 시 그 확장 행 자체가 비활성화되어 추가 단계도 사라진다(호스트 레이아웃 수정 0). 모듈이 호출하는 핸들러는 모듈 소유 네임스페이스(`{module-id}.handlerName`)를 사용해 모듈 미설치 시 미등록 핸들러로 무동작하게 하는 것을 권장한다.
### 상속(extends) 체인 전파 — base 레이아웃 타겟 overlay
`target_layout` 이 다른 화면이 `extends` 로 상속하는 **공통/베이스 레이아웃**(예: `_user_base`, `_admin_base`)인 overlay 는, 그 base 를 상속하는 모든 자식 레이아웃(`home`, `shop/index` 등)을 서빙할 때도 적용된다.
서빙 파이프라인은 `extends` 를 해소해 자식 문서를 만들 때 상속한 base 이름들을 내부 메타로 보존하고, overlay 매칭 시 `[자식 레이아웃명] + 상속 체인` 전체를 대상으로 한다. 따라서 `_user_base` 를 `target_layout` 으로 하는 overlay(예: 헤더 슬롯 주입, `init_actions` 통화 복원)는 base 를 직접 서빙하지 않아도 자식 화면에 정상 주입된다. 다단계 상속(`A extends B extends _user_base`)도 체인 끝까지 전파된다.
```text
overlay(target_layout=_user_base) 주입
↓ (home extends _user_base 서빙 시)
home 화면에 _user_base 타겟 overlay 가 적용됨 (슬롯·init_actions 포함)
```
- Extension Point 는 병합된 컴포넌트 트리를 직접 스캔하므로 상속과 무관하게 항상 동작한다(이 전파는 overlay 매칭에만 해당).
- 같은 overlay 가 중복 적용되지 않도록 매칭 결과는 확장 ID 기준으로 합집합 후 `priority` 로 재정렬된다.
- 편집기에서 자식 라우트 레이아웃을 편집할 때, base 타겟 overlay 가 주입한 영역은 확장 출처 메타가 부여되어 확장 잠금 영역으로 표시된다(해당 overlay 자체의 편집 호스트는 base 레이아웃).
### position 옵션
| 값 | 설명 | 다이어그램 |
|-----|------|----------|
| `prepend` | 타겟 **앞에** 형제로 삽입 | `[NEW] [TARGET] [siblings...]` |
| `append` | 타겟 **뒤에** 형제로 삽입 | `[TARGET] [NEW] [siblings...]` |
| `prepend_child` | 타겟 children **맨 앞에** 삽입 | `TARGET { [NEW] [children...] }` |
| `append_child` | 타겟 children **맨 뒤에** 삽입 | `TARGET { [children...] [NEW] }` |
| `replace` | 타겟 **완전 교체** | `[NEW]` (기존 타겟 제거) |
| `inject_props` | 타겟 컴포넌트의 **props에 값 주입** | `TARGET.props ← injection.props` |
### inject_props 상세
`inject_props`는 기존 컴포넌트의 props에 값을 주입하는 특수 position입니다. `components` 필드 대신 `props` 필드를 사용합니다.
#### 병합 전략
| 전략 | 설명 | 예시 |
|------|------|------|
| `_append` | 배열 끝에 추가 | `tabs._append: [newTab]` → 기존 tabs 뒤에 추가 |
| `_prepend` | 배열 앞에 추가 | `tabs._prepend: [newTab]` → 기존 tabs 앞에 추가 |
| `_merge` | 객체 병합 (shallow) | `style._merge: {color: 'red'}` → 기존 style에 병합 |
| (직접 값) | 스칼라 덮어쓰기 | `disabled: true` → 기존 값 대체 |
#### inject_props 예시: 탭에 항목 추가
```json
{
"target_layout": "admin_user_detail",
"injections": [
{
"target_id": "user_detail_tabs",
"position": "inject_props",
"props": {
"tabs": {
"_append": [
{
"id": "ext_verification",
"label": "$t:admin.users.form.sections.verification_info",
"iconName": "shield"
}
]
}
}
},
{
"target_id": "extension_tab_content",
"position": "append_child",
"components": [
{
"id": "ext_verification_content",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeUserDetailTab || query.tab || 'basic') === 'ext_verification'}}",
"children": [...]
}
]
}
],
"priority": 100
}
```
#### 주의사항
- `inject_props`일 때 `components` 필드는 무시됩니다
- 대상 prop이 표현식 문자열인 경우 `_append`/`_prepend`/`_merge` 적용이 불가하며, 경고 로그가 기록됩니다
- `_append`와 `_merge` 등 여러 전략을 하나의 prop에 혼합 사용할 수 없습니다 (첫 번째 매칭 전략 우선)
### 예시: 사용자 폼에 알림 설정 추가
```json
{
"target_layout": "admin_user_form",
"injections": [
{
"target_id": "section_marketing_consent",
"position": "append",
"components": [
{
"id": "section_notification_settings",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
},
{
"type": "composite",
"name": "ExtensionBadge",
"props": {
"type": "module",
"identifier": "sirsoft-board",
"installedModules": "{{_global.installedModules}}"
}
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_post_complete",
"checked": "{{_local.form?.notify_post_complete ?? false}}"
}
}
]
}
]
}
],
"priority": 100
}
```
---
## Extension Point 확장
레이아웃에 미리 정의된 확장 포인트에 컴포넌트를 주입합니다.
### 지원 위치
Extension Point는 레이아웃의 다음 섹션에서 사용할 수 있습니다:
| 위치 | 지원 | 설명 |
|----------------|------|--------------------------------------|
| `components` | ✅ | 메인 컴포넌트 트리 (기본) |
| `modals` | ✅ | 모달 내부 컴포넌트 트리 (v1.17.0+) |
```text
v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
✅ v1.17.0+: components와 modals 양쪽 모두 재귀적으로 extension_point 처리
```
### 레이아웃에서 Extension Point 정의
```json
{
"layout_name": "admin_user_form",
"components": [
{
"id": "user_form_container",
"type": "basic",
"name": "Div",
"children": [
{
"type": "extension_point",
"name": "user_form_additional_fields",
"default": [],
"props": {
"readOnlyFields": ["zipcode", "address"]
},
"callbacks": {
"onAddressSelect": { "handler": "setState", "params": { "target": "local", "form.zipcode": "{{$event.zipcode}}" } }
}
}
]
}
]
}
```
#### Extension Point 데이터 전달 (engine-v1.28.0+)
| 필드 | 타입 | 설명 |
|------|------|------|
| `props` | object | 주입 컴포넌트에 전달할 데이터. 표현식 평가됨. 플러그인에서 `{{extensionPointProps.xxx}}`로 접근 |
| `callbacks` | object | 주입 컴포넌트에 전달할 액션 객체. 평가 없이 그대로 전달. 플러그인에서 `{{extensionPointCallbacks.xxx}}`로 접근 |
전달된 값은 주입 컴포넌트와 그 **자손 전체**에서 참조할 수 있습니다.
#### 검색 봇 화면에서의 동작
일반 화면과 봇용 화면(SEO)은 같은 레이아웃을 각각 렌더합니다. 확장 포인트 데이터 전달의 지원 범위는 두 화면이 다릅니다. 봇 화면의 `props` 해석은 **7.0.6** 부터 동작합니다 — 그 이전 버전에서는 확장이 교체한 영역이 봇에게 빈 요소로 나갔습니다.
| 항목 | 일반 화면 | 봇 화면 |
|------|----------|--------|
| `{{extensionPointProps.xxx}}` | 해석됨 | 해석됨 |
| `{{extensionPointCallbacks.xxx}}` | 해석됨 | **해석되지 않음** |
봇 화면에는 액션 실행기가 없으므로 콜백은 의도적으로 전달하지 않습니다. **검색 결과에 노출되어야 할 내용을 콜백 경유로 만들지 마세요** — 봇에게는 빈 값이 됩니다. 본문·제목처럼 색인되어야 하는 값은 `props` 로 전달합니다.
사용자 작성 콘텐츠를 넘기는 확장 포인트라면 콘텐츠와 함께 **평문/HTML 판정 값(`isHtml`)도 전달**하세요. 두 화면 모두 이 값에 따라 평문으로 이스케이프하거나 위험 요소를 제거한 뒤 출력합니다. 판정 값을 넘기지 않으면 HTML 로 간주됩니다.
봇 화면의 노드 문법 지원 범위 전체는 [seo-system.md "SEO 렌더러 지원 노드 키"](../backend/seo-system.md)를, 정화 규칙은 같은 문서의 "봇 화면의 HTML 정화" 를 참조하세요.
### 모듈에서 Extension Point에 주입
```json
{
"extension_point": "user_form_additional_fields",
"mode": "replace",
"components": [
{
"id": "custom_field_section",
"type": "basic",
"name": "Div",
"children": [...]
}
],
"data_sources": [...],
"priority": 100
}
```
### JSON 스키마
| 필드 | 필수 | 타입 | 설명 |
|------|------|------|------|
| `extension_point` | ✅ | string | 확장 포인트 이름 |
| `mode` | ❌ | string | 주입 모드 (아래 참조, 기본값: `append`) |
| `components` | ❌ | array | 주입할 컴포넌트 배열 |
| `data_sources` | ❌ | array | 추가 데이터 소스 |
| `modals` | ❌ | array | 호스트 레이아웃에 병합할 모달 배열 |
| `scripts` | ❌ | array | 추가 스크립트 |
| `priority` | ❌ | number | 우선순위 (기본값: 100) |
### mode 옵션
| 값 | 설명 | 동작 |
| ----- | ------ | ------ |
| `append` | default **뒤에** 추가 (기본값) | `[default...] [NEW]` |
| `prepend` | default **앞에** 추가 | `[NEW] [default...]` |
| `replace` | default **완전 교체** | `[NEW]` (default 제거) |
### 확장 병합 칸 (주입 폼의 값을 템플릿 API 요청에 실어 보내기)
슬롯에 주입한 폼이 입력값을 서버로 보내려면, 그 값이 **템플릿이 소유한 apiCall 의 `params.body`** 에 도달해야 한다. 그런데 확장에는 body 에 키를 추가할 수단이 없다.
- 결제 버튼처럼 `id` 가 없는 노드는 overlay `target_id` 로 잡을 수 없다
- `inject_props` 는 컴포넌트 props 병합 전용이라 `actions[].params.body` 에 닿지 않는다 (`_merge` 는 shallow)
그래서 템플릿이 **확장 병합 칸**을 하나 열어 둔다. 규약은 이렇다.
**템플릿 쪽** — body 를 통짜 표현식으로 만들고 말미에 확장 칸을 spread 한다.
```json
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/user/orders",
"params": {
"body": "{{ ({ temp_order_id: _local.tempOrderId, payment_method: _computed.selectedPaymentMethod, ...(_local.checkoutExtraPayload ?? {}) }) }}"
}
}
```
**확장 쪽** — 자기 필드를 그 칸에 setState 한다.
```json
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"checkoutExtraPayload.cash_receipt_requested": "{{$event.target.checked}}"
}
}
```
지켜야 할 것:
- 확장은 **자기 도메인 키만** 쓴다. spread 가 뒤에 오므로 템플릿의 기존 키를 덮어쓸 수 있다
- 폼을 접거나 신청을 해제하면 칸을 `{}` 로 비운다. 남겨 두면 서버가 신청으로 오인한다
- 템플릿이 소유한 필드(예: 환불계좌)는 칸을 경유하지 않고 body 에 직접 기재한다
- body 를 통짜 표현식으로 전환할 때는 **전환 전후 산출값이 동일한지** 회귀 테스트로 고정한다 (`DataBindingEngine.evaluateExpression` 으로 실제 평가)
현재 열려 있는 칸:
| 칸 이름 | 위치 | 소비처 |
|---------|------|--------|
| `_local.checkoutExtraPayload` | `sirsoft-basic` 주문서 (`_checkout_summary.json`) | 주문 생성 `POST /user/orders` |
---
## 템플릿 오버라이드
템플릿은 모듈/플러그인의 확장을 오버라이드하여 커스터마이징할 수 있습니다.
### 디렉토리 구조
```
templates/_bundled/sirsoft-admin_basic/
└── extensions/
└── sirsoft-board/ ← 오버라이드 대상 모듈
└── user-notification-settings.json ← 오버라이드 파일
```
### 오버라이드 파일명 규칙
모듈의 원본 확장 파일명과 **동일한 이름**을 사용합니다.
```
모듈 원본: modules/_bundled/sirsoft-board/resources/extensions/user-notification-settings.json
템플릿 오버라이드: templates/_bundled/sirsoft-admin_basic/extensions/sirsoft-board/user-notification-settings.json
```
### 오버라이드 우선순위
```
1. 템플릿 오버라이드 (source_type = Template) ← 가장 높은 우선순위
2. 플러그인 확장 (source_type = Plugin)
3. 모듈 확장 (source_type = Module) ← 가장 낮은 우선순위
```
### 오버라이드 예시
모듈의 기본 확장을 템플릿에서 스타일 변경:
```json
{
"target_layout": "admin_user_form",
"injections": [
{
"target_id": "section_marketing_consent",
"position": "append",
"components": [
{
"id": "section_notification_settings",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg p-6 mb-6"
},
"children": [...]
}
]
}
],
"priority": 100
}
```
---
## 모듈 활성화/비활성화 동작
### 활성화 시
1. `resources/extensions/*.json` 파일 스캔
2. **대상 템플릿 적용 가능 여부 판별** (아래 "템플릿 대상 판정" 참조)
3. `template_layout_extensions` 테이블에 등록 (또는 기존 레코드 복원)
4. 레이아웃 캐시 무효화
### 템플릿 대상 판정
모듈/플러그인 확장은 admin·user 등 모든 활성 템플릿에 동기화되지만, 확장이 **대상으로 하는 레이아웃이 실제 존재하는 템플릿에만 등록**된다.
- **Overlay**: `target_layout` 이 그 템플릿에 레이아웃으로 존재해야 한다.
- **Extension Point**: `extension_point`(확장점명)이 그 템플릿의 어느 레이아웃 `content` 에 `type: extension_point` 노드로 정의되어 있어야 한다.
판정은 `RefreshesLayoutExtensions` trait 이 `LayoutExtensionService::isExtensionApplicableToTemplate()` 로 수행한다. 대상이 없는 템플릿(예: admin 레이아웃 대상 확장 ↔ user 템플릿)에는 등록하지 않으며, 과거 무차별 등록으로 잘못 생성된 행은 다음 동기화 시 `removeInapplicableExtension()` 으로 정리(soft delete)된다.
### 비활성화 시
1. 해당 모듈의 확장 레코드 Soft Delete
2. **렌더링 시 비활성화된 모듈의 확장 자동 스킵**
3. 레이아웃 캐시 무효화
### 템플릿 오버라이드와 모듈 비활성화
**중요**: 모듈이 비활성화되면, 해당 모듈을 오버라이드하는 템플릿 확장도 함께 숨김 처리됩니다.
```
sirsoft-board 모듈 비활성화
↓
sirsoft-board의 모든 확장 숨김
↓
sirsoft-board를 오버라이드하는 템플릿 확장도 숨김
```
---
## 우선순위
### priority 필드
- 기본값: 100
- 낮을수록 먼저 렌더링
- 같은 위치에 여러 확장이 있을 경우 순서 결정
```json
{
"target_layout": "admin_user_form",
"priority": 50,
"injections": [...]
}
```
### 우선순위 가이드라인
| 범위 | 용도 | 예시 |
|------|------|------|
| 1-25 | 필수 정보 | 보안 경고, 중요 알림 |
| 50-75 | 주요 기능 | 핵심 모듈 UI |
| 100 | 기본값 | 일반 확장 |
| 150+ | 보조 정보 | 분석, 통계 |
---
## 데이터 소스 병합
확장에서 정의한 `data_sources`는 호스트 레이아웃의 데이터 소스에 병합됩니다.
```json
{
"target_layout": "admin_user_form",
"injections": [...],
"data_sources": [
{
"id": "notification_settings",
"type": "api",
"method": "GET",
"endpoint": "/api/modules/sirsoft-board/admin/users/{{route.id}}/notification-settings",
"auto_fetch": true,
"auth_required": true
}
]
}
```
---
## 모달 병합
Extension Point 확장에서 정의한 `modals`는 호스트 레이아웃의 `modals` 배열에 자동 병합됩니다.
### 왜 modals 섹션을 사용해야 하는가?
Extension Point의 children으로 주입된 인라인 Modal(`show` prop 기반)은 정상 동작하지 않습니다. 그누보드7 Modal 시스템에서 안정적으로 동작하려면 `modals` 섹션에 등록하고 `openModal`/`closeModal` 핸들러로 제어해야 합니다.
> 상세 모달 규칙: [modal-usage.md](../frontend/modal-usage.md)
### Extension JSON에서 modals 정의
```json
{
"extension_point": "shop_checkout_extensions",
"modals": [
{
"id": "payment_error_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:plugin.payment_error_title",
"size": "small"
},
"children": [...]
}
],
"priority": 100
}
```
### 핸들러에서 모달 열기
Extension으로 병합된 모달은 커스텀 핸들러에서 `G7Core.modal.open()`으로 열 수 있습니다:
```typescript
const G7Core = (window as any).G7Core;
G7Core?.modal?.open?.('payment_error_modal');
```
또는 레이아웃 JSON 액션에서:
```json
{
"type": "click",
"handler": "openModal",
"target": "payment_error_modal"
}
```
### 병합 동작
- 여러 확장의 `modals`가 하나의 호스트 레이아웃에 모두 병합됩니다
- 호스트 레이아웃에 기존 `modals`가 있으면 확장 모달이 뒤에 추가됩니다
- 확장에 `modals`가 없으면 호스트 레이아웃에 영향 없음 (하위 호환)
### 주의사항
```text
modals 섹션 모달에 show prop 사용 금지 (무시됨)
모달 ID는 호스트 레이아웃의 기존 모달과 중복되지 않도록 플러그인 prefix 사용 권장
✅ 모달 닫기 시 setState → closeModal 순서 유지 (순서 중요)
```
---
## Overlay 레이아웃 섹션 병합
Overlay 확장에서 `computed`, `state`, `modals` 섹션을 정의하면 호스트 레이아웃에 자동 병합됩니다.
### 지원 섹션
| 섹션 | Extension Point | Overlay | 설명 |
|------|:---:|:---:|------|
| `data_sources` | ✅ | ✅ | API 데이터 소스 추가 |
| `scripts` | ✅ | ✅ | 커스텀 핸들러 스크립트 추가 |
| `modals` | ✅ | ✅ | 모달 컴포넌트 추가 |
| `computed` | ✅ | ✅ | 계산된 값 추가 |
| `state` | ✅ | ✅ | 초기 상태 추가 |
### 예시: Overlay에서 computed와 state 추가
```json
{
"target_layout": "admin_user_detail",
"injections": [...],
"computed": {
"verificationStatus": "{{user?.data?.identity_verified ? 'verified' : 'pending'}}"
},
"state": {
"showVerificationHistory": false
},
"modals": [
{
"id": "ext_verification_modal",
"type": "composite",
"name": "Modal",
"props": { "title": "인증 이력" },
"children": [...]
}
],
"priority": 100
}
```
### 병합 동작
- 여러 overlay의 같은 섹션은 priority 오름차순으로 순차 병합됩니다
- 동일 키 충돌 시 후순위 overlay가 덮어씁니다
- 해당 섹션이 없는 overlay는 기존 레이아웃에 영향 없음 (하위 호환)
---
## 레이아웃 오버라이드 vs 확장 오버라이드
| 구분 | 레이아웃 오버라이드 | 확장 오버라이드 |
|------|-------------------|---------------|
| 대상 | 모듈이 등록한 완전한 레이아웃 | 확장 지점/Overlay로 주입된 UI |
| 위치 | `templates/{t}/layouts/overrides/{module}/*.json` | `templates/{t}/extensions/{module}/*.json` |
| DB 테이블 | `template_layouts` | `template_layout_extensions` |
| 해석 서비스 | `LayoutResolverService` | `LayoutExtensionService` |
| version_constraint | 지원 | 지원 |
---
## 레이아웃 확장 편집 및 버전 관리
관리자는 모듈/플러그인이 주입한 레이아웃 확장을 일반 레이아웃과 동등한 수준으로 편집·버전관리·복구·미리보기할 수 있다.
### 관리자 편집
레이아웃 편집 화면(`admin_template_layout_edit`)의 좌측 트리에서 확장 항목을 선택하면 해당 확장의 `content` JSON 을 편집할 수 있다. 편집 권한은 일반 레이아웃과 동일한 `core.templates.layouts.edit` 를 사용한다.
확장 편집 API 는 `{extensionId}` 정수 PK 로 식별한다 — 동일 `target_name` 에 여러 source 의 확장이 존재할 수 있기 때문이다.
#### 좌측 트리는 오버라이드 해석 결과만 노출한다
편집 화면 좌측 트리(`GET .../layout-extensions`)는 화면 렌더링 경로와 동일한 오버라이드 해석을 적용한다. 동일 `(extension_type, target_name)` 범위에서 템플릿 오버라이드(`source_type = Template`)가 특정 모듈/플러그인을 오버라이드하면, 그 모듈/플러그인의 원본 확장은 화면에 적용되지 않으므로 트리에서도 제외된다.
이는 관리자가 "저장은 되지만 화면에 반영되지 않는" 가려진 확장 행을 편집하는 것을 막기 위함이다. 트리에는 항상 해당 템플릿에서 실제로 적용되는 확장(템플릿 오버라이드 + 오버라이드되지 않은 모듈/플러그인 확장)만 표시된다.
해석 로직은 `LayoutExtensionRepository::resolveOverrides()` 단일 출처를 사용하며, 렌더링 경로(`getResolvedExtensionPoints` / `getResolvedOverlays`)와 편집 트리(`getResolvedByTemplateId`)가 동일한 가시성 기준을 공유한다.
| 메서드 | 라우트 |
| ------ | ------ |
| `GET` | `/api/admin/templates/{templateName}/layout-extensions` (출처별 그룹핑) |
| `GET` | `.../layout-extensions/{extensionId}` |
| `PUT` | `.../layout-extensions/{extensionId}` |
| `GET` | `.../layout-extensions/{extensionId}/versions` |
| `GET` | `.../layout-extensions/{extensionId}/versions/{version}` |
| `POST` | `.../layout-extensions/{extensionId}/versions/{versionId}/restore` |
| `POST` | `.../layout-extensions/{extensionId}/preview` |
### 버전 이력
확장을 편집·저장하면 `template_layout_extension_versions` 테이블에 버전 스냅샷이 쌓인다. 일반 레이아웃의 `template_layout_versions` 와 동일한 구조이며, 이전 버전으로 복구할 수 있다. 버전 diff 계산 로직은 `App\Repositories\Concerns\CalculatesJsonContentDiff` trait 으로 일반 레이아웃과 공유한다.
### 사용자 수정 감지 (`original_content_hash`)
`template_layout_extensions` 의 `original_content_hash` / `original_content_size` 컬럼은 모듈/플러그인이 등록한 원본 확장 콘텐츠의 정규화 해시를 보존한다. 관리자가 확장을 편집해도 이 원본 해시는 변경되지 않는다 — 현재 `content` 해시와 원본 해시를 비교하여 "사용자가 수정한 확장"을 판별한다.
### 모듈/플러그인 업데이트 시 충돌 전략
`module:update` / `plugin:update` 의 `--layout-strategy` 옵션이 확장에도 적용된다 (일반 레이아웃과 동일한 수준):
- `overwrite` (기본): 파일 내용으로 확장 `content` 를 무조건 덮어쓴다. 관리자가 편집한 확장이 있으면 업데이트 전 경고를 출력한다.
- `keep`: 관리자가 편집한 확장(`original_content_hash` 불일치)은 `content`/`priority` 갱신을 건너뛰고 `is_active` 만 갱신한다. 사용자 수정분이 보존된다.
### 미리보기
확장 편집 중 미리보기는 편집 중인 `content` 를 대표 레이아웃에 임시 적용하여 렌더링한다.
- `overlay` 타입: `target_layout` 자체가 대표 레이아웃이므로 별도 선택이 불필요하다.
- `extension_point` 타입: 하나의 확장점이 여러 레이아웃에서 사용될 수 있으므로, 미리볼 대표 레이아웃을 선택하는 모달이 노출된다.
미리보기 URL 은 일반 레이아웃과 동일한 `/api/layouts/preview/{token}.json` 을 재사용하며, `preview_type` 컬럼으로 일반/확장 미리보기를 분기한다.
---
## 관련 파일
### 백엔드
| 파일 | 설명 |
|------|------|
| `app/Services/LayoutExtensionService.php` | 확장 적용·편집·버전관리 핵심 서비스 |
| `app/Models/LayoutExtension.php` | 확장 모델 (DB 테이블) |
| `app/Models/TemplateLayoutExtensionVersion.php` | 확장 버전 이력 모델 |
| `app/Repositories/LayoutExtensionRepository.php` | 확장 Repository |
| `app/Repositories/LayoutExtensionVersionRepository.php` | 확장 버전 Repository |
| `app/Repositories/Concerns/CalculatesJsonContentDiff.php` | JSON diff 계산 trait (일반 레이아웃과 공유) |
| `app/Http/Controllers/Api/Admin/LayoutExtensionController.php` | 확장 편집/버전관리 admin 컨트롤러 |
| `app/Enums/LayoutExtensionType.php` | 확장 타입 Enum (ExtensionPoint, Overlay) |
| `app/Enums/LayoutSourceType.php` | 출처 타입 Enum (Module, Plugin, Template) |
### 데이터베이스
| 테이블 | 설명 |
|--------|------|
| `template_layout_extensions` | 확장 등록 정보 저장 |
### 주요 컬럼
```
template_layout_extensions
├── template_id # 템플릿 ID
├── extension_type # ExtensionPoint | Overlay
├── target_name # 확장 포인트명 또는 대상 레이아웃명
├── source_type # Module | Plugin | Template
├── source_identifier # 모듈/플러그인/템플릿 식별자
├── override_target # 오버라이드 대상 (템플릿 오버라이드 시)
├── content # 확장 JSON 내용
├── original_content_hash # 원본 확장 콘텐츠 SHA-256 해시 (사용자 수정 감지용)
├── original_content_size # 원본 확장 콘텐츠 바이트 크기
├── priority # 우선순위
├── is_active # 활성 상태
└── deleted_at # Soft Delete
template_layout_extension_versions
├── extension_id # 레이아웃 확장 ID (FK, cascadeOnDelete)
├── version # 버전 번호 (자동 증가)
├── content # 확장 정의 JSON 스냅샷
├── changes_summary # 변경 요약 JSON (added/removed/modified/char_diff)
└── created_by # 저장자 ID
```
---
## 문제 해결
### 확장이 표시되지 않음
**확인 사항**:
1. 모듈/플러그인이 활성화되어 있는지 확인
2. `target_layout` 또는 `extension_point` 이름이 정확한지 확인
3. `target_id`가 대상 레이아웃에 존재하는지 확인
4. JSON 문법 오류 확인
**캐시 초기화**:
```bash
php artisan cache:clear
```
### 오버라이드가 적용되지 않음
**확인 사항**:
1. 템플릿 오버라이드 파일 경로 확인
- `templates/{template}/extensions/{module-identifier}/{filename}.json`
2. 파일명이 원본과 동일한지 확인
3. `template_layout_extensions` 테이블에서 `source_type = 'template'` 레코드 확인
### 모듈 비활성화 후에도 확장 표시됨
**원인**: 캐시 문제
**해결**:
```bash
php artisan cache:clear
```
### 여러 확장의 순서가 예상과 다름
**해결**: `priority` 값 조정 (낮을수록 먼저 렌더링)
---
## 관련 문서
- [module-layouts.md](module-layouts.md) - 모듈 레이아웃 등록
- [hooks.md](hooks.md) - 훅 시스템
- [layout-json.md](../frontend/layout-json.md) - 레이아웃 JSON 스키마
- [components.md](../frontend/components.md) - 컴포넌트 개발 규칙