# 레이아웃 JSON - 초기화, 모달, 액션, 스크립트 > **메인 문서**: [layout-json-features.md](layout-json-features.md) --- ## 목차 1. [초기화 액션 (init_actions)](#초기화-액션-init_actions) 2. [Named Actions (named_actions)](#named-actions-named_actions) 3. [모달 시스템 (modals)](#모달-시스템-modals) 4. [컴포넌트 이벤트 구독 (onComponentEvent)](#컴포넌트-이벤트-구독-oncomponentevent) 5. [액션 시스템 (actions)](#액션-시스템-actions) 6. [외부 스크립트 로드 (scripts)](#외부-스크립트-로드-scripts) --- ## 초기화 액션 (init_actions) **목적**: 레이아웃 로드 시 자동으로 실행할 핸들러를 정의합니다. ### 사용 사례 - 테마 초기화 (`initTheme`) - 사용자 설정 로드 - 분석 이벤트 전송 - 전역 상태 초기화 ### 구조 ```json { "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 | ❌ | 인증 필요 여부 | ### 실행 시점 1. 레이아웃 JSON 로드 2. blocking 데이터 소스 fetch 3. **초기 렌더링 완료** 4. **`init_actions` 실행** ← 여기서 실행 5. progressive/background 데이터 소스 fetch ### 핸들러 등록 방법 템플릿의 `handlers/index.ts`에서 핸들러를 등록합니다: ```typescript // handlers/initThemeHandler.ts export async function initThemeHandler( _action: any, _context?: any ): Promise { // 초기화 로직 const savedTheme = localStorage.getItem('g7_color_scheme'); applyTheme(savedTheme || 'auto'); } // handlers/index.ts import { initThemeHandler } from './initThemeHandler'; export const handlerMap = { initTheme: initThemeHandler, // ... 다른 핸들러들 } as const; ``` ### 실제 사용 예시 ```json { "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 데이터 등에 접근할 수 있습니다. ```json { "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` 는 마운트 후 실행이 보장되어 안전. ```json // ❌ 회귀 위험: 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` 핸들러를 사용하여 조건 분기를 실행할 수 있습니다. ```json { "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가 부모를 오버라이드 ### 구조 ```json { "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로 참조 ```json { "name": "Button", "actions": [{ "type": "click", "actionRef": "searchProducts" }] } ``` ```json { "name": "Input", "actions": [{ "type": "keypress", "key": "Enter", "actionRef": "searchProducts" }] } ``` ### 인라인 오버라이드 `actionRef`와 함께 인라인 속성을 지정하면 named\_actions 값을 오버라이드합니다: ```json { "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` 상태에 따라 자동으로 열림/닫힘이 제어됩니다. ### 핵심 원칙 ```text ✅ 필수: modals 배열에 Modal 컴포넌트 정의 ✅ 필수: 각 모달에 고유한 id 부여 ✅ 자동: isOpen, onClose props는 템플릿 엔진이 자동 주입 ``` ### 구조 ```json { "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` 핸들러를 사용합니다: ```json { "actions": [ { "type": "click", "handler": "openModal", "target": "confirm_modal" } ] } ``` ```json { "actions": [ { "type": "click", "handler": "closeModal" } ] } ``` ### 실제 사용 예시 ```json { "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+) 모달 스택 시스템을 통해 여러 모달을 동시에 열 수 있습니다: ```text ✅ 확인 모달 위에 에러 모달 중첩 표시 가능 ✅ closeModal 시 최상위 모달만 닫히고 이전 모달 유지 ✅ 스택 위치에 따른 z-index 자동 관리 ``` **중첩 모달 시나리오**: 1. 사용자가 "활성화" 버튼 클릭 → `confirm_modal` 열림 2. 확인 버튼 클릭 → API 호출 3. API 오류 발생 → `error_modal`이 `confirm_modal` 위에 중첩 표시 4. 에러 모달 닫기 → 확인 모달이 다시 보임 5. 확인 모달 닫기 → 모든 모달 닫힘 ### 주의사항 - ✅ 모달 ID는 레이아웃 내에서 고유해야 함 - ✅ `isOpen`과 `onClose`는 직접 정의하지 않아도 됨 (자동 주입) - ✅ 모달 본문에서 데이터 바인딩 사용 가능 (`{{_global.selectedItems.length}}` 등) - ✅ 멀티 모달 지원 - 모달 내부에서 다른 모달 열기 가능 (engine-v1.2.0+) - 이미 열려있는 모달은 스택에 중복 추가되지 않음 --- ## 컴포넌트 이벤트 구독 (onComponentEvent) > **버전**: engine-v1.11.0+ **목적**: 다른 컴포넌트에서 `emitEvent`로 발생한 이벤트를 레이아웃 JSON에서 구독합니다. 컴포넌트 마운트 시 자동 구독, 언마운트 시 자동 해제됩니다. ### 핵심 원칙 ```text ✅ 컴포넌트 레벨에서 이벤트 구독 정의 ✅ 마운트 시 자동 구독, 언마운트 시 자동 해제 ✅ 이벤트 데이터는 {{_eventData}}로 접근 가능 ✅ onSuccess/onError 콜백 지원 ``` ### 구조 ```json { "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}}`로 접근할 수 있습니다: ```json { "onComponentEvent": [ { "event": "item:selected", "handler": "setState", "params": { "target": "_local", "value": { "selectedItem": "{{_eventData.item}}" } } } ] } ``` ### 실제 사용 예시 **파일 업로드 후 데이터 새로고침**: TSX 컴포넌트에서 파일 업로드 완료 후 이벤트를 발생시킵니다: ```typescript // ImageUploader.tsx const handleUploadComplete = async (result) => { await G7Core.componentEvent.emit('upload:site_logo', { url: result.url, collection: 'site_logo' }); }; ``` 레이아웃 JSON에서 해당 이벤트를 구독하여 데이터를 새로고침합니다: ```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 업데이트**: ```json { "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`로 구독합니다: ```json { "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}}` + 컨텍스트 | | 용도 | 초기화 로직 | 컴포넌트 간 통신 | ### 주의사항 ```text ✅ event 이름은 필수 (빈 문자열이면 무시됨) ✅ 컴포넌트 언마운트 시 자동으로 구독 해제 ✅ 동일 이벤트에 여러 컴포넌트가 구독 가능 ✅ 이벤트 핸들러는 순차적으로 실행됨 G7Core.componentEvent가 없으면 구독 설정 실패 (경고 로그 출력) 핸들러 실행 실패 시 에러 로그 출력 후 다른 구독자는 계속 실행 ``` --- ## 액션 시스템 (actions) > **상세 문서**: [액션 핸들러 가이드](actions-handlers.md) - 내장 핸들러 목록, 사용법, 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` | 마우스 이탈 | ### 액션 정의 구조 ```json { "actions": [ { "type": "click", // 이벤트 타입 (필수) "handler": "navigate", // 액션 핸들러 (필수) "target": "/path", // 타겟 (핸들러별 상이) "params": {}, // 파라미터 (핸들러별 상이) "key": "Enter", // 키보드 이벤트 필터 (선택) "confirm": "확인 메시지" // 확인 대화상자 (선택) } ] } ``` ### 키보드 이벤트 필터링 (`key` 필드) `keydown`/`keyup`/`keypress` 이벤트에서 특정 키만 반응하도록 `key` 필드를 사용합니다: ```json { "actions": [ { "type": "keydown", "key": "Enter", "handler": "navigate", "params": { "path": "/search?q={{_global.searchQuery}}" } } ] } ``` **지원 키 값** (JavaScript `KeyboardEvent.key` 표준): - `Enter`, `Escape`, `Tab`, `Backspace`, `Delete` - `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight` - `a` ~ `z`, `0` ~ `9` 등 ### 실전 예시: 검색 입력 필드 ```json { "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](actions-handlers-ui.md#callexternal) **목적**: 레이아웃 진입 시 외부 스크립트를 1회만 로드합니다. 버튼 클릭마다 스크립트를 로드하는 것보다 효율적입니다. ### 핵심 원칙 ```text ✅ 레이아웃 로드 시 1회만 스크립트 로드 (중복 로드 방지) ✅ if 조건으로 조건부 로드 지원 (플러그인 활성화 상태 등) ✅ 비동기 로드 (async 기본값 true) ✅ 스크립트 로드 완료 후 data_sources fetch 진행 ``` ### 구조 ```json { "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) | ### 실행 시점 ```text 1. 레이아웃 JSON 로드 2. 조건 컨텍스트 구성 (route, query, _global) 3. **scripts 로드** ← 여기서 실행 4. data_sources 조건부 필터링 5. blocking 데이터 소스 fetch 6. 초기 렌더링 ``` ### 조건부 로드 예시 **플러그인 활성화 상태 체크**: ```json { "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')}}" } ] } ``` **전역 상태 체크**: ```json { "scripts": [ { "src": "https://cdn.example.com/analytics.js", "id": "analytics_script", "if": "{{_global.settings?.analytics?.enabled}}" } ] } ``` ### callExternal과 함께 사용 scripts로 로드된 외부 라이브러리는 `callExternal` 핸들러로 호출합니다: ```json { "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` 파라미터를 사용하면 외부 라이브러리의 콜백 데이터를 폼 필드에 직접 매핑할 수 있습니다. **중첩 객체 구조** (권장): ```json { "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`) 대신 중첩 객체 구조를 사용합니다. ### 주의사항 ```text ✅ 스크립트 ID는 고유해야 함 (동일 ID의 스크립트는 중복 로드되지 않음) ✅ if 조건이 없으면 항상 로드됨 ✅ 스크립트 로드 실패는 경고만 출력하고 레이아웃 렌더링은 계속 진행됨 스크립트가 로드되지 않은 상태에서 callExternal 호출 시 에러 발생 비활성화된 플러그인의 스크립트는 로드하지 않도록 if 조건 사용 권장 ``` --- ## 관련 문서 - [레이아웃 JSON 기능 인덱스](layout-json-features.md) - [스타일 및 계산된 값](layout-json-features-styling.md) - [에러 핸들링](layout-json-features-error.md) - [액션 핸들러](actions-handlers.md)