병합 셀 편집 (Merged Cell Editing)

틀고정 없는 병합 셀과 frozen row·column 경계의 병합 셀을 편집하고 모든 실제 행을 함께 변경하는 규칙을 설명합니다.

#cell-merge#editing#virtual-scroll#frozen-row#frozen-column#atomic-commit
검토일: 2026-08-29
LIVE DEMO병합 셀 편집 (Merged Cell Editing) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
GitHub
import { t } from './i18n';
import * as React from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
import { Select } from 'antd';
import { Pencil } from 'lucide-react';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import {
  applyEditingDataChange,
  type EditingOrder,
  withEditingCellClasses,
} from './editing/shared';

const customerGroups: Array<
  Pick<EditingOrder, 'customerCode' | 'customerName' | 'customerGrade'>
> = [
  { customerCode: 'C001', customerName: t('서울상사', 'Seoul Sangsa'), customerGrade: 'VIP' },
  { customerCode: 'C002', customerName: t('한빛물산', 'Hanbit Mulsan'), customerGrade: t('우수', 'Excellent') },
  { customerCode: 'C003', customerName: 'Northwind', customerGrade: t('일반', 'General') },
  { customerCode: 'C004', customerName: 'AxisJ Studio', customerGrade: t('우수', 'Excellent') },
  { customerCode: 'C005', customerName: t('대한유통 장기 고객사명 샘플', 'Daehan Distribution Long-term Client Name Sample'), customerGrade: 'VIP' },
  { customerCode: 'C006', customerName: 'Blue Ocean Trading', customerGrade: t('일반', 'General') },
  { customerCode: 'C007', customerName: t('새봄물류', 'Saebom Logistics'), customerGrade: t('우수', 'Excellent') },
  { customerCode: 'C008', customerName: 'Global Partners Korea', customerGrade: 'VIP' },
];

const statuses: EditingOrder['status'][] = [t('접수', 'Receipt'), t('진행', 'In Progress'), t('완료', 'Completed')];
const notes = [
  t('오전 배송', 'Morning Delivery'),
  t('담당자 확인', 'Assignee Confirmation'),
  t('행 높이를 늘리지 않는 긴 메모 내용이 말줄임으로 표시됩니다.', 'Long memo content that does not increase row height is truncated.'),
];

const createMergedEditingOrders = (): BGridDataItem<EditingOrder>[] =>
  customerGroups.flatMap((customer, groupIndex) =>
    Array.from({ length: 3 }, (_, groupRowIndex) => {
      const rowIndex = groupIndex * 3 + groupRowIndex;
      const quantity = (rowIndex % 5) + 1;
      const unitPrice = 9000 + (rowIndex % 4) * 3000;

      return {
        values: {
          id: `MERGED-ORDER-${String(rowIndex + 1).padStart(3, '0')}`,
          orderCode: `ORD-${2601 + rowIndex}`,
          ...customer,
          status: statuses[rowIndex % statuses.length],
          deliveryDate: `2026-09-${String((rowIndex % 28) + 1).padStart(2, '0')}`,
          approved: rowIndex % 2 === 0,
          quantity,
          unitPrice,
          amount: quantity * unitPrice,
          note: notes[rowIndex % notes.length],
          mergeGroup: `CUSTOMER-${String(groupIndex + 1).padStart(2, '0')}`,
        },
      };
    }),
  );

export default function MergedCellEditingExample() {
  const [data, setData] = React.useState(createMergedEditingOrders);
  const [frozenColumnIndex, setFrozenColumnIndex] = React.useState(0);
  const [frozenRowCount, setFrozenRowCount] = React.useState(0);
  const [changedRows, setChangedRows] = React.useState<number[]>([]);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);
  const hasFrozenBoundary = frozenColumnIndex > 0 || frozenRowCount > 0;
  const layoutMode = hasFrozenBoundary ? 'frozen-boundary' : 'standard';

  const columns = React.useMemo<BGridColumn<EditingOrder>[]>(
    () => withEditingCellClasses<EditingOrder>([
      { key: 'orderCode', label: t('주문 코드', 'Order Code'), width: 145, editable: false },
      {
        key: 'customerName',
        label: t('병합 고객명 · 편집', 'Merged Customer Name · Edit'),
        width: 240,
        editable: true,
        editTrigger: 'click',
        editor: { type: 'text' },
        editorIcon: { render: <Pencil aria-hidden='true' size={14} strokeWidth={1.8} />, ariaLabel: t('병합 고객명 편집', 'Edit Merged Customer Name') },
      },
      { key: 'status', label: t('상태', 'Status'), width: 120, editable: false },
      { key: 'note', label: t('메모', 'Memo'), width: 300, editable: false },
    ]),
    [],
  );

  return (
    <div className='flex min-h-0 flex-col gap-3'>
      <div className='rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm leading-6 text-slate-700'>
        <div className='flex flex-wrap items-center justify-between gap-3'>
          <p className='m-0'>
            {t('24개 행을 3개씩 병합해 세로 스크롤과 일반 병합·고정 경계 병합 편집을 함께 확인합니다.', 'Merge 24 rows in groups of 3 to check both vertical scrolling and regular/frozen-boundary merged cell editing.')}
          </p>
          <div className='flex flex-wrap items-center gap-3' aria-label={t('병합 셀 틀고정 옵션', 'Merged Cell Freeze Option')}>
            <label className='inline-flex items-center gap-2 font-medium'>
              <span>{t('왼쪽 고정 컬럼 수', 'Number of frozen left columns')}</span>
              <Select<number>
                aria-label={t('고정할 컬럼 수', 'Number of Columns to Freeze')}
                aria-describedby='merged-frozen-column-help'
                style={{ width: 84 }}
                value={frozenColumnIndex}
                options={[0, 1, 2].map(count => ({ value: count, label: t(`${count}개`, `${count} items`) }))}
                onChange={setFrozenColumnIndex}
              />
            </label>
            <label className='inline-flex items-center gap-2 font-medium'>
              <span>{t('위쪽 고정 행 수', 'Number of frozen top rows')}</span>
              <Select<number>
                aria-label={t('고정할 행 수', 'Number of Rows to Freeze')}
                aria-describedby='merged-frozen-row-help'
                style={{ width: 84 }}
                value={frozenRowCount}
                options={[0, 1, 2, 3].map(count => ({ value: count, label: t(`${count}개`, `${count} items`) }))}
                onChange={setFrozenRowCount}
              />
            </label>
          </div>
        </div>
        <div className='mt-2 grid gap-1 text-xs text-slate-600'>
          <p id='merged-frozen-column-help' className='m-0'>
            <strong>{t('왼쪽 고정 컬럼 수', 'Number of frozen left columns')}</strong>{t('는 가로 스크롤 중에도 왼쪽에 남겨 둘 컬럼 수입니다. 1개는 ‘주문 코드’만, 2개는 ‘주문 코드’와 편집 대상인 ‘병합 고객명’을 고정합니다.', ' is the number of columns to keep on the left during horizontal scrolling. 1 freezes only "Order Code", and 2 freezes both "Order Code" and "Merged Customer Name" which is the target for editing.')}
          </p>
          <p id='merged-frozen-row-help' className='m-0'>
            <strong>{t('위쪽 고정 행 수', 'Number of frozen top rows')}</strong>{t('는 세로 스크롤 중에도 위쪽에 남겨 둘 실제 데이터 행 수입니다. 1~2개는 첫 3행 병합 셀을 고정·스크롤 영역으로 나누고, 3개는 첫 병합 그룹 전체를 고정합니다. 나뉜 어느 조각을 편집해도 같은 3행이 함께 변경됩니다.', ' is the number of actual data rows to keep on top during vertical scrolling. 1-2 splits the first 3-row merged cell into frozen/scroll areas, and 3 freezes the entire first merged group. Editing any split piece modifies the same 3 rows together.')}
          </p>
        </div>
        <p className='mb-0 mt-2 text-xs text-slate-600'>
          {hasFrozenBoundary ? (
            <>
              <strong>{t('고정 경계 병합', 'Frozen Boundary Merge')}</strong> · <code>frozenColumnIndex={frozenColumnIndex}</code>,{' '}
              <code>frozenRowCount={frozenRowCount}</code>. {t('병합 그룹이 행 고정 경계를 가로지르면 각 영역 안에서 다시 병합되며, 어느 조각을 편집해도 세 행이 함께 변경됩니다.', 'If a merged group crosses the row freeze boundary, it is merged again within each area, and editing any piece modifies all three rows together.')}
            </>
          ) : (
            <>
              <strong>{t('일반 병합', 'Regular Merge')}</strong> · {t('틀고정 없이 하나의 병합 셀을 직접 편집하며, 병합 그룹의 세 실제 행이 함께 변경됩니다.', 'Directly edit a single merged cell without frozen panes, and all three actual rows of the merged group are modified together.')}
            </>
          )}
        </p>
        <output aria-live='polite' className='mt-1 block text-xs text-blue-700'>
          {t('마지막 트랜잭션의 변경 행:', 'Modified rows in the last transaction:')} {changedRows.length ? changedRows.map(index => index + 1).join(', ') : t('없음', 'None')}
        </output>
      </div>
      <DataGridContainer ref={containerRef} style={{ height: 360 }} data-merge-layout={layoutMode}>
        <BGrid<EditingOrder>
          key={`${frozenColumnIndex}:${frozenRowCount}`}
          width={width}
          height={height}
          data={data}
          columns={columns}
          rowKey='id'
          editable
          variant='vertical-bordered'
          itemHeight={22}
          itemPadding={4}
          frozenColumnIndex={frozenColumnIndex}
          frozenRowCount={frozenRowCount}
          cellMergeOptions={{ columnsMap: { 1: { mergeBy: 'mergeGroup' } } }}
          onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
            setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
            if (meta) setChangedRows([...meta.transaction.sourceIndexes]);
          }}
        />
      </DataGridContainer>
    </div>
  );
}

병합 셀을 편집하면 화면에 보이는 anchor 행만 바꾸지 않고 병합 그룹에 속한 모든 실제 행에 같은 변경을 적용합니다. editor와 icon callback은 병합 여부를 직접 계산할 필요가 없습니다. 라이브 예제는 24개 행을 사용하므로 세로 스크롤 중에도 병합 범위가 유지되는지 확인할 수 있으며, 기본 상태는 틀고정 없는 일반 병합 편집입니다.

틀고정 없는 병합 편집

frozenRowCount와 frozenColumnIndex가 모두 0이어도 병합 셀의 편집 트랜잭션은 동일하게 동작합니다. 라이브 예제에서 두 틀고정 값을 0개로 선택한 일반 병합 상태에서 하나의 고객명을 수정하면 같은 mergeBy 값을 가진 세 실제 행이 함께 변경됩니다.

<BGrid<Order>
  editable
  cellMergeOptions={{
    columnsMap: {
      1: { mergeBy: 'customerGroup' },
    },
  }}
/>

라이브 예제의 왼쪽 고정 컬럼 수는 가로 스크롤 중에도 왼쪽에 남겨 둘 컬럼 수입니다. 1개는 주문 코드만, 2개는 주문 코드와 편집 대상인 병합 고객명 컬럼까지 고정합니다. 위쪽 고정 행 수는 세로 스크롤 중에도 위쪽에 남겨 둘 실제 데이터 행 수입니다. 1개 또는 2개를 선택하면 첫 3행 병합 셀이 고정 영역과 스크롤 영역으로 나뉘며, 3개를 선택하면 첫 병합 그룹 전체가 고정됩니다. 어느 조각을 편집해도 같은 병합 그룹의 세 실제 행이 함께 변경됩니다. 기본 틀고정 동작은 행·컬럼 틀고정 예제에서도 확인할 수 있습니다.

논리 셀과 DOM 조각

frozen row 경계나 frozen column 레이어 때문에 하나의 병합 셀이 여러 td로 나뉠 수 있습니다. 3행 병합 그룹을 frozenRowCount=1로 나누면 고정 영역에는 1행 셀, 스크롤 영역에는 다시 병합된 2행 셀이 렌더링됩니다. 이 조각들은 렌더링 단위일 뿐 다음 상호작용에서는 같은 논리 셀입니다.

  • 어느 조각을 클릭해도 같은 canonical anchor가 활성화됩니다.
  • itemRender, editor, editorIcon callback은 canonical 행의 index, item, values, value를 받습니다.
  • editor는 사용자가 상호작용한 조각 한 곳에만 마운트됩니다.
  • commit이 끝나면 모든 병합 행과 화면 조각이 함께 갱신됩니다.

트랜잭션 범위

병합 범위는 편집 세션을 시작할 때 전체 visible data의 연속된 mergeBy 값으로 snapshot됩니다. 가상 스크롤의 현재 렌더 범위로 제한하지 않습니다.

onChangeValue: async ({ changes, rows, commit }) => {
  console.log(rows.map(row => row.sourceIndex));
  await commit(changes);
}

origin 컬럼의 병합 규칙만 행 범위를 결정합니다. 변경 목록에 다른 병합 컬럼이 포함되어도 범위를 연쇄 확장하지 않으며, 병합 key 자체를 바꾸더라도 저장 도중 범위를 다시 계산하지 않습니다. 대상 행 중 하나라도 유효하지 않으면 전체를 취소합니다.

병합 셀 복사와 붙여넣기

병합 셀 선택은 화면 높이 때문에 여러 실제 행을 포함하지만, 복사 결과에서는 하나의 논리 셀로 취급합니다. 병합 셀만 복사하면 canonical anchor의 getClipboardText 결과를 한 번만 기록합니다. 옆의 일반 셀까지 함께 선택한 경우에는 각 행의 열 위치를 보존해야 하므로 병합 셀의 두 번째 행부터 빈 TSV 칸을 기록합니다.

붙여넣기도 병합 그룹 단위로 원자적으로 처리합니다. 하나의 클립보드 값을 병합 셀에 붙이면 parseClipboardText를 한 번만 실행하고 그 결과를 모든 실제 행에 적용합니다. 과거 방식처럼 행마다 배열을 새로 파싱하지 않으므로 배열·객체가 mergeBy 값일 때도 병합이 깨지지 않습니다. 여러 클립보드 행이 같은 병합 셀을 가리킬 때 문자열이 모두 같으면 중복을 접고, 하나라도 다르면 onPasteError의 mergedCellConflict로 보고하며 해당 병합 셀은 전혀 변경하지 않습니다.

표시 목적의 일반 셀 병합 구성은 셀 병합 가이드, frozen 레이아웃 설정은 고정 컬럼과 행에서 확인하세요.

[!TIP] 모바일 환경에서의 고정 열과 병합 셀: 모바일 화면에서는 고정 컬럼 수를 늘리면 스크롤 가능한 본문 영역이 좁아집니다. 화면 크기에 맞춰 frozenColumnIndex를 1개 또는 0개로 설정하여 좁은 화면에서도 편집 영역을 충분히 확보하세요.