Files
Gnuboard7/docs/frontend/layout-json-features-actions.md
2026-05-11 11:29:41 +09:00

25 KiB

레이아웃 JSON - 초기화, 모달, 액션, 스크립트

메인 문서: layout-json-features.md


목차

  1. 초기화 액션 (init_actions)
  2. Named Actions (named_actions)
  3. 모달 시스템 (modals)
  4. 컴포넌트 이벤트 구독 (onComponentEvent)
  5. 액션 시스템 (actions)
  6. 외부 스크립트 로드 (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 ❌ 인증 필요 여부

실행 시점

  1. 레이아웃 JSON 로드
  2. blocking 데이터 소스 fetch
  3. 초기 렌더링 완료
  4. init_actions 실행 ← 여기서 실행
  5. 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 자동 관리

중첩 모달 시나리오:

  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에서 구독합니다. 컴포넌트 마운트 시 자동 구독, 언마운트 시 자동 해제됩니다.

핵심 원칙

✅ 컴포넌트 레벨에서 이벤트 구독 정의
✅ 마운트 시 자동 구독, 언마운트 시 자동 해제
✅ 이벤트 데이터는 {{_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, Delete
  • ArrowUp, ArrowDown, ArrowLeft, ArrowRight
  • a ~ 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 조건 사용 권장

관련 문서