Files
Gnuboard7/docs/frontend/actions-handlers-ui.md
T
HeuJung 50007d5cc6 fix(auth): 2단계 인증을 켠 사이트의 로그인 흐름 구현
2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.

원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.

이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.

## 구현

- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
 `ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
 「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
 수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
 코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
 뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
 사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
 못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
 문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.

## 이번 점검에서 함께 고친 것

- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
 그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
 한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
 성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
 같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.

## 버전

코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
2026-09-07 17:08:14 +09:00

34 KiB

액션 핸들러 - UI 인터랙션

메인 문서: actions-handlers.md


목차

  1. login / logout
  2. openModal / closeModal
  3. showAlert / toast
  4. confirm (액션 속성)
  5. switch
  6. conditions ⭐ NEW (engine-v1.10.0+)
  7. sequence / parallel
  8. reloadTranslations
  9. showErrorPage
  10. scrollIntoView ⭐ NEW (engine-v1.11.0+)
  11. loadScript
  12. callExternal
  13. 실전 예시

login 절에 loginTwoFactor · loginTwoFactorResend 가 함께 설명되어 있습니다.


login / logout

login

로그인을 처리하고 토큰을 저장합니다.

{
  "type": "submit",
  "handler": "login",
  "target": "admin",
  "params": {
    "body": {
      "email": "{{form.email}}",
      "password": "{{form.password}}"
    }
  },
  "onSuccess": [
    {
      "handler": "navigate",
      "params": { "path": "/admin/dashboard" }
    }
  ],
  "onError": [
    {
      "handler": "setError",
      "target": "{{error.response.message}}"
    }
  ]
}

target 값

값 설명
admin 관리자 인증
user 사용자 인증

login 의 반환값 — 2단계 인증이 켜진 사이트

서버는 보안 환경설정에 따라 두 가지 형태의 200 을 돌려줍니다. onSuccess 는 두 형태 모두에서 실행되므로, 후속 액션은 response.two_factor_required 로 분기해야 합니다.

필드 타입 설명
user object | null 로그인한 사용자. 인증번호 확인이 남았으면 null
two_factor_required boolean true 면 아직 로그인이 끝나지 않았습니다
challenge_id string 인증번호 확인에 그대로 전달할 식별자
provider_id string 인증번호를 보낸 프로바이더
expires_at string | null 인증 요청 만료 시각 (ISO8601)

분기를 두지 않으면 인증이 끝나기 전에 홈으로 이동하거나, 빈 사용자 정보가 상태에 실립니다.

{
  "handler": "login",
  "target": "user",
  "params": { "body": { "email": "{{form.email}}", "password": "{{form.password}}" } },
  "onSuccess": [
    {
      "handler": "setState",
      "if": "{{response.two_factor_required}}",
      "params": {
        "target": "global",
        "twoFactor": {
          "required": true,
          "challenge_id": "{{response.challenge_id}}",
          "expires_at": "{{response.expires_at}}",
          "code": ""
        }
      }
    },
    {
      "handler": "navigate",
      "if": "{{!response.two_factor_required}}",
      "params": { "path": "/" }
    }
  ]
}

같은 시퀀스·onSuccess 안에서는 방금 저장한 상태(_global.twoFactor.*)를 다시 읽지 않습니다 — 그 자리의 상태는 아직 갱신 전이므로 {{response.*}} 만 사용합니다.

loginTwoFactor

인증번호를 확인해 로그인을 완료합니다. 성공 시 토큰이 발급되고 { user } 를 돌려줍니다.

{
  "handler": "loginTwoFactor",
  "target": "user",
  "params": {
    "body": {
      "challenge_id": "{{_global.twoFactor?.challenge_id}}",
      "code": "{{_global.twoFactor?.code}}"
    }
  },
  "onSuccess": [
    { "handler": "setState", "params": { "target": "global", "currentUser": "{{response.user}}", "twoFactor": null } },
    { "handler": "navigate", "params": { "path": "/" } }
  ],
  "onError": [
    { "handler": "setState", "params": { "target": "global", "twoFactor.error": "{{error.message}}" } }
  ]
}

params.body 의 challenge_id 와 code 는 필수입니다. target 은 login 과 같은 값을 씁니다 (user / admin) — 관리자 대상은 관리자 전용 엔드포인트를 호출하고, 관리자가 아니면 403 이 됩니다.

loginTwoFactorResend

인증번호를 다시 보냅니다. 서버가 기존 인증 요청을 취소하고 새로 발행하므로, 반환된 challenge_id 로 반드시 교체하고 입력란을 비워야 합니다 — 앞서 받은 번호는 더 이상 통하지 않습니다.

{
  "handler": "loginTwoFactorResend",
  "target": "user",
  "params": { "body": { "challenge_id": "{{_global.twoFactor?.challenge_id}}" } },
  "onSuccess": [
    {
      "handler": "setState",
      "params": {
        "target": "global",
        "twoFactor": {
          "required": true,
          "challenge_id": "{{response.challenge_id}}",
          "expires_at": "{{response.expires_at}}",
          "code": "",
          "error": null,
          "resent": true
        }
      }
    }
  ]
}

반환값은 { two_factor_required, challenge_id, provider_id, expires_at } 입니다.

logout

로그아웃하고 토큰을 삭제합니다.

{
  "type": "click",
  "handler": "logout",
  "onSuccess": [
    {
      "handler": "navigate",
      "params": { "path": "/admin/login" }
    }
  ]
}

openModal / closeModal

모달을 열거나 닫습니다. 모달 스택을 사용하여 중첩 모달을 지원합니다 (engine-v1.2.0+).

{
  "type": "click",
  "handler": "openModal",
  "target": "bulk_activate_confirm_modal"
}
{
  "type": "click",
  "handler": "closeModal"
}

작동 원리:

  • openModal: _global.modalStack에 모달 ID를 push, _global.activeModal도 동시 설정
  • closeModal: _global.modalStack에서 최상위 모달만 제거, 이전 모달 유지

멀티 모달 지원 (engine-v1.2.0+):

✅ 확인 모달 위에 에러 모달 중첩 표시 가능
✅ closeModal 시 최상위 모달만 닫히고 이전 모달 유지
이미 열려있는 모달은 스택에 중복 추가되지 않음

→ Modal 컴포넌트 JSON 구조: modal-usage.md


showAlert / toast

showAlert

브라우저 기본 알림(alert)을 표시합니다.

{
  "type": "click",
  "handler": "showAlert",
  "target": "$t:messages.confirm_delete"
}

toast

토스트 알림을 스택 형태로 표시합니다.

{
  "handler": "toast",
  "params": {
    "type": "success",
    "message": "$t:admin.users.modals.bulk_activate_success"
  }
}

toast params 구조

필드 타입 기본값 설명
type string "info" 토스트 타입 (success, error, warning, info)
message string - 표시할 메시지 ($t: 다국어 지원)
icon string 타입별 기본값 커스텀 아이콘 이름
duration number 3000 자동 닫힘 시간 (ms), 0이면 자동 닫힘 비활성화

confirm (액션 속성)

주의: confirm은 핸들러가 아니라 액션 속성입니다. "handler": "confirm"으로 사용하면 "Unknown action handler" 오류가 발생합니다.

액션 실행 전 브라우저 기본 확인 대화상자(window.confirm())를 표시합니다. 사용자가 "확인"을 클릭하면 핸들러가 실행되고, "취소"를 클릭하면 실행을 중단합니다.

기본 사용법

{
  "type": "click",
  "handler": "apiCall",
  "target": "/api/admin/items/{{row.id}}",
  "params": { "method": "DELETE" },
  "confirm": "$t:common.confirm_delete"
}

커스텀 핸들러와 함께 사용

{
  "type": "click",
  "handler": "sirsoft-ecommerce.removeCountrySetting",
  "confirm": "$t:sirsoft-ecommerce.admin.shipping_policy.form.country_tab_remove_confirm",
  "params": {
    "index": "{{_local.activeCountryTab ?? 0}}"
  }
}

필드 설명

필드 타입 필수 설명
confirm string ❌ 확인 대화상자에 표시할 메시지 ($t: 다국어 지원)

작동 원리

  1. 액션이 트리거되면 confirm 속성이 있는지 확인
  2. $t: 접두사가 있으면 다국어 번역 수행
  3. window.confirm(message) 호출
  4. 사용자가 "확인" → 핸들러 실행
  5. 사용자가 "취소" → 핸들러 실행 중단 (아무 동작 없음)

주의사항

❌ 잘못됨: "handler": "confirm" → Unknown action handler 오류
✅ 올바름: "handler": "실제핸들러", "confirm": "메시지"

❌ 잘못됨: params.onConfirm 콜백 구조
✅ 올바름: confirm 속성 + handler에 실제 실행할 핸들러 지정

switch

조건에 따라 다른 액션을 실행합니다.

기본 사용법 ($args[0] 기반)

이벤트 핸들러에서 전달된 인자를 케이스 키로 사용합니다.

{
  "event": "onRowAction",
  "type": "action",
  "handler": "switch",
  "cases": {
    "view": {
      "type": "click",
      "handler": "navigate",
      "params": { "path": "/admin/users/{{$args[1].id}}" }
    },
    "edit": {
      "type": "click",
      "handler": "navigate",
      "params": { "path": "/admin/users/{{$args[1].id}}/edit" }
    },
    "delete": {
      "type": "click",
      "handler": "openModal",
      "target": "delete_confirm_modal"
    }
  }
}

params.value 지원 (engine-v1.9.0+)

params.value를 통해 동적으로 케이스 키를 지정할 수 있습니다. 데이터 바인딩을 사용하여 플러그인/모듈 환경설정 값을 케이스 키로 활용할 수 있습니다.

{
  "type": "click",
  "handler": "switch",
  "params": {
    "value": "{{_global.plugins['sirsoft-daum_postcode']?.display_mode ?? 'layer'}}"
  },
  "cases": {
    "popup": {
      "handler": "callExternal",
      "params": { "constructor": "daum.Postcode", "method": "open" }
    },
    "layer": {
      "handler": "callExternalEmbed",
      "params": { "constructor": "daum.Postcode" }
    },
    "default": {
      "handler": "callExternalEmbed",
      "params": { "constructor": "daum.Postcode" }
    }
  }
}

default 케이스 지원 (engine-v1.9.0+)

매칭되는 케이스가 없을 경우 default 케이스가 실행됩니다.

{
  "handler": "switch",
  "params": { "value": "{{_global.theme}}" },
  "cases": {
    "dark": { "handler": "setState", "params": { "colorScheme": "dark" } },
    "light": { "handler": "setState", "params": { "colorScheme": "light" } },
    "default": { "handler": "setState", "params": { "colorScheme": "auto" } }
  }
}

작동 원리

  1. 케이스 키 결정 (우선순위)
    • params.value 값 사용 (데이터 바인딩 지원)
    • params.value가 없으면 $args[0] 값 사용
  2. cases에서 해당 키에 매칭되는 액션 정의를 찾음
  3. 매칭되는 케이스가 없으면 default 케이스 실행
  4. default 케이스도 없으면 아무 동작 없음

필드 설명

필드 타입 필수 설명
params.value string ❌ 케이스 키 값 (데이터 바인딩 지원, engine-v1.9.0+)
cases object ✅ 케이스별 액션 정의
cases.default object ❌ 기본 케이스 (매칭 없을 때 실행, engine-v1.9.0+)

conditions

버전: engine-v1.10.0+

조건에 따라 다른 액션을 실행합니다. switch의 확장판으로, AND/OR 그룹과 if-else 체인을 지원합니다.

기본 사용법 (if-else 체인)

{
  "type": "click",
  "handler": "conditions",
  "conditions": [
    {
      "if": "{{$args[0] === 'edit'}}",
      "then": { "handler": "navigate", "params": { "path": "/edit/{{row.id}}" } }
    },
    {
      "if": "{{$args[0] === 'delete'}}",
      "then": { "handler": "openModal", "params": { "id": "delete_confirm_modal" } }
    },
    {
      "then": { "handler": "toast", "params": { "message": "알 수 없는 액션" } }
    }
  ]
}

타입 정의

type ConditionExpression =
  | string                              // 단순 표현식: "{{user.isAdmin}}"
  | { and: ConditionExpression[] }      // AND 그룹: 모든 조건 true → true
  | { or: ConditionExpression[] };      // OR 그룹: 하나라도 true → true

interface ConditionBranch {
  if?: ConditionExpression;             // 조건 (없으면 else 브랜치)
  then?: ActionDefinition | ActionDefinition[];  // 실행할 액션
}

필드 설명

필드 타입 필수 설명
conditions ConditionBranch[] ✅ if-else 브랜치 배열
conditions[].if ConditionExpression ❌ 조건 표현식 (없으면 else 브랜치)
conditions[].then ActionDefinition | ActionDefinition[] ✅ 조건 충족 시 실행할 액션

AND 조건으로 액션 실행

모든 조건이 true일 때만 해당 브랜치가 실행됩니다.

{
  "type": "click",
  "handler": "conditions",
  "conditions": [
    {
      "if": {
        "and": ["{{user.isLoggedIn}}", "{{user.hasPermission}}"]
      },
      "then": { "handler": "navigate", "params": { "path": "/premium" } }
    },
    {
      "then": { "handler": "toast", "params": { "type": "error", "message": "접근 권한 없음" } }
    }
  ]
}

OR 조건으로 액션 실행

하나라도 true면 해당 브랜치가 실행됩니다.

{
  "type": "click",
  "handler": "conditions",
  "conditions": [
    {
      "if": {
        "or": ["{{user.isAdmin}}", "{{user.isManager}}"]
      },
      "then": { "handler": "navigate", "params": { "path": "/admin" } }
    },
    {
      "then": { "handler": "navigate", "params": { "path": "/user" } }
    }
  ]
}

중첩 AND/OR 조건

복잡한 조건 로직을 표현할 수 있습니다.

{
  "type": "click",
  "handler": "conditions",
  "conditions": [
    {
      "if": {
        "or": [
          "{{user.isSuperAdmin}}",
          {
            "and": ["{{user.isAdmin}}", "{{user.department === 'sales'}}"]
          }
        ]
      },
      "then": { "handler": "navigate", "params": { "path": "/sales-dashboard" } }
    },
    {
      "then": { "handler": "navigate", "params": { "path": "/home" } }
    }
  ]
}

then 배열 (순차 실행)

then이 배열이면 sequence처럼 순차적으로 실행됩니다.

{
  "type": "click",
  "handler": "conditions",
  "conditions": [
    {
      "if": "{{$args[0] === 'delete'}}",
      "then": [
        { "handler": "setState", "params": { "target": "_local", "deleteTargetId": "{{row.id}}" } },
        { "handler": "openModal", "params": { "id": "delete_confirm_modal" } }
      ]
    }
  ]
}

switch vs conditions 비교

특성 switch conditions
케이스 매칭 값 일치 ($args[0] 또는 params.value) 조건 표현식 평가
AND/OR 조건 ❌ 미지원 ✅ 지원
중첩 조건 ❌ 미지원 ✅ 지원
else 브랜치 default 케이스 if 없는 브랜치
then 배열 ❌ 미지원 ✅ 지원 (순차 실행)

switch vs conditions 선택 가이드

상황 권장 핸들러
단순 값 매칭 (edit/delete/view) switch
복합 조건 (AND/OR) conditions
중첩 조건 conditions
여러 액션 순차 실행 conditions (then 배열)

DataGrid onRowAction 실전 예시

{
  "event": "onRowAction",
  "type": "action",
  "handler": "conditions",
  "conditions": [
    {
      "if": "{{$args[0] === 'edit'}}",
      "then": { "handler": "navigate", "params": { "path": "/products/edit/{{$args[1].id}}" } }
    },
    {
      "if": "{{$args[0] === 'delete'}}",
      "then": [
        { "handler": "setState", "params": { "target": "_local", "deleteTargetId": "{{$args[1].id}}" } },
        { "handler": "openModal", "params": { "id": "delete_confirm_modal" } }
      ]
    },
    {
      "if": "{{$args[0] === 'view'}}",
      "then": { "handler": "navigate", "params": { "path": "/products/view/{{$args[1].id}}" } }
    },
    {
      "then": { "handler": "toast", "params": { "type": "warning", "message": "알 수 없는 액션입니다." } }
    }
  ]
}

sequence / parallel

sequence

여러 액션을 순차적으로 실행합니다.

{
  "type": "click",
  "handler": "sequence",
  "actions": [
    {
      "handler": "setState",
      "params": { "target": "global", "selectedModule": "{{row}}" }
    },
    {
      "handler": "openModal",
      "target": "module_install_modal"
    }
  ]
}

결과 전달 컨텍스트

변수 설명
$prev 직전 액션의 결과
$results 모든 이전 결과 배열
$results[0] 특정 인덱스의 결과 접근

parallel

여러 액션을 병렬로 실행합니다.

{
  "handler": "parallel",
  "actions": [
    { "handler": "toast", "params": { "type": "success", "message": "완료!" } },
    { "handler": "refetchDataSource", "params": { "dataSourceId": "modules" } },
    { "handler": "refetchDataSource", "params": { "dataSourceId": "admin_menu" } }
  ]
}

sequence vs parallel 비교

특성 sequence parallel
실행 방식 순차 병렬
에러 처리 중간 실패 시 중단 일부 실패해도 계속
결과 전달 $prev, $results 사용 결과 전달 불가

sequence 고급 동작 (engine-v1.11.0+)

스텝 간 상태 동기화

sequence 내에서 setState 실행 후 다음 스텝은 갱신된 상태를 참조합니다. 이는 _computed 속성도 포함합니다:

1. setState로 _local.price = 500 설정
2. → _computed.totalPrice 자동 재계산 (skipCache: true)
3. → 다음 스텝에서 {{_computed.totalPrice}} 참조 시 최신값 반영

$prev와 $results 상세

변수 타입 설명
$prev any 직전 액션의 결과값 (직접 참조, 배열 아님)
$results array 모든 이전 액션 결과의 배열
$results[0] any 첫 번째 액션의 결과
{
  "handler": "sequence",
  "actions": [
    {
      "handler": "apiCall",
      "target": "/api/products/{{row.id}}",
      "params": { "method": "GET" }
    },
    {
      "handler": "setState",
      "params": {
        "target": "local",
        "productName": "{{$prev.data.name}}",
        "firstResult": "{{$results[0].data.id}}"
      }
    }
  ]
}

커스텀 핸들러와 __g7SequenceLocalSync

커스텀 핸들러(React 컴포넌트 내부에서 등록한 핸들러)가 sequence 내에서 _local 상태를 변경하면, 다음 스텝에서 해당 변경이 반영되지 않을 수 있습니다. 이를 해결하기 위해 __g7SequenceLocalSync 메커니즘이 사용됩니다:

✅ 내장 핸들러 (setState, apiCall 등): 자동으로 상태 동기화
커스텀 핸들러: __g7SequenceLocalSync 콜백을 통해 명시적으로 상태 동기화 필요
✅ 상세: g7core-api-advanced.md 참조

_isolated 상태 추적

sequence 내에서 target: "isolated" setState가 실행되면, 이후 스텝에서 _isolated 상태도 최신값으로 추적됩니다.


reloadTranslations

Deprecated (engine-v1.38.0+): 모듈/플러그인/템플릿 라이프사이클 onSuccess 에서는 reloadExtensions 사용을 권장합니다. 하위 호환을 위해 유지되며 내부적으로 TemplateApp.reloadExtensionState() 로 위임되어 routes/translations/layouts 가 함께 재동기화됩니다. 자세한 내용은 reloadExtensions 참조.

다국어 파일을 다시 로드합니다.

{
  "handler": "reloadTranslations"
}

사용 사례

  • 언어 설정 변경 후 번역 적용 (단독 사용)
  • (deprecated) 모듈/플러그인 설치 후 새 번역 적용 → reloadExtensions 사용

showErrorPage

버전: engine-v1.6.0+

에러 페이지를 렌더링합니다. URL 변경 없이 현재 페이지에서 에러 페이지를 표시합니다.

{
  "handler": "showErrorPage",
  "params": {
    "errorCode": 403,
    "target": "content"
  }
}

showErrorPage params 구조

필드 타입 필수 기본값 설명
errorCode number ❌ errorHandling 키값 에러 코드
target string ❌ "content" "full" 또는 "content"
containerId string ❌ - 렌더링할 컨테이너 ID
layout string ❌ - 사용할 레이아웃 경로

scrollIntoView

버전: engine-v1.11.0+

특정 요소를 화면에 보이도록 스크롤합니다. MutationObserver를 통한 요소 대기와 컨테이너 내 스크롤을 지원합니다.

기본 사용법

{
  "type": "click",
  "handler": "scrollIntoView",
  "params": {
    "selector": "#target_element",
    "behavior": "smooth",
    "block": "center"
  }
}

scrollIntoView params 구조

필드 타입 기본값 설명
selector string - 스크롤할 대상 요소 CSS 선택자 (필수)
scrollContainer string - 스크롤 컨테이너 선택자 (지정 시 해당 컨테이너만 스크롤)
behavior string "smooth" 스크롤 동작 (smooth, instant, auto)
block string "nearest" 수직 정렬 (start, center, end, nearest)
inline string "nearest" 수평 정렬 (start, center, end, nearest)
waitForElement boolean false MutationObserver로 요소 대기
timeout number 2000 waitForElement 타임아웃 (ms)
delay number 0 실행 전 지연 (ms)
retryCount number 0 재시도 횟수
retryInterval number 50 재시도 간격 (ms)

요소 대기 (waitForElement)

조건부 렌더링(if)으로 나타나는 요소를 대기할 때 사용합니다. MutationObserver를 사용하여 DOM에 요소가 추가될 때까지 대기합니다.

{
  "handler": "scrollIntoView",
  "params": {
    "selector": "#loading_indicator",
    "waitForElement": true,
    "timeout": 2000,
    "block": "end"
  }
}

컨테이너 내 스크롤 (scrollContainer)

특정 스크롤 컨테이너 내에서만 스크롤하고 브라우저 전체 스크롤에 영향을 주지 않으려면 scrollContainer를 지정합니다.

{
  "handler": "scrollIntoView",
  "params": {
    "selector": "#loading_indicator",
    "scrollContainer": "#template_list",
    "behavior": "smooth",
    "block": "end"
  }
}

작동 원리:

  • scrollContainer 미지정: 네이티브 Element.scrollIntoView() 사용 (모든 조상 스크롤 영향)
  • scrollContainer 지정: 해당 컨테이너의 scrollTop만 직접 조정 (브라우저 스크롤 유지)

무한스크롤 로딩 인디케이터 예시

{
  "id": "template_list",
  "type": "basic",
  "name": "Div",
  "props": {
    "className": "max-h-[calc(90vh-140px)] overflow-y-auto"
  },
  "actions": [
    {
      "type": "scroll",
      "debounce": 200,
      "handler": "sequence",
      "actions": [
        {
          "handler": "switch",
          "params": {
            "value": "{{$event.target.scrollHeight - $event.target.scrollTop <= $event.target.clientHeight + 100 && _global.infiniteScroll.hasMore && !_global.infiniteScroll.isLoadingMore}}"
          },
          "cases": {
            "true": {
              "handler": "sequence",
              "actions": [
                {
                  "handler": "setState",
                  "params": {
                    "target": "global",
                    "infiniteScroll.isLoadingMore": true
                  }
                },
                {
                  "handler": "scrollIntoView",
                  "params": {
                    "selector": "#loading_indicator",
                    "scrollContainer": "#template_list",
                    "behavior": "smooth",
                    "block": "end",
                    "waitForElement": true,
                    "timeout": 2000
                  }
                },
                {
                  "handler": "apiCall",
                  "target": "/api/items",
                  "params": {
                    "method": "GET",
                    "query": { "page": "{{_global.infiniteScroll.currentPage + 1}}" }
                  },
                  "onSuccess": [
                    {
                      "handler": "appendDataSource",
                      "params": {
                        "dataSourceId": "items",
                        "dataPath": "data",
                        "newData": "{{response.data}}"
                      }
                    }
                  ]
                }
              ]
            }
          }
        }
      ]
    }
  ],
  "children": [
    {
      "id": "loading_indicator",
      "type": "composite",
      "name": "LoadingSpinner",
      "if": "{{_global.infiniteScroll.isLoadingMore}}",
      "props": {
        "size": "sm",
        "text": "$t:common.loading"
      }
    }
  ]
}

block 옵션 비교

값 설명
start 요소가 컨테이너 상단에 오도록 스크롤
center 요소가 컨테이너 중앙에 오도록 스크롤
end 요소가 컨테이너 하단에 오도록 스크롤
nearest 가장 가까운 방향으로 최소 스크롤 (이미 보이면 스크롤 안 함)

loadScript

버전: engine-v1.7.0+

외부 스크립트를 동적으로 로드합니다. 외부 서비스(Daum 우편번호, 결제 SDK 등) 연동 시 사용합니다.

src 는 레이아웃 scripts[] 와 같은 출처 정책을 받습니다 — same-origin 절대 경로(/ 로 시작)이거나, 확장이 manifest(trusted_script_hosts)로 선언한 신뢰 호스트여야 합니다. 그 밖의 원격 URL 은 로드 전에 차단되고 액션이 실패합니다(onError·errorHandling 오류 채널로 전달). 상세: security.md

{
  "type": "click",
  "handler": "loadScript",
  "params": {
    "src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js",
    "id": "daum_postcode_script"
  },
  "onLoad": {
    "handler": "setState",
    "params": { "daumPostcodeLoaded": true }
  }
}

loadScript params 구조

필드 타입 기본값 설명
src string - 스크립트 URL (필수)
id string 자동 생성 스크립트 요소 ID
async boolean true 비동기 로드 여부
defer boolean false defer 속성

onLoad 콜백

스크립트 로드 완료 시 실행할 액션을 onLoad에 정의합니다.

{
  "handler": "loadScript",
  "params": {
    "src": "/api/plugins/assets/vendor-plugin/dist/vendor/example-sdk/1.0.0/sdk.js"
  },
  "onLoad": {
    "handler": "callExternal",
    "params": {
      "constructor": "ExampleSDK",
      "method": "init"
    }
  }
}

중복 로드 방지

동일한 ID의 스크립트가 이미 로드되었거나 DOM에 존재하면 다시 로드하지 않고 onLoad만 즉시 실행합니다.

같은 스크립트를 동시에 요청하면 태그는 하나만 만들어지고, 두 호출자 모두 그 태그의 로드가 끝난 뒤에 완료됩니다. 로드 중인 스크립트를 "이미 있다"는 이유로 먼저 완료 처리하면, 그 호출자의 onLoad 가 SDK 전역이 아직 없는 시점에 실행되어 아무 일도 일어나지 않습니다.

✅ 스크립트 중복 로드 자동 방지
✅ 이미 로드된 경우 onLoad 즉시 실행
✅ 동시 요청은 하나의 태그를 공유하고 각자 onLoad 실행

동의 관리(개인정보 배너)와의 관계

동의 관리 플러그인이 아직 동의받지 않은 스크립트를 차단하고 있으면, 이 액션은 스크립트를 붙이지 않고 미완료 상태로 끝납니다(오류가 아니라 "동의 전"이라는 상태이므로 실패로 취급하지 않습니다). onLoad 는 실행되지 않고, 캐시에도 기록하지 않으므로 동의 후 다시 호출하면 정상적으로 로드됩니다.


callExternal

버전: engine-v1.7.0+

외부 스크립트의 생성자나 메서드를 호출합니다. loadScript로 로드한 외부 라이브러리를 호출할 때 사용합니다.

{
  "type": "click",
  "handler": "callExternal",
  "params": {
    "constructor": "daum.Postcode",
    "args": { "oncomplete": true },
    "callbackEvent": "postcode:complete",
    "method": "open"
  }
}

callExternal params 구조

필드 타입 기본값 설명
constructor string - 호출할 생성자 경로 (필수, 예: "daum.Postcode")
args object {} 생성자에 전달할 인자 객체
method string - 인스턴스 생성 후 호출할 메서드 (예: "open")
methodArgs array [] 메서드에 전달할 인자 배열
callbackEvent string - 콜백 결과를 전달할 이벤트명
embedTarget string - embed 메서드 사용 시 대상 요소 선택자

콜백 처리

args에서 true로 설정된 속성은 콜백 함수로 변환됩니다. 콜백 호출 시 callbackEvent로 지정한 이벤트가 발생합니다.

{
  "handler": "callExternal",
  "params": {
    "constructor": "daum.Postcode",
    "args": {
      "oncomplete": true
    },
    "callbackEvent": "postcode:complete"
  }
}

위 설정은 다음과 동일합니다:

new daum.Postcode({
  oncomplete: (data) => {
    G7Core.componentEvent.emit('postcode:complete', data);
  }
}).open();

이벤트 구독

콜백 결과는 G7Core.componentEvent로 전달됩니다. 컴포넌트에서 onEvent로 구독할 수 있습니다:

{
  "actions": [
    {
      "event": "postcode:complete",
      "type": "action",
      "handler": "setState",
      "params": {
        "zonecode": "{{$args[0].zonecode}}",
        "address": "{{$args[0].address}}"
      }
    }
  ]
}

embed 모드

팝업 대신 특정 요소에 임베드하려면 embedTarget을 사용합니다:

{
  "handler": "callExternal",
  "params": {
    "constructor": "daum.Postcode",
    "args": { "oncomplete": true },
    "callbackEvent": "postcode:complete",
    "method": "embed",
    "embedTarget": "#postcode_container"
  }
}

Daum 우편번호 연동 전체 예시

{
  "id": "search_address_button",
  "type": "basic",
  "name": "Button",
  "text": "$t:common.search_address",
  "actions": [
    {
      "type": "click",
      "handler": "sequence",
      "actions": [
        {
          "handler": "loadScript",
          "params": {
            "src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js",
            "id": "daum_postcode"
          }
        },
        {
          "handler": "callExternal",
          "params": {
            "constructor": "daum.Postcode",
            "args": { "oncomplete": true },
            "callbackEvent": "postcode:complete",
            "method": "open"
          }
        }
      ]
    }
  ]
},
{
  "id": "address_form",
  "type": "basic",
  "name": "Div",
  "actions": [
    {
      "event": "postcode:complete",
      "type": "action",
      "handler": "setState",
      "params": {
        "zonecode": "{{$args[0].zonecode}}",
        "address": "{{$args[0].address}}",
        "roadAddress": "{{$args[0].roadAddress}}"
      }
    }
  ]
}

실전 예시

사용자 일괄 활성화 모달

{
  "id": "bulk_activate_confirm_modal",
  "type": "composite",
  "name": "Modal",
  "props": {
    "title": "$t:admin.users.modals.bulk_activate_title",
    "size": "small"
  },
  "children": [
    {
      "type": "basic",
      "name": "P",
      "text": "$t:admin.users.modals.bulk_activate_confirm|count={{(_global.selectedIds || []).length}}"
    },
    {
      "type": "basic",
      "name": "Div",
      "props": { "className": "flex justify-center gap-4 mt-4" },
      "children": [
        {
          "type": "basic",
          "name": "Button",
          "props": { "variant": "secondary" },
          "text": "$t:common.cancel",
          "actions": [
            { "type": "click", "handler": "closeModal" }
          ]
        },
        {
          "type": "basic",
          "name": "Button",
          "props": { "variant": "primary" },
          "text": "$t:common.confirm",
          "actions": [
            {
              "type": "click",
              "handler": "apiCall",
              "target": "/api/admin/users/bulk-status",
              "params": {
                "method": "PATCH",
                "body": {
                  "ids": "{{_global.selectedIds}}",
                  "status": "active"
                }
              },
              "onSuccess": [
                { "handler": "closeModal" },
                { "handler": "setState", "params": { "target": "global", "selectedIds": [] } },
                { "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, "query": {} } },
                { "handler": "toast", "params": { "type": "success", "message": "$t:admin.users.modals.bulk_activate_success" } }
              ]
            }
          ]
        }
      ]
    }
  ]
}

검색 필드 (Enter 키 검색)

{
  "id": "search_input",
  "type": "basic",
  "name": "Input",
  "props": {
    "type": "text",
    "placeholder": "$t:admin.users.search_placeholder",
    "value": "{{_global.searchQuery || query.search || ''}}"
  },
  "actions": [
    {
      "type": "change",
      "handler": "setState",
      "params": {
        "target": "global",
        "searchQuery": "{{$event.target.value}}"
      }
    },
    {
      "type": "keypress",
      "key": "Enter",
      "handler": "navigate",
      "params": {
        "path": "/admin/users",
        "mergeQuery": true,
        "query": {
          "search": "{{_global.searchQuery}}"
        }
      }
    }
  ]
}

관련 문서