# 레이아웃 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<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;
```

### 실제 사용 예시

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