@vmscloud/moz-ui-grid-vue 0.2.17 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  # moz-ui-grid-core 레퍼런스 (Config · 타입 · API · 이벤트)
2
2
 
3
3
  > 패키지: `@vmscloud/moz-ui-grid-core` (export 클래스명은 호환을 위해 `PureSheet` 유지)
4
- > 버전: 0.1.48 (소스 기준: `0481bd5`)
5
- > 마지막 업데이트: 2026-10-19
4
+ > 정적 소스 기준: `c945b9f5db6a930d07bc32f46ea2ebc90f455e46` (해당 Git object의 core manifest: `0.1.59`).
5
+ > 이 기준은 로컬 Git object이며 원격 최신·npm 게시 상태를 뜻하지 않습니다. 예제의 타입 검사와 실제 브라우저/Worker·파일 저장 동작 검증은 별개입니다.
6
6
 
7
7
  > 📋 **사용 예제 코드는 별도 파일**: [examples.md](examples.md) — Sub-API별 사용, Reactive React 통합, 이벤트, 선택 모드, Excel I/O 등 약 30개 시나리오
8
8
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  `onTelemetry`는 해당 그리드와 소유 processor/Worker의 진단만 받습니다. 다른 그리드의 생성·폐기는 콜백에 영향을 주지 않으며, 이 그리드 폐기 뒤의 진단은 전달되지 않습니다. 콜백의 동기 예외·비동기 rejection은 격리됩니다. Worker 진단의 `ctx.error`는 `{ name, message }` 형태이며 host 초기화 실패는 원래 Error를 유지합니다. Worker에서 대기하던 진단은 host 수신 시점의 콜백으로 전달합니다. `psWarn`/`psError` 검증 경고와 독립적이며 전역 로그 수집 옵션이 아닙니다.
14
14
 
15
- `keyFields`, `enableReferCache`, `engine`, `useWorker`, `workerFactory`, `undoStack` 등 생성/소유권 경계의 runtime 지원을 뜻하지 않습니다. 실제 인스턴스 교체 시에는 어댑터가 `GridChrome.captureReinitStateAsync()`를 기다린 뒤 이전 인스턴스를 파기합니다. 동기 `captureReinitState()`는 기존 직접 호출자용으로 남습니다.
15
+ `keyFields`, `enableReferCache`, `engine`, `loader`, `undoStack` 등 생성/소유권 경계의 runtime 지원을 뜻하지 않습니다. 실제 인스턴스 교체 시에는 어댑터가 `GridChrome.captureReinitStateAsync()`를 기다린 뒤 이전 인스턴스를 파기합니다. 동기 `captureReinitState()`는 기존 직접 호출자용으로 남습니다.
16
16
 
17
17
  ## 날짜 시간대 계약 (`timeZone`)
18
18
 
@@ -40,6 +40,9 @@ await grid.io.fromExcel(legacyFile, { timeZone: 'UTC' });
40
40
  - [FlatModeConfig](#flatmodeconfig)
41
41
  - [PivotModeConfig](#pivotmodeconfig)
42
42
  - [FieldDef (필드 정의)](#fielddef-필드-정의)
43
+ - [점 표기 필드 경로 (`field: 'owner.team'`)](#점-표기-필드-경로-field-ownerteam)
44
+ - [객체 값 열 (`dataType: 'object'`)](#객체-값-열-datatype-object)
45
+ - [계산 필드](#계산-필드)
43
46
  - [PureSheet 최상위 API](#puresheet-최상위-api)
44
47
  - [Sub-API](#sub-api)
45
48
  - [grid.rows — 행 API](#gridrows--행-api)
@@ -66,8 +69,8 @@ await grid.io.fromExcel(legacyFile, { timeZone: 'UTC' });
66
69
  - [ColumnGroupDef](#columngroupdef)
67
70
  - [TreeConfig](#treeconfig)
68
71
  - [MasterDetailConfig](#masterdetailconfig)
69
- - [GroupConfig](#groupconfig)
70
- - [GroupingConfig](#groupingconfig)
72
+ - [RowGroupConfig](#rowgroupconfig)
73
+ - [RowGroupAggregate](#rowgroupaggregate)
71
74
  - [EditorConfig](#editorconfig)
72
75
  - [MaskConfig](#maskconfig)
73
76
  - [피벗 필드 타입](#피벗-필드-타입)
@@ -89,11 +92,14 @@ await grid.io.fromExcel(legacyFile, { timeZone: 'UTC' });
89
92
  - [컬럼 식별자 유틸 — columnKeys](#컬럼-식별자-유틸--columnkeys)
90
93
  - [수식 엔진 — FormulaEngine](#수식-엔진--formulaengine)
91
94
  - [값 목록 위젯 — ValueCheckList](#값-목록-위젯--valuechecklist)
95
+ - [객체 값·필드 경로 헬퍼](#객체-값필드-경로-헬퍼)
92
96
  - [formatRow 컨텍스트 — FormatRowInfo](#formatrow-컨텍스트--formatrowinfo)
93
97
  - [formatHeader 컨텍스트 — FormatHeaderContext](#formatheader-컨텍스트--formatheadercontext)
94
98
  - [RowHeaderConfig](#rowheaderconfig)
95
99
  - [셀 속성 — CellAttributes / CellAttributesFn](#셀-속성--cellattributes--cellattributesfn)
96
100
  - [DataLoader 클래스](#dataloader-클래스--워커-기반-커스텀-데이터-로딩)
101
+ - [서버 행 모델 (SSRM)](#서버-행-모델-ssrm--rowmodel-server--loader)
102
+ - [객체 값 열·점 표기 필드의 서버 계약](#객체-값-열점-표기-필드의-서버-계약)
97
103
  - [UndoStack 클래스](#undostack-클래스--공유-undoredo)
98
104
  - [Row 클래스 정적 팩토리](#row-클래스-정적-팩토리)
99
105
  - [에러 코드 전체 레퍼런스](#에러-코드-전체-레퍼런스)
@@ -203,14 +209,12 @@ grid.on('row:click', ({ rowIndex }) => {
203
209
  | 속성 | 타입 | 기본값 | 설명 |
204
210
  |------|------|--------|------|
205
211
  | `data` | `Row[]` | — | 데이터 배열 |
206
- | `loader` | `LoaderConfig` | — | Worker 기반 데이터 로더 설정 |
212
+ | `loader` | `LoaderConfig` | — | 데이터 로더. `{ use: DataLoader 클래스 }`(메인 스레드) 또는 `{ worker: () => Worker }`(Worker 모드) 중 하나. 지정하면 `data`는 무시 (→ [LoaderConfig](#데이터-로더--loaderconfig)) |
207
213
  | `fields` | `FieldDef[]` | 필수 | 필드(컬럼) 정의 |
208
214
  | `keyFields` | `string[]` | — | 업무 키 필드 목록. 복합 키도 가능(예: `['exchange', 'symbol']`). 생략 시 `enableReferCache: true`가 필요하며 내부 자동 행 ID를 사용한다. 빈 배열은 허용하지 않는다. 데이터의 `id` 필드를 암묵적으로 업무 키로 사용하지 않는다. |
209
215
  | `enableReferCache` | `boolean` | `false` | 행 객체 참조 캐시 활성화 — 행 객체 기반 API 및 `keyFields` 생략 시 필수. 키와 캐시 설정을 모두 생략하면 초기화 오류가 발생한다. 자동 ID는 원본 데이터 필드에 주입하지 않으며 전체 데이터 교체 시 새로 발급된다. |
210
216
  | **엔진** | | | |
211
217
  | `engine` | `'native'` | `'native'` | 데이터 처리 엔진. **`'aq'`(Arquero)는 제거됨** — 단일 엔진(native)만 남았습니다(ITSM-2026-002190) |
212
- | `useWorker` | `boolean` | `false` | Web Worker 사용 여부 |
213
- | `workerFactory` | `() => Worker` | — | `useWorker: true`에서 앱이 빌드한 신뢰 Worker entry 생성. `@vmscloud/moz-ui-grid-core/worker`의 `registerWorkerFunctions`와 `installProcessorWorker`로 함수·loader를 정적으로 등록한다. |
214
218
  | **저장본 호환** | | | |
215
219
  | `migrateConfig` | `GridConfigMigrator` | — | `applyConfig()` 가 옛 버전 저장본을 받았을 때 호출할 변환기. core 는 **호출 시점·순서만** 책임지고 변환 내용은 소비처(wrapper)가 소유 (→ [저장 스키마 버전](#저장-스키마-버전--gridconfigmigrator)) |
216
220
  | **UI** | | | |
@@ -235,11 +239,12 @@ grid.on('row:click', ({ rowIndex }) => {
235
239
  | `cellSelection` | `CellSelectionConfig` | — | 셀 선택 설정 (자리 추종, viewIndex 기반). 서브필드: `mode`. 둘 다 미지정 시 기본 `{mode:'range'}` (셀 전용 Excel 스타일) |
236
240
  | `cellSelectionImpliesRows` | `boolean` | `false` | 셀만 선택된 상태에서 행 액션(Ctrl+H 등) 작동 여부 — Policy B fallback. Ctrl+A 동작은 `rowSelection.includeInSelectAll`이 별도 제어 |
237
241
  | **서버/수동 row-model** | | | |
238
- | `rowModel` | `'client' \| 'server'` | `'client'` | `'server'`이면 `sort()`/`filter()`는 정렬·필터 상태와 이벤트만 갱신하고 supplied row order/membership을 로컬에서 변경하지 않습니다. 데이터 재조회/append는 호출자가 서버 정본으로 수행합니다. |
242
+ | `rowModel` | `'client' \| 'server'` | `'client'` | `'server'`이면 `sort()`/`filter()`는 정렬·필터 상태와 이벤트만 갱신하고 행 순서·구성을 로컬에서 바꾸지 않습니다. `loader` 가 없으면 소비처가 서버 정본으로 재조회/append 합니다. `loader` 가 있으면 SSRM — 그리드가 `DataLoader.fetchRange()` 로 필요한 구간만 받습니다(→ [서버 행 모델](#서버-행-모델-ssrm--rowmodel-server--loader)). `'client'` + `loader` 는 `fetchAll()` 로 전체를 받습니다. |
243
+ | `serverSide` | `ServerSideConfig` | — | SSRM 설정 `{ blockSize: 100, maxBlocksInCache: 50, maxConcurrentRequests: 2, debounceMs: 50, sendChangesToAggregates: false }` |
239
244
  | `endReachedThresholdRows` | `number` | `0` | `scroll:endReached` 선발화 행 임계값. 현재 가시 범위 끝에서 남은 행 수가 이 값 이하이면 끝 도달로 간주합니다. |
240
245
  | **편집** | | | |
241
246
  | `editable` | `boolean` | `false` | 전역 편집 가능 여부 |
242
- | `preventKeyEdit` | `boolean` | `true` | 키 컬럼 편집 방지 — `keyFields` 컬럼은 신규 행(added)에서만 편집 가능, 기존 행(pristine/modified)에서는 자동 readonly |
247
+ | `preventKeyEdit` | `boolean` | `true` | 키 컬럼 편집 방지 — `keyFields` 컬럼은 신규 행(added)에서만 편집 가능, 기존 행(pristine/modified)에서는 자동 readonly. 키 컬럼은 열의 `field`로 판정합니다(`{ id:'cv', field:'code' }`도 `keyFields:['code']`면 키 컬럼 — 편집·붙여넣기 차단, 머리글 키 표시) |
243
248
  | **드래그앤드롭** | | | |
244
249
  | `dragDropConfig` | `DragDropConfig` | — | 행 드래그앤드롭 설정 (같은 그리드 reorder, 크로스-그리드 드롭, `rowOrderField`, `mutateOnDirty` 등). 런타임 변경은 `grid.dragDrop.setConfig()` |
245
250
  | **툴팁** | | | |
@@ -294,8 +299,7 @@ await automaticGrid.changes.commit(); // 같은 rowId 유지
294
299
  | 속성 | 타입 | 기본값 | 설명 |
295
300
  |------|------|--------|------|
296
301
  | `mode` | `'flat'` | `'flat'` | 그리드 모드 |
297
- | `rowGroup` | `GroupConfig` | — | 초기 그룹화 설정 (`{ columns, subtotals }`). 런타임에는 `grid.rowGroup.setConfig(GroupingConfig)`로 변경하며 시그니처가 다름 (→ [GroupConfig](#groupconfig) vs [GroupingConfig](#groupingconfig)) |
298
- | `group` | `GroupConfig` | — | **deprecated 하위호환 별칭** — `rowGroup`과 동일. 둘 다 주면 `rowGroup`이 우선. 신규 코드는 `rowGroup` 사용 |
302
+ | `rowGroupConfig` | `RowGroupConfig` | — | 초기 그룹화 설정. 런타임 `grid.rowGroup.setConfig()`와 같은 타입이며 `aggregates`는 `{ column, func, id?, scope? }[]` (→ [RowGroupConfig](#rowgroupconfig)) |
299
303
  | `rowTemplate` | `RowTemplate` | — | Multi-Row 레이아웃 템플릿 |
300
304
  | `columnGroups` | `ColumnGroupDef[]` | — | 컬럼 그룹(다단계 컬럼 헤더) 정의 (→ [ColumnGroupDef](#columngroupdef)). (구 `headerGroups`) |
301
305
  | ~~`onColumnGroupToggle`~~ | — | — | **제거됨.** 컬럼 그룹 토글은 `columnGroup:toggle` 이벤트로 노출됩니다. `grid.on('columnGroup:toggle', ...)`로 구독 (→ [이벤트](#이벤트)) |
@@ -324,9 +328,13 @@ await automaticGrid.changes.commit(); // 같은 rowId 유지
324
328
  | 속성 | 타입 | 필수 | 설명 |
325
329
  |------|------|------|------|
326
330
  | `id` | `string` | O | 컬럼 **식별자** — 이 컬럼을 *가리킬* 때 쓴다. 정렬/필터 상태(`SortState.id`/`FilterState.id`), 컬럼 폭 상태, DOM dataset, 저장 레이아웃이 전부 이 값을 참조 |
327
- | `field` | `string` | — | 원본 데이터 객체의 **키** — 값을 *읽을* 때만 쓴다(`row[field]`). 생략 시 `id` 와 같은 값으로 정규화 |
331
+ | `field` | `string` | — | 원본 데이터 객체의 **키** — 값을 *읽을* 때만 쓴다(`row[field]`). 생략 시 `id` 와 같은 값으로 정규화. 점 표기(`'owner.team'`)는 중첩 경로 → [점 표기 필드 경로](#점-표기-필드-경로-field-ownerteam) |
328
332
  | `header` | `string` | O | 헤더 표시명 |
329
- | `dataType` | `DataType` | O | 데이터 타입 (`'string' \| 'number' \| 'boolean' \| 'date' \| 'array'`) |
333
+ | `dataType` | `DataType` | O | 데이터 타입 (`'string' \| 'number' \| 'boolean' \| 'date' \| 'array' \| 'object'`). `'object'` 는 셀에 객체를 그대로 두는 열 → [객체 값 열](#객체-값-열-datatype-object) |
334
+ | `objectKey` | `ObjectKeyAccessor` — 경로 문자열 \| 함수 \| `WorkerFunctionRef` | — | 객체 값 열의 "같은 값" 키 (묶기·값 목록·in 필터·병합·select 비교) |
335
+ | `displayKey` | `ObjectKeyAccessor` — 경로 문자열 \| 함수 \| `WorkerFunctionRef` | — | 객체 값 열의 보이는 값 (`mask.formatter` 가 없을 때 표시·라벨·복사·내보내기, 정렬 다음 순위, 글자 필터, 경로 문자열이면 편집) |
336
+ | `sortValue` | `SortValueAccessor` — 경로 문자열 \| 함수 \| `WorkerFunctionRef` | — | 객체 값 열의 정렬 값. 배열이면 앞 값부터 비교. 생략하면 `displayKey`, 그것도 없으면 `objectKey` |
337
+ | `equals` | `(a, b) => boolean` | — | 객체 값 열의 변경 감지 비교. 생략하면 내용 비교 |
330
338
 
331
339
  > **`id` ≠ `field` — 이중 역할 해소 (ITSM-2026-002188)**
332
340
  > 예전에는 `id` 하나가 "식별자"와 "데이터 키"를 겸했습니다("필드 ID = 데이터 객체의 키"). 이제 두 축이 분리되어 **가리킬 때는 `id`, 읽을 때는 `field`** 입니다.
@@ -339,10 +347,177 @@ await automaticGrid.changes.commit(); // 같은 rowId 유지
339
347
  > { id: 'dept_short', field: 'dept', header: '부서(약칭)', dataType: 'string' }
340
348
  > ```
341
349
  >
350
+ > 엔진 결과도 같은 규칙입니다: `grid.search` 매치 `columnId`, 수식 필터 `$dept_short`·`[dept_short]`, 그룹 집계 키 `sum_<열 id>`, 트리 집계(`treeConfig.aggregates`)·트리 체크 수 키는 모두 **열 id** 로 나오고 값은 `row.dept` 로 읽습니다 — 같은 `field` 를 쓰는 두 열도 따로 나옵니다(ITSM-2026-004445).
351
+ >
342
352
  > ⚠️ 계산 필드(`formula`)는 원본 데이터에 존재하지 않으므로 `field` 가 의미 없습니다 — `id` 만 지정하고 값 조회는 `FormulaEngine` 이 담당합니다.
343
353
  >
344
354
  > 정규화 헬퍼는 core 가 export 합니다: `normalizeColumnKeys` / `getColumnId` / `getColumnField` / `resolveFieldKey` (→ [컬럼 식별자 유틸](#컬럼-식별자-유틸--columnkeys)).
345
355
 
356
+ ### 점 표기 필드 경로 (`field: 'owner.team'`)
357
+
358
+ `field` 에 점이 있으면 **중첩 경로**로 읽습니다. 따로 켜는 옵션은 없습니다(기본 켬).
359
+
360
+ ```typescript
361
+ // 데이터: { no: 1, owner: { id: 'u2', name: '김철수', team: 'B' } }
362
+ { id: 'ownerTeam', header: '담당 팀', field: 'owner.team' } // → 'B'
363
+ { id: 'ownerName', header: '담당자명', field: 'owner.name' } // → '김철수'
364
+ ```
365
+
366
+ | 규칙 | 입력 → 결과 |
367
+ |---|---|
368
+ | 읽기 | `field: 'owner.team'` → `row.owner?.team`. 중간 값이 `null`/`undefined` 면 `undefined`(셀은 빈 칸) |
369
+ | 점이 든 키가 행에 그대로 있으면 그 키 우선 | `{ 'owner.team': 'literal', owner: { team: 'nested' } }` → `'literal'` (기존 데이터 호환) |
370
+ | 경로로 보지 않는 이름 | 점으로 시작·끝나거나 빈 조각이 있는 이름(`'.a'`, `'a.'`, `'a..b'`)은 일반 키 |
371
+ | 쓰기 (편집·붙여넣기·`changes.updateCellById`·`bulkUpdate`) | **최상위 객체를 복사한 뒤 교체**합니다. `owner.team` 을 `'Z'` 로 고치면 `row.owner` 를 얕게 복사해 `team` 만 바꾼 새 객체를 `row.owner` 에 넣습니다. 같은 `owner` 객체를 여러 행이 공유해도 다른 행은 바뀌지 않습니다 |
372
+ | 중간 객체가 없을 때 쓰기 | `owner: null` 인 행에 `owner.team = 'Q'` → `owner: { team: 'Q' }` |
373
+ | 변경 기록 | `changes.getAll()`·`data:changed`·undo 에는 **최상위 키**(`owner`)와 새 객체가 남습니다. `'owner.team'` 키는 생기지 않습니다. 같은 값으로 되돌리면 dirty 가 풀립니다 |
374
+ | 점이 든 키가 행에 그대로 있을 때 쓰기 | 그 키(`row['owner.team']`)에 씁니다. 변경 기록의 필드도 `'owner.team'` |
375
+
376
+ ```typescript
377
+ // 5행과 6행이 같은 owner 객체 { id: 'u1', name: '홍길동', team: 'A' } 를 공유
378
+ await grid.changes.updateCellById(5, 'owner.team', 'Z');
379
+ grid.changes.getAll().modified;
380
+ // → 5행: fields ['owner'], data.owner = { id: 'u1', name: '홍길동', team: 'Z' }, original.owner.team = 'A'
381
+ // 6행의 owner.team 은 'A' 그대로
382
+ await grid.changes.updateCellById(5, 'owner.team', 'A'); // 되돌리면 modified 에서 빠진다
383
+ ```
384
+
385
+ - 표시·정렬·필터·값 목록·묶기·병합·고정 행·`cellRenderer`/`cellAttributes` 의 `value`·CSV/XLSX 내보내기·선택 집계(상태바)·수식이 모두 같은 경로 규칙으로 읽습니다. 메인 스레드와 Worker 모드 모두 같습니다.
386
+ - 경로 열(`owner.team`)의 정렬·필터는 최상위 키(`owner`)가 바뀌어도 다시 계산됩니다.
387
+ - 그리드와 같은 규칙으로 직접 읽고 쓰려면 [객체 값·필드 경로 헬퍼](#객체-값필드-경로-헬퍼)의 `getFieldValue`·`setFieldValue` 를 씁니다.
388
+
389
+ ### 객체 값 열 (`dataType: 'object'`)
390
+
391
+ 셀에 객체(`{ id: 'u2', name: '김철수' }`)를 그대로 두는 열입니다. 그리드는 기능마다 객체에서 단순 값을 하나씩 뽑아 씁니다.
392
+
393
+ ```typescript
394
+ const users = [
395
+ { id: 'u1', name: '홍길동', team: 'A', rank: 1 },
396
+ { id: 'u2', name: '김철수', team: 'B', rank: 2 },
397
+ { id: 'u3', name: '김철수', team: 'C', rank: 3 }, // 동명이인
398
+ ];
399
+
400
+ {
401
+ id: 'owner', header: '담당자', dataType: 'object',
402
+ objectKey: 'id', // 같은 id = 같은 값
403
+ displayKey: 'name', // 보이는 값 (mask 가 없을 때 화면 글자, 글자 필터·편집)
404
+ sortValue: (v) => [v.rank, v.name], // 정렬: rank → name
405
+ equals: (a, b) => a?.id === b?.id, // 변경 감지 (생략하면 내용 비교)
406
+ mask: {
407
+ type: 'function',
408
+ formatter: (v) => v?.name ?? '', // 화면 글자
409
+ parser: (text) => users.find((u) => u.id === text || u.name === text), // 글자 → 객체
410
+ },
411
+ editor: { type: 'dropdown', options: users }, // 선택 사항
412
+ }
413
+ ```
414
+
415
+ **기능별로 쓰는 값**
416
+
417
+ | 기능 | 쓰는 값 | 예 (`{ id: 'u2', name: '김철수', rank: 2 }`) |
418
+ |---|---|---|
419
+ | 셀 표시·값 목록 라벨·글자 검색·복사·내보내기·그룹 배너 라벨 | `mask.formatter` (함수형 mask) → 없으면 `displayKey` 값의 문자열(`null`/`undefined` → `''`) → 둘 다 없으면 JSON | `'김철수'` |
420
+ | 묶기·groupId·`in`/`notIn` 필터·값 목록 항목 키·셀 병합·select/dropdown/multiselect 선택 비교 | `objectKey` | `'u2'` → groupId `owner:u2` |
421
+ | 정렬 (열 정렬·그룹 순서)·크기 비교 필터 | `sortValue` → `displayKey` → `objectKey` | `[2, '김철수']` (배열은 앞 값부터) |
422
+ | 변경 감지 (dirty·같은 값 확정은 변경 아님) | `equals` → 없으면 내용 비교 | 같은 내용의 새 객체 = 변경 아님 |
423
+ | 텍스트 편집 확정·붙여넣기 | 아래 **편집 우선순위** | `'u2'` → 객체 |
424
+
425
+ - 객체가 아닌 값(문자열·숫자·`null`·Date·배열)은 위 규칙을 타지 않고 기존 동작 그대로입니다.
426
+ - `objectKey`·`sortValue` 문자열은 객체 **안의** 경로입니다(`'id'`, `'dept.code'`). 함수도 됩니다(Worker 모드 제약은 아래).
427
+ - 같은 id 의 **서로 다른 객체 참조**(서버 JSON 을 행마다 따로 파싱한 경우)도 한 그룹·한 값 목록 항목입니다. 동명이인(이름이 같고 id 가 다른 객체)은 따로입니다 — 값 목록 라벨은 둘 다 `김철수` 로 보이지만 항목은 둘입니다.
428
+ - `in` 필터 값은 키 배열(`['u2']`)이든 객체 배열(`[{ id: 'u3', … }]`)이든 됩니다 — 그리드가 `objectKey` 로 바꿔 비교합니다. `[null]` 은 값이 `null` 인 행입니다.
429
+ - `eq`/`neq` 행 필터는 셀이나 필터 값이 객체일 때 아래 **그룹 결과 필터**와 같은 규칙입니다: 필터 값이 객체면 `objectKey` 비교(같은 id 의 다른 참조도 같고, 동명이인은 다름), 필터 값이 문자열이고 필터에 mask(함수형 `formatter`)가 있으면 기존처럼 **표시값** 비교(`'김철수'` → 동명이인 둘 다), mask 가 없고 열에 `displayKey` 가 있으면 그 **보이는 글자**와 대소문자 무시 비교, 둘 다 없이 문자열·숫자면 `objectKey` 와 비교(`'u2'`). 셀도 필터 값도 객체가 아니면(`eq null` 등) 기존 그대로입니다. 서버로 보내는 `filterModel` 의 객체 필터 값은 키로 바뀌어 갑니다(`in` 과 같음).
430
+ - 글자 조건(`contains`·`notContains`·`startsWith`·`endsWith`·`like`·`notLike`)은 셀이 객체면 보이는 글자(mask 가 실린 필터면 기존 표시값, 아니면 `displayKey` 글자, 그것도 없으면 JSON)로 비교합니다.
431
+ - 크기 비교(`gt`·`gte`·`lt`·`lte`·`between`)는 셀이 객체면 정렬 기준 값(`sortValue` → `displayKey` → `objectKey`)과 비교합니다. `sortValue` 가 배열이면 첫 값과 비교합니다.
432
+ - 위 필터 규칙은 그리드 엔진(메인·Worker)·그룹 결과 필터·`createRowFilterPredicate`(SSRM 받아 둔 행 판정 포함)가 같은 함수 하나로 씁니다.
433
+ - `grid.getFilterValues('owner')` 는 키로 중복을 없애고 `values` 에 라벨, `rawValues` 에 원본 객체를 줍니다. 필터는 `rawValues` 로 만드세요.
434
+ - mask 도 `displayKey` 도 없으면 셀은 JSON 문자열로 보입니다(`{"id":"u1","name":"홍길동"}`). 사람이 읽는 글자를 보이려면 `displayKey: 'name'`(경로 하나면 충분) 또는 `mask: { type: 'function', formatter }` 를 주세요. 둘 다 있으면 `mask.formatter` 가 보입니다.
435
+ - `displayKey` 는 `objectKey` 와 같은 형태(경로 문자열·함수·등록 함수 참조)이고 Worker 규칙도 같습니다. `objectKey` 가 없을 때 그룹 키로 `displayKey` 를 대신 쓰지 않습니다(`PS314` 경고 + JSON 키 그대로).
436
+ - `objectKey` 가 `null`/`undefined` 를 내는 객체는 `null` 값과 같은 그룹이 됩니다.
437
+ - `equals` 는 변경 감지용입니다. 점 표기 경로(`owner.team`)로 쓴 변경은 `equals` 대신 **내용 비교**로 판정합니다 — `equals: id 비교` 여도 `team` 변경이 묻히지 않습니다. Date·배열 값의 변경 감지는 기존 규칙(참조 비교) 그대로입니다.
438
+
439
+ **편집 우선순위** (위에서부터 먼저 맞는 것)
440
+
441
+ | 순위 | 열 | 편집 |
442
+ |---|---|---|
443
+ | 1 | `editor` 지정(`select`·`dropdown`·`multiselect`·`custom` 등, 또는 `editorResolver` 결과) | 그 에디터 |
444
+ | 2 | `mask.parser` 있음 | 글자 → `parser` (아래 **편집 규칙**) |
445
+ | 3 | `displayKey` 가 **경로 문자열** | 입력 글자를 그 경로 값으로 기록: `owner = { ...owner, name: 글자 }` (최상위 복사 후 교체 — 점 표기 쓰기와 같음, `changes` 는 최상위 키 `owner`, `objectKey` 그대로). `owner` 가 `null` 이면 `{ name: 글자 }` 로 새로 만듦. 빈 글자 확정 → 그 경로 값 `''`(`null` 로 지우지 않음). 시작 글자는 그 경로 값, type-to-edit 은 일반 문자열 열처럼 누른 글자로 시작. 이름 변경은 열 `equals`(id 비교 등)가 아닌 내용 비교로 판정 |
446
+ | — | `displayKey` 가 함수·등록 함수 (parser·editor 없음) | **편집 불가**(쓸 경로가 없음) |
447
+ | 4 | `displayKey`·`parser`·`editor` 모두 없음 | **JSON 편집** (한 줄 입력칸) — 아래 |
448
+
449
+ **JSON 편집** (4순위)
450
+
451
+ | 상황 | 결과 |
452
+ |---|---|
453
+ | 시작 글자 | 한 줄 JSON(`null` → `''`). `mask.formatter` 가 있어도 JSON |
454
+ | type-to-edit | 원래 JSON **끝에 이어 붙임**(IME 는 원래 JSON 끝에서 조합) |
455
+ | 빈 글자(공백만) 확정 | `null` |
456
+ | JSON 객체 확정 | 그 객체. 기존 값과 `equals`(없으면 내용)가 같으면 기록 안 함 |
457
+ | 문법 오류·배열·원시값 + Enter/Tab | 편집 유지 + 오류 표시(`ps-editor-invalid`, 숫자 칸 오류와 같음) + `validationError`. 문구는 i18n `editor.objectJsonInvalid`(ko: `JSON 객체({ … })만 입력할 수 있습니다`) + 문법 오류 내용 |
458
+ | 문법 오류 등 + 그리드가 확정(창 밖 클릭·다른 셀 클릭/편집·`commitEdit()` API) | **원래 값 유지**(편집 취소 — `editCancel`) |
459
+
460
+ 여러 줄 편집기·문법 색이 필요하면 래퍼의 JSON 편집기(`editor: jsonEditor()` — 래퍼 문서)를 쓰거나 `custom` 에디터를 지정하세요(1순위).
461
+
462
+ **편집 규칙** (2순위 `mask.parser`)
463
+
464
+ | 상황 | 결과 |
465
+ |---|---|
466
+ | 편집 시작 글자 | 열 표시값(`mask.formatter` → `displayKey` → JSON). type-to-edit 은 누른 글자로 시작 |
467
+ | 시작 글자를 바꾸지 않고 확정 | 원래 값 유지(`parser` 를 부르지 않음) |
468
+ | 빈 글자로 확정 | `null` (`parser` 를 부르지 않음) |
469
+ | `parser` 가 `undefined` 를 돌려줌 | 편집 유지 + 오류 표시 + `validationError`. 문구는 i18n `editor.objectValueNotFound`(ko: `값을 찾을 수 없습니다`) |
470
+ | `parser` 가 `throw new Error(msg)` | 편집 유지 + 그 `msg` 를 오류 문구로 표시 (예: 동명이인 안내) |
471
+ | `parser` 가 객체를 돌려줌 | 그 객체로 확정. 기존 값과 `equals`(또는 내용)가 같으면 변경 아님 |
472
+ | `mask.parser` 도, `select`·`dropdown`·`multiselect`·`custom` 에디터도 없음 | 위 **편집 우선순위** 3·4순위(displayKey 경로 / JSON). `displayKey` 가 함수면 편집 불가 |
473
+ | `editorResolver` 가 있음 | 편집 시작 때 그 행의 에디터로 다시 판단 |
474
+
475
+ - **선택 에디터** — `editor.options` 에 **객체 배열만** 줘도 됩니다. 라벨은 열의 `mask.formatter`(없으면 `String(객체)` = `[object Object]`), 선택 표시와 "같은 값" 비교는 `objectKey` 입니다. 같은 키를 다시 고르면 변경이 아니고, 고른 옵션의 객체가 그대로 저장됩니다. `{ value: 객체, label }` 모양도 그대로 됩니다. 타입은 `options?: EditorOption[] | CellObject[]` 입니다.
476
+ - `multiselect` 는 `dataType: 'array'` 열에 `objectKey` 를 주면 객체 배열 값의 체크 표시를 키로 맞춥니다. 처음과 같은 선택으로 확정하면 변경이 아닙니다.
477
+ - `custom` 에디터(`CustomEditorParams.value`)는 원본 객체를 받습니다. 값을 읽는 규칙은 [EditorConfig](#editorconfig) 의 `getValue` 를 보세요.
478
+ - **붙여넣기** — 객체 열 칸은 편집 우선순위를 따릅니다. `mask.parser` 열은 글자를 객체로 바꾸고, 실패(`undefined`·throw)한 칸은 **건너뜁니다**(붙여넣은 칸 수에도 안 셈, 옆 칸은 그대로 붙음). 블록 안의 빈 칸은 `null`. `displayKey` 경로 열(3순위)은 글자를 그 경로 값으로 씁니다(`null` 이면 새 객체). JSON 열(4순위)은 JSON 객체 글자만 넣고(빈 칸 → `null`) 아니면 그 칸을 건너뜁니다 — 따옴표가 든 칸은 TSV 규칙대로 `"{""id"":…}"` 로 이스케이프돼 있어야 합니다(그리드·엑셀 복사는 그렇게 씀). `editor` 만 있고 `parser` 가 없는 열·`displayKey` 가 함수인 열은 건너뜁니다. 삽입 붙여넣기(`mode: 'insert'`)의 실패 칸은 비워 둡니다(글자를 그대로 쓰지 않음).
479
+ - **복사** — 보이는 글자(`mask.formatter` → `displayKey` → JSON)가 복사됩니다. formatter 와 parser 가 서로 되돌릴 수 있으면(예: id ↔ 객체) 복사 → 붙여넣기 왕복이 같은 내용의 객체를 만듭니다.
480
+
481
+ **경고 코드**
482
+
483
+ | 코드 | 언제 | 그리드 동작 |
484
+ |---|---|---|
485
+ | `PS314` | `dataType: 'object'` 열인데 `objectKey` 가 없음 | 객체 내용을 속성 순서와 무관한 JSON 으로 만들어 키로 씀. 열마다 1회 경고 |
486
+ | `PS315` | `dataType` 이 `'object'` 가 아닌 열에 순수 객체 값이 있음 | 처음 그려지는 행 중 최대 50행을 보고 열마다 1회 경고. 계산 열·Date·배열 값은 대상 아님. 묶기·필터 키는 위와 같은 JSON |
487
+
488
+ **Worker 모드 (`loader: { worker }`)**
489
+
490
+ - `objectKey`·`displayKey`·`sortValue` 는 **경로 문자열** 또는 **등록 함수 참조**(`{ workerFunction: '키', params? }`, → [runtime-contracts](runtime-contracts.md))만 됩니다. 인라인 함수면 조용히 무시하지 않고 오류입니다: `Worker objectValue.<열>.objectKey requires { workerFunction: string, params? }; inline functions are not supported` (`displayKey` 면 `…<열>.displayKey …`). `displayKey` 는 셀 표시처럼 메인 스레드에서도 읽으므로, 등록 함수 참조로 주면 그 함수를 메인 스레드에도 등록하거나 경로 문자열을 쓰세요.
491
+ - `equals` 는 Worker 로 보내지 않습니다(변경 감지는 메인 스레드) — 인라인 함수여도 오류가 나지 않습니다.
492
+ - `mask.formatter`·`mask.parser` 는 화면 표시·편집·붙여넣기·래퍼 일괄 편집이 **메인 스레드**에서 부르므로 Worker 모드에서도 **일반 함수**로 주세요. 등록 함수 참조로 주면 메인 스레드에서 부를 수 없어(`Registered mask formatter requires Worker execution`) 라벨·입력 변환이 동작하지 않습니다.
493
+
494
+ ```typescript
495
+ // Worker 파일
496
+ setupGridWorker({ loader: MyLoader, functions: { 'objectValue:rankName': (v) => [v?.rank, v?.name] } });
497
+ // 메인 설정
498
+ { id: 'owner', dataType: 'object', objectKey: 'id', sortValue: { workerFunction: 'objectValue:rankName' } }
499
+ ```
500
+
501
+ **수식에서** — `[owner]` 처럼 객체 자체를 `&`·`+` 로 이으면 `[object Object]` 가 됩니다. `[owner.name]` 처럼 속성 경로를 쓰거나 함수형 `formula` 를 쓰세요(→ [계산 필드](#계산-필드)).
502
+
503
+ **그룹 결과 필터** — `groupFilters` 의 `basis: 'groupValue'` 조건도 행 필터와 같습니다. `in`/`notIn` 은 `objectKey` 로 비교하고(키 배열·객체 배열 모두), `eq`/`neq` 는 그룹 값이나 필터 값이 객체일 때 `objectKey` 로 비교합니다(mask 또는 `displayKey` 가 있고 필터 값이 문자열이면 보이는 글자 비교). 글자 조건·크기 비교도 행 필터와 같은 규칙입니다. 서버용 `createGroupFilterPredicate(model, groupColumns, { objectSpecs, fieldOf })` 도 `objectSpecs`(`buildObjectFieldSpecs(열 정의)`)를 주면 같은 규칙입니다.
504
+
505
+ **엑셀 가져오기 (`fromExcel`)**
506
+
507
+ | 칸 | 결과 |
508
+ |---|---|
509
+ | 객체 열의 JSON 객체 글자 (앞뒤 공백 제외 `{` 로 시작, `JSON.parse` 결과가 순수 객체) | 그 객체를 값으로 씀(`parser` 를 부르지 않음, `parser` 가 없는 열도). 원본 내보내기(`exportSource: true`) 파일 왕복용. 깨진 JSON·배열·원시값 JSON 은 아래 `parser` 경로. 가져오기에만 적용(붙여넣기·셀 편집은 그대로) |
510
+ | 객체 열의 글자 | 붙여넣기와 같은 규칙으로 `mask.parser` 변환. 빈 칸 → `null` |
511
+ | 변환 실패(`undefined`·throw) | 그 칸만 건너뜀(기존 값 유지, 새 행이면 값 없음). `result.conflicts` 에 `reason: 'parse_failed'`(행 번호·키·`field`·`column`·원문 `text`·throw 문구 `message`) |
512
+ | `parser` 가 없고 `displayKey` 가 경로 문자열인 객체 열 | JSON 객체 글자가 아니면 글자를 그 경로 값으로 씀(편집 3순위와 같음 — 기존 행은 `{ ...owner, name: 글자 }`, 값이 없으면 `{ name: 글자 }`). 경고 없음 |
513
+ | `parser` 도 경로 `displayKey` 도 없는 객체 열 | JSON 객체 글자가 아닌 칸은 쓰지도 지우지도 않음. 결과 경고 `OBJECT_PARSER_MISSING:<열 id>` 1회 |
514
+ | 같은 사람을 다시 가져옴 | `equals`(없으면 내용)가 같으면 변경 아님 |
515
+ | 점 표기 열(`owner.team`) | 기존 행·새 행 모두 `owner: { … }` 중첩 객체에 씀. 같은 행의 `owner` 객체 열을 먼저 쓰고 경로 열을 그 위에 덮어씀(파일 열 순서 무관) |
516
+
517
+ XLSX 내보내기는 객체 열을 `preserveFormatting` 과 관계없이 `mask.formatter` 글자(없으면 `displayKey` 글자)로 씁니다. 그래서 formatter·parser 가 서로 되돌릴 수 있으면 내보내기 → 가져오기 왕복의 변경이 0건입니다. 원본 내보내기(`exportSource: true`)는 함수 mask 를 쓰지 않는 정책이라 객체 열이 JSON 으로 나가고, 가져오기가 그 JSON 을 객체로 되돌리므로 이 왕복도 변경이 0건입니다(동명이인·`parser` 없는 열 포함).
518
+
519
+ **알려진 한계** — 객체 열을 피벗 축(`rowFields`/`columnFields`)으로 쓰는 것은 지원하지 않습니다(점 표기 열 `owner.name` 을 축으로 쓰세요). 행 필터의 `eq`/`neq` 는 위 규칙(객체면 `objectKey`)으로 비교합니다 — 여러 값은 `in` 필터를 쓰세요(래퍼 우클릭 "선택 값으로 필터"는 객체 셀에서 `in` 필터를 겁니다).
520
+
346
521
  ### 너비/크기
347
522
 
348
523
  | 속성 | 타입 | 설명 |
@@ -358,7 +533,7 @@ await automaticGrid.changes.commit(); // 같은 rowId 유지
358
533
 
359
534
  | 속성 | 타입 | 설명 |
360
535
  |------|------|------|
361
- | `align` | `'left' \| 'center' \| 'right'` | 셀 정렬 (dataType에서 자동 추론) |
536
+ | `align` | `'left' \| 'center' \| 'right'` | 셀 정렬 (dataType에서 자동 추론: number→right, boolean→center, 그 외 left). 병합 셀(본문·고정 행)도 앵커 컬럼 정렬을 같은 규칙으로 따름 |
362
537
  | `hidden` | `boolean` | 숨김 필드 |
363
538
  | `lockVisible` | `boolean` | 가시성 잠금 (기본 `false`). 아래 참조 |
364
539
  | `pinned` | `'left' \| 'right'` | 컬럼 고정 위치 |
@@ -387,6 +562,8 @@ await automaticGrid.changes.commit(); // 같은 rowId 유지
387
562
  >
388
563
  > ⚠️ 이것은 **표시 축**이며 필터 **능력**을 막지 않습니다. 툴바 필터 창·수식 등 다른 경로로는 여전히 조건을 걸고 **해제**할 수 있습니다 — 능력까지 막으면 이미 걸어 둔 조건을 되돌릴 수 없습니다. (AG Grid 도 `suppressHeaderFilterButton`(표시)과 `filter: false`(능력)를 분리합니다.)
389
564
  >
565
+ > wrapper 우클릭 메뉴의 `필터 추가`·`선택 값으로 필터` 도 이 값을 보지 않습니다(ITSM-2026-004445). 두 항목의 활성 여부는 **툴바 고급 필터가 그 열을 후보로 주는지**와 같습니다 — 필드 목록(flat `getFields()`, 피벗은 행/열/값/필터 필드)에 있는 열이면 숨긴 열·아이콘 없는 열도 활성, 필드 목록에 없는 열(행 헤더·드래그 핸들 같은 합성 열)은 비활성입니다. `필터 해제` 는 이 판정과 무관하게 걸린 조건이 있으면 활성입니다.
566
+ >
390
567
  > 정렬 쪽에는 대응 옵션이 **없습니다.** 정렬 진입점은 아이콘과 **헤더 셀 클릭** 둘이라 아이콘만 숨기면 "정렬은 되는데 상태 표시가 없는" 상태가 됩니다. 정렬을 막으려면 `sortable` 을 쓰세요.
391
568
 
392
569
  > **⚠️ `filterable` 은 현재 미동작입니다 (ITSM-2026-002167, 실측)**
@@ -410,7 +587,7 @@ await automaticGrid.changes.commit(); // 같은 rowId 유지
410
587
  | 속성 | 타입 | 설명 |
411
588
  |------|------|------|
412
589
  | `mask` | `MaskConfig` | 마스킹 설정 |
413
- | `aggregate` | `AggregateFunc \| (values, ctx) => CellValue` | 그룹/피벗 집계 함수. wrapper의 `useSummaryFooter: true`에서는 컬럼 하단 집계에도 사용한다(기본 OFF). 표시를 꺼도 함수는 유지된다. 함수형은 그룹행 자기 칸·고정 합계 행 값을 계산한다 — [그룹 집계 표시 자리와 함수형 집계](#그룹-집계-표시-자리와-함수형-집계) |
590
+ | `aggregate` | `AggregateFunc \| (values, ctx) => CellValue` | 그룹/피벗 집계 함수. `summaryRow` 가 켜져 있으면 하단 합계 행 집계에도 사용한다(기본 `'none'`). 표시를 꺼도 함수는 유지된다. 함수형은 그룹행 자기 칸·고정 합계 행 값을 계산한다 — [그룹 집계 표시 자리와 함수형 집계](#그룹-집계-표시-자리와-함수형-집계) |
414
591
 
415
592
  ### 그룹 행
416
593
 
@@ -436,6 +613,35 @@ await automaticGrid.changes.commit(); // 같은 rowId 유지
436
613
  }
437
614
  ```
438
615
 
616
+ **문자열 수식의 이름 해석** — 이름은 **열 id 우선, 없으면 데이터 키**로 읽습니다(수식 필터·조건부 서식의 `$열id`와 같은 기준).
617
+
618
+ - 다른 계산 열 id → 그 계산 값 (`'total * 0.1'`)
619
+ - 열 id → 그 열의 `field` 값(화면에 보이는 값). 별칭 열 `{ id: 'price', field: 'cost' }`이면 `price`는 `row.cost`
620
+ - 열이 아닌 이름 → 데이터 키 그대로 (`'extra + 1'` → `row.extra`)
621
+ - 함수형 `formula: (row) => …`는 원본 행 객체를 받습니다(데이터 키 그대로).
622
+
623
+ 예) 데이터 `{ qty: 3, cost: 10, price: 999, extra: 5 }`, 열 `qsum{field:'qty'}`·`price{field:'cost'}` → `'qty * price'` = 30, `'qsum * 2'` = 6, `'extra + 1'` = 6.
624
+ 이름이 열 id이면서 다른 데이터 키(데이터에 같은 이름 키가 있거나 다른 열의 `field`)이기도 하면 열 id 쪽을 읽고 콘솔에 `PS313` 경고를 수식·이름 조합마다 한 번 냅니다(위 예의 `price`). 데이터 키를 읽으려면 함수형을 쓰거나 열 id를 바꾸세요.
625
+
626
+ **점 표기 경로 참조** — 이름에 점이 있으면 데이터 경로로 읽습니다. 규칙은 위와 같이 **열 id 우선 → 없으면 데이터 경로**입니다.
627
+
628
+ ```typescript
629
+ // 데이터: { owner: { id: 'u2', name: '김철수', rank: 2 } }, 열 owner{dataType:'object'}, ownerName{field:'owner.name'}
630
+ { id: 'label', formula: '[owner.name] & "님"', dataType: 'string' } // → '김철수님' (열이 아닌 이름 → 경로)
631
+ { id: 'byColumn', formula: 'ownerName & "님"', dataType: 'string' } // → '김철수님' (열 id → 그 열 field 경로)
632
+ { id: 'rank10', formula: 'owner.rank * 10' } // → 20 (괄호 없는 점 이름)
633
+ { id: 'chain', formula: 'rank10 + 1' } // → 21 (계산 → 계산)
634
+ // owner 가 null 인 행: label = '님', owner.rank = undefined
635
+ ```
636
+
637
+ - 행에 `'owner.name'` 키가 그대로 있으면 그 키가 경로보다 먼저입니다.
638
+ - 이름이 점 표기인 **열 id** 가 있으면 그 열이 이깁니다 — 열 `{ id: 'owner.name', field: 'nick' }` 이 있으면 `[owner.name]` 은 `row.nick` 을 읽습니다.
639
+ - 의존성은 최상위 키(`owner`)입니다. `owner` 를 새 객체로 바꾸면 경로 수식이 다시 계산됩니다(같은 객체를 공유하던 다른 행은 그대로).
640
+ - 객체 자체(`[owner]`)를 `&`·`+` 로 이으면 `[object Object]` 입니다 — 속성 경로(`[owner.name]`)를 쓰세요.
641
+ - 메인 스레드와 Worker 모드가 같은 규칙입니다.
642
+
643
+ 지원 문법: 이름은 영문·숫자·`_`(첫 글자 숫자 불가), 점 표기 경로 `owner.name`(점 뒤 조각도 영문·`_` 로 시작해야 이어짐 — 숫자 조각은 `[items.0]` 처럼 괄호로 쓰면 경로로 읽음), `[이름]`(괄호 안 글자 그대로 — 공백·점 허용, 함수로 보지 않음: `[unit price] * 2`), 숫자·문자열(`'…'`) 리터럴, `+ - * / %`, `&` 문자열 결합(Excel 과 같음 — `null` 은 빈 글자, `+`·`-` 보다 나중·비교보다 먼저: `qty + 1 & "개"` → `'3개'`), 비교 `> < >= <= == !=`, `&& || !`, `조건 ? 값 : 값`, 함수 `IF YEAR MONTH DAY ABS ROUND FLOOR CEIL UPPER LOWER CONCAT LEN MIN MAX`. 닫는 `]` 가 없거나(`[owner.name`) 점으로 끝나는 이름(`owner.`)은 수식 오류입니다.
644
+
439
645
  ### 렌더링
440
646
 
441
647
  | 속성 | 타입 | 설명 |
@@ -488,10 +694,15 @@ PureSheet 인스턴스에서 직접 호출하는 메서드들입니다. Sub-API
488
694
  | 메서드 | 반환 | 설명 |
489
695
  |--------|------|------|
490
696
  | `waitForReady()` | `Promise<void>` | 초기 렌더링 완료까지 대기 |
697
+ | `waitForSettled(signal?: AbortSignal)` | `Promise<void>` | 초기화와 core가 추적하는 activity·undo 작업 대기. 취소는 `AbortError`, disposed·초기화 실패는 rejection |
698
+ | `captureDataSession()` | `DataSessionSnapshot \| null` | 준비된 live·settled 인스턴스의 동기 세션 캡처 ([지원 범위](runtime-contracts.md)) |
699
+ | `captureDataSessionAsync()` | `Promise<DataSessionSnapshot \| null>` | Worker 소스 캡처 포함. **완료 후** 기존 인스턴스를 destroy |
491
700
  | `refresh()` | `Promise<void>` | 데이터 재처리 + 렌더링 갱신 |
492
701
  | `notifyResize()` | `void` | 컨테이너 가시성/크기 변경을 호스트가 명시적으로 알리는 훅. viewport 재측정 + 스크롤 재동기화 + 가상 가시범위 재계산 → 재렌더. 데이터/컬럼 변경 없이 레이아웃만 다시 맞추므로 `refresh()`보다 가벼움 (ResizeObserver로 감지 못 한 컨테이너 표시/크기 변경 시나리오용) |
493
702
  | `destroy()` | `void` | 리소스 해제, DOM 정리 |
494
703
 
704
+ `waitForSettled()`는 임의의 호스트 fetch·renderer의 모든 외부 비동기 작업을 기다리지 않습니다. `captureDataSessionAsync()` 자체는 준비/settled 대기를 대신하지 않습니다. 먼저 `await grid.waitForSettled()`를 호출하고 캡처 중 새 작업을 시작하지 마세요. 준비 전·disposed·진행 중 작업이 있거나 열린 편집을 확정하지 못하면 거절하며 캡처 중 상태 변경도 거절될 수 있습니다. 토큰은 같은 프로세스의 불투명 세션 이전용이며 JSON 프리셋이 아닙니다. [지원 범위와 복원 계약](runtime-contracts.md)을 참조하세요.
705
+
495
706
  ### 데이터
496
707
 
497
708
  | 메서드 | 반환 | 설명 |
@@ -501,9 +712,10 @@ PureSheet 인스턴스에서 직접 호출하는 메서드들입니다. Sub-API
501
712
  | `getVisibleData()` | `Promise<RowData[]>` | 필터/정렬 적용된 processor 데이터 조회. 하위 호환 API라 rowGroup/tree collapse로 renderer에서 숨긴 자식 행은 제외하지 않음. 커밋 전 드래그 재정렬도 반영하지 않음(원래 순서) — 화면 순서는 `getDisplayedData()` |
502
713
  | `getDisplayedData()` | `Promise<RowData[]>` | 현재 renderer view-index 순서의 순수 도메인 데이터 행. rowGroup/tree collapse 반영(접힌 자식 제외), group header/subtotal/grand-total/master-detail 구조 행 제외, DOM/viewport 가상화와 무관 |
503
714
  | `getDisplayedRowIds()` | `Promise<RowId[]>` | `getDisplayedData()`와 같은 순서의 도메인 rowId 목록 |
715
+ | `isServerRowModel()` | `boolean` | 서버 행 모델(`rowModel: 'server'`)인지. 받지 않은 행이 있어 "보이는 행 전체" 작업은 행 ID 목록 대신 `'all'` 범위로 해야 합니다 |
504
716
  | `rows.displayedCount()` | `number` | displayed 좌표계 총 행 수(그룹/트리 collapse 반영). traversal 경계 계산용 |
505
717
  | `rows.getByDisplayedIndex(i)` | `Promise<DisplayedRowInfo \\| null>` | displayed 좌표 i의 단일 행 정보(O(1)~O(log g), 배열 materialize 없음). 다음/이전 이동 등 traversal용. 범위 밖 `null` |
506
- | `rows.getDisplayedIndexById(id)` | `Promise<number>` | rowId → displayed 인덱스. 접힘/필터로 숨겨졌으면 `-1`. rowId만으로 호출 시 idMap O(N) 유발 가능하므로 대용량 traversal은 index 보관 권장 |
718
+ | `rows.getDisplayedIndexById(id)` | `Promise<number>` | rowId → displayed 인덱스(`getByDisplayedIndex`의 역: 정렬·필터·저장 전 추가 행을 반영한 화면 순번. 예: 원본 [1,2] 앞에 추가한 행 `'9'` → `'9'`=0, `1`=1). 접힘/필터로 숨겨졌으면 `-1`. 피벗 모드의 rowId는 피벗 결과 행의 `nodeId`(`getActiveCell().row.id`·`getSelectedRowIds()`와 같은 값, 예: `'g:G1\|i:I1'`·소계 `'g:G1'`·`'__grandtotal__'`)이며, 자기 행이 없는 그룹 id(소계 숨김)는 `-1`. rowId만으로 호출 시 idMap O(N) 유발 가능하므로 대용량 traversal은 index 보관 권장 |
507
719
  | `getRowById(rowId)` | `Promise<RowData \| undefined>` | ID로 단일 행 조회 |
508
720
  | `getRowIndexById(rowId)` | `Promise<number>` | ID → 데이터 인덱스 |
509
721
  | `getRowVirtualIndexById(rowId)` | `Promise<number>` | ID → 가상 인덱스 (그룹 행 포함) |
@@ -526,7 +738,10 @@ await grid.changes.commit();
526
738
 
527
739
  | 메서드 | 반환 | 설명 |
528
740
  |--------|------|------|
529
- | `sort(sorts)` | `Promise<void>` | 정렬 적용 |
741
+ | `sort(input: SortState[] \| SortGroupMap)` | `Promise<void>` | 정렬 전체 교체. 다른 소유자 보존은 `setSortGroup` 사용 |
742
+ | `setSortGroup(key: string, group: SortGroupMap[string] \| null)` | `Promise<void>` | 한 소유자의 정렬만 교체. `null`은 해당 소유자 삭제, 나머지 보존 |
743
+ | `getSortGroup(key: string)` | `SortGroupMap[string] \| undefined` | 소유자별 정렬 조회 |
744
+ | `getSortGroups()` | `SortGroupMap` | 전체 소유자 정렬 맵의 복사본 조회 |
530
745
  | `filter(filters)` | `Promise<void>` | 필터 적용. `FilterState[]`(평면, 하위 호환) 또는 **`FilterGroupMap`**(소스별 그룹) 둘 다 받음 |
531
746
  | `setFilterGroup(key, group)` | `Promise<void>` | 그룹 하나만 교체/삭제(`null`=삭제). 다른 그룹은 건드리지 않음 |
532
747
  | `getFilterGroup(key)` | `FilterGroup \| undefined` | 그룹 하나 조회 (sync) |
@@ -580,7 +795,7 @@ await grid.filter([{ id: 'dept', operator: 'in', filterValue: res.rawValues, seq
580
795
  | 메서드 | 반환 | 설명 |
581
796
  |--------|------|------|
582
797
  | `updateLoaderParams(params)` | `Promise<void>` | 로더 파라미터 변경 + 재조회 |
583
- | `refreshLoaderData()` | `Promise<void>` | 로더 데이터 재조회 |
798
+ | `refreshLoaderData()` | `Promise<void>` | 로더 데이터 재조회. 성공하면 `data:loaded` 발행(SSRM 제외) |
584
799
 
585
800
  ### 스크롤 / 내비게이션
586
801
 
@@ -618,6 +833,17 @@ grid.clearSearch(); // 종료
618
833
 
619
834
  ### 레이아웃 / UI
620
835
 
836
+ 추가 편의 API:
837
+
838
+ | 메서드 | 반환 | 설명 |
839
+ |---|---|---|
840
+ | `repaintRow(rowId: RowId)` | `void` | `repaintRows([rowId])`의 단일 행 편의 함수 |
841
+ | `getSummaryRow()` | `SummaryRowPosition` | 현재 합계 행 위치: `'none' \| 'pinnedTop' \| 'pinnedBottom'` |
842
+ | `registerShortcut(combo: string, handler: () => void \| Promise<void>)` | `Unsubscribe` | 단축키 등록. 같은 combo는 기존/기본 단축키를 덮어씀. 반환 해제 함수를 소비처 lifecycle에서 호출 |
843
+ | `hasShortcut(combo: string)` | `boolean` | 해당 단축키 등록 여부 |
844
+
845
+ 단축키는 `'ctrl+shift+v'`처럼 정규화한 문자열이며 수식어 순서는 `ctrl → meta → shift → alt`입니다.
846
+
621
847
  | 메서드 | 반환 | 설명 |
622
848
  |--------|------|------|
623
849
  | `setDynamicRowHeights(enabled)` | `void` | 동적 행 높이 켜기/끄기 |
@@ -665,7 +891,7 @@ grid.clearSearch(); // 종료
665
891
 
666
892
  | 메서드 | 반환 | 설명 |
667
893
  |--------|------|------|
668
- | `getTotalRowCount()` | `number` | 전체 행 수 (필터 전) |
894
+ | `getTotalRowCount()` | `number` | 전체 행 수 (필터 전). 피벗은 화면 피벗 행 수(소계·총합계 포함) — 원본 행 수는 `pivot.getSourceRowCount()` |
669
895
  | `getVisibleRowCount()` | `number` | 가시 행 수 (필터 후) |
670
896
 
671
897
  ### 내보내기
@@ -939,21 +1165,22 @@ PureSheet 인스턴스는 기능별 Sub-API 네임스페이스를 제공합니
939
1165
  | `selectRowsByViewIndices(viewIndices)` | `void` | viewIndex(화면 물리 순서) 배열로 행 선택. 정렬/필터 후에도 "화면상 그 위치"를 선택(sourceIndex 아님), data 행만 |
940
1166
  | `selectRowRangeByViewIndex(startViewIndex, endViewIndex)` | `void` | viewIndex 범위로 행 선택. "화면상 N~M번째" 범위, 구조행 자동 제외 |
941
1167
  | `selectRowRangeByIds(startRowId, endRowId)` | `void` | rowId 범위로 행 선택(정렬/필터 안정). 한쪽이라도 숨김이면 no-op |
942
- | `selectAll()` | `void` | 전체 행 선택 |
943
- | `clearSelection()` | `void` | 행 선택 해제 |
1168
+ | `selectAllRows()` | `void` | 전체 행 선택 (셀 선택과 구분) |
1169
+ | `clearRowSelection()` | `void` | 행 선택만 해제 (셀 선택 유지) |
1170
+ | `clearSelection()` | `void` | 행·셀 선택 모두 해제 |
944
1171
 
945
1172
  #### 선택 조회 (행 — grid.rows)
946
1173
 
947
1174
  | 메서드 | 반환 | 설명 |
948
1175
  |--------|------|------|
949
- | `selected()` | `MultiRowAccessor` | 선택된 행 접근자 |
1176
+ | `getSelectedRowsAccessor()` | `MultiRowAccessor` | 선택된 행 접근자 |
950
1177
  | `getSelectedRowIds()` | `(string \| number)[]` | 선택된 행 rowId |
951
1178
  | `getSelectedRowIndices()` | `Promise<number[]>` | 선택된 행 sourceIndex |
952
1179
  | `getSelectedRowViewIndices()` | `Promise<number[]>` | 선택된 행 viewIndex(화면 물리 순서) |
953
1180
  | `getSelectedRowDisplayedIndices()` | `Promise<number[]>` | 선택된 행 displayedIndex(화면 표시 순서, 접힘/필터 반영). 숨김 행 제외 |
954
- | `selectionAggregates()` | `SelectionAggregateResult \| null` | 선택 범위 집계 (컬럼별 + 총 집계) |
1181
+ | `selectionAggregates()` | `Promise<SelectionAggregateResult \| null>` | 선택 범위 집계 (컬럼별 + 총 집계). `const aggregates = await grid.rows.selectionAggregates();`로 조회. 반환값은 칸 수와 무관하지만, 내장 상태 표시줄·wrapper 하단 푸터는 `total.totalCount`(선택 칸 수) **2 이상**일 때만 집계를 보이고, 값이 `null`인 집계는 `-`로 표시한다. 셀 값은 화면과 같이 `field ?? id`로 읽어(`{ id: 'nmin', field: 'name' }` → `name` 값) 드래그·Shift·Ctrl+A·행 선택·함수형 `ctx`가 같은 값이다 |
955
1182
 
956
- > **displayed 좌표계 조회**(`displayedCount`/`getByDisplayedIndex`/`getDisplayedIndexById`)는 위 [행 조회](#grid.rows) 표 참조 — 다음/이전(traversal) 이동에 사용.
1183
+ > **displayed 좌표계 조회**(`displayedCount`/`getByDisplayedIndex`/`getDisplayedIndexById`)는 위 [행 조회](#gridrows--행-api) 표 참조 — 다음/이전(traversal) 이동에 사용.
957
1184
 
958
1185
  ### grid.cells — 셀 선택 API
959
1186
 
@@ -967,13 +1194,14 @@ PureSheet 인스턴스는 기능별 Sub-API 네임스페이스를 제공합니
967
1194
  | `selectCellsByViewIndices(cells)` | `void` | `{viewIndex, columnId}` 배열로 셀 선택 |
968
1195
  | `selectCellRangeByViewIndex(start, end)` | `void` | `{viewIndex, columnId}` 두 점으로 사각형 범위 선택 |
969
1196
  | `selectCellRangeByIds(start, end)` | `void` | `{rowId, columnId}` 두 점으로 사각형 범위 선택(정렬/필터 안정) |
1197
+ | `selectAllCells()` | `void` | 전체 셀 선택 (행 선택 유지) |
970
1198
  | `clearCellSelection()` | `void` | 셀 선택만 해제 (행 선택 유지) |
971
1199
 
972
1200
  #### 선택 조회 (셀 — grid.cells)
973
1201
 
974
1202
  | 메서드 | 반환 | 설명 |
975
1203
  |--------|------|------|
976
- | `getSelectedCellIds()` | `{rowId, columnId}[]` | 선택된 셀 목록 (셀 API 에는 `selected()` 가 없다 — 행 API `grid.rows.selected()` 와 다름) |
1204
+ | `getSelectedCellIds()` | `{rowId, columnId}[]` | 선택된 셀 목록 (셀 API 에는 `selected()` 가 없다 — 행 API `grid.rows.getSelectedRowsAccessor()` 와 다름) |
977
1205
  | `getSelectedCells()` | `SelectedCell[]` | 선택된 셀 상세 (행 핸들·값 포함) |
978
1206
  | `getActiveCell()` | `SelectedCell \| null` | 현재 활성 셀 — `selection.getActiveCell()`과 같음(화면 자리 기준, 아래 표 참고) |
979
1207
  | `getFocusCell()` | `SelectedCell \| null` | 논리적 선택 끝점 — `selection.getFocusCell()`과 같음. [끝점·데이터 세션 상세](runtime-contracts.md) |
@@ -994,10 +1222,12 @@ rowSelection(선택된 행들)·cellSelection(선택된 셀 range)은 서로 다
994
1222
  | `selection.getSelectedCells()` | `{ row: RowHandle; columnId }[]` | 현재 선택된 셀들 |
995
1223
  | `selection.getActiveCell()` | `{ row: RowHandle; columnId } \| null` | 마지막 상호작용 셀. traversal 컬럼 유지용. 행은 **화면 자리 기준** — 셀 선택(`getSelectedCellIds`)·화면 강조·키보드 이동과 같은 행이다. 정렬·필터·그룹/트리 접기·디테일 펼침·드래그 재정렬로 그 자리에 다른 행이 오면 그 행, 자리가 그룹 배너·디테일 행이거나 사라지면 `null`. 행 선택 모드(셀 선택 없음)의 앵커와 고정(pinned) 행은 클릭한 행 그대로 |
996
1224
  | `selection.getFocusCell()` | `SelectedCell \| null` | 현재 유효한 논리적 선택 끝점 `{ row: RowHandle, columnId }`. traversal 앵커인 `getActiveCell()`과 별개. 전체 선택 열거·비동기 fetch 없이 조회하며 확인 불가능한 identity는 null. [상세 계약](runtime-contracts.md) |
997
- | `selection.getSelectedGroups()` | `{ groupId: string; rowIds: RowId[] }[]` | 선택된 그룹 (`rowGroupConfig.selectable` — [그룹 배너 선택](#그룹-배너-선택-selectable)). `rowIds`는 리프 멤버 전체(접힘 무관, 중첩이면 하위 리프 전부). 옵션이 꺼져 있으면 `[]`. 행 선택 결과와 별개 |
1225
+ | `selection.getSelectedGroups()` | `{ groupId: string; groupKeys: CellValue[]; rowCount: number; rowIds?: RowId[] }[]` | 선택된 그룹 (`rowGroupConfig.selectable` — [그룹 배너 선택](#그룹-배너-선택-selectable)). `groupKeys`는 그룹 값 경로, `rowCount`는 그룹의 데이터 행 수. `rowIds`는 리프 멤버 전체(접힘 무관, 중첩이면 하위 리프 전부)이며 `rowModel: 'server'` 서버 그룹에서는 없음(멤버를 다 받지 않음 — `groupKeys`와 현재 필터로 서버에서 처리). 옵션이 꺼져 있으면 `[]`. 행 선택 결과와 별개 |
998
1226
  | `selection.selectGroupsByIds(groupIds, { addToExisting? })` | `string[]` | 그룹을 코드로 선택 — 배너 본문 클릭과 같은 의미([그룹 배너 선택](#그룹-배너-선택-selectable)). 적용된 groupId(입력 순서, 중복 제거)를 반환. 화면에 없는 그룹·옵션 꺼짐은 무시 |
999
1227
  | `selection.selectGroupsByViewIndices(viewIndices, { addToExisting? })` | `string[]` | viewIndex로 그룹 선택. 그룹행이 아닌 위치는 무시. 나머지는 `selectGroupsByIds`와 같음 |
1000
1228
 
1229
+ > **범위 선택의 시작·끝 셀**: SA_02에서 SA_03까지 드래그(또는 Shift+클릭·Shift+↓)하면 `getActiveCell()` = SA_02(시작), `getFocusCell()` = SA_03(끝)이고, 아래→위로 고르면 둘이 바뀝니다. 끝 행: `grid.selection.getFocusCell()?.row.data`. 방향과 관계없이 아래쪽 행(Wijmo `selection.bottomRow`에 해당)이 필요하면 `getSelectedCells()`에서 `row.viewIndex`가 가장 큰 셀을 고릅니다.
1230
+
1001
1231
  > **피벗 모드의 행(RowHandle)**: `row.id`는 피벗 행 nodeId(`'g:G1|i:I1'`, 소계 `'g:G1'`, 총합계 `'__grandtotal__'`), `row.data`는 **화면의 피벗 행** — `cell:click`/`cell:dblclick`의 `row`, `contextmenu`의 `rowData`와 같은 객체입니다. 내용은 `pivot.getAllRows()` 한 행의 `rowHeaders`와 `values`를 한 객체로 펼친 것에 메타 필드(`__pivotType: 'data' | 'subtotal' | 'grandtotal'`, `__nodeId`, `__parentNodeId`, `__level`, `__hasChildren`)를 더한 것입니다. 소계 행은 하위 행 필드에 `'Subtotal'`이, 총합계 행은 첫 행 필드에 `'Grand Total'`이 들어갑니다(화면 표시와 같음). 원본 행이 아닙니다. 접힘으로 화면에 없는 피벗 행은 `data`가 비어 있습니다. 예: 드래그 시작 셀의 행축 값 `const d = grid.selection.getActiveCell()?.row.data; d?.__pivotType === 'data' && d.g`.
1002
1232
 
1003
1233
 
@@ -1013,7 +1243,7 @@ rowSelection(선택된 행들)·cellSelection(선택된 셀 range)은 서로 다
1013
1243
 
1014
1244
  > `pinTop*`/`pinBottom*`은 본문 데이터 행을 고정 영역으로 **옮깁니다** — `rows.getById(id).setProps({ pinned: 'top' | 'bottom' })`와 같은 동작입니다. 호출 후 그 행은 본문에서 빠지고 고정 영역에 한 번만 보이며, 고정 영역에서의 편집은 원본 데이터에 반영됩니다(`changes` 포함). `unpin`/`unpinById`/`clearPinned`로 본문 원래 위치로 돌아옵니다. 같은 행을 다시 고정해도 중복되지 않고, `pinTop` ↔ `pinBottom`은 영역 이동입니다. 정렬·필터·그룹·트리 상태에서도 동작합니다.
1015
1245
  > `pinTop`/`pinBottom`은 **데이터 행 객체**를 받아 키(`keyFields`)로 본문 행을 찾습니다. 본문에 없는 객체/ID를 주면 기존처럼 화면 전용 고정 행으로 추가됩니다(`addPinnedTop`/`addPinnedBottom`과 동일). `Row` 인스턴스는 그대로 꽂힙니다. 새로운 행/구성을 추가하려면 [구조적 행 고정](#구조적-행-고정) 섹션의 `addPinnedTop` / `addPinnedBottom`을 사용하세요.
1016
- > Worker 모드(`useWorker: true`)에서는 메인 스레드에 원본 데이터가 없어 `pinTop`/`pinTopById`/`pinBottom`/`pinBottomById`가 본문 행을 동기로 찾지 못합니다. 이때는 화면 전용 고정 행으로 추가되고 `PS503` 경고가 콘솔에 나옵니다(본문 행 이동·편집 반영·해제 시 본문 복귀 없음). `pin*ByIndex`는 Worker 모드에서 행을 찾지 못해 예외를 던집니다.
1246
+ > Worker 모드(`loader.worker`)에서는 메인 스레드에 원본 데이터가 없어 `pinTop`/`pinTopById`/`pinBottom`/`pinBottomById`가 본문 행을 동기로 찾지 못합니다. 이때는 화면 전용 고정 행으로 추가되고 `PS503` 경고가 콘솔에 나옵니다(본문 행 이동·편집 반영·해제 시 본문 복귀 없음). `pin*ByIndex`는 Worker 모드에서 행을 찾지 못해 예외를 던집니다.
1017
1247
  > `enableReferCache: false`에서도 동작합니다(`*ByIndex`는 O(1), 객체·ID는 원본 데이터를 한 번 훑어 찾음). `setProps({ pinned })`로 직접 고정한 행도 같은 방식으로 원본 위치를 찾아 표시·클릭·우클릭·편집이 동작합니다.
1018
1248
 
1019
1249
  | 메서드 | 반환 | 설명 |
@@ -1190,7 +1420,11 @@ grid.columns.update('amount', { header: '판매금액' }, { updateSnapshot: true
1190
1420
 
1191
1421
  | 메서드 | 반환 | 설명 |
1192
1422
  |--------|------|------|
1193
- | `addRow(row, idx?)` | `Promise<string \| number>` | 행 추가 (dirty). idx는 기존 dirty 추가/삭제를 반영한 데이터 위치이며 정렬·그룹·고정·디테일을 포함한 화면 번호가 아닙니다. 생략 시 끝에 추가합니다. |
1423
+ | `addRow(newRow, anchorRow?, options?)` | `Promise<string \| number>` | 행 객체(`anchorRow`) **바로 앞**에 새 행 추가 (dirty). 생략하면 맨 끝. 두 번째 인자에 숫자를 넘기면 `PS805` — 위치로 넣으려면 `addRowByIndex` |
1424
+ | `addRowById(newRow, rowId, sourceIndex?, options?)` | `Promise<string \| number>` | 그 ID 행 **바로 앞**에 추가. 저장 전 새 행도 기준이 될 수 있습니다. 원래 행을 기준으로 할 때 `sourceIndex` 힌트를 주면 `enableReferCache` 없이도 찾습니다(힌트가 틀리면 버리고 ID로 다시 찾음, 못 찾으면 `PS804`) |
1425
+ | `addRowByIndex(newRow, sourceIndex?, options?)` | `Promise<string \| number>` | 원본 행 사이 틈 `sourceIndex`의 **맨 앞**에 추가. `0`=진짜 맨 위, `1`=원본 0번 행 바로 뒤, 생략·행 수 이상=맨 끝. 같은 값을 반복하면 나중 행이 위에 옵니다(`A, X2, X1, B`). 음수·소수는 `PS006` |
1426
+ | `addRowBeforeSelection(newRow, options?)` | `Promise<string \| number>` | 선택한 행(셀 선택 포함) 중 화면에서 가장 위 행 **앞**에 추가. 선택이 없으면 맨 위. `enableReferCache` 불필요 |
1427
+ | `addRowAfterSelection(newRow, options?)` | `Promise<string \| number>` | 선택한 행 중 화면에서 가장 아래 행 **뒤**에 추가. 선택이 없으면 맨 끝. `enableReferCache` 불필요 |
1194
1428
  | `updateCell(row, field, value)` | `Promise<void>` | 행 객체로 셀 수정 (`enableReferCache` 필요) |
1195
1429
  | `updateCellById(id, field, value, sourceIndex?)` | `Promise<void>` | ID로 셀 수정. 호출자가 sourceIndex를 알고 있으면 4번째 인자로 전달하여 idMap fallback을 회피할 수 있음 |
1196
1430
  | `updateCellByIndex(idx, field, value)` | `Promise<void>` | sourceIndex로 셀 수정 O(1) |
@@ -1204,6 +1438,21 @@ grid.columns.update('amount', { header: '판매금액' }, { updateSnapshot: true
1204
1438
  | `undeleteRowsById(ids)` | `Promise<number>` | ID 배열로 다중 삭제 복원 |
1205
1439
  | `deleteSelectedRows()` | `Promise<number>` | 선택 행 삭제 (삭제된 건수 반환). 참고: `grid.rows.deleteSelectedRows()`와 동작은 동일하지만 ChangesAPI 쪽 메서드 이름은 `deleteSelectedRows`로 다르다 |
1206
1440
 
1441
+ `add*` 공통 `options`: `{ startEdit?: boolean | columnId }`(기본 꺼짐). 켜면 추가 직후 그 행의 첫 편집 가능 칸(또는 지정 컬럼)에서 편집을 시작합니다 — `grid.editing.startEditById(새 id, columnId)`와 같고, undo batch 안에서는 무시됩니다.
1442
+
1443
+ - 새 행의 `sourceIndex`는 `undefined`입니다(원본 배열에 없음). 선택 행 기준으로 추가할 때는 `getSelectedRows()[0]?.sourceIndex`를 넘기지 말고 `addRowBeforeSelection` / `addRowAfterSelection`을 쓰세요.
1444
+ - 정렬이 켜져 있으면 새 행은 위치와 관계없이 맨 위에 보입니다.
1445
+
1446
+ ```ts
1447
+ // 선택 행 바로 아래에 추가하고 이름 칸 편집 시작
1448
+ await grid.changes.addRowAfterSelection({ name: '' }, { startEdit: 'name' });
1449
+ // 원래 행 기준(힌트 포함) — enableReferCache 없이 동작
1450
+ const r = grid.selection.getSelectedRows()[0];
1451
+ await grid.changes.addRowById({}, r.id, r.sourceIndex);
1452
+ // 진짜 맨 위
1453
+ await grid.changes.addRowByIndex({}, 0);
1454
+ ```
1455
+
1207
1456
  #### 변경 취소
1208
1457
 
1209
1458
  | 메서드 | 반환 | 설명 |
@@ -1262,7 +1511,7 @@ await grid.changes.commit({ allowInvalid: true });
1262
1511
 
1263
1512
  | 메서드 | 반환 | 설명 |
1264
1513
  |--------|------|------|
1265
- | `bulkUpdate(columnId, value, scope?)` | `Promise<number>` | 일괄 셀 업데이트 |
1514
+ | `bulkUpdate(columnId, value, scope?)` | `Promise<number>` | 일괄 셀 업데이트. `columnId`는 열 id이고 값은 그 열의 `field`에 씁니다(예: `{ id:'nm', field:'name' }` → `name`). `scope`: `'selected'`(기본)·`'all'`·행 ID 배열(문자열로 비교) — Worker 모드도 같습니다. `rowModel: 'server'` 는 받지 않은 행까지 범위 수정(`rangeUpdates`)으로 남깁니다 |
1266
1515
 
1267
1516
  #### 행 재정렬
1268
1517
 
@@ -1274,6 +1523,22 @@ await grid.changes.commit({ allowInvalid: true });
1274
1523
 
1275
1524
  ---
1276
1525
 
1526
+ ### grid.editing — 코드로 셀 편집
1527
+
1528
+ | 메서드 | 반환 | 설명 |
1529
+ |--------|------|------|
1530
+ | `startEditById(rowId, columnId?, options?)` | `Promise<boolean>` | 그 행·칸에서 편집 시작. `columnId` 생략 시 편집 가능한 첫 보이는 칸 |
1531
+ | `startEditByIndex(sourceIndex, columnId?, options?)` | `Promise<boolean>` | 원본 위치로 행을 찾아 편집 시작 |
1532
+ | `startEditActiveCell(options?)` | `Promise<boolean>` | 활성 칸에서 편집 시작 (F2와 같음) |
1533
+ | `commitEdit()` | `Promise<boolean>` | 열린 편집 확정. 검증 등으로 막히면 `false`, 열린 편집이 없으면 `true` |
1534
+ | `cancelEdit()` | `void` | 열린 편집 취소 |
1535
+
1536
+ - `options`: `{ initialValue?: string; scroll?: boolean = true }`. `initialValue`는 문자를 친 것처럼 그 값으로 시작합니다.
1537
+ - 순서: 열린 편집 확정(막히면 `false`) → 그 칸 선택 → 보이게 스크롤 → `cell:beforeEdit`(취소 가능) → 편집기 열기.
1538
+ - `false`가 되는 경우: 편집 가능한 칸이 없음(readonly·`editable:false`·`editableWhen`·키 컬럼 정책), 필터·접힌 그룹으로 숨겨짐, 전역 편집 불가, 피벗 모드, 행을 못 찾음, `scroll:false`인데 화면 밖.
1539
+
1540
+ ---
1541
+
1277
1542
  ### grid.undoManager — Undo/Redo API
1278
1543
 
1279
1544
  | 메서드/속성 | 반환 | 설명 |
@@ -1327,7 +1592,7 @@ await grid.changes.commit({ allowInvalid: true });
1327
1592
  | `setConfig(config)` | `Promise<void>` | 그룹화 설정 (`null`로 해제) |
1328
1593
  | `groupBy(columns)` | `Promise<void>` | 컬럼으로 그룹화 |
1329
1594
  | `clear()` | `Promise<void>` | 그룹화 해제 |
1330
- | `getConfig()` | `GroupingConfig \| null` | 현재 그룹화 설정 조회 (내부 참조) |
1595
+ | `getConfig()` | `RowGroupConfig \| null` | 현재 그룹화 설정 조회 (내부 참조) |
1331
1596
  | `toggle(groupId)` | `Promise<void>` | 그룹 접힘/펼침 토글 |
1332
1597
  | `expand(groupId)` | `Promise<void>` | 특정 그룹 펼침 |
1333
1598
  | `collapse(groupId)` | `Promise<void>` | 특정 그룹 접힘 |
@@ -1358,7 +1623,7 @@ await grid.changes.commit({ allowInvalid: true });
1358
1623
  | Shift+배너 클릭 | 마지막으로 클릭한 그룹 ~ 클릭한 그룹 사이의 그룹 전부 |
1359
1624
  | ▾ 버튼 클릭 / Ctrl+▾ | 접기/펼치기 / 같은 depth 일괄 접기/펼치기 (선택은 바뀌지 않음) |
1360
1625
 
1361
- - 결과: `grid.selection.getSelectedGroups()` → `[{ groupId, rowIds }]`, `selection:changed`의 `selectedGroups`. `rowIds`는 그룹의 리프 멤버 전체(접혀 있어도, 중첩 그룹이면 하위 리프 전부)이며 표기는 `getGroupRows`와 같습니다(단일 키 원시값).
1626
+ - 결과: `grid.selection.getSelectedGroups()` → `[{ groupId, groupKeys, rowCount, rowIds }]`, `selection:changed`의 `selectedGroups`. 서버 그룹(SSRM)에서는 `rowIds` 가 없습니다. `rowIds`는 그룹의 리프 멤버 전체(접혀 있어도, 중첩 그룹이면 하위 리프 전부)이며 표기는 `getGroupRows`와 같습니다(단일 키 원시값).
1362
1627
  - 행 선택(`rowSelection`) 결과는 바뀌지 않습니다 — 그룹 멤버를 행 선택에 넣으려면 소비처가 `rowIds`를 사용합니다.
1363
1628
  - 배너에서 고른 그룹은 배너와 그룹행 셀을 행 선택 색으로 칠합니다(`.ps-group-banner-selected`, `.ps-group-row-selected`). 범위가 지나간 그룹은 결과에만 기록되고 화면 표시는 없습니다(사각형은 배너에서 분할).
1364
1629
  - 배너 커서: selectable이면 배너 본문·그룹행은 데이터 셀과 같은 커서이고 ▾ 버튼만 pointer입니다. 옵션(`selectable`/`defaultCollapsed`/`defaultExpandLevel`)은 그룹 컬럼을 모두 뺐다가(`removeColumn`/`groupBy([])`/`setConfig({ columns: [] })`, `getConfig()`는 `null`) 다시 넣어도 유지되며, 생략 시 이어받고 명시값(`false` 포함)이 이깁니다. `clear()`/`setConfig(null)`은 옵션까지 지웁니다.
@@ -1418,7 +1683,7 @@ interface AggregateContext {
1418
1683
 
1419
1684
  - `ctx.sum('x')` 등은 엔진이 계산한 값을 읽으므로 **그 조합이 선언돼 있어야** 합니다 — 컬럼 정의 `aggregate: 'sum'`(숨김 컬럼 가능) 또는 `rowGroupConfig.aggregates`의 `{ column: 'x', func: 'sum' }`. 선언되지 않은 조합을 읽으면 `PS312` 오류입니다(그룹행: `refresh()`가 거절, 고정 합계 행: `console.error` 후 빈 값). 고정 합계 행은 컬럼 정의 `aggregate`만 선언으로 봅니다.
1420
1685
  - 다단 그룹은 단계마다 그 단계 집계로 다시 계산하고, 필터·편집·행 추가 후에도 다시 계산합니다.
1421
- - 결과가 `null`/`undefined`/`NaN`이면 그룹행 칸은 빈 칸(고정 합계 행은 기존 null 표기 `-`). 0 나누기는 함수에서 처리합니다.
1686
+ - 결과가 `null`/`undefined`/`NaN`이면 그룹행 칸은 빈 칸, 고정 합계 행은 `-`입니다. 0 나누기는 함수에서 처리합니다. (내장 함수의 빈 데이터 결과는 아래 `AggregateFunc` 규칙 — `sum`·개수류 `0`, 그 외 `null`.)
1422
1687
  - 일반 Main 모드는 기존 inline 함수를 그대로 사용합니다. Worker 모드는 `{ workerFunction: 'app:aggregate', params: ... }` 참조를 사용하며, 앱 Worker entry에 정적으로 등록된 함수가 실제 멤버 `values`와 `ctx`를 Worker 안에서 받습니다. 메인 계산 왕복·fallback은 없고 inline 함수·미등록 ID는 오류입니다. 함수의 마지막 인수는 descriptor의 `params`입니다.
1423
1688
  - 함수형 컬럼 aggregate가 하나라도 있으면 선언된 문자열 컬럼 aggregate를 모두(숨김·배너 아래 컬럼 포함) 계산만 합니다. 이 값들은 배너 글자에 나오지 않습니다.
1424
1689
  - 함수형 aggregate는 프리셋(JSON)에 저장되지 않고 코드가 소유합니다 — `applyConfig`가 지우거나 문자열로 바꾸지 않으며, wrapper 집계 탭은 "사용자 함수"로 표시하고 변경을 막습니다.
@@ -1457,7 +1722,7 @@ summaryRow: 'pinnedBottom', // 고정 합계 행도 전체 Σ/Σ
1457
1722
  #### 그룹행 정렬 기준
1458
1723
 
1459
1724
  core의 `RowGroupConfig.sorts`는 같은 부모 아래 그룹행을 그룹 컬럼별로 정렬합니다.
1460
- 각 규칙은 `{ column, direction, by }`이며 `column`은 해당 그룹 레벨의 컬럼 id,
1725
+ 각 `RowGroupSort` 규칙은 `{ id, direction, basis?, by? }`이며 `id`는 해당 그룹 레벨의 컬럼 ID,
1461
1726
  `direction`은 `'asc' | 'desc'`입니다.
1462
1727
 
1463
1728
  - `id`: 정렬할 그룹 레벨의 컬럼 ID (집계 데이터 컬럼이 아님).
@@ -1512,6 +1777,8 @@ wrapper 정렬 패널의 **그룹 정렬 → 정렬 기준**에서도
1512
1777
  | `expandAtLevel(level)` | `Promise<void>` | 정확히 그 레벨의 노드만 펼침 — 다른 레벨 보존 (Ctrl/Cmd+클릭 제스처와 동일) |
1513
1778
  | `collapseAtLevel(level)` | `Promise<void>` | 정확히 그 레벨의 노드만 접힘 — 다른 레벨 보존 |
1514
1779
  | `isCollapsed(nodeId)` | `boolean` | 접힘 상태 조회 |
1780
+ | `getTreeLevel(nodeId: string \| number)` | `number` | 마지막 트리 조회 결과 기준 깊이. root=0, 미존재/조회 전=-1 |
1781
+ | `getNodeMeta(nodeId: string \| number)` | `{ level: number; hasChildren: boolean; descendantCount: number; aggregates?: Record<string, CellValue> } \| null` | 마지막 트리 조회 결과의 노드 메타데이터. 없으면 `null` |
1515
1782
  | `expandPath(nodeId)` | `Promise<void>` | 조상 전체 펼치기 (검색 결과 표시용) |
1516
1783
  | `moveNode(nodeId, newParentId, newIndex?)` | `Promise<void>` | **프로그램 방식 트리 노드 이동** (드래그 없이 코드로). flat 그리드 `changes.reorderRow`의 tree 대칭 API. 아래 참조 |
1517
1784
  | `getChildren(nodeId)` | `RowId[]` | 직계 자식 rowId 목록 (소스 순서). 리프/미존재 시 `[]` |
@@ -1519,6 +1786,8 @@ wrapper 정렬 패널의 **그룹 정렬 → 정렬 기준**에서도
1519
1786
  | `getParent(nodeId)` | `RowId \| null` | 부모 rowId. root/미존재 시 `null` |
1520
1787
  | `getAncestors(nodeId)` | `RowId[]` | 조상 rowId 목록 (가까운 부모부터 root 방향). root/미존재 시 `[]` |
1521
1788
 
1789
+ `getNodeMeta().aggregates`는 `treeConfig.aggregates` 설정 시 **자기+자손 서브트리**의 집계이며 키는 `${func}_${columnId}`(예: `sum_amount`)입니다. 노드 셀 값을 자동 대체하지 않으므로 표시는 소비자 렌더러가 결정합니다.
1790
+
1522
1791
  > **트래버설 (`getChildren` / `getDescendants` / `getParent` / `getAncestors`)** — `treeConfig.idField`/`parentField` 값 기준으로 **소스 전체를 스캔**하므로 접힘·필터·정렬 상태와 무관하게 논리적 트리 구조를 반환합니다. 반환값은 rowId 배열(원형 유지)이며 `grid.rows.getByIds(...)`와 조합해 일괄 수정에 사용합니다 (부모↔자손 체크박스 cascade 등). `treeConfig` 미설정 시 모두 빈 결과. 조회당 O(N) — 프레임 내 반복 호출은 피하세요.
1523
1792
 
1524
1793
  > **`moveNode(nodeId, newParentId, newIndex?)`** — `nodeId`를 `newParentId`의 `newIndex`번째 자식으로 이동합니다.
@@ -1616,6 +1885,7 @@ wrapper 정렬 패널의 **그룹 정렬 → 정렬 기준**에서도
1616
1885
  | `getSourceRowsForSelection()` | `Promise<number[]>` | 선택 영역의 소스 행 (`trackSourceRows: true` 필요) |
1617
1886
  | `getGrandTotals()` | `Promise<PivotGrandTotal[]>` | 값 필드별 전체 총합계(코너). 현재 필터 반영, 표시 on/off 무관 상시 계산. 비피벗이면 `[]` |
1618
1887
  | `getColumnGrandTotals(columnIds?)` | `Promise<PivotColumnGrandTotal[]>` | 컬럼별 행 총합계. `columnIds` 생략 시 전체 데이터 컬럼. 표시 on/off 무관. 비피벗이면 `[]` |
1888
+ | `getSourceRowCount()` | `{ total: number; filtered: number } \| null` | 피벗 **원본 행 수**(소계·총합계·접기 무관). `total` = 필터 전, `filtered` = 차원·값 필터 반영(값 필터는 통과한 피벗 행에 속한 원본 행 수). `filtered` 가 0 이면 총합계 행도 없는 빈 표. 화면 피벗 행 수는 `getTotalRowCount()`/`getVisibleRowCount()`. 예) 원본 5행·소계+총합계 → 피벗 7행, `{ total: 5, filtered: 5 }`. 비피벗이면 `null` |
1619
1889
  | `getFieldValues(fieldId, options?)` | `Promise<CellValue[]>` | 필드의 값 목록 (행/열 자동 판별). `options.unique` 기본 `true` |
1620
1890
  | `getAllRows()` | `Promise<PivotRow[]>` | lazy 모드에서도 전체 피벗 행을 가져옴 |
1621
1891
  | `getDataMatrix(options?)` | `Promise<DataMatrix \| null>` | 피벗 결과를 2D 표로 변환 |
@@ -1814,7 +2084,10 @@ interface InvalidCell {
1814
2084
  |--------|------|------|
1815
2085
  | `copy()` | `Promise<string \| null>` | 선택 셀 복사 |
1816
2086
  | `copy({ includeHeaders: true })` | `Promise<string \| null>` | 헤더 포함 복사 |
1817
- | `paste()` | `Promise<number>` | 클립보드에서 붙여넣기 (셀 수 반환) |
2087
+ | `paste(options?: PasteOptions)` | `Promise<number>` | 값이 쓰인 셀 수 반환. `options.mode`는 `'overwrite'`(기본) 또는 `'insert'` |
2088
+ | `pasteInsert()` | `Promise<number>` | `paste({ mode: 'insert' })` 편의 함수 |
2089
+
2090
+ `overwrite`는 앵커 셀부터 기존 값을 덮어쓰며 행 수를 늘리지 않습니다. `insert`는 앵커 행 위치에 새 행을 삽입하고 기존 행을 아래로 밉니다. 정렬·필터·행 그룹·트리·피벗이 적용된 view에서는 삽입 위치를 확정할 수 없어 수행하지 않고 `clipboard:paste`에 `rejected: true`를 전달합니다. 클립보드 접근에는 브라우저 권한/보안 컨텍스트 제약이 적용됩니다.
1818
2091
 
1819
2092
  #### 키보드 단축키
1820
2093
 
@@ -1866,6 +2139,52 @@ interface InvalidCell {
1866
2139
  | `getSheetInfo(file)` | `Promise<SheetInfo[]>` | 시트 목록과 예상 행 수 조회 (import 전 미리보기용) |
1867
2140
  | `previewExcel(file, options?)` | `Promise<PreviewResult>` | 헤더 + 첫 N행 반환 (그리드에 로드하지 않음) |
1868
2141
 
2142
+ `grid.io`는 그리드에 로드된 데이터를 내보냅니다. 아직 로드하지 않은 서버 조회 전체를 내보내려면 아래 `XlsxExporter`에 호출자가 전체 페이지를 공급해야 합니다.
2143
+
2144
+ #### 전체 조회 스트리밍 — XlsxExporter
2145
+
2146
+ `XlsxExporter`, `MemoryWriteSink`, `FileWriteSink`, `canUseFileSystemAccess` 및 타입 `XlsxWriteSink`, `ExportColumnMeta`는 core 루트에서 import할 수 있습니다. 이 진입점은 그리드 인스턴스 없이 외부 조회 페이지를 XLSX로 작성합니다. 서버 조회·페이지 순서·컬럼 메타데이터·총 행 수는 소비처 책임이며 그리드의 필터나 표시 서식이 자동 적용되지는 않습니다.
2147
+
2148
+ | 진입점 | 반환 | 계약 |
2149
+ |---|---|---|
2150
+ | `new XlsxExporter(columns: ExportColumnMeta[], options?)` | `XlsxExporter` | 옵션 타입은 `ConstructorParameters<typeof XlsxExporter>[1]` 참조. `totalRows`, `rowsPerSheet`, `includeHeaders`, `sheetName`, `compression` 등을 받으며 `ExcelExportOptions`와 다름 |
2151
+ | `beginStream(sink: XlsxWriteSink)` | `Promise<void>` | 작성 시작. 시트 수는 `totalRows`로 사전 결정 |
2152
+ | `writeChunkStream(rows)` | `Promise<void>` | 데이터 청크 순차 작성. 일반 `Record<string, unknown>[]` 페이지를 전달 가능 |
2153
+ | `finalizeStream()` | `Promise<void>` | 마지막 시트·ZIP 마무리와 sink.close까지 대기 |
2154
+ | `abortStream(reason?: unknown)` | `Promise<void>` | sink가 abort를 제공하면 호출. 이미 작성한 모든 외부 효과의 롤백을 보장하지 않음 |
2155
+ | `getProgress()` | `ExportProgress` | 현재 행 작성 진행률 조회 ([진행률 타입](#진행률--importprogress--exportprogress)). 100%만으로 finalize/저장 완료를 판정하지 않음 |
2156
+ | `exportAllToBlob(rows: Record<string, unknown>[])` | `Promise<Blob>` | 이미 메모리에 있는 전체 배열을 한 번에 변환하는 편의 API |
2157
+
2158
+ `XlsxWriteSink`는 `readonly position: number`, `write(data: Uint8Array): Promise<void>`, `close(): Promise<void>` 계약입니다. `MemoryWriteSink`는 바이트를 메모리에 누적하며 `toBlob(type?: string): Blob`, `dispose(): void`를 제공합니다. 메모리 사용량이 파일 크기에 비례하므로 대용량에서는 파일 sink를 고려하세요. `FileWriteSink`는 호출자가 연 writable file stream을 생성자로 받아 직접 기록하며 `abort(reason?)`도 제공합니다.
2159
+
2160
+ `canUseFileSystemAccess(): boolean`은 `showSaveFilePicker` 존재 여부만 확인합니다. 저장 권한이나 성공을 보장하지 않으며 file picker 호출·사용자 제스처·취소 처리는 호스트 책임입니다. 직접 `XlsxExporter`를 사용하면 `grid.io.saveExcelToFile`의 다운로드 폴백/Worker 실행/AbortSignal 처리가 자동으로 붙지 않습니다.
2161
+
2162
+ 스트림마다 새 exporter/sink를 만들고 `beginStream → writeChunkStream(각 페이지를 await) → finalizeStream` 순서를 지키세요. 시트 분할을 쓸 때는 정확한 `totalRows`를 제공하고 `rowsPerSheet`에 헤더 행도 포함하세요. 실패 시 `abortStream(error)`로 정리하며 메모리 sink는 별도로 `dispose()`합니다.
2163
+
2164
+ ```typescript
2165
+ import { XlsxExporter, MemoryWriteSink, type ExportColumnMeta } from '@vmscloud/moz-ui-grid-core';
2166
+
2167
+ async function exportPages(
2168
+ columns: ExportColumnMeta[],
2169
+ pages: AsyncIterable<Record<string, unknown>[]>,
2170
+ totalRows: number,
2171
+ ): Promise<Blob> {
2172
+ const sink = new MemoryWriteSink();
2173
+ const exporter = new XlsxExporter(columns, { totalRows, rowsPerSheet: 1_048_576 });
2174
+ try {
2175
+ await exporter.beginStream(sink);
2176
+ for await (const rows of pages) await exporter.writeChunkStream(rows);
2177
+ await exporter.finalizeStream();
2178
+ return sink.toBlob(); // 다운로드/파일 저장은 호출자가 처리
2179
+ } catch (error) {
2180
+ await exporter.abortStream(error);
2181
+ throw error;
2182
+ } finally {
2183
+ sink.dispose();
2184
+ }
2185
+ }
2186
+ ```
2187
+
1869
2188
  #### Export 옵션 — `ExcelExportOptions`
1870
2189
 
1871
2190
  `timeZone?: string`은 해당 작업만 코어 시간대보다 우선합니다. `saveExcelToFile`과 Worker 경로에도 적용됩니다.
@@ -1877,7 +2196,7 @@ interface InvalidCell {
1877
2196
  | `includeHeaders` | `boolean` | `true` | 헤더 행 포함 |
1878
2197
  | `sheetName` | `string` | `'Sheet1'` | 시트 이름 |
1879
2198
  | `preserveFormatting` | `boolean` | `false` | 숫자 decimalFormat 및 구조화된 날짜 mask → 컬럼별 Excel numFmt 매핑 |
1880
- | `structuralPinnedRows` | `'exclude' \| 'include'` | `'exclude'` | flat XLSX의 구조적 고정 행 포함 여부. 범위/제외 규칙은 [아래 설명](#structural-pinned-행-xlsx-export) 참조 |
2199
+ | `includePinnedRows` | `boolean` | `false` | 일반 flat XLSX·CSV의 화면에 보이는 구조/데이터 고정 행 포함 여부. 범위/제외 규칙은 [아래 설명](#고정-행-xlsxcsv-export-includepinnedrows) 참조 |
1881
2200
  | `chunkSize` | `number` | `10000` | 청크당 행 수 |
1882
2201
  | `compression` | `'deflate' \| 'store'` | `'deflate'` | 압축 방식 |
1883
2202
  | `freezeHeader` | `boolean` | `false` | 헤더 행 고정 (freeze pane) |
@@ -1891,8 +2210,14 @@ interface InvalidCell {
1891
2210
 
1892
2211
  ##### XLSX Worker 기본 경로와 실패 처리
1893
2212
 
1894
- - `toExcel()`과 `saveExcelToFile()`은 한 행짜리 export도 기본 공통 XLSX Worker 경로를 사용합니다. 그리드 생성 설정의 `useWorker`(데이터 처리 Worker, 기본 `false`)와 이 export 옵션은 별개입니다.
2213
+ - `toExcel()`과 `saveExcelToFile()`은 한 행짜리 export도 기본 공통 XLSX Worker 경로를 사용합니다. 그리드의 데이터 처리 Worker(`loader.worker`)와 이 export 옵션은 별개입니다.
2214
+ - 내보낼 행이 Proxy(예: Vue `reactive` 데이터)라 Worker로 그대로 못 보내면 평범한 값으로 깊은 복사해 보내고 `[PureSheet]` 경고를 냅니다(한 내보내기 안에서는 1회, export 결과는 같음). 함수·클래스 인스턴스 값은 여전히 보낼 수 없습니다.
1895
2215
  - export 옵션에 `useWorker: false`를 명시한 경우만 메인 스레드 생성 경로를 선택합니다. Worker 실행 실패 시 메인 스레드에서 자동 재시도하지 않고 export Promise가 reject됩니다.
2216
+ - **함수 서식 열**(`mask: { type: 'function', formatter: v => … }` inline 함수, 피벗 값 필드의 사용자 `mask` 포함)은 `preserveFormatting: true`(래퍼 `ExcelController` 기본)일 때 표시 문자열 글자 셀로 나가며, 기본 Worker 경로로도 내보낼 수 있습니다. 화면 표시와 같은 규칙으로, 메인이 행 청크를 Worker로 보내기 직전에 그 청크의 표시 문자열을 계산해 함께 보내고 Worker는 그 글자를 글자 셀로 씁니다(ZIP/XML 생성은 Worker). 결과는 `useWorker: false`와 셀 단위로 같습니다. 예: `formatter: v => '₩' + v.toLocaleString()` 열의 값 `1000` → 글자 셀 `₩1,000`.
2217
+ - formatter 인자는 화면과 같은 `(value, rowData, columnKey, timeZone)`입니다(`useWorker: false`도 같음). 값이 `null`/`undefined`인 셀은 formatter를 부르지 않고 빈 셀로 둡니다. 빈 문자열 반환도 빈 셀입니다.
2218
+ - formatter가 예외를 던지면 export Promise가 그 오류로 reject됩니다(`useWorker: false`와 같음). 메인 재생성으로 숨기지 않습니다.
2219
+ - 등록 참조(`formatter: { workerFunction: 'app:money', params }`)는 그대로 XLSX Worker 안에서 실행합니다. Worker가 메인에 함수 실행을 요청하는 왕복 콜백은 없습니다.
2220
+ - 메인 추가 비용은 함수 서식 셀마다 formatter 1회(메인 생성과 같은 호출 수)이며, 청크(`chunkSize`, 기본 10,000행) 단위로 나뉘어 실행됩니다.
1896
2221
  - `saveExcelToFile()`의 FSA 미지원 다운로드 폴백은 저장 방식의 전환입니다. 내부 `toExcel(options)`에도 같은 export 옵션을 전달하므로 Worker 실패를 메인 생성으로 우회하는 폴백이 아닙니다. `fallbackToDownload: false`이면 FSA 미지원 시 오류를 반환합니다.
1897
2222
  - 이 실패 정책은 **XLSX export**에 해당합니다. `fromExcel()`의 import는 별도 크기 기반 Worker 선택을 사용하며, Worker 오류(취소 `AbortError` 제외) 시 메인 스레드 파싱으로 폴백합니다.
1898
2223
 
@@ -1921,6 +2246,7 @@ XLSX·CSV의 행 순서는 **화면에 보이는 순서**입니다(원본 Wijmo
1921
2246
  고정 행 데이터의 `__merge` 같은 사용자 필드는 `includePinned` 여부와 관계없이 병합으로 해석하지 않습니다(고정 행 병합은 위 설정에서만 나옴).
1922
2247
 
1923
2248
  - `exportSource: true`는 항상 원본 데이터만 내보냅니다. 피벗 결과 export와 CSV는 이 옵션을 무시합니다.
2249
+ - `exportSource: true`의 원본은 `getAllDataSync()` 값입니다. `keyFields`가 있는 그리드는 **저장(commit)된 원본**이라 저장 전 편집·추가 행이 빠집니다(예: qty를 4→7로 고치고 저장 전 내보내면 파일은 4). `keyFields`가 없는 메인 스레드 그리드는 저장 전 편집·추가가 반영된 값입니다. 편집 내용을 포함하려면 먼저 `changes.commit()` 하세요(ITSM-2026-004445).
1924
2250
  - 구조 고정 행의 집계는 현재 supplier 집계 요청 완료를 기다린 뒤 **숫자 등 원래 타입**으로 내보냅니다. 같은 `field`를 쓰는 서로 다른 컬럼의 sum/avg도 컬럼 ID별로 분리됩니다. 원본 row 데이터와 집계값을 단순 spread로 합치지 않습니다.
1925
2251
  - 구조 고정 행의 라벨은 pinned 행의 `data`에 **데이터 키(field)** 로 선언합니다. 예: `{ data: { name: '합계' }, aggregates: [{ columnId: 'total', func: 'sum' }] }`. 집계가 선언된 컬럼은 그 집계값이 우선합니다.
1926
2252
  - 데이터 고정 행은 본문 행과 같은 셀 값 변환(날짜·숫자, `preserveFormatting` 서식)으로 현재 데이터(편집 반영)를 씁니다.
@@ -2333,7 +2659,7 @@ const b = store.select(s => s.canRedo);
2333
2659
  | `pivotMode` | `'flat' \| 'pivot'` | 현재 피벗 모드 (`setMode()`의 권위 있는 신호) |
2334
2660
  | `treeConfig` | `Readonly<TreeConfig> \| null` | 현재 트리 설정 |
2335
2661
  | `masterDetailConfig` | `Readonly<MasterDetailConfig> \| null` | 현재 마스터-디테일 설정 |
2336
- | `groupConfig` | `Readonly<GroupingConfig> \| null` | 현재 그룹화 설정 (`viewState.groups`는 이로부터 합성됨) |
2662
+ | `rowGroupConfig` | `Readonly<RowGroupConfig> \| null` | 현재 그룹화 설정 (`viewState.groups`는 이로부터 합성됨) |
2337
2663
  | `dragDropConfig` | `Readonly<DragDropConfig> \| null` | 현재 드래그-드롭 설정 |
2338
2664
  | `mergeConfig` | `Readonly<MergeConfig> \| null` | 현재 셀 병합 설정 |
2339
2665
 
@@ -2506,7 +2832,7 @@ grid.off('cell:click', handler);
2506
2832
 
2507
2833
  | 이벤트 | 페이로드 | 설명 |
2508
2834
  |--------|---------|------|
2509
- | `data:loaded` | `{ rowCount, columnCount }` | 데이터 로드 완료 |
2835
+ | `data:loaded` | `{ rowCount, columnCount }` | 데이터 로드 완료. `rowCount` 는 불러온 원본 행 수(`loadData`·`loader` 공통, 피벗이어도 총합계·소계를 포함한 결과 행 수가 아님 — 예: 피벗+행 총합계+0행 → `0`). `loader` 는 첫 로드와 `refreshLoaderData()` 재조회가 **성공할 때마다 한 번씩** 발행(필터 중이어도 필터 전 원본 수, 실패하면 발행 안 함). `rowModel: 'server'`(SSRM)의 `refreshLoaderData()` 는 발행하지 않음 |
2510
2836
 
2511
2837
  #### 행
2512
2838
 
@@ -2808,7 +3134,7 @@ grid.on('changes:redone', (e) => syncChange(e.change, 'redo'));
2808
3134
 
2809
3135
  | 이벤트 | 페이로드 | 설명 |
2810
3136
  |--------|---------|------|
2811
- | `scroll` | `{ scrollTop, scrollLeft }` | (예약) 현재 코드 경로에서는 발행되지 않음. 스크롤 상태는 `grid.select(s => s.scrollTop / visibleStartIndex / visibleEndIndex)`로 구독하세요 |
3137
+ | `scroll` | `ScrollPayload` | 세로 range 변경 시 발행. [스크롤 이벤트 상세](#스크롤-1)의 payload 참조. 수평 이동만으로 발행된다는 보장은 없음 |
2812
3138
 
2813
3139
  #### 검색 / 구조 변경
2814
3140
 
@@ -2857,11 +3183,14 @@ interface TreeConfig {
2857
3183
  defaultCollapsed?: boolean; // 기본 접힘 상태
2858
3184
  defaultExpandLevel?: number; // 기본 확장 레벨 (0=루트만, Infinity=전체)
2859
3185
  aggregates?: Record<string, AggregateFn>; // 집계 설정
2860
- lazyLoad?: boolean; // 서버 lazy loading
2861
- fetchChildren?: (nodeId: string | number) => Promise<Row[]>; // 자식 fetch 함수
3186
+ childCountField?: string; // rowModel: 'server' 트리 — 자식 여부 필드 (기본: 'childCount'). 숫자=자식 수, true=있음(수 모름)
2862
3187
  }
2863
3188
  ```
2864
3189
 
3190
+ 재로드(`loadData`) 때 `defaultCollapsed`/`defaultExpandLevel`은 행 그룹과 같이 **처음 보는 부모 노드**(자식이 생긴 노드 포함)에 적용하고, 이미 본 노드는 지금 상태(사용자가 펴거나 접은 것)를 유지합니다. 예: `defaultCollapsed: true`로 빈 데이터에서 시작해 조회하면 부모 노드가 모두 접힌 채 나오고, `A`를 편 뒤 재조회로 `C`가 새로 생기면 `A`는 펼친 채, `C`는 접힌 채입니다. `grid.tree.setConfig`로 설정을 바꾸면 모든 노드에 다시 적용합니다.
3191
+
3192
+ 필드 옵션 `checkboxIncludesDescendants: true`(boolean 열) — 트리에서 **부모가 자손을 포함**하는 체크박스. 부모 칸은 자손 상태(전부 checked / 일부 indeterminate / 없음 unchecked)로 보이고, 부모를 누르면 부모와 자손 전체(필터로 숨은 자손 포함)가 같은 값이 됩니다(실행 취소 한 번). 잎은 평소처럼 자기 값만 바뀝니다. 예전 이름 `checkboxTriState`(표시만 하고 퍼뜨리기는 소비처 몫이던 옵션)를 대신합니다. 클라이언트 트리에서 자손 값을 읽으려면 `enableReferCache: true` 가 필요합니다. `rowModel: 'server'` 트리는 [서버 행 모델 — 트리](#서버-행-모델-ssrm--rowmodel-server--loader) 참조.
3193
+
2865
3194
  ### MasterDetailConfig
2866
3195
 
2867
3196
  ```typescript
@@ -2896,19 +3225,37 @@ interface MasterDetailConfig {
2896
3225
  - `visible:false`·Ctrl+H·데이터 행 고정·그룹/트리 접힘으로 안 보이는 동안 디테일은 표시하지 않습니다. 기본(`true`)은 행 키의 펼침 상태를 기억하여 다시 보일 때 복원하고, `false`는 기존처럼 접습니다.
2897
3226
  - `singleExpand: true`와 함께 쓰면 마지막으로 펼친 한 행만 유지됩니다.
2898
3227
 
2899
- ### GroupConfig
3228
+ ### RowGroupConfig
2900
3229
 
2901
- 초기 그리드 생성 시 `FlatModeConfig.rowGroup`으로 전달하는 가벼운 설정 (구 `group` 필드는 deprecated 별칭). *런타임*에는 `grid.rowGroup.setConfig()`로 더 풍부한 [GroupingConfig](#groupingconfig)를 사용합니다.
3230
+ 생성자의 `rowGroupConfig`와 런타임 `grid.rowGroup.setConfig(config)`는 같은 공개 타입을 사용합니다. 구 `rowGroup`/`group` 키는 옛 저장본 마이그레이션 대상이지 현행 생성 옵션 별칭이 아닙니다.
2902
3231
 
2903
3232
  ```typescript
2904
- interface GroupConfig {
2905
- /** 그룹핑 기준 컬럼 (계층 순서) */
3233
+ interface RowGroupConfig {
2906
3234
  columns: string[];
2907
- /** 소계 표시할 컬럼 ID 목록 — 각 컬럼의 FieldDef.aggregate 함수를 사용 */
2908
- subtotals?: string[];
3235
+ aggregates?: RowGroupAggregate[];
3236
+ sorts?: RowGroupSort[];
3237
+ defaultCollapsed?: boolean;
3238
+ defaultExpandLevel?: number;
3239
+ selectable?: boolean;
2909
3240
  }
2910
3241
  ```
2911
3242
 
3243
+ `defaultCollapsed: true`는 `defaultExpandLevel`보다 우선합니다. `defaultExpandLevel`은 0이면 최상위 그룹행만, 1이면 그 아래 한 단계까지 펼치며 생략하면 모두 펼칩니다. `selectable`은 기본 `false`입니다.
3244
+
3245
+ 재로드(`loadData`) 때 기본 접힘 규칙은 **처음 보는 그룹**에 적용하고, 이미 본 그룹은 지금 상태(사용자가 펴거나 접은 것)를 유지합니다(서버 그룹과 같은 규칙). 예: `defaultCollapsed: true`로 빈 데이터에서 시작해 조회하면 도착한 그룹이 모두 접힌 채 나오고, `HR`을 편 뒤 재조회로 `Ops`가 새로 생기면 `HR`은 펼친 채, `Ops`는 접힌 채입니다. 그룹 열이 바뀌거나 `defaultCollapsed`/`defaultExpandLevel`을 바꾸면 모든 그룹에 다시 적용합니다. Vue/React 래퍼의 `data`는 참조로 비교하므로 같은 배열을 다시 넣으면 재로드(`data:loaded`)가 일어나지 않습니다 — 조회마다 새 배열을 넘기세요.
3246
+
3247
+ ```typescript
3248
+ const rowGroupConfig: RowGroupConfig = {
3249
+ columns: ['region', 'product'],
3250
+ aggregates: [
3251
+ { column: 'amount', func: 'sum', scope: ['region'] },
3252
+ { column: 'amount', func: 'avg', scope: ['region', 'product'] },
3253
+ ],
3254
+ };
3255
+ // 생성자: new PureSheet(container, { fields, data, keyFields: ['id'], rowGroupConfig })
3256
+ await grid.rowGroup.setConfig(rowGroupConfig);
3257
+ ```
3258
+
2912
3259
  #### 그룹 행 rowHeader 체크박스
2913
3260
 
2914
3261
  `rowSelection.trigger === 'checkbox'` 와 함께 쓰면 그룹 행 rowHeader 에도 체크박스가 표시됩니다(기본 활성).
@@ -2938,27 +3285,25 @@ rowSelection: { mode: 'multi', trigger: 'checkbox', groupRowCheckbox: false } /
2938
3285
  ⚠️ `rowHeader.cellRenderer` 로 커스텀 렌더러를 지정하면 이 기능은 비활성입니다 — 그 칸의 내용은
2939
3286
  소비처 소유이므로 그리드가 덮어쓰지 않습니다.
2940
3287
 
2941
- ### GroupingConfig
2942
-
2943
- `grid.rowGroup.setConfig(config)`로 런타임에 사용. 집계 함수와 기본 접힘 상태를 직접 지정할 수 있습니다.
3288
+ ### RowGroupAggregate
2944
3289
 
2945
3290
  ```typescript
2946
- interface GroupingConfig {
2947
- /** 그룹화할 컬럼 키들 (계층 순서) */
2948
- columns: string[];
2949
- /** 집계 설정 (컬럼 키 → 집계 함수). AggregateFn은 문자열 또는 콜백 */
2950
- aggregates?: Record<string, AggregateFn>;
2951
- /** 기본 접힘 상태 */
2952
- defaultCollapsed?: boolean;
2953
- /** 그룹 배너 선택 허용 (기본 false) — [그룹 배너 선택](#그룹-배너-선택-selectable) */
2954
- selectable?: boolean;
3291
+ interface RowGroupAggregate {
3292
+ id?: string;
3293
+ column: string;
3294
+ func: AggregateFn;
3295
+ scope?: string[];
2955
3296
  }
2956
3297
  ```
2957
3298
 
3299
+ `func`는 반드시 명시하며 `FieldDef.aggregate`에서 빌려오지 않습니다. `scope`는 그룹 컬럼 ID 경로이며 생략하면 모든 레벨, 지정하면 그 경로의 레벨별 그룹에 적용합니다. 기본 집계 ID는 내장 함수의 경우 `${func}_${column}`, 콜백은 `column`입니다. 같은 레벨에 적용되는 ID는 유일해야 하며 여러 콜백은 명시 ID로 구분하세요. 집계 설정은 Record 맵이나 `subtotals` 배열이 아닙니다.
3300
+
2958
3301
  ### EditorConfig
2959
3302
 
2960
3303
  가상 스크롤은 편집 확정/취소 요청이 아니다. 편집 중인 행 shell과 입력 DOM은 연결된 상태로 재사용을 유예하며, viewport에 필요한 다른 행은 별도로 확보한다. 화면 밖 입력창을 고정 floating editor로 옮기지 않는다. dropdown/datepicker/multiselect popup도 스크롤만으로 닫지 않으며, 선택·검색·표시 월 상태를 유지한다. 일반 blur/다른 셀 편집으로 종료할 때는 시작 시점의 행 ID/열 ID에 적용하며 기존 입력 제약·확정 거부를 우회하지 않는다.
2961
3304
 
3305
+ **Esc 2단계**: 편집 중 Esc는 편집만 취소한다(선택 유지, `selection:changed` 없음 — text·dropdown·datepicker·multiselect 공통). 취소로 포커스가 body로 떨어지면 그리드로 되돌리므로, 한 번 더 Esc를 누르면 선택이 해제된다. 편집 중이 아닐 때 Esc 1번은 선택 해제다.
3306
+
2962
3307
  커스텀 에디터의 body portal은 `CustomEditorParams.registerOwnedSurface?.(portalElement)`로 해당 편집의 focus surface임을 등록할 수 있다. 반환 함수는 등록을 해제하며 편집 종료 시 core 등록도 해제된다. core는 portal DOM 자체를 제거하지 않는다. portal 안으로 이동한 focus는 외부 blur로 확정하지 않고, 그 surface 밖으로 이동하면 기존 `getValue`/확정 경로를 사용한다. `destroy`는 정상 blur를 포함한 종료에서 호출되므로 portal DOM/사용자 listener 정리는 그 hook에서 수행한다. 소비자가 독자적으로 등록한 `blur → onCancel`의 의미를 core가 재정의하지 않는다. 등록하지 않은 외부 surface를 전역 CSS class로 추정하지 않는다.
2963
3308
 
2964
3309
  이 보존은 필터 정책·삭제 정책 변경과 별개다. 데이터 전체 교체는 기존 설치 경계에서 이전 편집을 무효화하며, 이전 dataset의 비동기 확정이 새 dataset에 쓰이지 않도록 검사한다. native IME 후보창/브라우저별 focus 수용 검증과 framework/Worker 통합은 별도 검증 대상이다.
@@ -2972,17 +3317,29 @@ interface EditorConfig {
2972
3317
  max?: number; // number 전용, 포함하는 최댓값
2973
3318
  step?: number; // number 전용, 양수 간격 (기준: min 또는 0)
2974
3319
  inputMask?: string; // text 전용, 입력 마스크 (예: '99:99') — 표시 mask와 별개
2975
- options?: EditorOption[]; // dropdown/select 옵션
3320
+ options?: EditorOption[] | CellObject[]; // dropdown/select/multiselect 옵션 (객체 값 열은 객체 배열만 줘도 됨 → FieldDef 의 "객체 값 열" 절)
2976
3321
  searchable?: boolean; // 검색 활성화 (dropdown, multiselect)
2977
3322
  placeholder?: string; // placeholder 텍스트
2978
3323
  dateFormat?: string; // datepicker 포맷 (기본: 'yyyy-MM-dd')
2979
3324
  separator?: string; // multiselect 구분자 (기본: ',')
2980
3325
  renderer?: (params: CustomEditorParams) => HTMLElement; // 커스텀 에디터 렌더러
2981
- getValue?: (el: HTMLElement) => CellValue; // 커스텀 값 추출
3326
+ getValue?: (el: HTMLElement) => CellValue; // 커스텀: 그리드가 값을 가져가는 함수 (아래)
2982
3327
  destroy?: (el: HTMLElement) => void; // 커스텀 정리
2983
3328
  }
2984
3329
  ```
2985
3330
 
3331
+ **커스텀 에디터의 `getValue`** — 그리드가 값을 **가져가는** 함수입니다. 에디터가 `onCommit` 을 부르지 않은 채 그리드가 스스로 편집을 확정할 때(창 밖 클릭·다른 셀 클릭/편집 시작·Enter/Tab 이 그리드까지 올라옴·`commitEdit()` API·설정 적용 전 확정) renderer 가 돌려준 요소를 받아 **셀에 저장할 데이터 값**을 돌려줍니다.
3332
+
3333
+ | `getValue` | 결과 |
3334
+ |---|---|
3335
+ | 값을 돌려줌(`null` 포함) | 그 값으로 확정(값이 같으면 기록 안 함, `cell:editCommitting` 취소 가능) |
3336
+ | `undefined` 를 돌려줌 — 지금 내용을 값으로 읽을 수 없음(입력 오류 등) | **원래 값 유지**(편집 취소, `editCancel`). 확정 호출은 막히지 않음(다른 셀로 이동됨) |
3337
+ | 예외를 던짐 | 원래 값 유지 + `console.warn` 1회 |
3338
+ | 지정 안 함 + renderer 요소가 `input`/`select`/`textarea` | 그 요소의 값(기존 규칙) |
3339
+ | 지정 안 함 + 그 밖의 요소(`div` 등) | 원래 값 유지 (예전에는 `null` 이 저장됐다) |
3340
+
3341
+ 에디터 안에서 Enter/Tab/Esc 를 직접 쓰려면(여러 줄 입력 등) keydown 의 `stopPropagation()` 으로 그리드에 올라가지 않게 하세요 — 올라가면 그리드가 위 규칙으로 확정·취소합니다.
3342
+
2986
3343
  #### 숫자 입력 제약
2987
3344
 
2988
3345
  `min`/`max`/`step`은 **`type: 'number'` 전용**입니다. 생략 시 범위 제한이 없고 소수 입력도 허용됩니다(`step` 생략 시 HTML input의 `step='any'`). `step`을 지정하면 `min ?? 0`을 기준으로 그 간격에 맞는 값만 확정할 수 있습니다. 소수 간격도 지원하며 값을 반올림하거나 범위에 맞춰 자동 보정하지 않습니다.
@@ -3095,7 +3452,7 @@ interface FunctionMaskConfig {
3095
3452
  type: 'function';
3096
3453
  /** 포맷 함수 — 추가 context(rowData, columnKey)는 피벗 등에서 자동 전달 */
3097
3454
  formatter: (value: unknown, ...context: any[]) => string;
3098
- /** 역변환 함수 (스마트 필터링용, 선택) */
3455
+ /** 역변환 함수 (스마트 필터링용, 선택). 객체 값 열(`dataType: 'object'`)에서는 편집 확정·붙여넣기(글자 → 객체)에 쓴다 */
3099
3456
  parser?: (displayValue: string) => unknown;
3100
3457
  }
3101
3458
  ```
@@ -3124,6 +3481,17 @@ interface FunctionMaskConfig {
3124
3481
  }
3125
3482
  ```
3126
3483
 
3484
+ #### Worker 그리드의 inline 함수 mask (검색·필터·값 목록)
3485
+
3486
+ `loader.worker` 그리드에서 열 `mask` 가 inline 함수(`{ type: 'function', formatter: v => … }`, 등록 참조 아님)여도 **검색(`grid.search`/툴바 검색), 표시값 필터(`eq`·`neq`·`contains`·`notContains`·`startsWith`·`endsWith`·`like`·`notLike`), 필터 값 목록(`getFilterValues`)** 이 메인 스레드 그리드와 같은 결과로 동작합니다. 그룹·트리·집계·피벗 쿼리의 필터, 그룹 결과 필터(`basis: 'groupValue'`), 피벗 값 필드의 사용자 `mask`(검색)도 같습니다. 예: `amt` 열 `formatter: v => v.toLocaleString() + '원'`, 데이터 `1000`·`2500` → 검색 `'1,0'` → 1000 행, `'원'` → 두 행, `contains '500원'` → 2500 행, 값 목록 `'1,000원'`·`'2,500원'`.
3487
+
3488
+ - **계약: inline mask 는 값만으로 표시 글자가 정해져야 합니다.** Worker 모드에서는 Worker 가 그 열의 고유값(중복 제거)만 메인으로 보내고, 메인이 `formatter(value)` 로 만든 '값 → 표시 글자 대조표'를 Worker 가 씁니다. 그래서 formatter 가 두 번째 이후 인수(`rowData`, `columnKey`)나 바깥 상태에 따라 다른 글자를 내면 이 경로의 결과가 화면과 어긋날 수 있습니다(화면 셀 표시는 지금처럼 메인에서 행 정보와 함께 실행). 메인 스레드 그리드의 검색·필터·값 목록도 `formatter(value)` 하나만 부르므로 같은 결과입니다. 행에 따라 달라지는 서식이 필요하면 등록 참조(`{ workerFunction }`, [Worker 실행 계약](runtime-contracts.md))를 쓰세요.
3489
+ - 대조표는 **청크(5,000 값) 단위**로 주고받고, 메인의 formatter 실행도 약 8ms 마다 다음 작업으로 양보해 화면이 멈추지 않게 합니다. 표는 (formatter 함수, 열, 데이터 버전) 단위로 기억해 **두 번째 검색·필터부터는 주고받지 않습니다.** 셀 편집·행 추가 등으로 데이터가 바뀌면 다음 요청에서 **새로 생긴 값만** 추가로 받고, 데이터를 통째로 다시 불러오면(`loadData`·loader 새로 고침) 표를 버리고 새로 만듭니다. `mask` 의 formatter 함수가 바뀌면(새 함수 객체) 새 표를 만듭니다 — 렌더마다 새 화살표 함수를 만드는 설정은 매번 다시 만들게 됩니다.
3490
+ - **첫 검색 비용**은 대략 `고유값 수 × formatter 한 번 비용` 이 더해집니다. 측정 예(Edge, 1,000,000행·고유값 100만, `v.toLocaleString()` 약 9µs/값): 첫 검색 약 12~15초(그동안 화면은 멈추지 않으며 메인이 한 번에 붙잡는 시간은 최장 약 15ms), 같은 표를 쓰는 두 번째 검색부터는 표 왕복 없이 약 2초(Worker 검색 시간만). 가벼운 formatter(`` `${v}원` ``)면 표 왕복 추가는 100만 값에 약 0.5초입니다.
3491
+ - 표를 채우는 중 새 검색(또는 검색어 지우기)·새 값 목록 요청이 오면 이전 요청은 빈 결과로 끝나고(기존 검색 중단 규칙과 같음), 같은 표가 필요하면 진행 중인 채우기를 새 요청이 이어받습니다.
3492
+ - formatter 가 어떤 값에서 예외를 던지면, 그 값을 실제로 평가하는 검색·필터 요청이 같은 메시지로 실패합니다(메인 그리드와 같음). 문자열이 아닌 객체·함수를 반환하면 `String()` 한 글자로 둡니다.
3493
+ - 등록 참조 mask, 패턴 mask(`numeric`·`string`·`date`·`secure`), 숫자 열 기본 천단위 mask 는 지금처럼 Worker 안에서 바로 처리합니다(대조표 없음). 정렬은 원본 값 기준이라 해당 없습니다. 피벗 값 필드의 inline `mask` 는 피벗 계산에 쓰이지 않으므로 Worker 로 보내지 않습니다(표시는 메인).
3494
+
3127
3495
  ### 피벗 필드 타입
3128
3496
 
3129
3497
  #### PivotRowField
@@ -3140,10 +3508,10 @@ interface PivotRowField {
3140
3508
  formula?: string | ((row: Record<string, unknown>) => unknown); // 계산된 그룹 축 (집계 전)
3141
3509
  headerAttributes?: CellAttributes;
3142
3510
  cellAttributes?: CellAttributes | CellAttributesFn;
3511
+ align?: 'left' | 'center' | 'right'; // 행 머리글 값 셀·세로 병합 값·소계/총합계 라벨 정렬. 미지정 = dataType 기본(문자 왼쪽, 숫자 오른쪽). outline 모드에서는 무시 (ITSM-2026-004445 버그 23)
3143
3512
  }
3144
- // 참고: 피벗 정렬은 field-level 옵션이 아니라 viewState 단일 진입점(SSOT)으로 적용한다 —
3145
- // grid.sort([{ id: 'region', direction: 'asc', sequence: 1 }]). PivotRowField 에 sort/descending
3146
- // 같은 필드를 넣어도 silently 무시된다.
3513
+ // 참고: 피벗 행 정렬은 grid.sort([...]) 로 지정한다 (viewState 단일 진입점, SSOT) —
3514
+ // grid.sort([{ id: 'region', direction: 'asc', sequence: 1 }]).
3147
3515
  ```
3148
3516
 
3149
3517
  #### PivotColumnField
@@ -3160,8 +3528,7 @@ interface PivotColumnField {
3160
3528
  formula?: string | ((row: Record<string, unknown>) => unknown); // 계산된 열 축 (집계 전)
3161
3529
  headerAttributes?: CellAttributes;
3162
3530
  }
3163
- // 참고: 열 축 정렬도 viewState 단일 진입점으로 — grid.sort([{ id, direction, sequence }]).
3164
- // PivotColumnField 에 sort/sortByValue 를 넣어도 무시된다.
3531
+ // 참고: 열 축 정렬도 grid.sort([...]) 로 지정한다 — grid.sort([{ id, direction, sequence }]).
3165
3532
  ```
3166
3533
 
3167
3534
  > **`PivotFieldBase.id` — 값 필드 중복 시 필수 (ITSM-2026-002188)**
@@ -3255,8 +3622,8 @@ interface PivotValueField {
3255
3622
 
3256
3623
  | 타입 | 값 |
3257
3624
  |------|-----|
3258
- | `DataType` | `'string' \| 'number' \| 'boolean' \| 'date' \| 'array'` |
3259
- | `AggregateFunc` | `'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'countAll' \| 'first' \| 'last' \| 'range' \| 'stdev' \| 'stdevPop' \| 'variance' \| 'variancePop' \| 'countNumbers'` (FieldDef.aggregate / 피벗 valueField에서 사용). `count`=컬럼 non-null 값 수, `countAll`=그룹 전체 행 수(null 포함), `countNumbers`=숫자 타입 값 수 |
3625
+ | `DataType` | `'string' \| 'number' \| 'boolean' \| 'date' \| 'array' \| 'object'` (`'object'` → [객체 값 열](#객체-값-열-datatype-object)) |
3626
+ | `AggregateFunc` | `'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'countAll' \| 'first' \| 'last' \| 'range' \| 'stdev' \| 'stdevPop' \| 'variance' \| 'variancePop' \| 'countNumbers'` (FieldDef.aggregate / 피벗 valueField에서 사용). `count`=컬럼 non-null 값 수, `countAll`=그룹 전체 행 수(null 포함), `countNumbers`=숫자 타입 값 수. **문자열 `min`/`max`**: 정렬과 같은 문자열 순서 규칙(SortState 아래)으로 최소 = 오름차순 맨 앞, 최대 = 맨 뒤 — 예: `[cherry, Zeta, apple, Banana]` → 최소 `apple`, 최대 `Zeta`. 숫자·날짜 값과 숫자↔문자열이 섞인 비교는 원래 `<`/`>` 그대로. **빈 데이터**(대상 행 0개 또는 숫자 값 없음 — null만 있는 그룹 포함): `sum`·`count`·`countAll`·`countNumbers`는 `0`, 나머지(`avg`·`min`·`max`·`first`·`last`·`range`·`stdev` 계열·`variance` 계열)는 `null`(고정 합계 행 `-`, 그룹행 빈 칸). 예: 행 0개 → 합계 `0`, 평균 `-`. 고정 합계 행·그룹행·트리·피벗·선택 집계·Worker·내보내기 모두 같습니다 |
3260
3627
  | `AggregateType` | `'sum' \| 'avg' \| 'count' \| 'countAll' \| 'min' \| 'max' \| 'first' \| 'last'` (그룹화/트리 aggregates에서 사용) |
3261
3628
  | `AggregateFn` | `AggregateType \| ((values: CellValue[]) => CellValue)` |
3262
3629
  | `ValidationTrigger` | `'input' \| 'change' \| 'blur' \| 'commit'` |
@@ -3277,7 +3644,7 @@ interface PivotValueField {
3277
3644
  | `EngineType` | `'native'` — **`'aq'`(Arquero)는 제거됨.** 단일 엔진화로 유니온에 값이 하나만 남았습니다(ITSM-2026-002190) |
3278
3645
  | `TotalPosition` | `'before' \| 'after'` |
3279
3646
 
3280
- > **`AggregateFunc` vs `AggregateFn` 구분**: `AggregateFunc`는 FieldDef/PivotValueField에서 사용하는 **문자열 enum**(14종). `AggregateFn`은 GroupingConfig/TreeConfig의 `aggregates` 맵에서 사용하며 **문자열 + 콜백** 모두 허용 (8종 enum + 커스텀 함수).
3647
+ > **`AggregateFunc` vs `AggregateFn` 구분**: `AggregateFunc`는 내장 집계 이름 14종의 문자열 유니온입니다. `AggregateFn = AggregateFunc | CustomAggregateFunction | WorkerFunctionRef`이며 `CustomAggregateFunction`은 `(values: CellValue[], ctx: AggregateContext) => CellValue`입니다. 행 그룹은 `RowGroupConfig.aggregates: RowGroupAggregate[]`의 `func`에, 트리는 `TreeConfig.aggregates` 맵의 값에 지정합니다. 일반 inline 콜백은 메인 스레드에서 실행하며 Worker 모드의 `values`는 빈 배열이므로 선언된 집계를 `ctx`로 읽어야 합니다. 등록된 `WorkerFunctionRef`는 별도 Worker 실행 계약이며 함수 본문을 직렬화하는 방식이 아닙니다. [Worker 실행 계약](runtime-contracts.md)을 참조하세요.
3281
3648
 
3282
3649
  ---
3283
3650
 
@@ -3290,9 +3657,16 @@ interface PivotValueField {
3290
3657
  #### CellValue
3291
3658
 
3292
3659
  ```typescript
3293
- type CellValue = string | number | boolean | Date | null | undefined | CellValue[];
3660
+ type CellValue = string | number | boolean | Date | null | undefined | CellValue[] | CellObject;
3661
+
3662
+ /** 객체 셀 값 — `dataType: 'object'` 열에 들어가는 순수 객체 */
3663
+ interface CellObject {
3664
+ readonly [key: string]: unknown;
3665
+ }
3294
3666
  ```
3295
3667
 
3668
+ > `CellObject` 는 [객체 값 열](#객체-값-열-datatype-object)용입니다. 묶기·필터·병합은 열의 `objectKey`, 정렬은 `sortValue`, 표시는 `mask.formatter` 로 다룹니다. 이벤트·`cellRenderer`·`changes` 의 `value` 가 객체일 수 있으니 값을 문자열로 다루는 소비 코드는 `isCellObject(value)` 로 먼저 가르세요.
3669
+
3296
3670
  #### Row / RowData
3297
3671
 
3298
3672
  ```typescript
@@ -3304,13 +3678,16 @@ interface Row {
3304
3678
  type RowData = Row;
3305
3679
  ```
3306
3680
 
3681
+ > `__psRowId`는 저장 전(dirty) 행을 그리드 안에서 식별하는 내부 표식입니다. `getAllData()`, `getRowById()`, `getVisibleData()`, `getDisplayedData()`, `getExportData()`, `changes.getAll()`이 돌려주는 행과 행 핸들 데이터(`rows.getById/getByIndex(...).data`, `rows.where` 조건 함수 인자, `rows.getByDisplayedIndex(...).data`, `selection.getSelectedRows()`/`getActiveRow()`의 `.data`, `rows.observeById(...)`의 `getSnapshot()`/`.data`), 이벤트 행 데이터(`cell:click`·`cell:dblclick`·`row:click`의 `row`, `contextmenu`의 `rowData`)에는 들어 있지 않으며 commit 후 원본 데이터에도 남지 않습니다. 저장 전 행의 식별자는 `changes.getAll()`의 `rowId`를 사용하세요.
3682
+ > 단, 셀을 그릴 때마다 불리는 설정 콜백(`cellRenderer`·`cellStyle`·`cellTooltip`·`formatter`·`validator`·`editableWhen`·`editorResolver`·커스텀 에디터)이 받는 행 데이터에는 `__psRowId`가 들어 있을 수 있습니다(셀마다 복사하지 않기 위함). 이 값은 읽기 전용으로 보고 저장·전송하지 마세요(ITSM-2026-004445).
3683
+
3307
3684
  #### ColumnDef
3308
3685
 
3309
3686
  ```typescript
3310
3687
  interface ColumnDef {
3311
3688
  id: string;
3312
3689
  label?: string; // 헤더 표시명 (FieldDef.header와 동일 역할)
3313
- type?: 'string' | 'number' | 'boolean' | 'date' | 'array';
3690
+ type?: 'string' | 'number' | 'boolean' | 'date' | 'array' | 'object';
3314
3691
  width?: number;
3315
3692
  minWidth?: number;
3316
3693
  maxWidth?: number;
@@ -3321,6 +3698,9 @@ interface ColumnDef {
3321
3698
  sortable?: boolean;
3322
3699
  filterable?: boolean;
3323
3700
  align?: CellAlign;
3701
+ objectKey?: ObjectKeyAccessor; // 객체 값 열 규칙 (FieldDef 에서 그대로 복사)
3702
+ sortValue?: SortValueAccessor;
3703
+ equals?: ObjectEqualsFn;
3324
3704
  // ... FieldDef와 거의 동일 (FieldDef는 사용자 입력용, ColumnDef는 정규화된 내부 형태)
3325
3705
  }
3326
3706
  ```
@@ -3468,6 +3848,12 @@ interface SortState {
3468
3848
  mode?: 'pre' | 'post';
3469
3849
  }
3470
3850
 
3851
+ // 문자열 순서 규칙(정렬·행 그룹·트리·피벗 행/열 순서·sortKey 가 돌려준 문자열·조건 필터 gt/gte/lt/lte/between·수식 < >):
3852
+ // 영문 대소문자만 무시한 문자 코드 비교, 실행 환경 언어와 무관. 대소문자만 다르면 대문자 먼저.
3853
+ // 예) ['삼성전자','LG전자','현대'] → LG전자, 삼성전자, 현대 / ['Banana','Zeta','apple'] → apple, Banana, Zeta
3854
+ // 숫자 문자열은 글자 단위('item10' < 'item9'). 같음 판정(eq, 그룹 키, 피벗 병합)은 대소문자를 구분한다.
3855
+ // 문자열 min/max 집계도 같은 규칙: 최소 = 오름차순 맨 앞, 최대 = 맨 뒤. 예) [cherry, Zeta, apple, Banana] → 최소 apple, 최대 Zeta (숫자·날짜는 그대로).
3856
+
3471
3857
  interface FilterStateBase {
3472
3858
  /** 필터링할 컬럼 ID (플랫: 컬럼 ID, 피벗: 행/열 필드 ID) */
3473
3859
  id: string;
@@ -3480,7 +3866,11 @@ interface FilterStateBase {
3480
3866
  * 같은 키끼리 먼저 접고 그룹 결과끼리 AND 로 합친다.
3481
3867
  */
3482
3868
  groupKey?: string;
3483
- /** 스마트 필터링용 mask (보통 컬럼의 mask가 자동 주입됨 — 직접 지정 불필요) */
3869
+ /**
3870
+ * 스마트 필터링용 mask (보통 컬럼의 mask가 자동 주입됨 — 직접 지정 불필요).
3871
+ * 있으면 `eq`·`neq`·`contains`·`notContains`·`startsWith`·`endsWith`·`like`·`notLike`(문자열 값)를 **화면 표시값** 기준으로 비교.
3872
+ * 플랫·피벗·그룹 결과 필터(그룹 값 조건 — 그룹 열의 mask 자동 주입) 모두 같은 규칙.
3873
+ */
3484
3874
  mask?: MaskConfig;
3485
3875
  /**
3486
3876
  * (피벗 전용) 필터 적용 단계 override. 플랫 그리드에서는 무시.
@@ -3670,7 +4060,8 @@ await grid.filter({
3670
4060
  `getViewState().filters`는 실행 가능한 일반 행 필터의 평탄화 결과다.
3671
4061
  `getConfig()`/`applyConfig()`와 `getViewState()`/`setViewState()` 왕복은 소유 맵을 보존한다.
3672
4062
  - 피벗에서는 행 그룹 결과 필터를 실행하지 않고 보존한다. 서버 row-model도 상태만 보존하며
3673
- 서버가 준 행을 로컬에서 탈락시키지 않는다.
4063
+ 서버가 준 행을 로컬에서 탈락시키지 않는다. `rowModel: 'server'` + `loader`(SSRM) 서버 그룹에서는
4064
+ `fetchGroups`·`fetchAggregates` 요청의 `groupFilterModel` 로 서버에 넘겨 서버가 거른다(SSRM 절 참조).
3674
4065
  - Worker는 조건식·수식을 지원한다. 실행 대상 그룹의 함수형 `custom` predicate는 직렬화할 수 없어
3675
4066
  명시적으로 거부한다. 비활성·대상 레벨 부재 묶음은 Worker 전송에서 제외하되 원본에서 삭제하지 않는다.
3676
4067
  - 집계 `id`는 선택적이며 생략 시 `getRowGroupAggregateId()`의 결정적 계산 alias를 사용한다.
@@ -3732,37 +4123,38 @@ interface PerformanceTiming {
3732
4123
 
3733
4124
  ### 데이터 로더 — LoaderConfig
3734
4125
 
3735
- > 현재 Worker는 런타임 임의 URL import가 아니라 **앱 Worker entry의 정적 loader map**을 사용합니다. `module`은 map의 정확한 키입니다. [Worker 등록·데이터 세션 계약](runtime-contracts.md)을 먼저 확인하세요.
4126
+ 데이터를 어디서 가져올지 정합니다. `use`와 `worker` 중 **하나만** 지정합니다(타입으로 강제).
3736
4127
 
3737
- Web Worker에서 직접 데이터를 페칭하는 모듈을 지정합니다. 메인스레드 비경유로 UI 블로킹 없음.
4128
+ | 형태 | 실행 위치 | 가리키는 것 |
4129
+ |---|---|---|
4130
+ | `loader: { use: OrderLoader, params }` | 메인 스레드 | `DataLoader`를 상속한 클래스 |
4131
+ | `loader: { worker: () => new Worker(...), params }` | Worker | `setupGridWorker({ loader })`를 호출하는 앱의 Worker 파일 |
4132
+ | `data: [...]` | 메인 스레드 | 행 배열 (loader 없이) |
3738
4133
 
3739
4134
  ```typescript
3740
- interface LoaderConfig {
3741
- /** 앱 Worker entry의 loaders map에 등록한 키 (URL이면 문자열 표현과 일치해야 함) */
3742
- module: URL | string;
3743
- /** 초기 파라미터 (직렬화 가능한 plain object). 런타임에 grid.updateLoaderParams()로 갱신 */
3744
- params?: Record<string, unknown>;
3745
- }
4135
+ type LoaderConfig =
4136
+ | { use: DataLoaderClass; worker?: never; params?: Record<string, unknown> }
4137
+ | { worker: () => Worker; use?: never; params?: Record<string, unknown> };
3746
4138
  ```
3747
4139
 
4140
+ - `params`는 loader 인스턴스의 `this.params`가 됩니다. Worker 모드에서는 structured-clone 가능한 값을 넘기세요. Proxy(예: Vue `reactive`/`ref`)처럼 감싸여 복제가 안 되는 값은 그리드가 평범한 값으로 깊은 복사해 보내고 `[PureSheet] … 깊은 복사한 뒤 전송했습니다` 경고를 냅니다(같은 Worker·같은 요청 종류당 1회, 행 추가·수정 값과 XLSX 내보내기 행도 같음). 함수·클래스 인스턴스는 복사해도 보낼 수 없어 여전히 `DataCloneError`입니다. 런타임에 `grid.updateLoaderParams()`로 머지 갱신합니다.
4141
+ - 잘못된 설정(`use`에 일반 클래스, `use`와 `worker` 동시 지정, `worker`가 함수가 아님 등)은 `waitForReady()`가 오류로 거절합니다.
4142
+ - Worker 모드에서는 `data`/`loadData()`를 쓸 수 없습니다(PS502). Worker 등록 규칙은 [Worker 등록·데이터 세션 계약](runtime-contracts.md)을 보세요.
4143
+
3748
4144
  ```typescript
3749
- // my-loader.ts
3750
- import { DataLoader } from '@vmscloud/moz-ui-grid-core';
3751
- export default class MyLoader extends DataLoader {
3752
- async fetchData(): Promise<Row[]> {
3753
- const res = await fetch(`/api/data?token=${this.params.authToken}`);
3754
- return res.json();
3755
- }
3756
- }
4145
+ // 메인 스레드
4146
+ new PureSheet(container, {
4147
+ keyFields: ['id'],
4148
+ loader: { use: OrderLoader, params: { year: 2026 } },
4149
+ fields: [...],
4150
+ });
3757
4151
 
3758
- // 그리드 생성
4152
+ // Worker — orders.worker.ts 에서 setupGridWorker({ loader: OrderLoader }) 호출
3759
4153
  new PureSheet(container, {
3760
- enableReferCache: true,
3761
- useWorker: true,
3762
- workerFactory: () => new Worker(new URL('./app-grid.worker.ts', import.meta.url), { type: 'module' }),
4154
+ keyFields: ['id'],
3763
4155
  loader: {
3764
- module: 'app:data', // worker entry: installProcessorWorker({ loaders: { 'app:data': MyLoader } })
3765
- params: { authToken: 'abc' },
4156
+ worker: () => new Worker(new URL('./orders.worker.ts', import.meta.url), { type: 'module' }),
4157
+ params: { year: 2026 },
3766
4158
  },
3767
4159
  fields: [...],
3768
4160
  });
@@ -3867,7 +4259,7 @@ type MergeConfig = BuiltInMergeConfig | CustomMergeConfig;
3867
4259
  interface BuiltInMergeConfig {
3868
4260
  /** 'content': 인접한 같은 값 / 'hierarchical': 상위 컬럼 범위 내에서만 하위도 병합 */
3869
4261
  type: 'content' | 'hierarchical';
3870
- /** 병합 대상 컬럼 (순서대로 계층) */
4262
+ /** 병합 대상 컬럼 id (순서대로 계층). 값 비교는 화면 셀과 같은 `row[field ?? id]` — `{ id:'nm', field:'name' }`은 name 값으로 병합 */
3871
4263
  columns: string[];
3872
4264
  /** 'lazy'(동적 탐색) | 'eager'(사전 계산) @default 'lazy' */
3873
4265
  strategy?: 'lazy' | 'eager';
@@ -3901,13 +4293,15 @@ interface MergeRangeContext {
3901
4293
  rowIndex: number;
3902
4294
  /** 현재 컬럼의 `setMergeConfig` `columns` 배열 인덱스(화면 순서 아님) — `rowIndex`의 가로 대칭. `columnsInOrder` 기준 */
3903
4295
  colIndex: number;
4296
+ /** 열 id (`field`가 따로 있어도 id) */
3904
4297
  columnId: string;
3905
4298
  /** columns(설정) 배열 내 인덱스 (계층 레벨) */
3906
4299
  columnLevel: number;
4300
+ /** 이 칸의 화면 값 = `row[field ?? id]` (예: 열 `{ id:'nm', field:'name' }` → `row.name`) */
3907
4301
  value: CellValue;
3908
- /** 인접 행 데이터 조회 (세로 경계 탐색용) */
4302
+ /** 인접 행 데이터 조회 (세로 경계 탐색용) — 원본 행 그대로(데이터 키) */
3909
4303
  getRowData: (index: number) => Row | null;
3910
- /** 임의 셀 값 조회 (2D 경계 탐색용) — `getRowData(rowIndex)?.[columnId]`와 동치 */
4304
+ /** 임의 셀의 화면 값 (2D 경계 탐색용) — 열 id를 주면 `getRowData(rowIndex)?.[그 열의 field ?? id]`. 열이 아닌 이름은 `row[이름]` */
3911
4305
  getCellValue: (rowIndex: number, columnId: string) => CellValue;
3912
4306
  /** 병합 대상 컬럼 ID 목록 — `setMergeConfig`의 `columns` 배열 그대로 (가로 경계 탐색용) */
3913
4307
  columnsInOrder: readonly string[];
@@ -3929,6 +4323,8 @@ interface MergeRangeResult {
3929
4323
 
3930
4324
  > **세로 / 가로 / 2D 병합** — `startCol`/`endCol`을 **생략하면 세로 병합**(현재 컬럼만, 기존 `{ startRow, endRow }` 계약 그대로), **지정하면 가로/2D 병합**입니다. 컬럼 인덱스(`colIndex`/`startCol`/`endCol`)는 **`setMergeConfig`의 `columns` 배열 순서**(= `columnsInOrder`) 기준이며 화면(가시) 컬럼 순서가 아닙니다 — 사용자가 컬럼을 숨기거나 옮겨도 인덱스는 변하지 않습니다. 단 병합 칸은 앵커 컬럼(`columns[startCol]`)부터 오른쪽으로 그려지므로, 가로 병합은 덮는 컬럼들이 **화면에서도 같은 순서로 붙어 있고 같은 파티션(center/pinned) 안에 있을 때만** 시각적으로 올바릅니다. 화면상 사이에 다른 컬럼이 끼거나 순서가 뒤바뀌면 끼어든 컬럼까지 덮어 보일 수 있으며, 이때 `console.warn`을 영역·컬럼 조합당 한 번(병합 설정이나 가시 컬럼 배치가 바뀌면 다시) 남깁니다. 숨긴 컬럼은 화면 폭이 없으므로 병합 컬럼 사이에 끼어 있어도 문제가 되지 않습니다 — 병합 칸은 덮는 병합 컬럼 중 보이는 것들의 폭 합으로 그려집니다(본문·고정 행 동일). 영역 내 아무 셀에서 같은 사각형을 반환해도 전략이 anchor=`(startRow, startCol)`를 잡고 나머지를 숨김 처리합니다. `null` 또는 단일 셀(`startRow===endRow && startCol===endCol`)이면 병합 없음.
3931
4325
 
4326
+ > 변경: 커스텀 병합 콜백의 `value`·`getCellValue(rowIndex, 열 id)`는 이제 `field` 별칭을 따른 화면 값입니다(예전에는 `row[열 id]`라 `{ id:'nm', field:'name' }` 열이면 `undefined`). `columnId`·`columnsInOrder`·`getRowData`와 별칭 없는 열은 그대로입니다.
4327
+
3932
4328
  > **고정 행 병합 (`includePinned`)** — 기본값(`false`)은 기존 동작 그대로 본문만 병합합니다. `true`면 같은 설정이 상단/하단 고정 행 영역에도 적용되며, 병합은 **각 영역 안에서만** 일어납니다(본문↔고정 영역을 넘는 병합 없음). 콜백은 `area`로 영역을 구분하고, 반환 `startRow`/`endRow`는 그 영역 안의 행 번호입니다. 좌우 고정 컬럼은 영역이 아니므로 같은 컬럼 구역 안에서만 가로 병합되며, 경계를 넘는 범위는 경고 후 무시됩니다. 병합 셀(본문·고정 행 모두)은 앵커(좌상단) 셀이 단독으로 그려질 때와 같게 — 앵커 컬럼의 `cellRenderer`로 그려지며(집계 고정 행은 원래처럼 포맷 텍스트), 데이터 고정 행의 클릭/선택은 앵커 셀로 전달되고 선택 강조는 본문 병합 셀과 같은 규칙(덮는 셀/행 중 하나라도 선택이면 강조)을 따릅니다(복사에는 병합이 반영되지 않음. 엑셀 내보내기는 `includePinnedRows: true`일 때 고정 행 병합을 반영).
3933
4329
  >
3934
4330
  > ```typescript
@@ -4158,13 +4554,21 @@ interface CustomEditorParams {
4158
4554
  value: CellValue;
4159
4555
  column: ColumnDef;
4160
4556
  rowData: Record<string, unknown>;
4557
+ /** type-to-edit 으로 열렸을 때 친 글자 (IME 는 조합이 끝난 글자). 더블클릭·Enter·F2 로 열면 undefined */
4558
+ initialText?: string;
4161
4559
  /** 값을 확정하고 에디터 닫기 */
4162
4560
  onCommit: (newValue: CellValue) => void;
4163
4561
  /** 편집 취소하고 에디터 닫기 */
4164
4562
  onCancel: () => void;
4563
+ /** 편집 소유 portal 등록. 반환 함수는 등록만 해제하며 DOM은 제거하지 않음. */
4564
+ registerOwnedSurface?: (surface: HTMLElement) => () => void;
4165
4565
  }
4166
4566
  ```
4167
4567
 
4568
+ portal의 focus·해제·destroy 책임과 `getValue` 규칙은 [EditorConfig](#editorconfig)를 참조하세요. React/Vue template props에 이 멤버가 자동 전달되는 것은 아닙니다.
4569
+
4570
+ **`initialText`** — 셀을 선택한 채 글자 키를 눌러(type-to-edit) 열면 그 글자가 옵니다(`'x'`). IME 키(한글 등)로 열면 그리드가 셀에 임시 입력칸을 띄워 조합을 받고, 조합이 끝난 뒤 그 글자(`'한'`)로 renderer 를 부릅니다 — 이때 포커스는 임시 입력칸에서 빠지므로 renderer 가 돌려준 요소가 스스로 focus 해야 합니다. 조합 중 Esc 는 편집 취소입니다. 더블클릭·Enter·F2 로 열면 `undefined` 입니다. `startEditById(id, col, { initialValue })` 를 주면 그 글자입니다. 글자를 원래 값 대신 쓸지, 원래 값 끝에 이어 붙일지는 에디터가 정합니다. (예전에는 custom 에디터가 type-to-edit 으로 열리지 않았습니다.)
4571
+
4168
4572
  ```typescript
4169
4573
  // 드롭다운 (검색 가능)
4170
4574
  {
@@ -4331,7 +4735,7 @@ interface GridConfigSnapshot {
4331
4735
 
4332
4736
  // Flat 모드 ↓
4333
4737
  fields?: FieldDef[]; // 런타임 width/visible/order/pinned 반영됨
4334
- group?: GroupConfig;
4738
+ rowGroupConfig?: RowGroupConfig | null; // null = 그룹 없음
4335
4739
  rowTemplate?: RowTemplate;
4336
4740
  columnGroups?: ColumnGroupDef[];
4337
4741
 
@@ -4360,8 +4764,9 @@ interface GridConfigSnapshot {
4360
4764
 
4361
4765
  // 공통 view 상태 ↓
4362
4766
  sorts?: SortState[];
4363
- filters?: FilterState[]; // 평탄화된 파생값 (옛 소비처 호환)
4364
- filterGroups?: FilterGroupMap; // 소스별 그룹 — 있으면 이것이 우선 (ITSM-2026-002182)
4767
+ sortGroups?: SortGroupMap; // 소유자별 정렬 — 있으면 sorts보다 우선
4768
+ filters?: FilterState[]; // row 스코프만 평탄화한 파생값 (옛 소비처 호환)
4769
+ filterGroups?: FilterGroupMap; // 있으면 filters보다 우선; 그룹 결과 필터도 이 맵으로 복원
4365
4770
 
4366
4771
  // 트리/마스터-디테일 ↓
4367
4772
  treeConfig?: TreeConfig;
@@ -4397,11 +4802,11 @@ interface GridConfigSnapshot {
4397
4802
  **제외되는 항목** (직렬화 불가):
4398
4803
  - `data` — `loadData()`로 별도 관리
4399
4804
  - 모든 함수 (`cellRenderer`, `cellTooltip`, `formatHeader`, validator, mask formatter, `formatRow` 등) — 사용자 코드에서 재등록
4400
- - DOM/객체 참조 (`undoStack`, `loader.module`, `dragDropConfig` 등)
4805
+ - DOM/객체 참조 (`undoStack`, `loader`, `dragDropConfig` 등)
4401
4806
  - dirty 상태 (`grid.changes.getAll()`로 별도 저장)
4402
4807
 
4403
4808
  **현재 직렬화 안 되는 옵션 (향후 추가 여지)**:
4404
- - `engine`, `useWorker`, `dynamicRowHeights`, `rowHeightChangeThreshold`
4809
+ - `engine`, `dynamicRowHeights`, `rowHeightChangeThreshold`
4405
4810
  - `enableReferCache`, `preventKeyEdit`
4406
4811
  - `rowStyle`, `headerStyle` (`height:Npx` 추출 alias — 일반 CSS 적용 아님)
4407
4812
  - `PivotConfig.sorts` / `PivotConfig.filters` (피벗 전용 — `GridConfigSnapshot.sorts/filters` 와 별개)
@@ -4471,6 +4876,7 @@ const grid = new PureSheet(el, {
4471
4876
  | `ColumnKeyInput` / `ColumnKeys` | 타입 | 입력/출력 형태 |
4472
4877
 
4473
4878
  > **읽을 때 `field`, 가리킬 때 `id`.** 원본 행에서 값을 꺼내는 자리(`row[...]`)는 반드시 `field`, 정렬/필터/저장/DOM dataset 처럼 *컬럼을 지목*하는 자리는 `id` 입니다.
4879
+ > 예) `{ id: 'nm', field: 'name' }` 셀의 `cell:click`·`cell:dblclick`·`contextmenu`·`cell:tooltip` `value`, `cellTooltip`·`cellAttributes(fn)` 의 `value`, `formatRow` `cells.nm.value`·`originalValue`·`isModified`, 유효성 검사 validator 의 값은 모두 `row.name` 이고, 열을 가리키는 `columnId`·`cells` 키·검사 오류 `field` 는 `'nm'` 입니다(ITSM-2026-004445).
4474
4880
 
4475
4881
  **피벗 쪽 대응** — `PivotFieldBase.id?` 가 추가됐습니다(미지정 시 `field`). 값 필드는 같은 `field` 를 합계·평균으로 **여러 번** 넣을 수 있어 `field` 만으로는 식별이 깨집니다.
4476
4882
 
@@ -4544,6 +4950,13 @@ const grid = new PureSheet(el, {
4544
4950
  - 기존의 잘못된 저장본 `$변동 상태 …`는 자동으로 추측 복구하지 않습니다. 원문을 보존하고 편집기에서 오류를 표시합니다. 사용자가 컬럼을 다시 선택하거나 `$["변동 상태"]`로 명시적으로 고쳐야 합니다. 조건부 서식에서 오류가 있으면 규칙 전체 적용을 막고 이전 적용본을 유지합니다.
4545
4951
  - 새 표기를 읽지 못하는 구버전 Core로 되돌리면 해당 수식은 동작하지 않습니다. Core와 공용 wrapper를 함께 배포하고, 소비처 업그레이드 전에는 새 저장본을 공유하지 마세요.
4546
4952
 
4953
+ **점 표기 경로 (수식 필터·조건부 서식)**
4954
+
4955
+ - 열 `{ id: 'ownerTeam', field: 'owner.team' }` 의 `$ownerTeam`·`[ownerTeam]` 은 `row.owner.team` 을 읽습니다(열 id 우선 규칙 그대로).
4956
+ - 열이 아닌 점 이름은 데이터 경로로 읽습니다: `[owner.team] == "B"`, `$["owner.name"] == "김철수"`. 경로에 값이 없으면(`[owner.missing]`) `null` 입니다.
4957
+ - 같은 이름의 열 id 가 있으면 열이 이깁니다(열 `{ id: 'owner.team', field: 't' }` 이면 `[owner.team]` 은 `row.t`). 행에 `'owner.team'` 키가 그대로 있으면 그 키가 경로보다 먼저입니다.
4958
+ - `$owner.name` 은 지원하지 않습니다 — `$` 뒤 단순 이름에는 점이 들어가지 않습니다. `[owner.name]` 또는 `$["owner.name"]` 로 쓰세요.
4959
+
4547
4960
  ---
4548
4961
 
4549
4962
  ### 값 목록 위젯 — ValueCheckList
@@ -4575,7 +4988,7 @@ list.destroy();
4575
4988
 
4576
4989
  | 메서드 | 설명 |
4577
4990
  |---|---|
4578
- | `setValues(values, selectedKeys?, meta?)` | 목록 채우기. `meta`: `{ truncated?, limit?, rawValues?, offListPicks? }`. `selectedKeys` 생략 시 **모드 기본 체크**(`'exclude'`=전체 선택, `'include'`=전체 해제) |
4991
+ | `setValues(values, selectedKeys?, meta?)` | 목록 채우기. `meta`: `{ truncated?, limit?, rawValues?, offListPicks?, objectSpec? }`. `selectedKeys` 생략 시 **모드 기본 체크**(`'exclude'`=전체 선택, `'include'`=전체 해제). 객체 값 열이면 `objectSpec`(`{ objectKey: 'id' }` 등)을 넘겨 항목 키를 `objectKey` 로 만든다 — 아래 참조 |
4579
4992
  | `setLoading(loading)` | 로딩 표시 |
4580
4993
  | `getSelectedKeys()` / `getSelectedItems()` | 현재 선택 |
4581
4994
  | `setMode(mode)` | 저장 방향 전환 — 지목 집합은 유지하고 의미만 뒤집으므로 **체크가 반전**됩니다 |
@@ -4607,7 +5020,7 @@ list.destroy();
4607
5020
 
4608
5021
  | 속성 | 타입 | 설명 |
4609
5022
  |---|---|---|
4610
- | `key` | `string` | 선택 상태 관리용 키(문자열화된 값, 빈 값은 `VALUE_LIST_BLANK_KEY`) |
5023
+ | `key` | `string` | 선택 상태 관리용 키(문자열화된 값, 빈 값은 `VALUE_LIST_BLANK_KEY`). 원본이 객체면 `objectKey` 문자열(`objectSpec` 없으면 객체 JSON) |
4611
5024
  | `label` | `string` | 화면 표시 라벨 |
4612
5025
  | `value` | `CellValue` | 필터에 실을 **원본값**(빈 값은 `null`) |
4613
5026
  | `pinned` | `boolean?` | 현재 후보 목록에는 없지만 사용자가 지목해 **목록 맨 위에 고정 표시 중**인 항목 |
@@ -4642,6 +5055,68 @@ interface ColumnValuesProgress {
4642
5055
 
4643
5056
  > **왜 `rawValues` 를 함께 돌려주나** — 표시값으로 필터링하면 행마다 `applyMask` 를 불러야 하는데 `toLocaleString()` 자체가 비싸서 5M 행에서 **1,903ms(전체의 92%)** 를 먹습니다(실측). 원본끼리 비교하면 62ms 로 **30.6배** 빠릅니다. 게다가 함수형 mask 는 Worker 로 복제되지 않아 워커 모드가 크래시합니다. 문자열 `parser` 역변환은 mask 가 다대일이라 왕복이 깨집니다(실측: `NaN` → `"NaN"` → `0`).
4644
5057
 
5058
+ **객체 값 열 (`dataType: 'object'`)** — `getFilterValues` 는 `objectKey` 로 중복을 없애고, `values` 에 라벨(mask), `rawValues` 에 원본 객체를 줍니다. 동명이인은 라벨이 같아도 항목이 따로입니다. 위젯에 `objectSpec` 을 넘기면 항목 키가 `objectKey` 가 됩니다(넘기지 않으면 라벨이 같은 두 객체도 JSON 키로 나뉩니다).
5059
+
5060
+ ```typescript
5061
+ list.setValues(['김철수', '김철수', '홍길동', null], undefined, {
5062
+ rawValues: [{ id: 'u2', name: '김철수' }, { id: 'u3', name: '김철수' }, { id: 'u1', name: '홍길동' }, null],
5063
+ objectSpec: { objectKey: 'id' },
5064
+ });
5065
+ // 항목 key: 'u2', 'u3', 'u1' (+ 빈 값) — label 은 '김철수'·'김철수'·'홍길동', value 는 원본 객체
5066
+ list.setSelectedKeys(['u3']);
5067
+ list.getSelectedItems().map(i => i.value); // → [{ id: 'u3', name: '김철수' }]
5068
+ // 필터: { operator: 'in', filterValue: [위 value 들] } — 그리드가 objectKey 로 비교한다
5069
+ ```
5070
+
5071
+ 저장된 필터에서 체크 상태를 되살릴 때는 같은 규칙의 `valueListKeyOf(label, raw, spec)` 로 키를 만듭니다: `valueListKeyOf('김철수', { id: 'u3', name: '김철수' }, { objectKey: 'id' })` → `'u3'`, `valueListKeyOf('1,000', 1000)` → `'1,000'`, `valueListKeyOf(null, null)` → `VALUE_LIST_BLANK_KEY`. `appendValues(values, meta)` 도 `meta.objectSpec` 을 받습니다.
5072
+
5073
+ ---
5074
+
5075
+ ### 객체 값·필드 경로 헬퍼
5076
+
5077
+ 그리드가 [점 표기 필드 경로](#점-표기-필드-경로-field-ownerteam)와 [객체 값 열](#객체-값-열-datatype-object)에 쓰는 규칙을 소비처(래퍼·저장 로직·JS 서버)가 그대로 쓰도록 export 합니다.
5078
+
5079
+ ```typescript
5080
+ import {
5081
+ getFieldValue, createFieldGetter, resolveFieldWrite, setFieldValue,
5082
+ isCellObject, buildObjectFieldSpecs, objectKeyOf, objectKeyString,
5083
+ sortValueOf, displayKeyText, objectDisplayText, compareSortKeys, valuesEqual, valueListKeyOf,
5084
+ type ObjectFieldSpec, type ObjectFieldSpecMap,
5085
+ } from '@vmscloud/moz-ui-grid-core';
5086
+ ```
5087
+
5088
+ **필드 경로**
5089
+
5090
+ | Export | 입력 → 출력 |
5091
+ |---|---|
5092
+ | `getFieldValue(row, field)` | `({ owner: { team: 'A' } }, 'owner.team')` → `'A'`. 행에 `'owner.team'` 키가 그대로 있으면 그 값. 중간이 `null` 이면 `undefined` |
5093
+ | `createFieldGetter(field)` | 루프용 읽기 함수. 필드당 한 번 만들고 행마다 부른다: `const read = createFieldGetter('owner.team'); rows.map(read)` |
5094
+ | `resolveFieldWrite(row, field, value)` | 행은 그대로 두고 실제로 바뀔 **최상위 변경**만 계산: `({ owner: { id: 'u1', team: 'A' } }, 'owner.team', 'B')` → `{ key: 'owner', value: { id: 'u1', team: 'B' } }` |
5095
+ | `setFieldValue(row, field, value)` | 행에 직접 쓴다(경로면 최상위 객체를 복사해 교체). 쓴 최상위 키를 돌려준다: `'owner'` |
5096
+
5097
+ **객체 값**
5098
+
5099
+ | Export | 입력 → 출력 |
5100
+ |---|---|
5101
+ | `isCellObject(value)` | 순수 객체만 `true` (Date·배열·클래스 인스턴스·`null` 은 `false`) |
5102
+ | `buildObjectFieldSpecs(columns)` | `ColumnDef[]` 중 `type: 'object'` 열의 규칙을 모은다. 키는 데이터 키(`field ?? id`), 같은 데이터 키를 두 열이 쓰면 첫 열 규칙 → `ObjectFieldSpecMap` |
5103
+ | `objectKeyOf(value, spec?, column?)` | 객체면 `spec.objectKey` 결과(없으면 `null`), 객체가 아니면 값 그대로. `{ id: 'u2' }`, `{ objectKey: 'id' }` → `'u2'`. 숫자 키는 숫자 그대로. `objectKey` 가 없으면 안정 JSON + `PS314`(spec 있음) / `PS315`(spec 없음) 경고 1회 |
5104
+ | `objectKeyString(value, spec?, column?)` | `objectKeyOf` 를 문자열로(Map/Set·groupId 비교용). `null`/`undefined` 는 그대로 |
5105
+ | `sortValueOf(value, spec?, column?)` | 객체면 `sortValue` → `displayKey` → `objectKey` → 안정 JSON 순. `{ rank: 2, name: '김철수' }`, `{ sortValue: v => [v.rank, v.name] }` → `[2, '김철수']` |
5106
+ | `displayKeyText(value, spec?)` | 객체이고 `spec.displayKey` 가 있을 때만 그 값의 문자열(`null`/`undefined` → `''`), 아니면 `undefined`. `{ name: '김철수' }`, `{ displayKey: 'name' }` → `'김철수'` |
5107
+ | `objectDisplayText(value, spec?)` | mask 가 없을 때의 보이는 글자 — `displayKeyText`, 없으면 JSON. 객체가 아니면 `undefined` |
5108
+ | `compareSortKeys(a, b, cmp?)` | 오름차순 비교(-1/0/1). 배열이면 앞 값부터, 원소의 `null` 이 앞. 배열이 아니면 그리드 기본 셀 비교와 같다 |
5109
+ | `valuesEqual(a, b, spec?)` | 변경 감지 비교. 둘 중 하나라도 순수 객체일 때만 `spec.equals`(없으면 내용 비교), 아니면 `Object.is` |
5110
+ | `valueListKeyOf(label, raw?, spec?)` | 값 목록 항목 키 — [ValueCheckList](#값-목록-위젯--valuechecklist) 참조 |
5111
+
5112
+ `ObjectFieldSpec` = `{ column?, objectKey?, displayKey?, sortValue?, equals? }`, `ObjectFieldSpecMap` = `Record<데이터 키, ObjectFieldSpec>`. 열 정의 쪽 타입 `ObjectValueColumnOptions`·`ObjectKeyAccessor`·`SortValueAccessor`·`ObjectValueAccessor`·`ObjectEqualsFn` 도 export 됩니다.
5113
+
5114
+ ```typescript
5115
+ // JS 서버에서 그리드와 같은 키로 묶기
5116
+ const specs = buildObjectFieldSpecs([{ id: 'owner', type: 'object', objectKey: 'id' }]);
5117
+ const key = objectKeyString(row.owner, specs.owner); // { id: 'u2', … } → 'u2'
5118
+ ```
5119
+
4645
5120
  ---
4646
5121
 
4647
5122
  ### formatRow 컨텍스트 — FormatRowInfo
@@ -5060,63 +5535,449 @@ interface CellAttributeParams {
5060
5535
 
5061
5536
  ---
5062
5537
 
5063
- ### DataLoader 클래스 — 워커 기반 커스텀 데이터 로딩
5538
+ ### DataLoader 클래스 — 커스텀 데이터 로딩
5064
5539
 
5065
- 앱 Worker entry의 `installProcessorWorker({ loaders })` map에 등록하는 클래스의 베이스. 워커 안에서 실행되므로 DOM/localStorage 접근은 불가하고, `fetch`/`WebSocket`/`XMLHttpRequest` 등 네트워크 API는 사용 가능.
5540
+ `loader.use`(메인 스레드)와 `setupGridWorker({ loader })`(Worker)에 넘기는 클래스의 베이스입니다. 같은 클래스를 두 모드에서 그대로 쓸 수 있습니다. Worker 모드에서는 Worker 안에서 실행되므로 DOM/localStorage는 쓸 수 없고 `fetch`/`WebSocket` 등은 쓸 수 있습니다.
5066
5541
 
5067
5542
  ```typescript
5068
5543
  abstract class DataLoader {
5069
- /** 메인스레드에서 전달받은 파라미터 (this.params로 접근) */
5544
+ /** config 의 loader.params (updateLoaderParams() 로 머지 갱신) */
5070
5545
  protected params: Record<string, unknown>;
5071
5546
 
5072
- /** 라이브러리 내부에서 호출 — grid.updateLoaderParams() 시 자동 동기화 (Object.assign 머지) */
5547
+ /** 라이브러리가 호출 — grid.updateLoaderParams() 시 Object.assign 머지 */
5073
5548
  updateParams(newParams: Record<string, unknown>): void;
5074
5549
 
5075
- /** [필수] 초기 데이터 로드. 플랫/트리 공통 */
5076
- abstract fetchData(): Promise<Row[]>;
5550
+ /** rowModel 'client' — 전체 행을 한 번에. 초기화와 refreshLoaderData() 때 호출. 플랫/트리 공통 */
5551
+ fetchAll?(): Promise<Row[]>;
5552
+
5553
+ /** rowModel 'server' — 요청 구간의 행만. 화면에 필요한 블록마다 호출 */
5554
+ fetchRange?(request: FetchRangeRequest): Promise<FetchRangeResult>;
5555
+
5556
+ /** rowModel 'server' — (선택) 필터 값 목록(체크박스). 없으면 받아 둔 행에서만 모음 */
5557
+ fetchFilterValues?(request: FetchFilterValuesRequest): Promise<FetchFilterValuesResult>;
5558
+
5559
+ /** rowModel 'server' — (선택) 툴바 검색. 없으면 받아 둔 행에서만 찾음 */
5560
+ fetchSearch?(request: FetchSearchRequest): Promise<FetchSearchResult>;
5561
+
5562
+ /** rowModel 'server' — (선택) 집계. 하단 합계 행·선택 합계(상태 표시줄). 없으면 둘 다 표시하지 않음 */
5563
+ fetchAggregates?(request: FetchAggregatesRequest): Promise<FetchAggregatesResult>;
5077
5564
 
5078
- /** [선택] 트리 자식 노드 lazy load (TreeConfig.lazyLoad=true 시 호출) */
5079
- fetchChildren?(nodeId: string | number): Promise<Row[]>;
5565
+ /** rowModel 'server' + rowGroupConfig — 그룹 행 목록. 그룹을 쓰려면 필요 */
5566
+ fetchGroups?(request: FetchGroupsRequest): Promise<FetchGroupsResult>;
5080
5567
  }
5081
5568
  ```
5082
5569
 
5570
+ - 어떤 메서드를 부를지는 config 의 `rowModel` 이 정합니다. 그 모드에 맞는 메서드가 없으면 `waitForReady()` 가 오류로 거절합니다. 두 메서드를 모두 구현하면 `rowModel` 만 바꿔 전체 불러오기와 SSRM 을 오갈 수 있습니다.
5571
+
5572
+ - `@vmscloud/moz-ui-grid-core`와 `@vmscloud/moz-ui-grid-core/worker` 양쪽에서 export됩니다. Worker 파일에서는 `/worker`에서 import하세요(메인 스레드 UI 코드가 Worker 번들에 들어가지 않음).
5573
+ - 클래스 판별은 `instanceof`가 아니라 static 표시(`DataLoader.isMozGridLoader`)로 하므로 번들이 두 벌이어도 동작합니다.
5574
+ - 트리 입력은 `fetchAll()`가 중첩(`children` 배열) 또는 평탄(`parentField`) 행을 반환하면 됩니다. 자식 지연 로딩은 지원하지 않습니다.
5575
+
5083
5576
  ```typescript
5084
- // my-loader.ts (별도 ES 모듈)
5577
+ // order-loader.ts
5085
5578
  import { DataLoader } from '@vmscloud/moz-ui-grid-core';
5086
5579
  import type { Row } from '@vmscloud/moz-ui-grid-core';
5087
5580
 
5088
- export default class MyLoader extends DataLoader {
5089
- async fetchData(): Promise<Row[]> {
5090
- const res = await fetch('https://api.example.com/data', {
5091
- headers: { Authorization: `Bearer ${this.params.authToken}` },
5092
- });
5581
+ export class OrderLoader extends DataLoader {
5582
+ async fetchAll(): Promise<Row[]> {
5583
+ const res = await fetch(`/api/orders?year=${this.params.year}`);
5584
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
5093
5585
  return res.json();
5094
5586
  }
5587
+ }
5095
5588
 
5096
- async fetchChildren(nodeId: string | number): Promise<Row[]> {
5097
- const res = await fetch(`/api/tree/${nodeId}/children`);
5098
- return res.json();
5589
+ // 그리드 생성 (메인 스레드)
5590
+ const grid = new PureSheet(container, {
5591
+ keyFields: ['id'],
5592
+ loader: { use: OrderLoader, params: { year: 2026 } },
5593
+ fields: [...],
5594
+ });
5595
+
5596
+ // 런타임 파라미터 갱신 후 다시 불러오기
5597
+ await grid.updateLoaderParams({ year: 2025 });
5598
+ await grid.refreshLoaderData();
5599
+ ```
5600
+
5601
+ ```typescript
5602
+ // orders.worker.ts — 같은 클래스를 Worker 에서 사용
5603
+ import { setupGridWorker } from '@vmscloud/moz-ui-grid-core/worker';
5604
+ import { OrderLoader } from './order-loader';
5605
+ setupGridWorker({ loader: OrderLoader });
5606
+ ```
5607
+
5608
+ #### 받아 오는 방식과 포맷은 자유
5609
+
5610
+ `fetchAll`·`fetchRange` 등은 소비처가 직접 쓰는 async 함수라, **그 안에서 무엇으로 어떻게 받든 상관없습니다**(fetch·WebSocket·SSE, JSON·Arrow·protobuf·CSV, 압축 등). 그리드가 요구하는 것은 **돌려주는 값의 모양**(행 객체 `Row[]`)뿐입니다.
5611
+
5612
+ ```typescript
5613
+ // 예: 서버가 Apache Arrow IPC 로 보내는 경우 (apache-arrow 패키지는 소비처가 설치)
5614
+ import { tableFromIPC } from 'apache-arrow';
5615
+
5616
+ class OrderLoader extends DataLoader {
5617
+ async fetchRange(req: FetchRangeRequest): Promise<FetchRangeResult> {
5618
+ const res = await fetch(`/api/orders.arrow?start=${req.startRow}&end=${req.endRow}`, { signal: req.signal });
5619
+ const table = tableFromIPC(new Uint8Array(await res.arrayBuffer()));
5620
+ return { rows: table.toArray().map(row => row.toJSON()), rowCount: Number(res.headers.get('X-Total-Count')) };
5621
+ }
5622
+ }
5623
+ ```
5624
+
5625
+ ---
5626
+
5627
+ ### 서버 행 모델 (SSRM) — `rowModel: 'server'` + `loader`
5628
+
5629
+ 행은 서버에 두고, 그리드가 **화면에 필요한 구간만** 블록 단위(기본 100행)로 요청합니다. 1천만 행이어도 브라우저에는 본 블록만 남습니다.
5630
+
5631
+ ```typescript
5632
+ class OrderLoader extends DataLoader {
5633
+ async fetchRange(req: FetchRangeRequest): Promise<FetchRangeResult> {
5634
+ const res = await fetch('/api/orders/rows', {
5635
+ method: 'POST',
5636
+ body: JSON.stringify({ startRow: req.startRow, endRow: req.endRow, sort: req.sortModel, filter: req.filterModel, anchor: req.anchor }),
5637
+ signal: req.signal, // 필요 없어진 요청은 그리드가 취소
5638
+ });
5639
+ const { rows, total, nextCursor } = await res.json();
5640
+ return { rows, rowCount: total, nextCursor };
5099
5641
  }
5100
5642
  }
5101
5643
 
5102
- // 그리드 생성
5103
5644
  new PureSheet(container, {
5104
- enableReferCache: true,
5105
- useWorker: true,
5106
- workerFactory: () => new Worker(new URL('./app-grid.worker.ts', import.meta.url), { type: 'module' }),
5107
- loader: {
5108
- module: 'app:data', // worker entry: installProcessorWorker({ loaders: { 'app:data': MyLoader } })
5109
- params: { authToken: getToken() },
5110
- },
5645
+ keyFields: ['orderNo'],
5646
+ rowModel: 'server',
5647
+ loader: { use: OrderLoader, params: { year: 2026 } },
5648
+ serverSide: { blockSize: 100 },
5111
5649
  fields: [...],
5112
5650
  });
5651
+ ```
5113
5652
 
5114
- // 런타임 파라미터 갱신 (예: 토큰 리프레시)
5115
- await grid.updateLoaderParams({ authToken: newToken });
5116
- await grid.refreshLoaderData();
5653
+ **요청·응답**
5654
+
5655
+ | `FetchRangeRequest` | 뜻 |
5656
+ |---|---|
5657
+ | `startRow`, `endRow` | 요청 구간 `[startRow, endRow)` — 항상 블록 경계 |
5658
+ | `sortModel`, `filterModel` | 현재 정렬·필터 상태(`SortState[]`, `ServerFilterState[]`). **서버가 적용**. 수식 필터(`operator: 'formula'`)에는 `expression` 과 함께 수식 트리 `ast`(JSON)가 실려, 서버가 문자열을 해석하지 않고 트리를 따라 SQL 등으로 바꿀 수 있습니다. JS 서버는 `createRowFilterPredicate(filterModel, { columnTypes })` 로 그리드와 같은 판정 함수를 쓸 수 있습니다(아래) |
5659
+ | `groupColumns`, `groupKeys` | 행 그룹이 있으면 그룹 열(레벨 순)과 이 행들이 속한 그룹의 값 경로(예: `['서울', '강남구']`). 서버는 이 그룹 안의 행만 돌려줍니다. 그룹이 없으면 둘 다 `[]`. 객체 값 그룹 열이면 `groupKeys` 에는 객체 대신 `objectKey` 값이 옵니다(→ [서버 계약](#객체-값-열점-표기-필드의-서버-계약)) |
5660
+ | `parentIds` | 트리(`treeConfig`)면 이 행들의 부모 노드 ID 경로(최상위부터). `[]` = 최상위 노드, `['A', 'A-0']` = A 아래 A-0 의 자식들. 트리가 아니면 `[]` |
5661
+ | `aggregateModel?` | 트리에서 `treeConfig.aggregates` 나 부모가 자손을 포함하는 체크박스 열(`checkboxIncludesDescendants`)이 있을 때만 — 노드별 **자기 + 자손**(서브트리) 집계 요청 (아래 트리 참조) |
5662
+ | `anchor?` | 커서 단서 `{ row, cursor }` — "행 번호 `row` 의 행은 `cursor` 바로 다음". 커서 방식 서버는 `cursor` 다음부터 `startRow - row` 행을 건너뛰면 됩니다(바로 앞 블록이면 0). 오프셋 방식 서버는 무시하고 `startRow` 로 조회 |
5663
+ | `signal` | 취소 신호 (`fetch` 에 그대로 전달) |
5664
+
5665
+ | `FetchRangeResult` | 뜻 |
5666
+ |---|---|
5667
+ | `rows` | 구간의 행. 끝에 닿으면 요청보다 적게 |
5668
+ | `rowCount?` | 전체 행 수(정렬·필터 후). 모르면 생략 → 끝(요청보다 적은 응답)에 닿을 때까지 스크롤 범위를 한 블록씩 늘립니다 |
5669
+ | `nextCursor?` | 이 블록 마지막 행 다음을 가리키는 커서. 다음 요청의 `anchor.cursor` 로 돌아옵니다 |
5670
+ | `aggregates?` | 트리 — 요청의 `aggregateModel` 에 대한 노드별 서브트리(자기 + 자손) 집계 `{ [행 ID]: { 열: { 함수: 값 } } }`. 자식 없는 행은 생략 가능 |
5671
+
5672
+ **그리드가 하는 일**
5673
+
5674
+ - 아직 안 온 행은 칸마다 회색 막대(스켈레톤)로 그립니다(`.ps-row-loading`). 선택·편집·복사 대상이 아닙니다.
5675
+ - 받은 블록은 캐시합니다(`maxBlocksInCache` 초과 시 가장 오래 안 본 블록부터 버림). 다시 스크롤해 온 구간은 다시 요청하지 않습니다. `maxBlocksInCache: Infinity` 면 받은 블록을 버리지 않고(메모리 사용은 받은 만큼 늘어남), `0` 이면 보이는 구간의 블록만 남깁니다(미리 받기 없음, 다시 스크롤하면 다시 요청). 정렬·필터 변경과 `refreshLoaderData()` 때는 조건이 달라지므로 항상 비웁니다.
5676
+ - 스크롤이 멈출 때까지 요청을 미루고(`debounceMs`), 동시 요청 수를 제한하고(`maxConcurrentRequests`), 보이는 구간 다음 한 블록을 미리 받습니다.
5677
+ - 정렬·필터가 바뀌면 받아 둔 블록을 버리고 맨 위부터 다시 요청합니다. 이전 조건의 응답은 버립니다.
5678
+ - `refreshLoaderData()` 는 같은 조건으로 지금 보이는 구간부터 다시 받습니다(행 수·스크롤 위치 유지). `updateLoaderParams()` 값은 다음 요청부터 반영됩니다.
5679
+ - 일부만 다시 받기: `refreshLoaderData({ parentIds: ['A'] })`(트리 — A 의 자식 목록과 그 아래 펼친 목록), `refreshLoaderData({ groupKeys: ['서울'] })`(행 그룹 — 서울 그룹 아래 하위 그룹·행). 그 목록의 `fetchRange`(그룹 목록은 `fetchGroups`)를 다시 부릅니다. 펼침·스크롤은 유지하고, 그 노드·그룹 행 자신(부모 목록에 있음)의 값·행 수·집계는 그대로입니다. client 모드에서는 범위를 무시하고 전체를 다시 불러옵니다.
5680
+ - 특정 행만: `refreshLoaderData({ rowIds: ['A-17', 'B-3'] })` — 평면·그룹·트리 공통. 그 행이 든(받아 둔) **블록**만 `fetchRange` 로 다시 받습니다(예: `A-17` 이 0~99 블록이면 `startRow: 0, endRow: 100`). 같은 블록의 다른 행도 새 값이 되고, 새 블록이 올 때까지 이전 값이 보입니다. 받지 않은 행 ID 는 무시합니다(다음에 받을 때 새 값이 옴). 하단 합계도 다시 묻습니다.
5681
+ - 블록 요청이 실패하면 그 행을 `.ps-row-load-failed` 로 표시하고 `error` 이벤트(`code: 'SERVER_ROWS_FAILED'`)를 냅니다. 실패 행을 누르면 다시 요청합니다.
5682
+
5683
+ **필터 값 목록** — 헤더 필터의 체크박스 목록은 `DataLoader.fetchFilterValues({ columnId, filterModel, keyword, limit })` 로 서버에 묻습니다(`{ values, truncated? }`). 구현하지 않으면 받아 둔 행의 값만 보이고 `truncated: true` 로 표시됩니다.
5684
+
5685
+ **편집** — 셀 수정·행 추가·행 삭제는 저장 전 표시(덧씌움)로 보관합니다. 블록이 캐시에서 빠졌다 다시 와도 유지됩니다.
5686
+
5687
+ | 동작 | 화면 |
5688
+ |---|---|
5689
+ | 셀 수정 | 받은 행에 수정값을 덧씌워 표시(`.ps-row-modified`) |
5690
+ | 행 추가 (`changes.addRowByIndex(row, index)` 등) | 그 위치에 임시 행으로 끼움. 서버 블록 번호는 밀리지 않음. 정렬·필터가 바뀌면 맨 위로 이동 |
5691
+ | 행 삭제 | 행을 지우지 않고 삭제 표시(`.ps-row-deleted`) |
5692
+ | `changes.commit()` | 로컬 원본을 고치지 않음 — 저장 전 표시를 지우고 서버에서 다시 받음 |
5693
+
5694
+ 저장 순서: `const changes = grid.changes.getAll()` → 서버에 저장 → `await grid.changes.commit()`.
5695
+
5696
+ **전체 선택** — `selection.selectAllRows()`(헤더 체크박스·Ctrl+A 포함)는 행을 불러오지 않고 "전부 + 예외 목록"으로 표시합니다(1억 행도 즉시). 이후 해제한 행은 예외로 쌓입니다.
5697
+
5698
+ ```typescript
5699
+ grid.selection.getRowSelectionState();
5700
+ // 전체 선택 뒤 2행 해제 → { selectAll: true, excludedIds: ['7', '9'] } "이 둘을 뺀 전부"
5701
+ // 보통 선택 → { selectAll: false, selectedIds: ['1', '2'] } "이 둘만"
5702
+ // 타입: RowSelectionState — selectAll 을 확인해야 excludedIds / selectedIds 를 꺼낼 수 있다
5703
+ ```
5704
+
5705
+ `getSelectedRowIds()` 는 받아 둔 행만 돌려주므로, 선택 행 삭제·내보내기 같은 일괄 작업은 `getRowSelectionState()` 를 현재 필터와 함께 서버에 보내 처리합니다. 선택한 행 전체의 ID 목록을 서버에서 받아 오는 API 는 두지 않습니다(1억 행 선택이면 ID 도 1억 개).
5706
+
5707
+ **집계 (하단 합계·선택 합계)** — `DataLoader.fetchAggregates(request)` 로 서버에 묻고, 돌려준 값을 그대로 표시합니다(클라이언트 계산 없음). 구현하지 않으면 하단 합계 칸은 비고 선택 합계는 나오지 않습니다(오류 없음).
5708
+
5709
+ ```typescript
5710
+ class OrderLoader extends DataLoader {
5711
+ async fetchAggregates({ aggregateModel, filterModel, selection, changes, signal }: FetchAggregatesRequest) {
5712
+ // aggregateModel: [{ columnId: 'amount', aggregate: 'sum' }, ...]
5713
+ // selection 없음 → 필터 결과 전체 / 있음 → 그중 선택한 행만 ({ selectAll, excludedIds } | { selectAll: false, selectedIds })
5714
+ // changes: serverSide.sendChangesToAggregates 가 true 일 때만 — 저장 전 편집을 반영한 것처럼 집계
5715
+ const res = await fetch('/api/orders/aggregates', { method: 'POST', body: JSON.stringify({ aggregateModel, filterModel, selection, changes }), signal });
5716
+ return res.json(); // { amount: { sum: 12345 } } — 열 ID → 함수 → 값
5717
+ }
5718
+ }
5719
+ ```
5720
+
5721
+ | `FetchAggregatesRequest` | 뜻 |
5722
+ |---|---|
5723
+ | `aggregateModel` | `[{ columnId, aggregate }]`. 한 열에 함수가 여러 개 올 수 있음 |
5724
+ | `filterModel` | 현재 필터 (정렬은 합계와 무관해 싣지 않음) |
5725
+ | `selection?` | 있으면 선택 합계 요청. `RowSelectionState` 그대로 |
5726
+ | `changes?` | 저장 전 편집 `{ added: [{ id, row }], modified: [{ id, changes: { 필드: { from, to } } }], deleted: [id] }`. `sendChangesToAggregates: true` 일 때만 |
5727
+ | `signal` | 취소 신호 — 같은 종류의 새 요청이 나가면 이전 요청을 취소 |
5728
+
5729
+ | 쓰임 | 언제 묻나 | 무엇을 묻나 |
5730
+ |---|---|---|
5731
+ | 하단 합계 행(`summaryRow`) | 처음, 필터 변경, `refreshLoaderData()`, 열의 집계 함수 변경. `sendChangesToAggregates: true` 면 편집할 때마다(0.3초 묶음) | 열 정의의 `aggregate`(문자열)가 있는 열 — 그 함수 하나 |
5732
+ | 그룹 행·트리 노드 집계 (`fetchNodeAggregates`) | `sendChangesToAggregates: true` 이고 편집할 때마다(0.3초 묶음) — 편집이 없으면 묻지 않음 | 그룹: 그룹 행 집계(`rowGroupConfig.aggregates`), 트리: `treeConfig.aggregates` |
5733
+ | 선택 합계(상태 표시줄) | 행 선택이 바뀔 때(전체 선택 포함) | 숫자 열과 문자열 `aggregate` 열. 숫자 열은 상태 표시줄 합계용으로 `sum`·`count`·`min`·`max` 도 함께 |
5734
+
5735
+ - 정렬만 바뀌면 합계가 같으므로 다시 묻지 않습니다.
5736
+ - 응답에 없는 열·함수는 빈칸입니다. 요청이 실패하면 `error` 이벤트(`code: 'SERVER_AGGREGATES_FAILED'`)를 내고 합계를 비웁니다. 다음 계기(필터 변경·refresh·편집)에 다시 묻습니다.
5737
+ - 셀 범위(드래그) 선택의 합계는 SSRM 에서 계산하지 않습니다(`selectionAggregates()` 가 null). 행 선택만 서버에 묻습니다.
5738
+ - `sendChangesToAggregates` 가 꺼져 있으면(기본) 저장 전 편집은 저장 후 다시 불러올 때 합계에 반영됩니다.
5739
+
5740
+ **저장 전 편집을 그룹 행·트리 노드 집계에 반영 (`sendChangesToAggregates` + `fetchNodeAggregates`)** — 그룹 행 집계는 `fetchGroups`, 트리 노드 집계는 `fetchRange` 가 처음 줍니다(저장된 값). 이 옵션을 켜고 `DataLoader.fetchNodeAggregates` 를 구현하면, 편집 뒤 그 편집이 바꾸는 **받아 둔** 그룹·노드만 다시 묻습니다.
5741
+
5742
+ ```typescript
5743
+ // 트리: 'D0-T1-E4' 급여 수정 → 바뀌는 노드 = 받아 둔 조상 D0-T1, D0 (부모 노드면 자기도)
5744
+ async fetchNodeAggregates({ aggregateModel, filterModel, changes, treeNodes, groupColumns, groupKeys, groupFilterModel, signal }: FetchNodeAggregatesRequest) {
5745
+ // treeNodes: [{ id: 'D0-T1', parentIds: ['D0'] }, { id: 'D0', parentIds: [] }]
5746
+ return { treeNodes: { 'D0-T1': { salary: { sum: 1500300 } }, 'D0': { salary: { sum: 45000300 } } } };
5747
+ // 그룹: groupKeys: [['서울', '강남구'], ['서울']] → { groups: [{ groupKeys: ['서울', '강남구'], aggregates: { amount: { sum: 1200 } } }, …] }
5748
+ }
5749
+ ```
5750
+
5751
+ - 대상: 편집된 행(수정·삭제)이 속한 그룹 경로·조상 노드, 새 행이 들어갈 그룹·부모. 범위 수정(그룹·트리 체크박스, `bulkUpdate`)이 있으면 받아 둔 그룹·부모 노드 전부. 나중에 받은 그룹·노드가 대상이면 그때 묻습니다.
5752
+ - 서버는 저장된 값에 `changes` 를 반영한 것처럼 집계합니다(`fetchAggregates` 의 `changes` 와 같은 규칙). 응답에 없는 그룹·노드는 처음 받은 값으로 보입니다.
5753
+ - 편집을 모두 되돌리면 묻지 않고 처음 받은 값으로 돌아갑니다. 저장(`commit`)하면 다시 불러오므로 새 저장 값이 옵니다.
5754
+ - 체크박스 상태(그룹 행·포함 체크박스)는 이 요청과 무관합니다 — 화면이 편집을 직접 더해 정합니다.
5755
+ - `fetchNodeAggregates` 가 없으면 그룹 행·노드 집계는 저장 후 다시 불러올 때 반영됩니다(하단 합계만 실시간).
5756
+
5757
+ **검색** — 툴바 검색은 `DataLoader.fetchSearch({ keyword, columnIds, caseSensitive, sortModel, filterModel, signal })` 로 서버에 묻습니다. 응답 `{ matches: [{ rowIndex, columnId }] }` 의 `rowIndex` 는 현재 정렬·필터 결과의 행 번호입니다. 다음/이전 이동 시 그 위치의 블록을 받아 강조합니다. 구현하지 않으면 받아 둔 행에서만 찾습니다. 트리는 아래 트리 항목을 보세요(`tree: true`, 매치마다 위치).
5758
+
5759
+ **행 그룹** — `rowGroupConfig`(그룹 바·보기 설정 포함)를 쓰면 그리드가 직접 묶지 않고 서버에 묻습니다. 그룹 행은 `fetchGroups`, 그룹 안의 행은 `fetchRange({ groupColumns, groupKeys })` 로 받습니다.
5760
+
5761
+ ```typescript
5762
+ class OrderLoader extends DataLoader {
5763
+ async fetchGroups({ groupColumns, groupKeys, sortModel, filterModel, aggregateModel, signal }: FetchGroupsRequest) {
5764
+ // groupColumns: ['region', 'city'], groupKeys: ['서울'] → 서울 아래 city 그룹들 (GROUP BY city WHERE region = '서울')
5765
+ return {
5766
+ groups: [
5767
+ { value: '강남구', count: 1200, aggregates: { amount: { sum: 5000 } } }, // 집계 모양은 fetchAggregates 와 같음
5768
+ ],
5769
+ };
5770
+ }
5771
+ async fetchRange({ startRow, endRow, groupColumns, groupKeys, sortModel, filterModel, signal }: FetchRangeRequest) {
5772
+ // groupKeys: ['서울', '강남구'] → 그 그룹 안의 행만
5773
+ }
5774
+ }
5775
+ ```
5776
+
5777
+ | `ServerGroup` | 뜻 |
5778
+ |---|---|
5779
+ | `value` | 그룹 값. 객체 값 열이면 대표 객체 또는 `objectKey` 값 → [객체 값 열·점 표기 필드의 서버 계약](#객체-값-열점-표기-필드의-서버-계약) |
5780
+ | `count` | 그룹에 속한 데이터 행 수(필터 후). 펼쳤을 때의 스크롤 길이 |
5781
+ | `aggregates?` | 그룹 행 집계 — `rowGroupConfig.aggregates`·그룹 행에 보이는 열 `aggregate`(문자열 함수만)를 `aggregateModel` 로 묻습니다 |
5782
+ | `descendantGroupCounts?` | 아래 레벨마다의 하위 그룹 수(예: 그룹 열 3개의 1레벨 그룹이면 `[2레벨 수, 3레벨 수]`). 주면 하위 그룹을 받기 전에도 펼친 높이가 정확해 스크롤바가 튀지 않습니다. 없으면 `count` 로 어림잡고 하위 그룹이 오면 고칩니다 |
5783
+
5784
+ - **언제 묻나:** 최상위 그룹은 처음에 받습니다. 하위 그룹과 그룹 안의 행은 **펼쳐져 있고 화면에 보이는 그룹의 것만** 받습니다(스크롤하면 그때 그 그룹 것을). 그룹마다 블록 캐시를 따로 둡니다.
5785
+ - **펼침 상태:** 기존 옵션(`defaultCollapsed`, `defaultExpandLevel`, 없으면 전부 펼침)을 따릅니다. 나중에 도착한 하위 그룹에도 같은 규칙을 적용하고, 사용자가 바꾼 접힘은 유지합니다.
5786
+ - **정렬·필터:** 필터가 바뀌면 그룹을 처음부터 다시 받습니다. 그룹 열 정렬은 서버가 그룹 순서에 반영합니다.
5787
+ - **편집:** 셀 수정·삭제 표시는 평면과 같습니다. 새 행은 **그룹 열 값이 같은 그룹 맨 위**에 보이고, 서버에 없는 값이면 임시 그룹이 생깁니다(그리드가 직접 묶을 때와 같은 규칙). 저장 후 다시 불러오면 서버 위치로 갑니다. 그룹 행 집계는 서버 값 그대로입니다(저장 전 편집 미반영).
5788
+ - **그룹 체크박스(행 선택):** 그룹 행의 rowHeader 체크박스는 그 그룹의 행 전부를 선택/해제합니다(받지 않은 행 포함). 선택 상태는 `getRowSelectionState()` 에 그룹 경로별로 겹쳐 적힙니다(`groups`, 위 전체 선택 예 참조). 일부만 선택된 그룹은 부분 선택으로 표시됩니다. 그룹 열이 바뀌면(그룹 추가·변경·해제) 행 선택은 **전체 선택을 포함해 모두 해제**됩니다 — 선택이 그룹 경로 기준으로 저장되므로 일관되게 비웁니다.
5789
+ - **그룹 결과 필터(고급 필터의 "그룹 필터" 탭):** 서버가 거릅니다. `fetchGroups`·`fetchAggregates` 요청에 `groupFilterModel` 이 실립니다.
5790
+ ```typescript
5791
+ groupFilterModel: {
5792
+ conditions: [{ type: 'formula', expression: 'GROUP("dept", "aggregate", "total") > 50', ast: { type: 'BinaryOp', operator: '>', … } }],
5793
+ aggregates: { total: { columnId: 'amount', aggregate: 'sum' } }, // by / GROUP(…, "aggregate", id) 가 가리키는 집계
5794
+ }
5795
+ ```
5796
+ 규칙은 그리드가 직접 거를 때와 같습니다 — 행 필터 후 그룹별 값으로 **맨 아래 그룹 경로마다** 판정하고, 살아남은 행으로 그룹 행 수·집계와 하단 합계를 다시 계산합니다(SQL 의 HAVING). 조건식(`type: 'conditional'`)도 같은 모델로 옵니다. JS 서버는 `createGroupFilterPredicate(model, groupColumns)` 로 같은 판정 함수를 쓸 수 있습니다(객체 값 그룹 열이면 세 번째 인자 `{ objectSpecs, fieldOf }` — [객체 값 열](#객체-값-열-datatype-object) 참조).
5797
+
5798
+ **행 필터 판정 함수 (`createRowFilterPredicate`, JS 서버용)** — 그리드 엔진과 **같은 코드**로 `filterModel` 을 판정합니다(값 비교·마스크·수식 필터·`groupKey` 괄호 결합).
5799
+
5800
+ ```typescript
5801
+ import { createRowFilterPredicate } from '@vmscloud/moz-ui-grid-core';
5802
+
5803
+ async fetchRange({ filterModel, startRow, endRow }: FetchRangeRequest) {
5804
+ // columnTypes = 그리드 필드의 dataType — 수식 필터가 문자열 열('001', 'sales 2026')을 숫자·날짜로 오인하지 않게
5805
+ const matches = createRowFilterPredicate(filterModel, { columnTypes: { name: 'string', code: 'string', amount: 'number' } });
5806
+ const rows = ALL_ROWS.filter(matches);
5807
+ return { rows: rows.slice(startRow, endRow), rowCount: rows.length };
5808
+ }
5117
5809
  ```
5118
5810
 
5119
- > **Worker loader 경계**: `LoaderConfig.module`에 클래스나 임의 URL을 전달해 동적 실행시키는 방식은 지원하지 않습니다. 앱에서 loader 클래스를 정적으로 import하고 등록하세요. `InlineDataLoader`라는 export의 존재가 런타임 클래스 전달 지원을 뜻하지 않습니다.
5811
+ - 서버에서 판정할 수 없는 것: `custom` 필터(조건이 JS 함수라 요청에 실리지 않음 — 통과로 봄), 그리드 계산 필드에 건 필터.
5812
+ - 빈 셀(null)은 문자열 연산자에서 `''` 로 봅니다(`contains ''` 통과, `notContains ''` 탈락) — `isNull`·`in`·`like` 와 같은 규칙. SQL 로 옮길 때는 `COALESCE(col, '') LIKE …` 로 맞춥니다.
5813
+ - **그룹 배너 선택(`selectable`):** 선택 결과에 `groupKeys`·`rowCount` 가 실리고 `rowIds` 는 없습니다.
5814
+ - **그룹 화면에서 미지원:** 함수형 집계.
5815
+
5816
+ **범위 수정 (받지 않은 행까지 일괄 변경)** — 그룹 행의 boolean 열 체크박스, `changes.bulkUpdate(column, value, 'all' | 'selected' | 행 ID 목록)` 는 행을 하나씩 바꾸지 않고 **저장 전 범위 수정**으로 남깁니다. 모양은 두 가지입니다(`rowIds` 가 있으면 행 ID 목록 모양 — `RangeUpdate = ScopeRangeUpdate | RowIdsRangeUpdate`).
5817
+
5818
+ | 모양 | 만드는 것 | 서버 저장 |
5819
+ |---|---|---|
5820
+ | 선택 범위 (`scope`) | 그룹·트리 체크박스, `bulkUpdate(…, 'all' \| 'selected')` | `UPDATE … WHERE filterModel AND groupFilterModel AND scope` |
5821
+ | 행 ID 목록 (`rowIds`) | `bulkUpdate(…, ['1', '3'])` | `UPDATE … WHERE id IN (rowIds)` — 필터·그룹과 무관 |
5822
+
5823
+ ```typescript
5824
+ grid.changes.getAll().rangeUpdates
5825
+ // 영업팀 그룹의 [확정] 체크 → 그 안의 17번만 다시 끔
5826
+ // [{ field: 'confirmed', value: true,
5827
+ // groupColumns: ['dept'], filterModel: [...], // 범위를 만든 순간의 맥락
5828
+ // scope: { selectAll: false, selectedIds: [], // RowSelectionState 와 같은 모양
5829
+ // groups: { '영업팀': { selectAll: true, excludedIds: ['17'] } } } }]
5830
+ // modified: 17번(confirmed: false) — 17번의 원래 값이 이미 false 였다면 modified 에도 없음
5831
+
5832
+ grid.changes.bulkUpdate('confirmed', true, ['3', '17', '9001']) // → 3 (중복 제외 대상 수)
5833
+ // [{ field: 'confirmed', value: true, rowIds: ['3', '17', '9001'] }] // 받지 않은 9001 도 포함
5834
+ ```
5835
+
5836
+ 행 ID 목록 규칙: ID 는 문자열로 비교합니다(숫자 ID 행에 `'3'` 을 줘도 같은 행). 저장 전 새 행의 ID 는 범위가 아니라 그 행의 셀 편집(`added` 의 값)으로, 저장 전 삭제한 행의 ID 는 뺍니다. 받아 둔 행은 바로 그 값으로 보이고, 그룹·트리 체크박스는 받아 둔 행만 셀 편집처럼 셉니다(받지 않은 행은 어느 그룹에 있는지 몰라 저장 후 서버 값으로 맞춰짐).
5837
+
5838
+ | 규칙 | 내용 |
5839
+ |---|---|
5840
+ | 화면 | 범위 안의 행은 받아 둔 것이든 나중에 받는 것이든 그 값으로 보입니다. 그룹 체크박스 상태는 범위·셀 편집·서버 참 개수로 정합니다 |
5841
+ | 그룹 체크박스 상태 | 범위가 없으면 서버에 참 개수를 묻습니다 — 그룹 행에 체크박스가 보이는 boolean 열(고정 열, center 양 끝 `unmergeOnGroupRow`)이면 `fetchGroups` 의 `aggregateModel` 에 `{ columnId, aggregate: 'sum' }`(true = 1)이 자동으로 실립니다 |
5842
+ | 겹침 | 같은 열·같은 맥락의 범위끼리는 겹치지 않게 정리합니다(새 범위가 겹친 부분을 가져감). 범위 안의 행을 셀 편집하면 그 행은 `scope` 에서 빠지고 `modified` 로 갑니다. 같은 그룹을 다시 체크하면 그 안의 셀 편집은 지워집니다 |
5843
+ | 맥락 | `groupColumns`·`filterModel`(+ `groupFilterModel`)은 범위를 만든 순간의 것입니다. 저장 전에 그룹·필터를 바꿔도 같은 행을 가리킵니다 |
5844
+ | 저장 | 서버는 `UPDATE … SET field = value WHERE filterModel AND groupFilterModel AND scope` 후 `modified` 를 적용합니다. 순서와 무관합니다(맥락이 다른 범위끼리는 목록 순서대로). JS 서버는 `isRowInSelectionState(scope, groupKeys, rowId)` 로 행이 범위에 드는지 판정할 수 있습니다 |
5845
+ | 실행 취소 | 한 번의 동작으로 되돌립니다. `commit()`·`discardAll()` 이 범위를 비웁니다. `hasChanges` 에 포함됩니다 |
5846
+ | 합계 | `sendChangesToAggregates: true` 면 `fetchAggregates` 의 `changes.rangeUpdates` 로도 갑니다 |
5847
+
5848
+ 범위로 값이 실제로 바뀐 행에도 수정 표시(`.ps-row-modified`)가 붙습니다(원래부터 그 값이던 행은 붙지 않음). 화면에서 범위가 행을 덮는지는 범위를 만든 순간의 필터로도 판단합니다 — 그 뒤 필터를 바꿔 새로 보이게 된 행은 범위 값으로 바뀌지 않습니다(저장 때 서버가 적용하는 대상과 같음).
5849
+ - `fetchGroups` 가 없으면 `error` 이벤트(`code: 'SERVER_GROUPS_FAILED'`)를 내고 그룹이 비어 보입니다.
5850
+
5851
+ **트리** — `treeConfig` 를 쓰면 노드의 자식 목록을 `fetchRange({ parentIds })` 로 받습니다. 펼쳐져 있고 화면에 보이는 노드의 자식만 요청합니다. 한 노드의 자식이 수백만이어도 보이는 블록만 받습니다.
5852
+
5853
+ ```typescript
5854
+ class DeptLoader extends DataLoader {
5855
+ async fetchRange({ parentIds, startRow, endRow, sortModel, filterModel, aggregateModel, signal }: FetchRangeRequest) {
5856
+ // parentIds: [] → 최상위, ['본사', '개발본부'] → 개발본부의 자식들 (마지막 값이 직계 부모)
5857
+ return {
5858
+ rows: [{ id: '플랫폼팀', name: '플랫폼팀', childCount: 12 }], // childCount: 자식이 있는지·몇 개인지
5859
+ rowCount: 40, // 이 부모의 자식 수(모르면 생략)
5860
+ aggregates: { '플랫폼팀': { confirmed: { sum: 3, countAll: 57 } } }, // aggregateModel 이 왔을 때만
5861
+ };
5862
+ }
5863
+ }
5864
+ new PureSheet(container, {
5865
+ keyFields: ['id'], rowModel: 'server', loader: { use: DeptLoader },
5866
+ treeConfig: { treeColumn: 'name', childCountField: 'childCount', defaultExpandLevel: 1 },
5867
+ fields: [...],
5868
+ });
5869
+ ```
5870
+
5871
+ | 항목 | 동작 |
5872
+ |---|---|
5873
+ | 자식 여부 (`treeConfig.childCountField`, 기본 `'childCount'`) | 숫자 = 직계 자식 수(받기 전에도 펼친 높이가 정확), `true` = 자식은 있지만 수 모름(자리 행 1개로 시작해 받을수록 늘어남), 0·`false`·없음 = 잎 |
5874
+ | 펼침 | `defaultCollapsed`·`defaultExpandLevel` 을 나중에 받은 노드에도 적용합니다. 사용자가 바꾼 접힘은 유지합니다. `expandAll`·`collapseAll`·`expandToLevel`·`collapseToLevel`·`expandAtLevel`·`collapseAtLevel` 은 받아 둔 노드를 바로 바꾸고, **나중에 받는 노드에도 같은 규칙**을 적용합니다(요청은 여전히 보이는 것만) |
5875
+ | 정렬 | 레벨마다 같은 `sortModel` 이 실립니다 — 서버가 형제 안에서 정렬합니다 |
5876
+ | 필터 | 서버가 거릅니다. 규칙은 클라이언트 트리와 같게: **조건에 맞는 행과 그 조상**만 남깁니다(조건에 맞는 부모의 자식이라도 맞지 않으면 빠짐). `childCount` 도 필터 후 기준으로 돌려주세요 |
5877
+ | 행 추가 | 추가하는 행의 `treeConfig.parentField`(기본 `'parentId'`) 값이 부모를 정합니다. `addRow(row)` = 그 부모의 자식 맨 위, `addRow(row, index)` = 그 부모의 자식 목록 안 `index` 번째. 부모 값이 없으면 최상위. 잎이던 부모에는 펼침 아이콘이 생깁니다 |
5878
+ | 셀 수정·삭제·저장 | 평면 SSRM 과 같습니다 |
5879
+ | 노드 이동 | `tree.moveNode`·`setParent`·드래그 재정렬은 지원하지 않습니다(받지 않은 형제 사이 위치를 정할 수 없음). 서버에서 옮긴 뒤 `refreshLoaderData({ parentIds })` |
5880
+ | `getChildren`·`getDescendants`·`getParent`·`getAncestors` | **받아 둔 노드만** 봅니다. 받은 노드의 조상은 받지 않았어도 정확합니다(부모 경로를 앎) |
5881
+
5882
+ **부모가 자손을 포함하는 체크박스 (`checkboxIncludesDescendants`)** — 부모 칸은 자손 상태(전부/일부/없음)로 보이고, 부모를 누르면 부모와 **받지 않은 자손까지** 같은 값이 됩니다. 행을 하나씩 바꾸지 않고 트리 범위 수정으로 남깁니다(실행 취소 한 번).
5883
+
5884
+ ```typescript
5885
+ grid.changes.getAll().rangeUpdates
5886
+ // A 아래 A-0 을 체크 → A-0 과 그 자손 전부 (필터와 무관)
5887
+ // [{ field: 'confirmed', value: true, tree: true, groupColumns: [], filterModel: [],
5888
+ // scope: { selectAll: false, selectedIds: [],
5889
+ // groups: { 'A': { selectAll: false, selectedIds: ['A-0'], // 노드 자신 = 부모 쪽 selectedIds
5890
+ // groups: { 'A-0': { selectAll: true, excludedIds: [] } } } } } }] // 자손 전부 = groups[노드 ID]
5891
+ ```
5892
+
5893
+ - 부모 칸의 자손 상태는 서버에 묻습니다. 이 열이 있으면 `fetchRange` 에 `aggregateModel: [{ columnId, aggregate: 'sum' }, { columnId, aggregate: 'countAll' }]` 이 실리고, 서버는 노드마다 **서브트리(자기 + 자손)** 의 참 개수·노드 수를 `aggregates` 로 돌려줍니다(아래 노드 집계와 같은 규칙 — 그리드가 자기 값을 빼서 자손 상태를 정함). 주지 않으면 받지 않은 자손이 있는 부모 칸은 부모 자기 값으로 보입니다. 화면은 여기에 범위 수정과 받아 둔 자손의 셀 편집을 더해 정합니다.
5894
+ - 저장: 서버는 `scope` 가 가리키는 노드(재귀 CTE 등)를 `UPDATE` 한 뒤 `modified` 를 적용합니다. JS 서버는 `isRowInSelectionState(scope, parentIds, rowId)` 로 판정할 수 있습니다(`groupKeys` 자리에 부모 ID 경로).
5895
+ - 클라이언트 트리에서도 같은 옵션이 부모·자손을 함께 바꿉니다(`checkboxIncludesDescendants` — 예전 이름 `checkboxTriState`).
5896
+
5897
+ **행 선택 + `rowSelection.selectChildren`** — 부모를 고르면 받지 않은 자손까지 선택됩니다. 선택 상태는 그룹과 같은 모양이고 `groups` 의 키가 노드 ID 입니다.
5898
+
5899
+ ```typescript
5900
+ grid.selection.getRowSelectionState()
5901
+ // A 를 고른 뒤 A-1 만 해제 → { selectAll: false, selectedIds: ['A'], groups: { 'A': { selectAll: true, excludedIds: ['A-1'] } } }
5902
+ ```
5903
+
5904
+ **검색** — `fetchSearch` 에 `tree: true` 가 실립니다. 트리 전체(접힌 노드 아래 포함)에서 찾고 매치마다 위치를 돌려주세요. 그리드가 조상을 받아 펼친 뒤 그 행으로 이동합니다.
5905
+
5906
+ ```typescript
5907
+ async fetchSearch({ keyword, columnIds, sortModel, filterModel, tree, signal }: FetchSearchRequest) {
5908
+ // 본사(최상위 0번) > 개발본부(본사의 자식 3번) > 그 자식 목록 7번 행
5909
+ return { matches: [{ parentIds: ['본사', '개발본부'], parentRowIndexes: [0, 3], rowIndex: 7, columnId: 'name' }] };
5910
+ }
5911
+ ```
5912
+
5913
+ **경로 펼치기 (`tree.expandPath(id)`)** — 받아 둔 노드는 아는 경로로 펼칩니다. 받지 않은 노드는 `DataLoader.fetchPath({ id, sortModel, filterModel, signal })` 로 위치(`TreeNodeLocation` — 검색 매치와 같은 `{ parentIds, parentRowIndexes, rowIndex }`)를 물어 조상을 받은 뒤 펼칩니다. `fetchPath` 가 없으면 받은 노드만 펼칩니다.
5914
+
5915
+ **노드 집계 (`treeConfig.aggregates`)** — 클라이언트 트리처럼 화면에 그리지 않고 `grid.tree.getNodeMeta(id).aggregates` 로 돌려줍니다(키 `${함수}_${열}`, 자기 + 자손 서브트리 기준).
5916
+
5917
+ ```typescript
5918
+ treeConfig: { treeColumn: 'name', aggregates: { salary: 'sum', id: 'count' } }
5919
+ // fetchRange 요청: aggregateModel: [{ columnId: 'salary', aggregate: 'sum' }, { columnId: 'id', aggregate: 'count' }]
5920
+ // fetchRange 응답: aggregates: { 'D0-T1': { salary: { sum: 1500000 }, id: { count: 501 } } } ← 자식 있는 노드만
5921
+ grid.tree.getNodeMeta('D0-T1') // { level: 1, hasChildren: true, descendantCount: 500, aggregates: { sum_salary: 1500000, count_id: 501 } }
5922
+ ```
5923
+
5924
+ | 항목 | 동작 |
5925
+ |---|---|
5926
+ | 자식 있는 노드 | 서버 값. 노드와 같은 응답으로 오므로 노드가 보이면 값도 있습니다 |
5927
+ | 잎 | 서버가 주지 않아도 그리드가 자기 값으로 채웁니다(합 = 자기 값, 개수 = 1, 분산 = 0). 저장 전 편집 반영 |
5928
+ | 저장 전 편집 | 부모 노드는 `sendChangesToAggregates` + `fetchNodeAggregates` 일 때 반영(위 합계 항목). 아니면 저장 후 다시 불러올 때 |
5929
+ | 필터 | 필터 후 남은 노드 기준(서버 책임 — 클라이언트 트리와 같음) |
5930
+ | 함수형 집계 | 무시(클라이언트 트리와 같음) |
5931
+ | 받지 않은 노드 | `getNodeMeta` 가 null |
5932
+ | `descendantCount` | 서버가 `countAll` 집계를 주면 그 값 - 1, 아니면 직계 자식 수(`childCount`, 모르면 0) |
5933
+
5934
+ **트리에서 미지원:** 노드 이동, 트리 export 의 `treeRowMode: 'include'`(받지 않은 노드).
5935
+
5936
+ **현재 범위** — 피벗은 아직 지원하지 않습니다(호출 시 오류 또는 빈 결과). `getAllData()` 등은 받아 둔 행(+ 저장 전 새 행)만 돌려줍니다. Worker 모드(`loader.worker`) SSRM 은 지원하지 않습니다(데이터가 서버에 있어 이점이 없음).
5937
+
5938
+ > 깊은 위치의 조회 속도는 서버·DB 몫입니다. 그리드는 요청을 줄이고 `anchor` 단서를 주며, 서버는 커서(keyset)·조건별 행 번호표 등으로 깊이와 무관하게 찾을 수 있습니다.
5939
+
5940
+ #### 객체 값 열·점 표기 필드의 서버 계약
5941
+
5942
+ [객체 값 열](#객체-값-열-datatype-object)(`dataType: 'object'`)을 그룹 열·필터 열로 쓰면, 그리드는 서버에 **객체 대신 `objectKey` 값(키)** 을 보냅니다. 서버는 그 열의 `objectKey` 와 **같은 규칙**으로 묶고 거릅니다.
5943
+
5944
+ ```typescript
5945
+ // 열: { id: 'owner', dataType: 'object', objectKey: 'id', mask: { type: 'function', formatter: v => v?.name ?? '' } }
5946
+ // 행: { id: 'r1', owner: { id: 'u2', name: '김철수' }, dept: { code: 7 } }
5947
+
5948
+ async fetchGroups({ groupColumns, groupKeys }: FetchGroupsRequest) {
5949
+ // groupColumns: ['owner', 'dept'], groupKeys: [] → GROUP BY owner.id
5950
+ // groupKeys: ['u2'] → WHERE owner.id = 'u2' GROUP BY dept.code
5951
+ return { groups: [
5952
+ { value: { id: 'u2', name: '김철수' }, count: 2 }, // 대표 객체 → 그룹 행 라벨 '김철수' (열 mask)
5953
+ { value: 'u3', count: 1 }, // 키만 줘도 됨 → 라벨은 'u3'
5954
+ { value: null, count: 1 },
5955
+ ] };
5956
+ }
5957
+ async fetchRange({ groupKeys }: FetchRangeRequest) {
5958
+ // groupKeys: ['u2', 7] — 숫자 키는 숫자 그대로
5959
+ }
5960
+ ```
5961
+
5962
+ | 요청·응답 자리 | 객체 값 열에서 오가는 값 |
5963
+ |---|---|
5964
+ | `fetchGroups`·`fetchRange` 의 `groupKeys`, `fetchNodeAggregates` 의 `groupKeys`(경로 배열) | `objectKey` 값 (`['u2']`, `['u2', 7]`, `null` 그룹은 `[null]`) — 객체는 오지 않음 |
5965
+ | `ServerGroup.value` (응답) | 대표 객체 **또는** 키. 어느 쪽이든 묶기·펼치기·선택·부분 새로고침이 같게 동작합니다. 객체를 주면 그룹 행 라벨이 열 `mask` 로 보이고, 키만 주면 키가 보입니다 |
5966
+ | `filterModel` 의 `in`/`notIn` `filterValue` | 키 배열. 사용자가 객체로 걸어도 `[{ id: 'u2', … }, null, 'u3']` → `['u2', null, 'u3']` 로 바꿔 보냅니다(그룹 목록·행 블록 요청 모두). 객체가 없는 필터는 그대로 |
5967
+ | `filterModel` 의 `eq`/`neq` `filterValue` | 객체로 걸면 키(`{ id: 'u2', … }` → `'u2'`, 숫자 키는 숫자). 문자열·`null` 은 그대로 — 문자열에 `mask` 가 실려 있으면 표시값 비교, 아니고 열에 `displayKey` 가 있으면 그 경로 값과 대소문자 무시 비교, 둘 다 없으면 키 비교로 판정하세요(`createRowFilterPredicate` 가 이 규칙) |
5968
+ | `filterModel` 의 글자 조건(`contains` 등)·크기 비교(`gt`·`between` 등) | 글자 조건은 `displayKey` 경로 값(없으면 객체 JSON 글자), 크기 비교는 정렬 기준 값(`sortValue` → `displayKey` → `objectKey`, 배열이면 첫 값) |
5969
+ | `sortModel` 의 객체 열 | 정렬 기준 값 `sortValue` → `displayKey` → `objectKey` 경로 순서 (예: `displayKey: 'name'`, `sortValue` 없음 → `ORDER BY owner.name`). 경로는 요청에 실리지 않음 — 열 정의와 같은 경로를 서버가 씀 |
5970
+ | `RowSelectionState.groups` 의 키 | `objectKey` 값의 문자열(`null` 그룹은 `''`) |
5971
+ | `fetchFilterValues` 응답 `values` | 객체를 그대로 줘도 됩니다 — 목록 라벨은 열 `mask`, 중복은 그리드가 `objectKey` 로 한 번 더 걸러 냅니다. 요청 `filterModel` 의 `in`/`notIn` 값도 키 |
5972
+
5973
+ - 저장 전 새 행의 그룹 값이 같은 id 의 다른 객체여도 기존 그룹에 들어갑니다(임시 그룹을 만들지 않음).
5974
+ - 받아 둔 행으로 만드는 값 목록(`fetchFilterValues` 미구현)도 `objectKey` 로 중복을 없앱니다. `rawValues` 는 원본 객체입니다.
5975
+
5976
+ **점 표기 `field` 열** — 요청의 열 이름(`groupColumns`, `sortModel`·`filterModel` 의 `id`)은 지금처럼 **열 id** 입니다. 그 열이 `field: 'owner.team'` 이면 서버가 그 경로(`owner.team`)를 스스로 풀어 정렬·필터·묶기에 씁니다.
5977
+
5978
+ - 범위 수정(`changes.getAll().rangeUpdates`)의 `field` 에는 열의 `field` 가 **점 표기 그대로** 실립니다: `bulkUpdate('ownerTeam', 'Z', ['5'])` → `{ field: 'owner.team', value: 'Z', rowIds: ['5'] }`. 서버는 경로로 `UPDATE` 하세요. 받아 둔 행 화면은 최상위 객체를 복사해 교체한 값으로 보입니다.
5979
+ - 셀 편집(`modified`)은 클라이언트와 같이 **최상위 키**(`owner`)와 새 객체로 남습니다.
5980
+ - JS 서버는 그리드와 같은 판정을 `createRowFilterPredicate(filterModel, { fieldOf, objectSpecs })` 로 쓸 수 있습니다 — `fieldOf` 는 열 id → 데이터 키(`'ownerTeam'` → `'owner.team'`), `objectSpecs` 는 `buildObjectFieldSpecs(열 정의)` 결과(`in`/`notIn`·`eq`/`neq` 를 그리드와 같은 키 규칙으로 비교). 그룹 키는 `objectKeyString(값, specs[field])` 로 같은 규칙을 씁니다(→ [객체 값·필드 경로 헬퍼](#객체-값필드-경로-헬퍼)).
5120
5981
 
5121
5982
  ---
5122
5983
 
@@ -5170,7 +6031,7 @@ const grid2 = new PureSheet(el2, { undoStack: sharedUndo, fields, data, enableRe
5170
6031
  // 두 그리드에서 일어난 변경이 동일 시간순으로 스택에 쌓임
5171
6032
  ```
5172
6033
 
5173
- > 외부 주입 시 그리드의 개별 Ctrl+Z/Y는 비활성됨 — 공유 스택의 `attachKeyboard`로 일원화 권장. 공유 emitter 안전 규칙은 [docs/RULES.md](docs/RULES.md) 참조.
6034
+ > 외부 주입 시 그리드의 개별 Ctrl+Z/Y는 비활성됨 — 공유 스택의 `attachKeyboard`로 일원화 권장.
5174
6035
 
5175
6036
  ---
5176
6037
 
@@ -5191,7 +6052,7 @@ class Row {
5191
6052
  variant?: 'subtotal' | 'grandtotal'; // @default 'subtotal'
5192
6053
  height?: number;
5193
6054
  className?: string;
5194
- labelColumn?: string; // 라벨 표시 컬럼
6055
+ labelColumn?: string; // 라벨 표시 열 — 열 id 우선, 그런 열이 없으면 데이터 키(라벨은 data[그 열 field] 에 저장)
5195
6056
  label?: string; // @default '소계' | '총합계'
5196
6057
  }
5197
6058
  ): Row;
@@ -5227,6 +6088,10 @@ grid.rows.addPinnedBottom(totalRow);
5227
6088
  // 또는 `grid.rows.addGrandTotal(aggregates)` 단축 API 사용 시 위 코드가 내부에서 호출됨
5228
6089
  ```
5229
6090
 
6091
+ > 합계·소계 행의 칸: 집계가 있는 열은 집계값, 없는 열은 행 `data[열 field]`(라벨 등)를 그 열 서식으로 보여 줍니다 — CSV·XLSX 내보내기와 같은 규칙. 예) 위 예시 → `name` 칸 `총합계`, `salary` 칸 합계. `{ variant: 'subtotal', data: { name: '소계' }, aggregates }` 도 같습니다.
6092
+ >
6093
+ > `labelColumn` 해석: 열 id 우선, 같은 id 의 열이 없으면 데이터 키. 예) 열 `{ id: 'nm', field: 'name' }` — `labelColumn: 'nm'`(열 id)·`'name'`(데이터 키) 둘 다 `nm` 칸에 라벨(화면·CSV·XLSX·고정 병합 글자 같음). 열 id 가 다른 열의 데이터 키와 겹치면 열 id 가 이깁니다. 해석은 행이 고정 영역에 꽂힌 뒤 현재 열 정의로 합니다.
6094
+
5230
6095
  ---
5231
6096
 
5232
6097
  ### 에러 코드 전체 레퍼런스
@@ -5263,6 +6128,10 @@ import { PS, psError, psWarn, ErrorMessages } from '@vmscloud/moz-ui-grid-core';
5263
6128
  | `PS309` | `PS.PIVOT_CONFIG_NULL` | pivotConfig 없음 |
5264
6129
  | `PS310` | `PS.SINGLE_EXPAND_ALL` | singleExpand에서 expandAll 호출 |
5265
6130
  | `PS311` | `PS.INVALID_LEVEL` | level < 0 |
6131
+ | `PS312` | `PS.AGGREGATE_NOT_DECLARED` | 함수형 집계가 `ctx.<func>('<column>')` 로 읽은 집계가 선언돼 있지 않음 |
6132
+ | `PS313` | `PS.FORMULA_NAME_AMBIGUOUS` | 계산 필드 수식의 이름이 열 id 이면서 다른 데이터 키이기도 함 — 열 id 쪽을 읽음(경고) |
6133
+ | `PS314` | `PS.OBJECT_KEY_MISSING` | 객체 값 열(`dataType: 'object'`)에 `objectKey` 가 없어 객체 내용(JSON)을 키로 씀 — 열마다 1회 경고 (→ [객체 값 열](#객체-값-열-datatype-object)) |
6134
+ | `PS315` | `PS.OBJECT_VALUE_UNDECLARED` | `dataType: 'object'` 가 아닌 열에 객체 값이 있음 — `dataType: 'object'` 와 `objectKey` 를 지정하라는 경고, 열마다 1회 |
5266
6135
  | **Worker (PS501~)** | | |
5267
6136
  | `PS501` | `PS.WORKER_SYNC_API` | Worker에서 getAllDataSync 호출 |
5268
6137
  | `PS502` | `PS.WORKER_DATA_DIRECT` | Worker 모드에서 `data` 직접 전달 불가 — `loader`를 사용할 것 |
@@ -5291,7 +6160,7 @@ if (!rowId) {
5291
6160
  | Export | 위치 | 용도 |
5292
6161
  |---|---|---|
5293
6162
  | `createReactiveRow(deps, rowId)` | `@vmscloud/moz-ui-grid-core` | `ReactiveRow` 직접 생성 (래퍼/테스트용). 보통 `grid.rows.observeById(id)` 사용 권장 |
5294
- | `InlineDataLoader` | `@vmscloud/moz-ui-grid-core` | 클래스 자체를 인라인 전달하는 변형 (URL 모듈 대신). 일반적으론 `LoaderConfig.module` 권장 |
6163
+ | `setupGridWorker(options)` | `@vmscloud/moz-ui-grid-core/worker` | 앱 Worker 파일을 그리드 Worker로 설정. `{ loader, functions?, xlsxWorkerFactory? }` (→ [runtime-contracts](runtime-contracts.md)) |
5295
6164
  | `fieldToColumn(fieldDef)` | `@vmscloud/moz-ui-grid-core` | `FieldDef` → 내부 `ColumnDef` 변환. 외부 컬럼 메타 생성용 |
5296
6165
  | `configToInternalOptions(config)` | `@vmscloud/moz-ui-grid-core` | 외부 `PureSheetConfig` → 내부 옵션 객체 변환 |
5297
6166
  | `getGridMode(config)` | `@vmscloud/moz-ui-grid-core` | `'flat' \| 'pivot'` 판별 |