Skip to content

시트 셀 병합 · 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. 핵심 난점 — 왜 아키텍처급인가 ​

엑셀의 병합은 "고정된 격자 위의 사각형"이지만, 우리 시트의 행은 동적이다:

  1. 정렬/필터: 행 순서가 바뀌면 "3~5행 병합"의 의미가 사라진다. 병합 블록이 정렬로 찢어지면 시각적 쓰레기가 된다.
  2. 가상 스크롤: 500행 초과 시 viewport 윈도우만 마운트한다(virtualizer.ts, buffer 10). rowSpan 블록이 윈도우 경계에 걸리면 앵커 <tr>이 DOM에 없어서 스팬이 그려지지 않는다.
  3. 행 삭제/이월: 병합 앵커 행이 지워지면 고아 병합이 남는다. 월 이월 복사 시 rowId가 바뀐다.
  4. 셀 주소성: 선택·클립보드·수식 행참조([colKey@rowId])·커밋 lineage 모두 (rowId, colKey) 단일 셀 전제.

2. 설계 결정 (제안) ​

D-3.1 병합은 값 동기화다 (2026-07-06 확정 — 사용자 결정) ​

병합된 모든 셀은 같은 값으로 간주한다:

  • 병합 시: 블록 전체가 앵커(좌상단) 값으로 통일된다 (가려진 셀에도 실제로 기록). 통일 직전 값들은 undo tx에 담겨 실행 취소로만 복구 — undo 히스토리가 사라진 뒤의 병합 해제는 원래 값을 되돌리지 않는다 (전부 앵커 값 유지).
  • 앵커 편집 시: 병합된 모든 셀의 값이 같은 값으로 함께 변경된다 (화면엔 안 보이지만 동기화).
  • 수식/행참조/커밋은 셀의 실제 값을 읽으므로 어디를 참조해도 같은 값 — 별도 특례 없음.
  • 수식 분기 (2026-07-07 확정): 블록에 수식이 있으면 값 통일 대신 좌상단에서 가장 먼저 나오는 수식으로 수식 통일한다 — 값 셀과 달리 수식 셀은 값 통일이 없고, 각 행이 자기 행 값으로 평가되어 블록 내부 값이 서로 다를 수 있다(화면엔 앵커 값만 표시). 앵커 수식을 편집하면 재동기화가 블록의 수식을 다시 맞춘다.
  • 서버는 병합을 모른다: 값 통일·전파는 클라이언트가 기존 셀 쓰기 경로(batchSet)로 수행하고, 서버 저장분은 cellMerges jsonb + 검증뿐이다.

D-3.2 저장 모델 — sheet_views.cellMerges (jsonb) ​

jsonc
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 + 안내)
undomerge / unmerge tx — revert/reapply 클로저 (A-1 커맨드 패턴 그대로)
XLSXExcelJS ws.mergeCells(top,left,bottom,right) — 표시 격자 좌표로 변환해 반영
행 참조 R4[colKey@rowId]는 실제 셀 값을 읽는다 — 값 동기화라 블록 내 어느 행을 참조해도 같은 값

2.8 UI 진입점 ​

  • 범위 선택 → 우클릭 → "셀 병합" (직사각형 검증 + §2.3 조건 검사) / 병합 셀 우클릭 → "병합 해제"
  • 서식 툴바에 병합 토글 버튼 (D-4 테두리 팝오버 옆)

3. 서버 변경 (소) ​

  1. 마이그레이션: sheet_views.cellMerges jsonb NULL
  2. sheetView.update 입력에 cellMerges (Zod: 상한 §2.6 + shape) — 얕은 정합성 검증
  3. exportXlsx에 mergeCells 반영
  4. resolver·커밋·수식 변경 없음 (D-3.1)

4. 클라이언트 변경 (대 — 공수의 90%) ​

#작업난이도
1merge 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 전부 확정 ​

  1. 보고서형 시트 신설 안 함 — viewKind 추가 없이 §2.3 상태 기반 암묵 규칙(정렬/필터 off일 때만 세로 병합)으로 간다.
  2. 월 이월은 별도 일괄 작업 — 이월 데이터 정책 자체(식별값은 그대로 가져오고, 조정입력이 필요한 셀만 초기화)와 함께 추후 진행. v1에서는 이월 시 cellMerges를 복사하지 않는다.
  3. 붙여넣기는 앵커 값만 — 블록 자동 해제 없음. 붙여넣기 범위가 블록을 덮으면 앵커 좌표 값으로 블록 전체 동기화.

7. 검증 계획 ​

  • 단위: merge map 전개(무효 병합 스킵, 윈도우 확장 경계), 선택 스냅, TSV 복사 모양 — 전부 순수 함수로 추출해 테스트
  • 라이브: 1200행 시트(가상 스크롤 on)에서 병합 블록 스크롤 통과, 정렬 토글 시 일시 해제/복원, XLSX 병합 확인