셀 병합 (Cell Merge)
인접한 행의 동일한 데이터를 시각적으로 하나로 합쳐서 그룹화된 가독성 높은 보고서 테이블을 만드는 방법을 학습합니다.
LIVE DEMO셀 병합 (Cell Merge) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
LIVE DEMO셀 병합 (Cell Merge) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
import { t } from './i18n';
import * as React from 'react';
import { BGrid, BGridColumn, BGridDataItem } from 'beautiful-grid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
interface InventoryItem {
mainCategory: string;
subCategory: string;
itemCode: string;
itemName: string;
unitPrice: number;
stockQty: number;
warehouse: string;
}
const inventoryRows: InventoryItem[] = [
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('컴퓨터 주변기기', 'Computer Peripherals'), itemCode: 'IT-001', itemName: t('프리미엄 무선 키보드', 'Premium Wireless Keyboard'), unitPrice: 129000, stockQty: 84, warehouse: 'A-01' },
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('컴퓨터 주변기기', 'Computer Peripherals'), itemCode: 'IT-002', itemName: t('인체공학 마우스', 'Ergonomic Mouse'), unitPrice: 69000, stockQty: 46, warehouse: 'A-01' },
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('컴퓨터 주변기기', 'Computer Peripherals'), itemCode: 'IT-003', itemName: t('USB-C 멀티 허브', 'USB-C Multi Hub'), unitPrice: 89000, stockQty: 31, warehouse: 'A-02' },
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('모니터/디스플레이', 'Monitor/Display'), itemCode: 'IT-004', itemName: t('27인치 QHD 모니터', '27-inch QHD Monitor'), unitPrice: 389000, stockQty: 18, warehouse: 'B-01' },
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('모니터/디스플레이', 'Monitor/Display'), itemCode: 'IT-005', itemName: t('32인치 4K 모니터', '32-inch 4K Monitor'), unitPrice: 629000, stockQty: 9, warehouse: 'B-01' },
{ mainCategory: t('가구/인테리어', 'Furniture/Interior'), subCategory: t('사무용 가구', 'Office Furniture'), itemCode: 'FN-001', itemName: t('모션 데스크 1400', 'Motion Desk 1400'), unitPrice: 459000, stockQty: 22, warehouse: 'C-01' },
{ mainCategory: t('가구/인테리어', 'Furniture/Interior'), subCategory: t('사무용 가구', 'Office Furniture'), itemCode: 'FN-002', itemName: t('인체공학 메시 의자', 'Ergonomic Mesh Chair'), unitPrice: 329000, stockQty: 37, warehouse: 'C-01' },
{ mainCategory: t('가구/인테리어', 'Furniture/Interior'), subCategory: t('수납 가구', 'Storage Furniture'), itemCode: 'FN-003', itemName: t('이동식 서랍장', 'Mobile Drawer'), unitPrice: 119000, stockQty: 41, warehouse: 'C-02' },
{ mainCategory: t('가구/인테리어', 'Furniture/Interior'), subCategory: t('수납 가구', 'Storage Furniture'), itemCode: 'FN-004', itemName: t('5단 철제 선반', '5-tier steel shelf'), unitPrice: 149000, stockQty: 26, warehouse: 'C-02' },
{ mainCategory: t('생활/주방', 'Living/Kitchen'), subCategory: t('홈카페', 'Home Cafe'), itemCode: 'KT-001', itemName: t('전자동 커피머신', 'Fully Automatic Coffee Machine'), unitPrice: 749000, stockQty: 12, warehouse: 'D-01' },
{ mainCategory: t('생활/주방', 'Living/Kitchen'), subCategory: t('홈카페', 'Home Cafe'), itemCode: 'KT-002', itemName: t('온도조절 전기포트', 'Temperature Control Electric Kettle'), unitPrice: 99000, stockQty: 53, warehouse: 'D-01' },
{ mainCategory: t('생활/주방', 'Living/Kitchen'), subCategory: t('조리도구', 'Cooking Utensils'), itemCode: 'KT-003', itemName: t('스테인리스 팬 세트', 'Stainless Steel Pan Set'), unitPrice: 189000, stockQty: 29, warehouse: 'D-02' },
];
const data: BGridDataItem<InventoryItem>[] = inventoryRows.map(values => ({ values }));
function CellMergeExample() {
const [columns, setColumns] = React.useState<BGridColumn<InventoryItem>[]>([
{ key: 'mainCategory', label: t('대분류', 'Large Category'), width: 130, align: 'center' },
{ key: 'subCategory', label: t('중분류', 'Medium Category'), width: 150, align: 'center' },
{ key: 'itemCode', label: t('품목코드', 'Item Code'), width: 100, align: 'center' },
{ key: 'itemName', label: t('품목명', 'Item Name'), width: 220 },
{
key: 'unitPrice',
label: t('단가', 'Unit Price'),
width: 120,
align: 'right',
itemRender: ({ values }) => <>{values.unitPrice.toLocaleString()}{t('원', 'KRW')}</>,
},
{ key: 'stockQty', label: t('재고', 'Stock'), width: 80, align: 'right', itemRender: ({ values }) => <>{values.stockQty}{t('개', 'ea')}</> },
{ key: 'warehouse', label: t('창고', 'Warehouse'), width: 90, align: 'center' },
]);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
return (
<DataGridContainer ref={containerRef}>
<BGrid<InventoryItem>
showLineNumber
frozenColumnIndex={2}
width={width}
height={height}
data={data}
columns={columns}
rowKey='itemCode'
onChangeColumns={(_columnIndex, { columns }) => setColumns(columns)}
cellMergeOptions={{
columnsMap: {
0: { mergeBy: 'mainCategory' },
1: { mergeBy: 'subCategory' },
},
}}
variant='vertical-bordered'
/>
</DataGridContainer>
);
}
export default CellMergeExample;import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* BGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .bgrid-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}1. 언제 사용하며 왜 필요한가요?
경영 정보 대시보드나 정산 리포트, 재고 집계표를 작성할 때 “대분류”, “중분류”, **“담당자”**처럼 동일한 값이 여러 행에 걸쳐 반복 출력되면 테이블이 산만해 보입니다.
BeautifulGrid의 셀 병합(Cell Merge) 기능을 사용하면:
- 동일한 연속된 값을 가지는 인접 행의 셀을 자동으로 감지하여
rowspan효과로 시각적 병합을 수행합니다. - 일반 컬럼과 Frozen 컬럼에서 같은
columnsMap병합 기준을 사용합니다. - 데이터의 정렬 순서에 맞추어 유연하게 병합 기준(
mergeBy)을 설정할 수 있습니다.
2. 실무 완성형 예제: 카테고리별 판매 품목 보고서
아래 코드는 대분류와 중분류 컬럼을 기준으로 셀을 병합하여 렌더링하는 예제입니다:
import React, { useState } from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
interface CategoryItem {
mainCategory: string;
subCategory: string;
itemCode: string;
itemName: string;
unitPrice: number;
stockQty: number;
}
export default function CategoryMergeGrid() {
const [data] = useState<BGridDataItem<CategoryItem>[]>([
{ values: { mainCategory: '가전/디지털', subCategory: '컴퓨터 주변기기', itemCode: 'IT-01', itemName: '무선 키보드', unitPrice: 45000, stockQty: 120 } },
{ values: { mainCategory: '가전/디지털', subCategory: '컴퓨터 주변기기', itemCode: 'IT-02', itemName: '게이밍 마우스', unitPrice: 38000, stockQty: 85 } },
{ values: { mainCategory: '가전/디지털', subCategory: '모니터/디스플레이', itemCode: 'IT-03', itemName: '27인치 4K 모니터', unitPrice: 420000, stockQty: 30 } },
{ values: { mainCategory: '가전/디지털', subCategory: '모니터/디스플레이', itemCode: 'IT-04', itemName: '32인치 커브드 모니터', unitPrice: 580000, stockQty: 15 } },
{ values: { mainCategory: '가구/인테리어', subCategory: '사무용 가구', itemCode: 'FN-01', itemName: '모션 데스크 1400', unitPrice: 350000, stockQty: 25 } },
{ values: { mainCategory: '가구/인테리어', subCategory: '사무용 가구', itemCode: 'FN-02', itemName: '인체공학 메시 의자', unitPrice: 280000, stockQty: 40 } },
]);
const columns: BGridColumn<CategoryItem>[] = [
{ key: 'mainCategory', label: '대분류', width: 140, align: 'center' },
{ key: 'subCategory', label: '중분류', width: 160, align: 'center' },
{ key: 'itemCode', label: '품목코드', width: 100, align: 'center' },
{ key: 'itemName', label: '품목명', width: 200 },
{
key: 'unitPrice',
label: '단가',
width: 120,
align: 'right',
itemRender: ({ values }) => `${values.unitPrice.toLocaleString()}원`,
},
{
key: 'stockQty',
label: '재고',
width: 90,
align: 'right',
itemRender: ({ values }) => `${values.stockQty}개`,
},
];
return (
<div>
<BGrid<CategoryItem>
width={810}
height={320}
columns={columns}
data={data}
rowKey="itemCode"
frozenColumnIndex={2} // 대분류, 중분류를 좌측 고정
// 셀 병합 옵션 구성
cellMergeOptions={{
columnsMap: {
0: { mergeBy: 'mainCategory' }, // 0번째 컬럼(대분류)은 mainCategory 값이 같을 때 병합
1: { mergeBy: 'subCategory' }, // 1번째 컬럼(중분류)은 subCategory 값이 같을 때 병합
},
}}
headerHeight={34}
itemHeight={30}
/>
</div>
);
}
3. cellMergeOptions 설정 명세
type CellMergeOptions = {
columnsMap: {
[columnIndex: number]: BGridCellMergeColumn;
};
};
interface BGridCellMergeColumn {
wordWrap?: boolean;
mergeBy: string | string[]; // 동일성 판단 기준 데이터 키
}
columnIndex: 병합을 적용할 컬럼의 0-based 인덱스 번호입니다.mergeBy: 인접 행끼리 값이 같은지 비교할 데이터 필드명입니다.
4. 실무 팁 & 주의사항 (Gotchas)
[!IMPORTANT] 데이터 정렬 선행 필수: 셀 병합은 연속된 인접 행의 값이 같을 때만 병합됩니다. 만약
대분류: '가전'인 행 사이에대분류: '가구'행이 끼어있으면 병합이 끊어지므로, 데이터를 그리드에 넘기기 전에 병합 기준 컬럼으로 미리sort()를 수행해두어야 깔끔하게 병합됩니다.
[!TIP] 모바일 환경에서의 병합 열 틀고정: 모바일 화면에서 병합 컬럼을 여러 개 고정(
frozenColumnIndex)하면 화면 폭이 좁아 가로 스크롤 영역이 가려질 수 있습니다. 모바일 뷰포트에서는 고정 열을 1개로 줄이거나 해제하는 반응형 레이아웃을 권장합니다.