시트 셀 병합 · 2단 헤더 설계 (D-3)
원문 피드백: "엑셀에서 쓰던 보고서 양식처럼 셀을 병합하고 2단 헤더를 만들고 싶다." 관련: 시트 UX 피드백 v3 D-3 · 참조·수식 설계
상태: 구현됨 (2026-07-06, 수식 분기 2026-07-07 추가) — 값 동기화 병합 + 수식 블록의 수식 통일 분기, merge map 렌더, 정렬 시 일시 해제, 가상 스크롤 윈도우 확장, 키보드 스냅, undo tx, XLSX mergeCells까지 라이브 검증 완료. 잔여: §5 2단 헤더 소형 후속 + 월 이월 정책(별도 일괄)
0. 현황 요약
| 축 | 현재 상태 |
|---|---|
| 2단 헤더 | ✅ 사실상 구현됨 — ColumnDef.columnGroup + grid.html의 columnGroupSpans super-header band. 인접한 같은 그룹 컬럼 위에 스패닝 셀("지급" 위에 기본급/호봉수당…)을 그린다. 열 설정 모달 고급 섹션에서 그룹명 편집 가능 |
| 본문 셀 병합 | ❌ 없음 — 엔진(render.ts)은 행×열 격자를 1:1로 그린다 |
| 내보내기 | XLSX가 ExcelJS 기반(2026-07-06 전환)이라 ws.mergeCells()를 바로 쓸 수 있음 |
→ D-3의 실질 범위는 본문 셀 병합이다. 2단 헤더는 잔여 과제(§5)만 남는다.
1. 핵심 난점 — 왜 아키텍처급인가
엑셀의 병합은 "고정된 격자 위의 사각형"이지만, 우리 시트의 행은 동적이다:
- 정렬/필터: 행 순서가 바뀌면 "3~5행 병합"의 의미가 사라진다. 병합 블록이 정렬로 찢어지면 시각적 쓰레기가 된다.
- 가상 스크롤: 500행 초과 시 viewport 윈도우만 마운트한다(virtualizer.ts, buffer 10).
rowSpan블록이 윈도우 경계에 걸리면 앵커<tr>이 DOM에 없어서 스팬이 그려지지 않는다. - 행 삭제/이월: 병합 앵커 행이 지워지면 고아 병합이 남는다. 월 이월 복사 시 rowId가 바뀐다.
- 셀 주소성: 선택·클립보드·수식 행참조(
[colKey@rowId])·커밋 lineage 모두 (rowId, colKey) 단일 셀 전제.
2. 설계 결정 (제안)
D-3.1 병합은 값 동기화다 (2026-07-06 확정 — 사용자 결정)
병합된 모든 셀은 같은 값으로 간주한다:
- 병합 시: 블록 전체가 앵커(좌상단) 값으로 통일된다 (가려진 셀에도 실제로 기록). 통일 직전 값들은 undo tx에 담겨 실행 취소로만 복구 — undo 히스토리가 사라진 뒤의 병합 해제는 원래 값을 되돌리지 않는다 (전부 앵커 값 유지).
- 앵커 편집 시: 병합된 모든 셀의 값이 같은 값으로 함께 변경된다 (화면엔 안 보이지만 동기화).
- 수식/행참조/커밋은 셀의 실제 값을 읽으므로 어디를 참조해도 같은 값 — 별도 특례 없음.
- 수식 분기 (2026-07-07 확정): 블록에 수식이 있으면 값 통일 대신 좌상단에서 가장 먼저 나오는 수식으로 수식 통일한다 — 값 셀과 달리 수식 셀은 값 통일이 없고, 각 행이 자기 행 값으로 평가되어 블록 내부 값이 서로 다를 수 있다(화면엔 앵커 값만 표시). 앵커 수식을 편집하면 재동기화가 블록의 수식을 다시 맞춘다.
- 서버는 병합을 모른다: 값 통일·전파는 클라이언트가 기존 셀 쓰기 경로(batchSet)로 수행하고, 서버 저장분은
cellMergesjsonb + 검증뿐이다.
D-3.2 저장 모델 — sheet_views.cellMerges (jsonb)
cellMerges: [
{
"anchorRowId": "…uuid…", // 좌상단 셀의 행
"anchorColKey": "note", // 좌상단 셀의 열
"colKeys": ["note", "note2"], // 가로 확장 — 열 key 명시 (열 이동·삽입에 안전)
"rowSpan": 3 // 세로 확장 — 현재 sortOrder 기준 인접 행 수
}
]- 가로는 colKey 목록으로 명시 → 열 순서 변경/삽입에도 의미 유지 (인접하지 않게 되면 §2.4 규칙으로 비활성).
- 세로는 anchorRowId + rowSpan → "앵커 행 아래로 N-1개 행" (sortOrder 순). 대상 행 id를 저장하지 않는 이유: 행 삽입 시 병합이 자동으로 밀려나는 엑셀式 동작이 목표가 아니라, 보고서형 시트의 고정 레이아웃이 목표이기 때문(§2.3).
D-3.3 세로 병합은 레이아웃 고정 시트에서만
rowSpan > 1 병합은 다음 조건에서만 생성/표시한다:
- 시트에 정렬·필터가 꺼져 있을 때 (엔진 sortState/filterState 없음)
- 수동 정렬(sortOrder) 기반 행 순서일 때
정렬/필터가 켜지면 세로 병합은 일시 해제 표시(앵커 셀에 ⚠ badge + 툴팁 "정렬 중에는 병합이 풀려 보입니다")하고, 끄면 복원. 가로(colSpan) 병합은 행 순서와 무관하므로 항상 동작한다. → 데이터 시트는 가로 병합만, 보고서 시트는 자유.
D-3.4 무효 병합의 처리 규칙 — "조용히 비활성"
앵커 행 삭제, colKeys 중 열 삭제/숨김/비인접, rowSpan 범위 내 행 부족 등 무효 상태는 렌더 시점에 감지해 그 병합만 건너뛴다 (데이터는 jsonb에 남음 — 열 복원 시 되살아남). 저장 시점 정합성 검증은 서버가 얕게만 한다(앵커 존재 + colKeys ⊆ columnLayout).
D-3.5 렌더 — merge map 전개
renderBody 진입 시 cellMerges를 O(1) 조회 맵 2개로 전개:
anchorMap: Map<"rowId:colKey", {colSpan, rowSpan}>→ 해당 td에colspan/rowspan속성hiddenSet: Set<"rowId:colKey">→ td 생성 자체를 skip (colgroup 폭 계산은 그대로)
rowSpan 전개에는 "앵커 행 기준 아래 N-1개 행"의 실제 rowId가 필요하므로, 전개는 현재 filteredSortedRows 순서로 수행한다 (여기서 §2.3 조건 검사도 함께).
가상 스크롤 경계: 윈도우의 first 행이 어떤 병합 블록의 중간이면 윈도우를 앵커 행까지 위로 확장한다 (first = min(first, anchorIndex)). rowSpan 상한(§2.6)이 있어 확장 폭은 유계. topSpacer 계산도 확장된 first 기준으로.
D-3.6 상한
rowSpan ≤ 50,colSpan ≤ 10, 시트당 병합 ≤ 200 (Zod + 서버 검증). 가상 스크롤 확장 비용과 jsonb 크기를 함께 묶는 안전핀.
D-3.7 상호작용 의미론
| 영역 | 규칙 |
|---|---|
| 클릭/선택 | 병합 블록은 한 셀처럼 선택된다 — 선택 anchor는 병합 anchor로 스냅, 범위 선택이 블록을 스치면 블록 전체 포함 (엑셀 동일) |
| 편집 | 앵커 셀만 편집 가능 — 커밋 시 블록 전체에 같은 값 전파 (D-3.1) |
| 복사 | 값 동기화 덕에 셀별 복사가 곧 "앵커 값 반복" — TSV 모양 그대로, 특례 없음 |
| 붙여넣기 | 블록은 앵커 값만 받는다 (Q3 확정): 앵커 좌표의 값으로 블록 전체 동기화, 병합 유지 — 자동 해제 없음 |
| fill handle | 병합 블록에서 시작하는 fill은 v1 미지원 (no-op + 안내) |
| undo | merge / unmerge tx — revert/reapply 클로저 (A-1 커맨드 패턴 그대로) |
| XLSX | ExcelJS ws.mergeCells(top,left,bottom,right) — 표시 격자 좌표로 변환해 반영 |
| 행 참조 R4 | [colKey@rowId]는 실제 셀 값을 읽는다 — 값 동기화라 블록 내 어느 행을 참조해도 같은 값 |
2.8 UI 진입점
- 범위 선택 → 우클릭 → "셀 병합" (직사각형 검증 + §2.3 조건 검사) / 병합 셀 우클릭 → "병합 해제"
- 서식 툴바에 병합 토글 버튼 (D-4 테두리 팝오버 옆)
3. 서버 변경 (소)
- 마이그레이션:
sheet_views.cellMerges jsonb NULL sheetView.update입력에cellMerges(Zod: 상한 §2.6 + shape) — 얕은 정합성 검증- exportXlsx에 mergeCells 반영
- resolver·커밋·수식 변경 없음 (D-3.1)
4. 클라이언트 변경 (대 — 공수의 90%)
| # | 작업 | 난이도 |
|---|---|---|
| 1 | merge map 전개 + render.ts colspan/rowspan/skip | 중 |
| 2 | 가상 스크롤 윈도우-앵커 확장 | 중 |
| 3 | 선택 스냅/범위 포함 (selection.ts) | 상 — 키보드 내비게이션 포함 |
| 4 | 편집/클립보드/붙여넣기 해제 | 중 |
| 5 | 병합/해제 UI + undo tx + 서버 저장 | 하 |
| 6 | 정렬/필터 연동 일시 해제 | 하 |
권장 단계: Phase A = 가로(colSpan) 병합만 (1,3,4,5 축소판 — 세로 없음이라 2,6 불필요) → 사용자 검증 → Phase B = 세로 병합 (2,3 완성, 6).
5. 2단 헤더 잔여 과제 (소)
- 열 설정 고급의
columnGroup텍스트 입력이 유일한 편집 경로 — 헤더 밴드 우클릭 → 그룹명 변경/그룹 해제 컨텍스트 메뉴 추가 - 그룹 밴드 셀도 XLSX 내보내기에 반영 (현재 헤더 1행만 내보냄 — ExcelJS mergeCells로 밴드 행 추가)
- 인접하지 않은 같은 그룹명은 지금처럼 별도 스팬 (의도된 동작, 문서화만)
6. 열린 질문 — 2026-07-06 전부 확정
- 보고서형 시트 신설 안 함 — viewKind 추가 없이 §2.3 상태 기반 암묵 규칙(정렬/필터 off일 때만 세로 병합)으로 간다.
- 월 이월은 별도 일괄 작업 — 이월 데이터 정책 자체(식별값은 그대로 가져오고, 조정입력이 필요한 셀만 초기화)와 함께 추후 진행. v1에서는 이월 시 cellMerges를 복사하지 않는다.
- 붙여넣기는 앵커 값만 — 블록 자동 해제 없음. 붙여넣기 범위가 블록을 덮으면 앵커 좌표 값으로 블록 전체 동기화.
7. 검증 계획
- 단위: merge map 전개(무효 병합 스킵, 윈도우 확장 경계), 선택 스냅, TSV 복사 모양 — 전부 순수 함수로 추출해 테스트
- 라이브: 1200행 시트(가상 스크롤 on)에서 병합 블록 스크롤 통과, 정렬 토글 시 일시 해제/복원, XLSX 병합 확인