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.
34 KiB
액션 핸들러 - UI 인터랙션
메인 문서: actions-handlers.md
목차
- login / logout
- openModal / closeModal
- showAlert / toast
- confirm (액션 속성)
- switch
- conditions ⭐ NEW (engine-v1.10.0+)
- sequence / parallel
- reloadTranslations
- showErrorPage
- scrollIntoView ⭐ NEW (engine-v1.11.0+)
- loadScript
- callExternal
- 실전 예시
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: 다국어 지원) |
작동 원리
- 액션이 트리거되면
confirm속성이 있는지 확인 $t:접두사가 있으면 다국어 번역 수행window.confirm(message)호출- 사용자가 "확인" → 핸들러 실행
- 사용자가 "취소" → 핸들러 실행 중단 (아무 동작 없음)
주의사항
❌ 잘못됨: "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" } }
}
}
작동 원리
- 케이스 키 결정 (우선순위)
params.value값 사용 (데이터 바인딩 지원)params.value가 없으면$args[0]값 사용
cases에서 해당 키에 매칭되는 액션 정의를 찾음- 매칭되는 케이스가 없으면
default케이스 실행 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}}"
}
}
}
]
}