# 액션 핸들러 - 상태 관리

> **메인 문서**: [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)
