28 KiB
액션 핸들러 - 상태 관리
메인 문서: actions-handlers.md
목차
- apiCall
- setState
- setError
- refetchDataSource
- updateDataSource
- appendDataSource
- remount
- sortable (드래그앤드롭 정렬)
- onSuccess/onError 후속 액션
- API 데이터 바인딩 규칙
- 에러 핸들링 시스템
apiCall
API를 호출합니다. 주의: api가 아닌 apiCall을 사용해야 합니다.
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/users/bulk-status",
"params": {
"method": "PATCH",
"body": {
"ids": "{{_global.selectedIds}}",
"status": "active"
}
},
"onSuccess": [
{ "handler": "closeModal" },
{ "handler": "toast", "params": { "type": "success", "message": "$t:common.success" } }
]
}
액션 레벨 속성
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
auth_required |
boolean | false | true이면 Bearer 토큰을 Authorization 헤더에 포함 (401 에러 발생 시 로그인 페이지 리다이렉트) |
auth_mode |
string | "required" | 인증 모드: "required" (토큰 없으면 에러), "optional" (토큰 있으면 포함, 없어도 진행) |
identity_target |
object | — | 본인인증(IDV) 대상. 이 apiCall이 HTTP 428(본인인증 필요)을 받으면 인증 코드/링크를 보낼 { email?, phone? } 을 흐름이 직접 선언 (engine-v1.51.0+) |
중요: auth_required, auth_mode, identity_target은 params 안이 아닌 액션 정의 최상위에 선언해야 합니다.
identity_target 사용 예시
본인인증 정책이 켜진 상태에서 비로그인(게스트) 사용자가 호출하는 API는, 인증 코드를 보낼 대상(이메일·전화)을 화면 입력값에서 선언해야 합니다. 서버는 428을 던지는 시점에 사용자가 방금 화면에 입력한 값을 알 수 없으므로, 대상 수집은 레이아웃의 책임입니다.
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/user/orders",
"auth_mode": "optional",
"identity_target": {
"email": "{{_local.orderer?.email || ''}}",
"phone": "{{_local.orderer?.phone || _local.shipping?.recipient_phone || ''}}"
},
"params": { "method": "POST", "body": { } }
}
email/phone둘 중 하나만 있어도 충분합니다 (서버가 둘 다 허용).- 우선순위는 표현식 자체가 결정합니다 (예: 주문자 정보 우선 → 수취인 정보 폴백).
- 표현식은
|| ''fallback으로 빈 문자열을 보장합니다 (undefined 방지). - 로그인 사용자는 빈 값이어도 서버가 세션에서 대상을 도출하므로 무방합니다.
G7Core.api(axios) 직접 호출 경로에서는 호출 config에identity_target을 실어 동일하게 동작합니다.
auth_mode 사용 예시
비회원도 접근 가능하지만, 로그인 시 추가 기능을 제공하는 API:
{
"handler": "apiCall",
"target": "/api/cart",
"auth_mode": "optional",
"params": { "method": "GET" }
}
| auth_mode | 토큰 있음 | 토큰 없음 |
|---|---|---|
"required" (기본) |
Bearer 토큰 전송 | 에러 발생 |
"optional" |
Bearer 토큰 전송 | 토큰 없이 요청 진행 |
// ✅ 올바른 사용
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/users",
"params": { "method": "POST" }
}
// ❌ 잘못된 사용 (Bearer 토큰이 전송되지 않음)
{
"handler": "apiCall",
"target": "/api/admin/users",
"params": { "method": "POST", "auth_required": true }
}
apiCall params 구조
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
method |
string | "GET" | HTTP 메서드 (GET, POST, PUT, PATCH, DELETE) |
body |
object | - | 요청 본문 (JSON 또는 FormData) |
headers |
object | - | 추가 헤더 |
contentType |
string | "application/json" | 요청 Content-Type ("multipart/form-data" 지정 시 FormData 자동 변환) |
multipart/form-data 지원 (파일 업로드)
버전: engine-v1.19.0+
contentType: "multipart/form-data"를 지정하면 body가 자동으로 FormData로 변환됩니다.
Content-Type 헤더는 설정하지 않으며 (브라우저가 boundary를 포함하여 자동 설정), File/Blob 객체는 원본 그대로 전송됩니다.
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/modules/manual-install",
"params": {
"method": "POST",
"contentType": "multipart/form-data",
"body": {
"file": "{{_global.moduleUploadFile}}",
"source": "file_upload"
}
}
}
FormData 변환 규칙
| body 값 타입 | FormData 처리 |
|---|---|
File / Blob |
formData.append(key, value) (원본 유지) |
string / number / boolean |
formData.append(key, String(value)) |
object / array |
formData.append(key, JSON.stringify(value)) |
null / undefined |
제외 (전송하지 않음) |
주의사항
주의: contentType: "multipart/form-data" 시 Content-Type 헤더를 수동 설정하지 말 것
→ 브라우저가 boundary를 포함한 Content-Type을 자동 생성
주의: File/Blob 객체는 setState로 저장 시 원본 참조 유지됨 (engine-v1.19.0+)
→ deepMergeWithState가 non-plain 객체(File, Blob, Date 등)를 spread 없이 직접 할당
필수: 파일 업로드 API는 백엔드에서 multipart/form-data를 기대해야 함
apiCall body에서 조건부 필드 제외 (undefined 패턴)
드라이버/모드 선택에 따라 특정 필드만 전송해야 하는 경우, undefined를 반환하는 삼항 표현식을 사용합니다. JSON.stringify()는 값이 undefined인 키를 자동으로 제거합니다.
{
"handler": "apiCall",
"target": "/api/admin/settings/test-mail",
"params": {
"method": "POST",
"body": {
"mailer": "{{_local.form?.mail?.mailer || 'smtp'}}",
"from_address": "{{_local.form?.mail?.from_address || ''}}",
"host": "{{_local.form?.mail?.mailer === 'smtp' ? (_local.form?.mail?.host ?? '') : undefined}}",
"mailgun_domain": "{{_local.form?.mail?.mailer === 'mailgun' ? (_local.form?.mail?.mailgun_domain ?? '') : undefined}}"
}
}
}
동작 원리:
| 조건 | 표현식 결과 | JSON 직렬화 |
|---|---|---|
| mailer === 'smtp' | host: "smtp.example.com" |
"host":"smtp.example.com" (포함) |
| mailer === 'mailgun' | host: undefined |
키 자체가 제거됨 |
핵심 규칙:
주의: 불필요한 필드를 빈 문자열('')로 전송하지 말 것
필수: undefined 패턴으로 해당 드라이버 필드만 전송
공통 필드(from_address, from_name 등)는 조건 없이 항상 전송
주의: 모든 드라이버 필드를 항상 전송하면 서버 측 불필요한 처리 유발
삼항 표현식 패턴:
현재 드라이버 필드: 조건 ? (값 ?? 기본값) : undefined
다른 드라이버 필드: undefined (JSON에서 제거)
공통 필드: 조건 없이 항상 포함
CSRF 토큰
apiCall은 자동으로 CSRF 토큰을 처리합니다:
- POST, PUT, PATCH, DELETE 요청 시
/sanctum/csrf-cookie호출 - 쿠키에서
XSRF-TOKEN을 추출하여X-XSRF-TOKEN헤더에 포함
globalHeaders 자동 적용
버전: engine-v1.16.0+
레이아웃에 globalHeaders가 정의되어 있으면, apiCall 핸들러도 해당 패턴에 매칭되는 API에 헤더를 자동으로 포함합니다.
// 레이아웃 최상위
{
"globalHeaders": [
{ "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
]
}
// apiCall 핸들러 - X-Cart-Key 헤더 자동 포함
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/cart/add",
"params": {
"method": "POST",
"body": { "productId": "{{item.id}}" }
}
}
헤더 우선순위: params.headers > globalHeaders
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/cart",
"params": {
"method": "GET",
"headers": { "X-Cart-Key": "custom-key" } // globalHeaders보다 우선
}
}
상세 문서: layout-json.md
setState
상태를 변경합니다.
병합 모드 (merge 옵션)
버전: engine-v1.18.0+ (
replace모드 추가)
setState 핸들러의 params에 merge 속성을 지정하여 상태 병합 방식을 선택할 수 있습니다.
merge 값 |
동작 | 사용 시점 |
|---|---|---|
생략 또는 "deep" |
재귀적 깊은 병합 (중첩 객체 보존) | 개별 필드 업데이트 (기본값) |
"shallow" |
최상위 키만 덮어쓰기 (1단계) | 프리셋 적용, 필터 초기화 |
"replace" |
기존 상태 완전 무시, 새 값으로 교체 | 폼 초기화, 서버 데이터로 전체 교체 |
// replace 모드: 기존 _local을 완전히 payload로 교체
{
"handler": "setState",
"params": {
"target": "local",
"merge": "replace",
"formData": { "name": "", "price": 0 }
}
}
// shallow 모드: 최상위 키만 덮어쓰기
{
"handler": "setState",
"params": {
"target": "local",
"merge": "shallow",
"filter": { "searchField": "all", "status": "all" }
}
}
상세 비교: state-management-forms.md
전역 상태 변경 (target: "global")
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"selectedIds": "{{$args[0]}}",
"searchQuery": "{{$event.target.value}}"
}
}
로컬 상태 변경 (target: "local" 또는 생략)
{
"type": "change",
"handler": "setState",
"params": {
"isExpanded": true
}
}
격리된 상태 변경 (target: "isolated")
버전: engine-v1.14.0+
isolatedState 속성이 정의된 컴포넌트 내에서만 동작합니다. 해당 영역의 상태 변경 시 전체 레이아웃이 아닌 격리된 영역만 리렌더링됩니다.
{
"type": "click",
"handler": "setState",
"params": {
"target": "isolated",
"selectedId": "{{item.id}}",
"currentStep": 2
}
}
사용 요건
주의: target: "isolated"는 isolatedState 속성이 정의된 컴포넌트 내에서만 동작합니다.
격리 스코프 외부에서 호출 시 → _local로 폴백되며 경고 로그 출력
isolatedState가 있는 컴포넌트 내에서 호출 시 → 격리된 상태만 업데이트
레이아웃 정의 예시
{
"type": "Div",
"isolatedState": {
"selectedCategories": [null, null, null, null],
"currentStep": 1
},
"isolatedScopeId": "category-selector",
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"label": "다음 단계",
"onClick": {
"handler": "setState",
"params": {
"target": "isolated",
"currentStep": "{{_isolated.currentStep + 1}}"
}
}
}
}
]
}
target별 비교
| target | 상태 스코프 | 리렌더링 범위 | 사용 시점 |
|---|---|---|---|
local (기본) |
_local |
전체 레이아웃 | 일반 폼 데이터, 필터 |
global |
_global |
전체 앱 | 사용자 인증, 사이드바 상태 |
isolated |
_isolated |
격리된 영역만 | 빈번한 인터랙션 (카테고리 선택, 드래그) |
주요 사용 패턴
// 체크박스 선택 ID 저장
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"selectedIds": "{{$args[0]}}"
}
}
// 검색어 입력 시 상태 저장
{
"type": "change",
"handler": "setState",
"params": {
"searchQuery": "{{$event.target.value}}"
}
}
// 토글 상태 변경
{
"type": "click",
"handler": "setState",
"params": {
"isExpanded": "{{!_local.isExpanded}}"
}
}
setError
에러 상태를 설정합니다. apiError 상태 키에 저장됩니다.
{
"handler": "setError",
"target": "{{error.response.message}}"
}
target 값
| 형식 | 설명 |
|---|---|
{{error.message}} |
데이터 바인딩 (에러 객체에서 추출) |
$t:errors.login_failed |
다국어 키 |
"로그인에 실패했습니다." |
직접 문자열 |
refetchDataSource
특정 데이터 소스를 다시 fetch합니다.
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "modules"
}
}
refetchDataSource params 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
dataSourceId |
string | ✅ | - | 다시 fetch할 데이터 소스 ID |
sync |
boolean | ❌ | false | true면 즉시 동기 렌더링 |
sync 옵션
버전: engine-v1.4.0+
sync: true를 사용하면 React의 startTransition 없이 즉시 동기적으로 렌더링합니다.
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "admin_menu",
"sync": true
}
}
| 케이스 | sync 필요 여부 |
|---|---|
| 드래그 앤 드롭 순서 변경 | ✅ sync: true |
| 토글/체크박스 상태 변경 | ✅ sync: true |
| 일반 데이터 갱신 | ❌ 기본값 사용 |
상태 오버라이드 (engine-v1.17.0+)
refetch 시 상태를 임시로 오버라이드하여 데이터소스 파라미터 치환에 반영할 수 있습니다.
| 필드 | 타입 | 필수 | 엔진 버전 | 설명 |
|---|---|---|---|---|
globalStateOverride |
object | ❌ | engine-v1.17.0+ | _global 값 임시 오버라이드 |
localStateOverride |
object | ❌ | engine-v1.19.0+ | _local 값 임시 오버라이드 |
isolatedStateOverride |
object | ❌ | engine-v1.19.0+ | _isolated 값 임시 오버라이드 |
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "products",
"sync": true,
"globalStateOverride": {
"currentPage": 1
},
"localStateOverride": {
"filter": { "status": "active" }
}
}
}
오버라이드는 해당 refetch에만 적용 (실제 상태 미변경)
✅ 상세 문서: data-sources-advanced.md "상태 오버라이드 파라미터" 섹션
updateDataSource
데이터 소스를 직접 업데이트합니다. API 응답을 사용하여 데이터 소스를 갱신할 때 사용합니다. refetchDataSource와 달리 추가 API 요청 없이 즉시 데이터를 업데이트합니다.
{
"handler": "apiCall",
"target": "/api/checkout",
"params": { "method": "PUT", "body": "{{_local.checkout}}" },
"onSuccess": [
{
"handler": "updateDataSource",
"params": {
"dataSourceId": "checkoutData",
"data": "{{response}}"
}
}
]
}
updateDataSource params 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
dataSourceId |
string | ✅ | - | 업데이트할 데이터 소스 ID |
data |
any | ✅ | - | 새로운 데이터 (API 응답 등) |
refetchDataSource vs updateDataSource
| 핸들러 | 동작 | 네트워크 요청 | 사용 시점 |
|---|---|---|---|
refetchDataSource |
데이터 소스의 endpoint를 다시 호출 | ✅ 발생 | 서버에서 최신 데이터를 가져와야 할 때 |
updateDataSource |
전달받은 data로 직접 교체 | ❌ 없음 | PUT/POST 응답으로 즉시 갱신할 때 |
사용 예시
PUT API 응답으로 데이터 소스 갱신 (추가 GET 요청 없이):
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/checkout",
"params": { "method": "PUT", "body": "{{_local.checkout}}" },
"onSuccess": [
{
"handler": "updateDataSource",
"params": {
"dataSourceId": "checkoutData",
"data": "{{response}}"
}
},
{
"handler": "toast",
"params": { "type": "success", "message": "$t:common.saved" }
}
]
}
주의: onSuccess 콜백에서 response 변수를 사용할 때 $response가 아닌 response 사용
"data": "{{$response}}" → undefined로 평가됨
"data": "{{response}}" → 정상 동작
appendDataSource
기존 데이터 소스에 새 데이터를 병합합니다. 무한스크롤 구현에 유용합니다.
{
"handler": "appendDataSource",
"params": {
"dataSourceId": "templates",
"dataPath": "data",
"newData": "{{response.data}}"
}
}
appendDataSource params 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
dataSourceId |
string | ✅ | - | 대상 데이터 소스 ID |
dataPath |
string | ❌ | null | 병합할 데이터 경로 (예: "data") |
newData |
array | ✅ | - | 병합할 새 데이터 배열 |
무한스크롤 예시
{
"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": "apiCall",
"auth_required": true,
"target": "/api/items",
"params": {
"method": "GET",
"query": {
"page": "{{_global.infiniteScroll.currentPage + 1}}",
"per_page": 20
}
},
"onSuccess": [
{
"handler": "appendDataSource",
"params": {
"dataSourceId": "items",
"dataPath": "data",
"newData": "{{response.data}}"
}
},
{
"handler": "setState",
"params": {
"target": "global",
"infiniteScroll.currentPage": "{{_global.infiniteScroll.currentPage + 1}}",
"infiniteScroll.hasMore": "{{(response.data?.length ?? 0) >= 20}}",
"infiniteScroll.isLoadingMore": false
}
}
]
}
]
}
}
}
]
}
scroll 이벤트 속성
scroll 이벤트에서 $event.target으로 접근 가능한 속성:
| 속성 | 타입 | 설명 |
|---|---|---|
scrollHeight |
number | 전체 콘텐츠 높이 |
scrollTop |
number | 현재 스크롤 위치 (상단 기준) |
clientHeight |
number | 보이는 영역 높이 |
scrollWidth |
number | 전체 콘텐츠 너비 |
scrollLeft |
number | 현재 스크롤 위치 (좌측 기준) |
clientWidth |
number | 보이는 영역 너비 |
remount
컴포넌트를 강제로 리마운트합니다.
{
"handler": "remount",
"params": {
"componentId": "template_card_grid"
}
}
사용 사례
- Toggle/Checkbox 상태 복원
- 폼 초기화
- 컴포넌트 상태 리셋
sortable (드래그앤드롭 정렬)
버전: engine-v1.14.0+ 기반: @dnd-kit/core + @dnd-kit/sortable (React 네이티브 D&D 라이브러리)
레이아웃 JSON의 sortable 속성을 사용하여 드래그앤드롭 정렬을 구현합니다.
HTML5 네이티브 D&D가 아닌 @dnd-kit 기반으로 동작하며, DynamicRenderer가 자동으로 SortableContainer/SortableItemWrapper를 렌더링합니다.
sortable 속성 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
source |
string | ✅ | - | 배열 바인딩 표현식 (예: "{{_local.form.options}}") |
itemKey |
string | ❌ | "id" |
아이템 고유 키 필드명 |
strategy |
string | ❌ | "verticalList" |
정렬 전략: verticalList / horizontalList / rectSorting |
handle |
string | ❌ | - | 드래그 핸들 CSS 선택자 (예: "[data-drag-handle]") |
itemVar |
string | ❌ | "$item" |
아이템 컨텍스트 변수명 |
indexVar |
string | ❌ | "$index" |
인덱스 컨텍스트 변수명 |
기본 사용 예시
{
"type": "basic",
"name": "Div",
"sortable": {
"source": "{{_local.form.additional_options}}",
"itemKey": "id",
"strategy": "verticalList",
"handle": "[data-drag-handle]"
},
"itemTemplate": {
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 p-2 border rounded"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"data-drag-handle": true,
"className": "cursor-grab"
},
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "fa-grip-vertical" } }]
},
{
"type": "basic",
"name": "Span",
"props": { "textContent": "{{$item.name}}" }
}
]
},
"actions": [
{
"event": "onSortEnd",
"handler": "setState",
"params": {
"target": "local",
"form.additional_options": "{{$sortedItems}}"
}
}
]
}
sortable 전용 이벤트
| 이벤트 | 설명 | 컨텍스트 변수 |
|---|---|---|
onSortEnd |
정렬 완료 시 | $sortedItems (정렬된 배열), $oldIndex, $newIndex |
onSortStart |
드래그 시작 시 | $activeId (드래그 중인 아이템 ID) |
드래그 핸들
handle 속성을 지정하면 해당 CSS 선택자를 가진 요소만 드래그 가능합니다.
data-drag-handle 속성이 있는 요소에 DynamicRenderer가 자동으로 드래그 리스너를 바인딩합니다.
{
"sortable": {
"source": "{{_local.form.items}}",
"handle": "[data-drag-handle]"
}
}
핸들을 지정하지 않으면 아이템 전체가 드래그 가능합니다.
주의 사항
sortable은 iteration보다 우선 처리됨 (같은 컴포넌트에 둘 다 있으면 sortable만 동작)
sortable 아이템 내부에서는 폼 자동 바인딩(auto-binding)이 비활성화됨
→ 인덱스 기반 경로가 정렬 후 stale 값을 참조하는 문제 방지
→ 핸들러(setState 등)에서 직접 상태를 관리해야 함
onSortEnd에서 setState로 소스 배열을 업데이트해야 정렬 결과가 반영됨
아이템에 고유한 id 필드가 필수 (itemKey로 지정)
HTML5 네이티브 D&D(dragstart/dragover/drop 이벤트)는 sortable에 사용하지 않음
→ @dnd-kit이 PointerSensor/KeyboardSensor로 자체 처리
onSuccess/onError 후속 액션
API 호출 등의 액션 후에 후속 액션을 실행할 수 있습니다.
배열 지원
onSuccess와 onError는 단일 액션 또는 배열을 지원합니다.
{
"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" } }
]
}
onError에서 접근 가능한 데이터
| 바인딩 | 설명 |
|---|---|
{{error.message}} |
에러 메시지 (번역됨) |
{{error.status}} |
HTTP 상태 코드 |
{{error.data}} |
API 응답 전체 (success, message, errors 포함) |
{{error.data.errors}} |
errors 객체 (추가 에러 정보) |
API 데이터 바인딩 규칙
API 응답 데이터 구조를 절대 추측하지 않음
1. 새로운 API 연동 시 → 실제 API 응답 구조를 컨트롤러 코드에서 확인
2. onSuccess/onError 콜백 → 정확한 컨텍스트 변수 경로 확인 필수
3. 불확실한 경우 → 기존 레이아웃의 유사 패턴 참조
그누보드7 API 응답 표준 구조
ResponseHelper를 사용하는 모든 API는 다음 구조를 따릅니다:
성공 응답 (success):
{
"success": true,
"message": "번역된 메시지",
"data": { ... }
}
에러 응답 (error):
{
"success": false,
"message": "번역된 에러 메시지",
"errors": { ... }
}
콜백별 컨텍스트 변수 경로
주의: onSuccess/onError 콜백에서 `$response` 사용 금지 — `response` 사용
올바른 변수명: `response` ($ 접두사 없음)
`$response`는 ActionDispatcher 컨텍스트에 존재하지 않는 변수 → undefined 반환
→ preprocessOptionalChaining이 `$response?.data?.xxx`로 변환 → undefined (에러 없이 조용히 실패)
→ fallback 값이 있으면 항상 fallback만 표시되어 버그가 은폐됨
| 콜백 | 변수 | 실제 경로 | 설명 |
|---|---|---|---|
| onSuccess | response |
- | 전체 API 응답 (권장) |
| onSuccess | response.data |
- | 성공 시 data 필드 |
| onSuccess | result |
- | response와 동일 (하위 호환성) |
| onError | error.message |
- | 번역된 에러 메시지 |
| onError | error.status |
- | HTTP 상태 코드 |
| onError | error.data |
- | 전체 API 응답 |
| onError | error.data.errors |
- | errors 객체 |
| onError | error.data.errors.필드명 |
- | 특정 에러 필드 |
API 데이터 바인딩 체크리스트
□ API 응답 구조를 실제로 확인했는가? (추측 금지)
□ ResponseHelper의 success/error 반환 구조를 이해했는가?
□ 중첩된 객체 경로가 정확한가? (예: error.data.errors.field)
□ 배열 vs 객체 구분이 명확한가?
□ Optional Chaining(?.)을 적절히 사용했는가?
자주 하는 실수
// ❌ 잘못된 예: 응답 구조 추측
"deactivateWarningData": "{{error.response}}"
"deactivateWarningData": "{{error.dependent_templates}}"
// ✅ 올바른 예: 실제 구조 확인 후 사용
"deactivateWarningData": "{{error.data}}"
// 모달에서: {{_global.deactivateWarningData.errors.dependent_templates}}
에러 핸들링 시스템 (errorHandling)
버전: engine-v1.6.0+
HTTP 에러 코드별로 다른 처리 로직을 정의할 수 있습니다.
errorHandling vs onError 역할 분리
| 속성 | 역할 | 실행 조건 |
|---|---|---|
errorHandling |
특정 에러 코드별 처리 | 해당 코드 또는 default가 정의된 경우 |
onError |
범용 에러 처리 (폴백) | errorHandling에 해당 코드가 없을 때 |
처리 흐름
API 에러 발생 (예: 403)
↓
errorHandling[403] 있음?
├── ✅ → errorHandling[403] 실행 (종료)
↓ ❌
errorHandling[default] 있음?
├── ✅ → errorHandling[default] 실행 (종료)
↓ ❌
onError 있음?
├── ✅ → onError 실행 (종료)
↓ ❌
상위 레벨 errorHandling 확인
기본 사용법
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/users/{{id}}",
"params": { "method": "DELETE" },
"errorHandling": {
"403": {
"handler": "openModal",
"target": "permission_denied_modal"
},
"404": {
"handler": "toast",
"params": { "type": "warning", "message": "$t:errors.user_not_found" }
}
},
"onError": {
"handler": "toast",
"params": { "type": "error", "message": "{{error.message}}" }
}
}