브라우저·SEO 실측(T1~T7 매트릭스 + 봇 렌더 스윕)에서 발견된 결함을 보완한다. - 코어: 회원 일괄 상태변경 응답 메시지의 :count 플레이스홀더 미치환 수정 - 게시판·페이지: 비밀글·삭제글·미발행 첨부 썸네일이 KVE-2026-1914 게이트로 깨진 것을 한시 서명 preview URL(게이트 통과 직렬화 발급 + 유효 서명 허용)로 복구 — 무서명·변조·만료 요청 게이트는 종전과 동일 - 페이지: 발행/미발행 전환(after_publish)에도 SEO 캐시·사이트맵 갱신 구독 - 게시판: 게시판 디렉토리(전체 목록 + 내장 최근글)에 열람 권한 필터 적용 - 검색 화면: searchResults 데이터소스 auth_mode=optional 선언으로 로그인 회원 검색 범위 복구 + data-sources.md 선언 의무 규정화(coverage 등록)
24 KiB
데이터 소스 (Data Sources)
중요도: 매우 중요 관련 문서: data-binding.md | layout-json.md | index.md
TL;DR (5초 요약)
1. data_sources 배열에 API 정의: id, endpoint, method
2. 로딩 전략: eager(즉시), lazy(지연), on_demand(명시적)
3. 컴포넌트 바인딩: data="users" 또는 {{users.data}}
4. params 필드: GET → 쿼리스트링, POST/PUT/PATCH → request body
5. ID 고유성: 부모-자식 레이아웃 간 data_sources ID 충돌 금지
6. globalHeaders: 레이아웃에 정의 시 패턴 매칭되는 API에 헤더 자동 적용 (engine-v1.16.0+)
7. contentType: "multipart/form-data" → params를 FormData로 자동 변환 (파일 업로드, engine-v1.19.0+)
분리된 문서
이 문서는 가독성을 위해 다음과 같이 분리되었습니다:
| 문서 | 내용 |
|---|---|
| data-sources.md (현재) | 개요, 기본 구조, 로딩 전략, 컴포넌트 바인딩, 엔드포인트 규칙, 파라미터 치환 |
| data-sources-advanced.md | 정적 데이터, 조건부 로딩, 에러 처리, 상태 초기화 (initLocal + _merge 3모드), 성능 최적화, 주의사항 |
목차
개요
정의: 레이아웃 JSON에서 API 데이터를 자동으로 fetch하고 컴포넌트에 바인딩하는 시스템
목적:
- 레이아웃 로딩 시 필요한 데이터를 자동으로 fetch
- 병렬 처리를 통한 성능 최적화
- Progressive loading으로 UX 향상
- 컴포넌트와 API 데이터 자동 연결
기본 구조
{
"data_sources": [
{
"id": "users",
"type": "api",
"endpoint": "/api/admin/users",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"params": {
"page": "{{query.page}}",
"keyword": "{{route.keyword}}"
}
}
]
}
필드 상세 설명
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
id |
string | ✅ | - | 데이터 소스 고유 ID (컴포넌트에서 {{id.data}} 형태로 참조) |
label_key |
string | ❌ | - | 친화 명칭 $t: 다국어 키 (예 $t:editor.data_source.products). 레이아웃 편집기 데이터 연결 검색 피커가 현재 로케일 명칭으로 표시. 미지정 시 id 폴백. 번들 템플릿은 전 data_source 보유 의무(정적 검사) |
type |
string | ✅ | - | 데이터 소스 타입 (api, static, route_params, query_params, websocket) |
endpoint |
string | ✅* | - | API 엔드포인트 (type이 api일 때 필수) |
method |
string | ❌ | GET |
HTTP 메서드 (GET, POST, PUT, PATCH, DELETE) |
auto_fetch |
boolean | ❌ | true |
자동 fetch 여부 (false 시 수동 호출 필요) |
auth_mode |
string | ❌ | none |
인증 모드: required (토큰 필수), optional (토큰 있으면 전송), none (토큰 안 보냄) |
auth_required |
boolean | ❌ | false |
deprecated - auth_mode 사용 권장. true는 auth_mode: "required"와 동일 |
loading_strategy |
string | ❌ | progressive |
로딩 전략 (blocking, progressive, background) |
params |
object | ❌ | {} |
요청 파라미터 (GET: 쿼리스트링, POST/PUT/PATCH: request body) |
data |
any | ✅* | - | 정적 데이터 (type이 static일 때 필수) |
initLocal |
string | object | ❌ | - | API 응답을 _local[key]에 자동 복사 (폼 편집용) |
initGlobal |
string | object | ❌ | - | API 응답을 _global[key]에 자동 복사 (전역 공유용) |
initIsolated |
string | object | ❌ | - | API 응답을 _isolated[key]에 자동 복사 (격리된 영역용, engine-v1.14.0+) |
refetchOnMount |
boolean | ❌ | false |
페이지 진입 시 항상 데이터를 다시 fetch |
contentType |
string | ❌ | application/json |
요청 Content-Type. "multipart/form-data" 지정 시 params를 FormData로 변환 (engine-v1.19.0+) |
if |
string | ❌ | - | 조건부 로딩 표현식 (truthy일 때만 fetch) |
conditions |
ConditionExpression | ❌ | - | 복합 조건부 로딩 (AND/OR 그룹 지원, engine-v1.10.0+) |
상세 문서:
initLocal,initGlobal,initIsolated,if, 에러 처리는 data-sources-advanced.md 참조
params 필드 동작 방식
중요:
params필드의 동작은 HTTP 메서드에 따라 다릅니다.
HTTP 메서드별 params 처리
| HTTP 메서드 | params 전송 방식 | 예시 |
|---|---|---|
GET |
쿼리 스트링 | /api/products?page=1&keyword=test |
POST |
request body | { "selected_ids": [1, 2, 3] } |
PUT |
request body | { "name": "홍길동", "email": "test@example.com" } |
PATCH |
request body | { "status": "active" } |
DELETE |
쿼리 스트링 | /api/products/1?force=true |
사용 예시
// GET 요청 - params가 쿼리 스트링으로 전송
{
"id": "products",
"endpoint": "/api/products",
"method": "GET",
"params": {
"page": "{{query.page}}",
"keyword": "{{_local.searchKeyword}}"
}
}
// 결과: GET /api/products?page=1&keyword=test
// POST 요청 - params가 request body로 전송
{
"id": "cartItems",
"endpoint": "/api/cart/query",
"method": "POST",
"params": {
"selected_ids": "{{_local.selectedItems?.length > 0 ? _local.selectedItems : undefined}}"
}
}
// 결과: POST /api/cart/query, body: { "selected_ids": [1, 2, 3] }
주의사항
주의: data_sources에서 "body" 필드 미지원 — "params" 사용
필수: POST 요청의 body 데이터도 "params" 필드에 정의
권장: 빈 배열/null 전송 방지를 위한 조건부 표현식 사용
서버 필터와 화면 필터는 같은 출처를 읽는다
params 로 서버에 넘겨 응답을 좁힌 값(채널·상태·분류 등)을 화면이 다시 필터 조건으로 쓴다면, 두 표현식은 같은 출처 하나를 읽어야 한다. 표현식을 글자까지 똑같이 적는 것으로는 부족하다 — 출처가 둘이면 갱신 시점이 달라 어긋난다.
// ❌ 두 출처 (URL + 페이지 로컬 상태)
"params": { "template_channel": "{{_local.activeChannel ?? query.channel ?? 'mail'}}" }
"if": "{{(row.templates ?? []).find(t => t.channel === (_local.activeChannel ?? query.channel ?? 'mail'))}}"
// ✅ URL 단일 출처
"params": { "template_channel": "{{query.channel ?? 'mail'}}" }
"if": "{{(row.templates ?? []).find(t => t.channel === (query.channel ?? 'mail'))}}"
_local 은 화면 안에서만 사는 값이라 URL 이 나르지 못한다. 다른 탭에 갔다 돌아오면 URL 의 필터 키는 사라지는데(탭 전환은 목록 상태를 승계하지 않는다) _local 만 이전 값으로 남는다. 그 사이 목록은 기본값으로 다시 불려 오고 화면은 남은 값으로 걸러내므로, 응답에는 있는 데이터를 화면이 "없음" 으로 표시한다. 예외도 콘솔 경고도 4xx/5xx 도 남지 않아 로그로는 발견되지 않는다.
같은 이유로 정적 대조 테스트도 이 결함을 놓친다. 두 표현식이 문자열로 동일하기 때문이다. 검사해야 하는 것은 동일성이 아니라 출처의 개수다.
로컬 상태를 함께 두고 싶은 이유가 "클릭 즉시 반응" 이라면, navigate 가 URL 을 갱신하면서 이미 같은 렌더 사이클에 반영되므로 별도 복제가 필요 없다.
multipart/form-data 지원 (파일 업로드)
버전: engine-v1.19.0+
contentType: "multipart/form-data"를 지정하면 params가 자동으로 FormData로 변환됩니다.
인증 경로(ApiClient)와 비인증 경로(raw fetch) 모두 지원됩니다.
{
"id": "upload_module",
"type": "api",
"endpoint": "/api/admin/modules/manual-install",
"method": "POST",
"auto_fetch": false,
"auth_mode": "required",
"contentType": "multipart/form-data",
"params": {
"file": "{{_global.moduleUploadFile}}",
"source": "file_upload"
}
}
표현식 기반 contentType
contentType은 {{}} 표현식을 지원합니다:
{
"contentType": "{{_global.uploadFile ? 'multipart/form-data' : 'application/json'}}"
}
FormData 변환 규칙
| params 값 타입 | 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 |
제외 (전송하지 않음) |
multipart 주의사항
주의: multipart 사용 시 params의 deep clone(JSON.parse/stringify)이 자동으로 비활성화됨
→ File/Blob 객체가 JSON 직렬화로 파괴되는 것을 방지
주의: Content-Type 헤더를 수동 설정하지 말 것
→ 브라우저가 boundary를 포함한 Content-Type을 자동 생성
필수: 파일 업로드 API는 백엔드에서 multipart/form-data를 기대해야 함
빈 값 전송 방지 패턴
// ❌ 금지: 빈 배열도 전송됨
"params": {
"selected_ids": "{{_local.selectedItems}}"
}
// ✅ 권장: 빈 배열일 때 undefined로 제외
"params": {
"selected_ids": "{{_local.selectedItems?.length > 0 ? _local.selectedItems : undefined}}"
}
ID 고유성 규칙
중요:
extends로 상속받는 레이아웃에서는 부모 레이아웃의data_sourcesID와 동일한 ID를 사용하면 검증 오류가 발생합니다.
규칙
필수: data_sources ID는 부모/자식 레이아웃 간 고유해야 함
자식 레이아웃에서는 고유한 ID 사용
✅ 허용: 부모의 data_sources를 자식에서 직접 참조 ({{부모ID.data}})
오류 예시
부모 레이아웃 (_user_base.json):
{
"layout_name": "_user_base",
"data_sources": [
{ "id": "boards", "endpoint": "/api/boards" },
{ "id": "notifications", "endpoint": "/api/notifications" }
]
}
자식 레이아웃 (board/boards.json) - ❌ 오류 발생:
{
"extends": "_user_base",
"data_sources": [
{ "id": "boards", "endpoint": "/api/boards?limit=100" } // ❌ 부모와 ID 충돌
]
}
해결 방법
방법 1: 자식에서 고유한 ID 사용
{
"extends": "_user_base",
"data_sources": [
{ "id": "boardList", "endpoint": "/api/boards?limit=100" } // ✅ 고유한 ID
]
}
방법 2: 부모의 data_sources 직접 사용 (새로 정의하지 않음)
{
"extends": "_user_base",
"data_sources": [], // 부모의 boards를 그대로 사용
"components": [
{
"props": { "data": "{{boards.data}}" } // ✅ 부모의 ID 참조
}
]
}
네이밍 컨벤션 권장
| 부모 ID | 자식 ID (권장) |
|---|---|
boards |
boardList, boardsFiltered |
notifications |
userNotifications, notificationList |
cart |
cartItems, cartData |
인증 모드 (auth_mode)
engine-v1.15.0+: 비회원/회원 공용 API를 위한
auth_mode: "optional"지원
모드 비교표
| 모드 | 토큰 전송 | 토큰 없을 때 | 사용 사례 |
|---|---|---|---|
none (기본) |
❌ 안 보냄 | 정상 요청 | 공개 API (상품 목록, 게시판 등) |
required |
✅ 필수 | 요청 스킵 → fallback 사용 | 마이페이지, 관리자 API |
optional |
✅ 있으면 전송 | 비회원으로 요청 | 장바구니, 위시리스트 (비회원/회원 공용) |
사용 예시
// 공개 API (인증 불필요)
{
"id": "products",
"endpoint": "/api/modules/sirsoft-ecommerce/products"
// auth_mode 미지정 = "none"
}
// 인증 필수 API
{
"id": "myOrders",
"endpoint": "/api/modules/sirsoft-ecommerce/orders/my",
"auth_mode": "required"
}
// 비회원/회원 공용 API (장바구니)
{
"id": "cart",
"endpoint": "/api/modules/sirsoft-ecommerce/cart",
"auth_mode": "optional",
"headers": {
"X-Cart-Key": "{{_global.cartKey}}"
}
}
동작 원리
auth_mode: "required" 동작 흐름:
1. 토큰 유무 확인 (AuthManager.isAuthenticated())
2. 토큰 있음 → ApiClient 사용 (Authorization: Bearer xxx)
3. 토큰 없음 → 요청 스킵, fallback 데이터 사용 (API 호출하지 않음)
auth_mode: "optional" 동작 흐름:
1. 토큰 유무 확인 (AuthManager.isAuthenticated())
2. 토큰 있음 → ApiClient 사용 (Authorization: Bearer xxx)
3. 토큰 없음 → 일반 fetch 사용 (headers에 X-Cart-Key 등 포함 가능)
중요:
auth_mode: "required"에서 토큰이 없으면 API 요청 자체를 하지 않습니다.fallback이 정의되어 있으면 fallback 데이터가 렌더링에 사용되고, 없으면 데이터 없이 처리됩니다. 이를 통해 비로그인 상태에서 인증 필수 API 호출로 인한 401 → 로그인 리다이렉트 문제를 방지합니다.
라우트가 optional.sanctum 이면 optional 선언 의무
로그인 여부에 따라 응답 범위가 달라지는 서버 라우트(optional.sanctum — 통합검색처럼 회원 열람 범위를 해석하는 API)를 소비하는 데이터 소스는 auth_mode: "optional" 을 반드시 선언한다.
기본값이 none 이므로 미선언 시 로그인 회원의 요청에도 토큰이 실리지 않는다. 이 실패는 오류·경고 없이 "로그인 회원이 비회원 범위 응답을 받는" 형태로만 나타난다 — 서버측 권한 해석이 통째로 무력화되는데 화면·콘솔·네트워크 어디에도 실패 신호가 없다. 서버 라우트의 미들웨어와 데이터 소스의 auth_mode 는 한 계약의 양끝이므로, 어느 한쪽을 바꾸면 반대편도 함께 확인한다.
하위 호환성
auth_required: true는 auth_mode: "required"와 동일하게 동작합니다.
// 기존 방식 (deprecated)
{ "auth_required": true }
// 권장 방식
{ "auth_mode": "required" }
globalHeaders 자동 적용
버전: engine-v1.16.0+
레이아웃에 globalHeaders가 정의되어 있으면, 해당 패턴에 매칭되는 모든 data_source API 호출에 헤더가 자동으로 추가됩니다.
동작 방식
// 레이아웃 최상위
{
"globalHeaders": [
{ "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
],
"data_sources": [
{
"id": "cart",
"endpoint": "/api/modules/sirsoft-ecommerce/cart", // 패턴 매칭됨
"method": "GET"
// X-Cart-Key 헤더 자동 포함
}
]
}
헤더 우선순위
개별 headers 속성은 globalHeaders보다 우선합니다:
{
"globalHeaders": [
{ "pattern": "*", "headers": { "X-Custom": "global-value" } }
],
"data_sources": [
{
"id": "products",
"endpoint": "/api/products",
"headers": { "X-Custom": "source-value" } // "source-value"가 사용됨
}
]
}
상세 문서
- globalHeaders 스키마: layout-json.md
- 상속 시 병합 규칙: layout-json-inheritance.md
로딩 전략 (Loading Strategy)
중요: 그누보드7 템플릿 엔진은 세 가지 로딩 전략을 지원하여 다양한 UX 요구사항에 대응합니다.
전략 비교표
| 전략 | 렌더링 시점 | 데이터 fetch 시점 | 사용 사례 | 장점 | 단점 |
|---|---|---|---|---|---|
blocking |
데이터 로드 완료 후 | 렌더링 전 | SEO 중요 페이지, 핵심 데이터 | 완전한 데이터로 렌더링 | 초기 로딩 느림 |
progressive |
즉시 (스켈레톤 UI) | 렌더링 후 병렬 | 관리자 대시보드, 목록 페이지 | 빠른 첫 렌더링 | 재렌더링 발생 |
background |
즉시 (빈 데이터) | 렌더링 후 비동기 | 부가 정보, 알림 | 초기 렌더링 최소화 | 데이터 없이 표시 |
동작 흐름
1. blocking (블로킹)
레이아웃 로드 → data_sources fetch (대기) → 렌더링
↓
모든 데이터 준비 완료
2. progressive (프로그레시브, 기본값)
레이아웃 로드 → 즉시 렌더링 (스켈레톤 UI)
↓
data_sources fetch (병렬)
↓
데이터 도착 → 재렌더링 (데이터 표시)
3. background (백그라운드)
레이아웃 로드 → 즉시 렌더링 (빈 데이터)
↓
data_sources fetch (비동기, 결과 무시)
↓
(사용자는 초기 화면 확인 가능)
선택 가이드
✅ blocking 사용 케이스:
- 메타 태그 정보 (SEO)
- 결제/주문 페이지 (데이터 무결성 중요)
- 사용자 권한 정보 (보안)
✅ progressive 사용 케이스 (권장):
- 관리자 대시보드
- 목록 페이지 (사용자, 상품, 주문 등)
- 사이드바 메뉴
- 프로필 정보
✅ background 사용 케이스:
- 알림 카운트
- 부가 통계 정보
- 실시간 업데이트가 필요 없는 데이터
실제 사용 예시
{
"version": "1.0.0",
"layout_name": "_admin_base",
"data_sources": [
{
"id": "admin_menu",
"type": "api",
"endpoint": "/api/admin/menus",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive"
},
{
"id": "current_user",
"type": "api",
"endpoint": "/api/admin/auth/user",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive"
},
{
"id": "notifications",
"type": "api",
"endpoint": "/api/admin/notifications",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive"
}
],
"components": [
{
"id": "admin_sidebar",
"type": "composite",
"name": "AdminSidebar",
"props": {
"menu": "{{admin_menu.data}}"
}
},
{
"id": "user_profile",
"type": "composite",
"name": "UserProfile",
"props": {
"user": "{{current_user.data}}"
}
}
]
}
컴포넌트 데이터 바인딩
바인딩 문법
{
"props": {
"menu": "{{admin_menu.data}}",
"user": "{{current_user.data}}",
"notifications": "{{notifications.data}}"
},
"data_binding": {
"menu": "admin_menu.data",
"user": "current_user.data",
"notifications": "notifications.data"
}
}
바인딩 규칙
- 데이터 소스 ID로 참조:
{{데이터소스ID.data}} - API 응답의
data필드가 자동으로 바인딩됨 data_binding필드로 명시적 매핑 가능
엔드포인트 규칙
중요: ApiClient는 baseURL로 '/api'를 사용합니다.
주의: ApiClient는 baseURL로 '/api'를 사용
✅ 필수: endpoint에 '/api' 포함 필요
✅ 자동: DataSourceManager가 중복 제거 처리
올바른 엔드포인트 작성
// ✅ DO: '/api' 포함 (권장)
{
"endpoint": "/api/admin/menus"
}
// ✅ DO: '/api' 미포함 (자동 추가)
{
"endpoint": "/admin/menus"
}
// ❌ DON'T: 절대 경로 사용 금지
{
"endpoint": "http://example.com/api/admin/menus"
}
DataSourceManager의 endpoint 정규화
// DataSourceManager.ts
const normalizedEndpoint = endpoint.startsWith('/api/')
? endpoint.substring(4) // '/api/admin/menus' → '/admin/menus'
: endpoint.startsWith('/api')
? endpoint.substring(4) || '/' // '/api' → '/'
: endpoint; // '/admin/menus' → '/admin/menus'
// ApiClient 호출 시 baseURL '/api'와 결합
// 결과: '/api' + '/admin/menus' = '/api/admin/menus'
파라미터 치환
라우트 및 쿼리 파라미터를 데이터 소스에서 활용할 수 있습니다.
기본 문법
| 문법 | 설명 | 예시 |
|---|---|---|
{{route.xxx}} |
라우트 파라미터 | /admin/users/:id → {{route.id}} |
{{query.xxx}} |
쿼리 파라미터 (점 표기법) | ?page=1 → {{query.page}} |
{{query['xxx']}} |
쿼리 파라미터 (대괄호 표기법) | ?filters[0][field]=name → {{query['filters[0][field]']}} |
Fallback 연산자 (기본값 지정)
쿼리 파라미터가 없을 때 기본값을 지정할 수 있습니다.
{
"params": {
"sort_order": "{{query.sort_order || 'desc'}}",
"filters[0][field]": "{{query['filters[0][field]'] || 'all'}}"
}
}
| 문법 | 설명 |
|---|---|
{{query.xxx || 'default'}} |
점 표기법 + fallback |
{{query['xxx'] || 'default'}} |
대괄호 표기법 + fallback |
동작 방식:
- 쿼리 파라미터가 존재하고 빈 문자열이 아니면 → 해당 값 사용
- 쿼리 파라미터가 없거나 빈 문자열이면 → fallback 값 사용
빈 파라미터 자동 제거
DataSourceManager는 치환 후 빈 문자열인 파라미터를 자동으로 제거합니다:
// 빈 문자열인 파라미터 제거 (API로 전송하지 않음)
Object.keys(params).forEach((key) => {
if (params[key] === '') {
delete params[key];
}
});
다중 필터 파라미터 예시
{
"data_sources": [
{
"id": "product_detail",
"type": "api",
"endpoint": "/api/admin/products/{{route.id}}",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "blocking"
},
{
"id": "filtered_users",
"type": "api",
"endpoint": "/api/admin/users",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"params": {
"page": "{{query.page}}",
"keyword": "{{query.keyword}}",
"status": "{{query.status}}",
"filters[0][field]": "{{query['filters[0][field]'] || 'all'}}",
"filters[0][value]": "{{query['filters[0][value]']}}",
"filters[0][operator]": "{{query['filters[0][operator]']}}"
}
}
]
}
부분 업데이트 API
배열 내 특정 아이템만 업데이트하여 성능을 최적화합니다.
G7Core.dataSource.updateItem()
G7Core.dataSource.updateItem(
dataSourceId: string,
itemPath: string,
itemId: string | number,
updates: Record<string, any>,
options?: {
idField?: string; // 기본: "id"
merge?: boolean; // 기본: true (깊은 병합)
skipRender?: boolean; // 기본: false
}
): boolean;
사용 예시
// 상품 옵션 업데이트
G7Core.dataSource.updateItem(
'products',
'data.data[0].options',
123, // optionId
{ selling_price: 15000, _modified: true }
);
// 사용자 정보 업데이트
G7Core.dataSource.updateItem(
'users',
'data',
'user-456',
{ name: '홍길동', updated_at: new Date().toISOString() },
{ idField: 'uuid' }
);
set() vs updateItem() 비교
| 메서드 | 용도 | 렌더링 영향 |
|---|---|---|
set() |
전체 데이터소스 교체 | 전체 리렌더링 |
updateItem() |
배열 내 특정 아이템 수정 | 최소 리렌더링 |
언제 사용하나요?
✅ updateItem 권장:
- 인라인 편집 (DataGrid 셀 편집)
- 목록 아이템 상태 변경 (토글, 체크박스)
- 부분 필드 업데이트 (가격, 수량 등)
❌ set 권장:
- 전체 목록 새로고침
- 정렬/필터 적용 후 결과 교체
- API 응답 전체 저장