25 KiB
레이아웃 JSON - 초기화, 모달, 액션, 스크립트
메인 문서: layout-json-features.md
목차
- 초기화 액션 (init_actions)
- Named Actions (named_actions)
- 모달 시스템 (modals)
- 컴포넌트 이벤트 구독 (onComponentEvent)
- 액션 시스템 (actions)
- 외부 스크립트 로드 (scripts)
초기화 액션 (init_actions)
목적: 레이아웃 로드 시 자동으로 실행할 핸들러를 정의합니다.
사용 사례
- 테마 초기화 (
initTheme) - 사용자 설정 로드
- 분석 이벤트 전송
- 전역 상태 초기화
구조
{
"init_actions": [
{
"handler": "initTheme"
},
{
"handler": "customInit",
"target": "some_value",
"params": {
"key": "value"
}
},
{
"handler": "apiCall",
"if": "{{!!query.user_id}}",
"target": "/api/admin/users/search",
"params": {
"method": "GET",
"query": { "id": "{{query.user_id}}" }
},
"onSuccess": [
{
"handler": "setState",
"params": { "target": "local", "userData": "{{response.data}}" }
}
]
}
]
}
필드 설명
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
handler |
string | ✅ | 실행할 핸들러 이름 (템플릿 handlerMap에 등록된 이름) |
target |
string | ❌ | 핸들러에 전달할 타겟 값 |
params |
object | ❌ | 핸들러에 전달할 파라미터 |
if |
string | ❌ | 조건부 실행 ({{expression}} 형식, truthy일 때만 실행) |
onSuccess |
object/array | ❌ | 성공 시 실행할 후속 액션 |
onError |
object/array | ❌ | 실패 시 실행할 후속 액션 |
resultTo |
object | ❌ | 핸들러 결과를 상태에 저장할 위치 |
auth_required |
boolean | ❌ | 인증 필요 여부 |
실행 시점
- 레이아웃 JSON 로드
- blocking 데이터 소스 fetch
- 초기 렌더링 완료
init_actions실행 ← 여기서 실행- progressive/background 데이터 소스 fetch
핸들러 등록 방법
템플릿의 handlers/index.ts에서 핸들러를 등록합니다:
// handlers/initThemeHandler.ts
export async function initThemeHandler(
_action: any,
_context?: any
): Promise<void> {
// 초기화 로직
const savedTheme = localStorage.getItem('g7_color_scheme');
applyTheme(savedTheme || 'auto');
}
// handlers/index.ts
import { initThemeHandler } from './initThemeHandler';
export const handlerMap = {
initTheme: initThemeHandler,
// ... 다른 핸들러들
} as const;
실제 사용 예시
{
"version": "1.0.0",
"layout_name": "admin_login",
"meta": {
"title": "$t:auth.login.title",
"auth_required": false
},
"data_sources": [],
"init_actions": [
{
"handler": "initTheme"
}
],
"components": [...]
}
데이터 바인딩 지원
init_actions는 정적 바인딩을 지원합니다. route params, query, _global, blocking 데이터 등에 접근할 수 있습니다.
{
"init_actions": [
{
"handler": "initWithPage",
"target": "{{query.page}}",
"params": {
"userId": "{{route.id}}",
"mode": "{{_global.mode}}"
}
}
]
}
접근 가능한 데이터:
{{route.id}},{{route.slug}}- URL 라우트 파라미터{{query.page}},{{query.filter}}- URL 쿼리 파라미터{{_global.xxx}}- 전역 상태- blocking 데이터 소스 결과
주의: form 데이터나 이벤트 컨텍스트는 init_actions 시점에 존재하지 않습니다. 이런 데이터가 필요하면 컴포넌트의 lifecycle.onMount를 사용하세요.
init_actions vs lifecycle.onMount — 시점 가이드 (engine-v1.47.1 명문화)
| 시점 | init_actions | lifecycle.onMount |
|---|---|---|
| 실행 단계 | 컴포넌트 마운트 전 (레이아웃 로드 직후) | 컴포넌트 마운트 후 |
| reactRoot 보장 | ❌ (updateTemplateData 가 reactRoot 미존재로 스킵) |
✅ |
| 글로벌 호스트 컴포넌트 (Toast/ModalRoot) 마운트 보장 | ❌ | ✅ |
| 적합한 액션 | 라우트 파라미터 기반 분기, 폼 초기 상태 setState, theme 초기화 | toast 발화, openModal, 글로벌 상태 변경의 화면 즉시 반영 |
toast / openModal 발화는 lifecycle.onMount 권장:
toast 핸들러는 _global.toasts 배열에 push 하며 화면 렌더는 Toast 호스트 컴포넌트가 담당. init_actions 시점에는 Toast 컴포넌트가 아직 마운트되지 않았거나 React 리렌더 사이클이 시작되지 않아 push 한 메시지가 화면에 반영되지 않을 수 있다 (silent failure 회귀). lifecycle.onMount 는 마운트 후 실행이 보장되어 안전.
// ❌ 회귀 위험: init_actions 의 toast 발화
{
"init_actions": [
{ "if": "{{query?.reason === 'session_expired'}}", "handler": "toast", "params": {...} }
]
}
// ✅ 권장: 레이아웃 root 컴포넌트의 lifecycle.onMount 로 이동
{
"components": [
{
"id": "page_wrapper",
"name": "Div",
"lifecycle": {
"onMount": [
{ "if": "{{query?.reason === 'session_expired'}}", "handler": "toast", "params": {...} }
]
},
"children": [...]
}
]
}
단, 베이스 레이아웃을
extends하는 자식 레이아웃은 init_actions 의 toast 도 정상 작동하는 경우가 있다 (베이스가 Toast 호스트를 먼저 마운트). 그러나 의존성이 베이스 구조에 묶이므로 모든 신규 layout 은 일관성을 위해 lifecycle.onMount 사용 권장.
conditions 핸들러 사용 (engine-v1.24.7+)
init_actions에서 conditions 핸들러를 사용하여 조건 분기를 실행할 수 있습니다.
{
"init_actions": [
{
"handler": "conditions",
"conditions": [
{
"if": "{{!!query.error}}",
"then": {
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"errorMessage": "{{query.error === 'not_found' ? '$t:common.not_found' : '$t:common.generic_error'}}"
}
},
{
"handler": "toast",
"params": { "type": "error", "message": "{{query.error}}" }
}
]
}
}
}
]
}
]
}
주의사항
- ❌
init_actions의 핸들러는 반드시 템플릿의handlerMap에 등록되어 있어야 함 - ❌ 등록되지 않은 핸들러는 경고 로그 출력 후 무시됨
- ✅ 핸들러는 순차적으로 실행됨 (병렬 실행 아님)
- ✅ 핸들러 실행 실패 시 다른 핸들러는 계속 실행됨
Named Actions (named_actions)
목적: 레이아웃 최상위에 재사용 가능한 액션을 정의하고, 컴포넌트에서 actionRef로 참조합니다. 동일한 액션이 여러 곳에서 반복될 때 중복을 제거합니다.
engine-v1.19.0+ | 상속(extends) 시 자식 레이아웃의 named_actions가 부모를 오버라이드
구조
{
"named_actions": {
"searchProducts": {
"handler": "navigate",
"params": {
"path": "/admin/products",
"replace": true,
"mergeQuery": true,
"query": { "page": 1, "search_keyword": "{{$computed.keyword || null}}" }
}
},
"resetFilters": {
"handler": "setState",
"params": { "target": "_local", "key": "filters", "value": {} }
}
}
}
actionRef로 참조
{
"name": "Button",
"actions": [{ "type": "click", "actionRef": "searchProducts" }]
}
{
"name": "Input",
"actions": [{ "type": "keypress", "key": "Enter", "actionRef": "searchProducts" }]
}
인라인 오버라이드
actionRef와 함께 인라인 속성을 지정하면 named_actions 값을 오버라이드합니다:
{
"actionRef": "searchProducts",
"type": "click",
"params": { "path": "/admin/products/v2" }
}
규칙
named_actions는 레이아웃 최상위 속성으로 정의 (Partial 파일에서는 정의 불가)actionRef사용 시handler/params/target생략 가능type,key,event등 이벤트 바인딩 속성은 항상 인라인에서 지정- 상속 시 동일 키의 named_actions는 자식이 부모를 완전 교체 (병합 아님)
- DevTools "Named Actions" 탭에서 정의 및 참조 이력 확인 가능
모달 시스템 (modals)
목적: 레이아웃에서 사용할 모달 컴포넌트를 정의합니다. 모달은 _global.activeModal 상태에 따라 자동으로 열림/닫힘이 제어됩니다.
핵심 원칙
✅ 필수: modals 배열에 Modal 컴포넌트 정의
✅ 필수: 각 모달에 고유한 id 부여
✅ 자동: isOpen, onClose props는 템플릿 엔진이 자동 주입
구조
{
"modals": [
{
"id": "confirm_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:modals.confirm_title",
"width": "400px"
},
"children": [...]
}
]
}
필드 설명
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
id |
string | ✅ | 모달 고유 ID (openModal 핸들러의 target으로 사용) |
type |
string | ✅ | 컴포넌트 타입 (보통 "composite") |
name |
string | ✅ | 컴포넌트 이름 (보통 "Modal") |
props |
object | ❌ | 모달에 전달할 props (title, width 등) |
children |
array | ❌ | 모달 본문에 표시할 자식 컴포넌트 |
자동 주입되는 Props
템플릿 엔진이 자동으로 다음 props를 주입합니다:
| Prop | 설명 |
|---|---|
isOpen |
_global.activeModal === modal.id 조건으로 자동 계산 |
onClose |
closeModal 핸들러로 자동 연결 |
모달 열기/닫기 액션
모달을 제어하려면 openModal과 closeModal 핸들러를 사용합니다:
{
"actions": [
{
"type": "click",
"handler": "openModal",
"target": "confirm_modal"
}
]
}
{
"actions": [
{
"type": "click",
"handler": "closeModal"
}
]
}
실제 사용 예시
{
"version": "1.0.0",
"layout_name": "admin_user_list",
"modals": [
{
"id": "bulk_activate_confirm_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:admin.users.modals.bulk_activate_title",
"width": "400px"
},
"children": [
{
"id": "activate_message",
"type": "basic",
"name": "P",
"text": "$t:admin.users.modals.bulk_activate_confirm"
},
{
"id": "activate_actions",
"type": "layout",
"name": "Container",
"props": {
"layout": "flex",
"justify": "end",
"gap": "2"
},
"children": [
{
"id": "cancel_btn",
"type": "basic",
"name": "Button",
"props": {
"variant": "secondary"
},
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "closeModal"
}
]
},
{
"id": "confirm_btn",
"type": "basic",
"name": "Button",
"props": {
"variant": "primary"
},
"text": "$t:common.confirm",
"actions": [
{
"type": "click",
"handler": "bulkUpdateStatus",
"params": {
"status": "active"
}
}
]
}
]
}
]
}
],
"components": [
{
"id": "activate_button",
"type": "basic",
"name": "Button",
"text": "$t:admin.users.activate",
"actions": [
{
"type": "click",
"handler": "openModal",
"target": "bulk_activate_confirm_modal"
}
]
}
]
}
레이아웃 상속 시 병합 규칙
modals 배열은 레이아웃 상속 시 다음 규칙으로 병합됩니다:
- ID 기반 병합: 동일한 ID의 모달은 자식 레이아웃이 부모를 오버라이드
- 추가: 부모에 없는 ID의 모달은 자식에서 추가됨
- 순서 유지: 부모 모달 먼저, 자식 모달 나중에 (ID 중복 시 자식으로 대체)
멀티 모달 (중첩 모달) 지원 (engine-v1.2.0+)
모달 스택 시스템을 통해 여러 모달을 동시에 열 수 있습니다:
✅ 확인 모달 위에 에러 모달 중첩 표시 가능
✅ closeModal 시 최상위 모달만 닫히고 이전 모달 유지
✅ 스택 위치에 따른 z-index 자동 관리
중첩 모달 시나리오:
- 사용자가 "활성화" 버튼 클릭 →
confirm_modal열림 - 확인 버튼 클릭 → API 호출
- API 오류 발생 →
error_modal이confirm_modal위에 중첩 표시 - 에러 모달 닫기 → 확인 모달이 다시 보임
- 확인 모달 닫기 → 모든 모달 닫힘
주의사항
- ✅ 모달 ID는 레이아웃 내에서 고유해야 함
- ✅
isOpen과onClose는 직접 정의하지 않아도 됨 (자동 주입) - ✅ 모달 본문에서 데이터 바인딩 사용 가능 (
{{_global.selectedItems.length}}등) - ✅ 멀티 모달 지원 - 모달 내부에서 다른 모달 열기 가능 (engine-v1.2.0+)
- 이미 열려있는 모달은 스택에 중복 추가되지 않음
컴포넌트 이벤트 구독 (onComponentEvent)
버전: engine-v1.11.0+
목적: 다른 컴포넌트에서 emitEvent로 발생한 이벤트를 레이아웃 JSON에서 구독합니다. 컴포넌트 마운트 시 자동 구독, 언마운트 시 자동 해제됩니다.
핵심 원칙
✅ 컴포넌트 레벨에서 이벤트 구독 정의
✅ 마운트 시 자동 구독, 언마운트 시 자동 해제
✅ 이벤트 데이터는 {{_eventData}}로 접근 가능
✅ onSuccess/onError 콜백 지원
구조
{
"id": "logo_preview",
"type": "basic",
"name": "Div",
"onComponentEvent": [
{
"event": "upload:site_logo",
"handler": "refetchDataSource",
"params": { "id": "site_settings" }
}
],
"children": [...]
}
필드 설명
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
event |
string | ✅ | 구독할 이벤트 이름 (예: "upload:site_logo") |
handler |
string | ✅ | 이벤트 발생 시 실행할 핸들러 |
params |
object | ❌ | 핸들러에 전달할 파라미터 |
onSuccess |
object/array | ❌ | 핸들러 성공 시 실행할 후속 액션 |
onError |
object/array | ❌ | 핸들러 실패 시 실행할 후속 액션 |
이벤트 데이터 접근
이벤트 발생 시 전달된 데이터는 {{_eventData}}로 접근할 수 있습니다:
{
"onComponentEvent": [
{
"event": "item:selected",
"handler": "setState",
"params": {
"target": "_local",
"value": {
"selectedItem": "{{_eventData.item}}"
}
}
}
]
}
실제 사용 예시
파일 업로드 후 데이터 새로고침:
TSX 컴포넌트에서 파일 업로드 완료 후 이벤트를 발생시킵니다:
// ImageUploader.tsx
const handleUploadComplete = async (result) => {
await G7Core.componentEvent.emit('upload:site_logo', {
url: result.url,
collection: 'site_logo'
});
};
레이아웃 JSON에서 해당 이벤트를 구독하여 데이터를 새로고침합니다:
{
"id": "logo_preview_container",
"type": "basic",
"name": "Div",
"onComponentEvent": [
{
"event": "upload:site_logo",
"handler": "refetchDataSource",
"params": { "id": "site_settings" }
}
],
"children": [
{
"id": "logo_image",
"type": "basic",
"name": "Image",
"props": {
"src": "{{site_settings.data.logo_url}}"
}
}
]
}
폼 제출 후 UI 업데이트:
{
"id": "form_container",
"type": "basic",
"name": "Div",
"onComponentEvent": [
{
"event": "form:submitted",
"handler": "setState",
"params": {
"target": "_local",
"value": { "showSuccess": true, "formData": "{{_eventData.data}}" }
}
}
],
"children": [...]
}
emitEvent와 함께 사용
emitEvent 핸들러로 이벤트를 발생시키고 onComponentEvent로 구독합니다:
{
"components": [
{
"id": "submit_button",
"type": "basic",
"name": "Button",
"text": "저장",
"actions": [
{
"type": "click",
"handler": "emitEvent",
"params": {
"event": "data:saved",
"data": { "timestamp": "{{Date.now()}}" }
}
}
]
},
{
"id": "status_display",
"type": "basic",
"name": "Div",
"onComponentEvent": [
{
"event": "data:saved",
"handler": "toast",
"params": {
"type": "success",
"message": "$t:common.saved_successfully"
}
}
]
}
]
}
lifecycle.onMount와의 차이
| 속성 | onMount | onComponentEvent |
|---|---|---|
| 실행 시점 | 컴포넌트 마운트 시 1회 | 이벤트 발생 시마다 |
| 트리거 | 자동 (마운트) | emitEvent 또는 TSX emit |
| 데이터 접근 | 컨텍스트 데이터 | {{_eventData}} + 컨텍스트 |
| 용도 | 초기화 로직 | 컴포넌트 간 통신 |
주의사항
✅ event 이름은 필수 (빈 문자열이면 무시됨)
✅ 컴포넌트 언마운트 시 자동으로 구독 해제
✅ 동일 이벤트에 여러 컴포넌트가 구독 가능
✅ 이벤트 핸들러는 순차적으로 실행됨
G7Core.componentEvent가 없으면 구독 설정 실패 (경고 로그 출력)
핸들러 실행 실패 시 에러 로그 출력 후 다른 구독자는 계속 실행
액션 시스템 (actions)
상세 문서: 액션 핸들러 가이드 - 내장 핸들러 목록, 사용법, onSuccess/onError 배열
지원 이벤트 타입
| 이벤트 타입 | React 핸들러 | 설명 |
|---|---|---|
click |
onClick |
클릭 이벤트 |
change |
onChange |
값 변경 이벤트 (input, select 등) |
input |
onInput |
입력 이벤트 |
submit |
onSubmit |
폼 제출 이벤트 |
focus |
onFocus |
포커스 획득 |
blur |
onBlur |
포커스 해제 |
keydown |
onKeyDown |
키 누름 이벤트 |
keyup |
onKeyUp |
키 떼기 이벤트 |
keypress |
onKeyPress |
키 입력 이벤트 (IME 조합 완료 후 발생) |
mouseenter |
onMouseEnter |
마우스 진입 |
mouseleave |
onMouseLeave |
마우스 이탈 |
액션 정의 구조
{
"actions": [
{
"type": "click", // 이벤트 타입 (필수)
"handler": "navigate", // 액션 핸들러 (필수)
"target": "/path", // 타겟 (핸들러별 상이)
"params": {}, // 파라미터 (핸들러별 상이)
"key": "Enter", // 키보드 이벤트 필터 (선택)
"confirm": "확인 메시지" // 확인 대화상자 (선택)
}
]
}
키보드 이벤트 필터링 (key 필드)
keydown/keyup/keypress 이벤트에서 특정 키만 반응하도록 key 필드를 사용합니다:
{
"actions": [
{
"type": "keydown",
"key": "Enter",
"handler": "navigate",
"params": { "path": "/search?q={{_global.searchQuery}}" }
}
]
}
지원 키 값 (JavaScript KeyboardEvent.key 표준):
Enter,Escape,Tab,Backspace,DeleteArrowUp,ArrowDown,ArrowLeft,ArrowRighta~z,0~9등
실전 예시: 검색 입력 필드
{
"id": "search_input",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"placeholder": "검색어 입력..."
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"searchQuery": "{{$event.target.value}}"
}
},
{
"type": "keydown",
"key": "Enter",
"handler": "navigate",
"params": {
"path": "/search?q={{_global.searchQuery}}"
}
}
]
}
위 예시에서:
change이벤트: 입력값을 전역 상태에 저장keydown+key: "Enter": Enter 키를 누르면 검색 페이지로 이동
외부 스크립트 로드 (scripts)
버전: engine-v1.8.0+ 관련 문서: 액션 핸들러 - callExternal
목적: 레이아웃 진입 시 외부 스크립트를 1회만 로드합니다. 버튼 클릭마다 스크립트를 로드하는 것보다 효율적입니다.
핵심 원칙
✅ 레이아웃 로드 시 1회만 스크립트 로드 (중복 로드 방지)
✅ if 조건으로 조건부 로드 지원 (플러그인 활성화 상태 등)
✅ 비동기 로드 (async 기본값 true)
✅ 스크립트 로드 완료 후 data_sources fetch 진행
구조
{
"extends": "_admin_form",
"scripts": [
{
"src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js",
"id": "daum_postcode_script",
"if": "{{_global.installedPlugins?.find(p => p.identifier === 'sirsoft-daum_postcode' && p.status === 'active')}}"
}
],
"components": [...]
}
필드 설명
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
src |
string | ✅ | 스크립트 URL (프로토콜 없이 //로 시작 권장) |
id |
string | ✅ | 스크립트 요소 ID (중복 로드 방지용) |
if |
string | ❌ | 조건부 로드 표현식 (예: {{_global.pluginActive}}) |
async |
boolean | ❌ | 비동기 로드 여부 (기본값: true) |
실행 시점
1. 레이아웃 JSON 로드
2. 조건 컨텍스트 구성 (route, query, _global)
3. **scripts 로드** ← 여기서 실행
4. data_sources 조건부 필터링
5. blocking 데이터 소스 fetch
6. 초기 렌더링
조건부 로드 예시
플러그인 활성화 상태 체크:
{
"scripts": [
{
"src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js",
"id": "daum_postcode_script",
"if": "{{_global.installedPlugins?.find(p => p.identifier === 'sirsoft-daum_postcode' && p.status === 'active')}}"
}
]
}
전역 상태 체크:
{
"scripts": [
{
"src": "https://cdn.example.com/analytics.js",
"id": "analytics_script",
"if": "{{_global.settings?.analytics?.enabled}}"
}
]
}
callExternal과 함께 사용
scripts로 로드된 외부 라이브러리는 callExternal 핸들러로 호출합니다:
{
"scripts": [
{
"src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js",
"id": "daum_postcode_script",
"if": "{{_global.installedPlugins?.find(p => p.identifier === 'sirsoft-daum_postcode' && p.status === 'active')}}"
}
],
"components": [
{
"id": "btn_search_address",
"type": "basic",
"name": "Button",
"text": "주소 검색",
"actions": [
{
"type": "click",
"handler": "callExternal",
"params": {
"constructor": "daum.Postcode",
"args": { "oncomplete": true },
"callbackSetState": {
"basic_info": {
"zipcode": "zonecode",
"base_address": "roadAddress"
}
},
"method": "open"
}
}
]
}
]
}
callbackSetState 파라미터
버전: engine-v1.8.0+
callExternal 핸들러의 callbackSetState 파라미터를 사용하면 외부 라이브러리의 콜백 데이터를 폼 필드에 직접 매핑할 수 있습니다.
중첩 객체 구조 (권장):
{
"callbackSetState": {
"basic_info": {
"zipcode": "zonecode",
"base_address": "roadAddress"
}
}
}
위 설정은 다음과 같이 동작합니다:
_local.basic_info.zipcode← 콜백 데이터의zonecode값_local.basic_info.base_address← 콜백 데이터의roadAddress값
주의: 트러블슈팅 가이드 사례 7-2에 따라, 같은 루트를 공유하는 dot notation(
basic_info.zipcode,basic_info.base_address) 대신 중첩 객체 구조를 사용합니다.
주의사항
✅ 스크립트 ID는 고유해야 함 (동일 ID의 스크립트는 중복 로드되지 않음)
✅ if 조건이 없으면 항상 로드됨
✅ 스크립트 로드 실패는 경고만 출력하고 레이아웃 렌더링은 계속 진행됨
스크립트가 로드되지 않은 상태에서 callExternal 호출 시 에러 발생
비활성화된 플러그인의 스크립트는 로드하지 않도록 if 조건 사용 권장