Files
Gnuboard7/docs/frontend/actions-handlers-state.md
2026-07-01 10:30:32 +09:00

998 lines
28 KiB
Markdown

# 액션 핸들러 - 상태 관리
> **메인 문서**: [actions-handlers.md](actions-handlers.md)
---
## 목차
1. [apiCall](#apicall)
2. [setState](#setstate)
3. [setError](#seterror)
4. [refetchDataSource](#refetchdatasource)
5. [updateDataSource](#updatedatasource)
6. [appendDataSource](#appenddatasource)
7. [remount](#remount)
8. [sortable (드래그앤드롭 정렬)](#sortable-드래그앤드롭-정렬)
9. [onSuccess/onError 후속 액션](#onsuccessonerror-후속-액션)
10. [API 데이터 바인딩 규칙](#api-데이터-바인딩-규칙)
11. [에러 핸들링 시스템](#에러-핸들링-시스템-errorhandling)
---
## apiCall
API를 호출합니다. **주의: `api`가 아닌 `apiCall`을 사용해야 합니다.**
```json
{
"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+) |
```text
중요: auth_required, auth_mode, identity_target은 params 안이 아닌 액션 정의 최상위에 선언해야 합니다.
```
#### identity_target 사용 예시
본인인증 정책이 켜진 상태에서 비로그인(게스트) 사용자가 호출하는 API는, 인증 코드를 보낼 대상(이메일·전화)을 화면 입력값에서 선언해야 합니다. 서버는 428을 던지는 시점에 사용자가 방금 화면에 입력한 값을 알 수 없으므로, 대상 수집은 레이아웃의 책임입니다.
```json
{
"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:
```json
{
"handler": "apiCall",
"target": "/api/cart",
"auth_mode": "optional",
"params": { "method": "GET" }
}
```
| auth_mode | 토큰 있음 | 토큰 없음 |
|-----------|----------|----------|
| `"required"` (기본) | Bearer 토큰 전송 | 에러 발생 |
| `"optional"` | Bearer 토큰 전송 | 토큰 없이 요청 진행 |
```json
// ✅ 올바른 사용
{
"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 객체는 원본 그대로 전송됩니다.
```json
{
"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` | 제외 (전송하지 않음) |
#### 주의사항
```text
주의: 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`인 키를 자동으로 제거합니다.
```json
{
"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` | 키 자체가 제거됨 |
**핵심 규칙**:
```text
주의: 불필요한 필드를 빈 문자열('')로 전송하지 말 것
필수: undefined 패턴으로 해당 드라이버 필드만 전송
공통 필드(from_address, from_name 등)는 조건 없이 항상 전송
주의: 모든 드라이버 필드를 항상 전송하면 서버 측 불필요한 처리 유발
```
**삼항 표현식 패턴**:
```text
현재 드라이버 필드: 조건 ? (값 ?? 기본값) : 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에 헤더를 자동으로 포함합니다.
```json
// 레이아웃 최상위
{
"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`
```json
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/cart",
"params": {
"method": "GET",
"headers": { "X-Cart-Key": "custom-key" } // globalHeaders보다 우선
}
}
```
> **상세 문서**: [layout-json.md](layout-json.md#전역-헤더-globalheaders)
---
## setState
상태를 변경합니다.
### 병합 모드 (merge 옵션)
> **버전**: engine-v1.18.0+ (`replace` 모드 추가)
setState 핸들러의 `params`에 `merge` 속성을 지정하여 상태 병합 방식을 선택할 수 있습니다.
| `merge` 값 | 동작 | 사용 시점 |
|-------------|------|----------|
| 생략 또는 `"deep"` | 재귀적 깊은 병합 (중첩 객체 보존) | 개별 필드 업데이트 (기본값) |
| `"shallow"` | 최상위 키만 덮어쓰기 (1단계) | 프리셋 적용, 필터 초기화 |
| `"replace"` | 기존 상태 완전 무시, 새 값으로 교체 | 폼 초기화, 서버 데이터로 전체 교체 |
```json
// 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](state-management-forms.md#병합-모드-선택)
### 전역 상태 변경 (target: "global")
```json
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"selectedIds": "{{$args[0]}}",
"searchQuery": "{{$event.target.value}}"
}
}
```
### 로컬 상태 변경 (target: "local" 또는 생략)
```json
{
"type": "change",
"handler": "setState",
"params": {
"isExpanded": true
}
}
```
### 격리된 상태 변경 (target: "isolated")
> **버전**: engine-v1.14.0+
`isolatedState` 속성이 정의된 컴포넌트 내에서만 동작합니다. 해당 영역의 상태 변경 시 전체 레이아웃이 아닌 격리된 영역만 리렌더링됩니다.
```json
{
"type": "click",
"handler": "setState",
"params": {
"target": "isolated",
"selectedId": "{{item.id}}",
"currentStep": 2
}
}
```
#### 사용 요건
```text
주의: target: "isolated"는 isolatedState 속성이 정의된 컴포넌트 내에서만 동작합니다.
격리 스코프 외부에서 호출 시 → _local로 폴백되며 경고 로그 출력
isolatedState가 있는 컴포넌트 내에서 호출 시 → 격리된 상태만 업데이트
```
#### 레이아웃 정의 예시
```json
{
"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` | 격리된 영역만 | 빈번한 인터랙션 (카테고리 선택, 드래그) |
### 주요 사용 패턴
```json
// 체크박스 선택 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` 상태 키에 저장됩니다.
```json
{
"handler": "setError",
"target": "{{error.response.message}}"
}
```
### target 값
| 형식 | 설명 |
|------|------|
| `{{error.message}}` | 데이터 바인딩 (에러 객체에서 추출) |
| `$t:errors.login_failed` | 다국어 키 |
| `"로그인에 실패했습니다."` | 직접 문자열 |
---
## refetchDataSource
특정 데이터 소스를 다시 fetch합니다.
```json
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "modules"
}
}
```
### refetchDataSource params 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `dataSourceId` | string | ✅ | - | 다시 fetch할 데이터 소스 ID |
| `sync` | boolean | ❌ | false | true면 즉시 동기 렌더링 |
### sync 옵션
> **버전**: engine-v1.4.0+
`sync: true`를 사용하면 React의 `startTransition` 없이 즉시 동기적으로 렌더링합니다.
```json
{
"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` 값 임시 오버라이드 |
```json
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "products",
"sync": true,
"globalStateOverride": {
"currentPage": 1
},
"localStateOverride": {
"filter": { "status": "active" }
}
}
}
```
```text
오버라이드는 해당 refetch에만 적용 (실제 상태 미변경)
✅ 상세 문서: data-sources-advanced.md "상태 오버라이드 파라미터" 섹션
```
---
## updateDataSource
데이터 소스를 직접 업데이트합니다. API 응답을 사용하여 데이터 소스를 갱신할 때 사용합니다. `refetchDataSource`와 달리 추가 API 요청 없이 즉시 데이터를 업데이트합니다.
```json
{
"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 요청 없이):
```json
{
"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" }
}
]
}
```
```text
주의: onSuccess 콜백에서 response 변수를 사용할 때 $response가 아닌 response 사용
"data": "{{$response}}" → undefined로 평가됨
"data": "{{response}}" → 정상 동작
```
---
## appendDataSource
기존 데이터 소스에 새 데이터를 병합합니다. 무한스크롤 구현에 유용합니다.
```json
{
"handler": "appendDataSource",
"params": {
"dataSourceId": "templates",
"dataPath": "data",
"newData": "{{response.data}}"
}
}
```
### appendDataSource params 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `dataSourceId` | string | ✅ | - | 대상 데이터 소스 ID |
| `dataPath` | string | ❌ | null | 병합할 데이터 경로 (예: "data") |
| `newData` | array | ✅ | - | 병합할 새 데이터 배열 |
### 무한스크롤 예시
```json
{
"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
컴포넌트를 강제로 리마운트합니다.
```json
{
"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"` | 인덱스 컨텍스트 변수명 |
### 기본 사용 예시
```json
{
"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가 자동으로 드래그 리스너를 바인딩합니다.
```json
{
"sortable": {
"source": "{{_local.form.items}}",
"handle": "[data-drag-handle]"
}
}
```
핸들을 지정하지 않으면 아이템 전체가 드래그 가능합니다.
### 주의 사항
```text
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`는 **단일 액션 또는 배열**을 지원합니다.
```json
{
"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 데이터 바인딩 규칙
```text
API 응답 데이터 구조를 절대 추측하지 않음
1. 새로운 API 연동 시 → 실제 API 응답 구조를 컨트롤러 코드에서 확인
2. onSuccess/onError 콜백 → 정확한 컨텍스트 변수 경로 확인 필수
3. 불확실한 경우 → 기존 레이아웃의 유사 패턴 참조
```
### 그누보드7 API 응답 표준 구조
ResponseHelper를 사용하는 모든 API는 다음 구조를 따릅니다:
**성공 응답 (success)**:
```json
{
"success": true,
"message": "번역된 메시지",
"data": { ... }
}
```
**에러 응답 (error)**:
```json
{
"success": false,
"message": "번역된 에러 메시지",
"errors": { ... }
}
```
### 콜백별 컨텍스트 변수 경로
```text
주의: 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(?.)을 적절히 사용했는가?
```
### 자주 하는 실수
```json
// ❌ 잘못된 예: 응답 구조 추측
"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에 해당 코드가 없을 때 |
### 처리 흐름
```text
API 에러 발생 (예: 403)
↓
errorHandling[403] 있음?
├── ✅ → errorHandling[403] 실행 (종료)
↓ ❌
errorHandling[default] 있음?
├── ✅ → errorHandling[default] 실행 (종료)
↓ ❌
onError 있음?
├── ✅ → onError 실행 (종료)
↓ ❌
상위 레벨 errorHandling 확인
```
### 기본 사용법
```json
{
"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}}" }
}
}
```
---
## 관련 문서
- [액션 핸들러 인덱스](actions-handlers.md)
- [네비게이션 핸들러](actions-handlers-navigation.md)
- [UI 핸들러](actions-handlers-ui.md)
- [상태 관리](state-management.md)
- [데이터 소스](data-sources.md)