셀 병합 (Cell Merge)
인접한 행의 동일한 데이터를 시각적으로 하나로 합쳐서 그룹화된 가독성 높은 보고서 테이블을 만드는 방법을 학습합니다.
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: '가전/디지털', subCategory: '컴퓨터 주변기기', itemCode: 'IT-001', itemName: '프리미엄 무선 키보드', unitPrice: 129000, stockQty: 84, warehouse: 'A-01' },
{ mainCategory: '가전/디지털', subCategory: '컴퓨터 주변기기', itemCode: 'IT-002', itemName: '인체공학 마우스', unitPrice: 69000, stockQty: 46, warehouse: 'A-01' },
{ mainCategory: '가전/디지털', subCategory: '컴퓨터 주변기기', itemCode: 'IT-003', itemName: 'USB-C 멀티 허브', unitPrice: 89000, stockQty: 31, warehouse: 'A-02' },
{ mainCategory: '가전/디지털', subCategory: '모니터/디스플레이', itemCode: 'IT-004', itemName: '27인치 QHD 모니터', unitPrice: 389000, stockQty: 18, warehouse: 'B-01' },
{ mainCategory: '가전/디지털', subCategory: '모니터/디스플레이', itemCode: 'IT-005', itemName: '32인치 4K 모니터', unitPrice: 629000, stockQty: 9, warehouse: 'B-01' },
{ mainCategory: '가구/인테리어', subCategory: '사무용 가구', itemCode: 'FN-001', itemName: '모션 데스크 1400', unitPrice: 459000, stockQty: 22, warehouse: 'C-01' },
{ mainCategory: '가구/인테리어', subCategory: '사무용 가구', itemCode: 'FN-002', itemName: '인체공학 메시 의자', unitPrice: 329000, stockQty: 37, warehouse: 'C-01' },
{ mainCategory: '가구/인테리어', subCategory: '수납 가구', itemCode: 'FN-003', itemName: '이동식 서랍장', unitPrice: 119000, stockQty: 41, warehouse: 'C-02' },
{ mainCategory: '가구/인테리어', subCategory: '수납 가구', itemCode: 'FN-004', itemName: '5단 철제 선반', unitPrice: 149000, stockQty: 26, warehouse: 'C-02' },
{ mainCategory: '생활/주방', subCategory: '홈카페', itemCode: 'KT-001', itemName: '전자동 커피머신', unitPrice: 749000, stockQty: 12, warehouse: 'D-01' },
{ mainCategory: '생활/주방', subCategory: '홈카페', itemCode: 'KT-002', itemName: '온도조절 전기포트', unitPrice: 99000, stockQty: 53, warehouse: 'D-01' },
{ mainCategory: '생활/주방', subCategory: '조리도구', itemCode: 'KT-003', itemName: '스테인리스 팬 세트', 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: '대분류', width: 130, align: 'center' },
{ key: 'subCategory', label: '중분류', width: 150, align: 'center' },
{ key: 'itemCode', label: '품목코드', width: 100, align: 'center' },
{ key: 'itemName', label: '품목명', width: 220 },
{
key: 'unitPrice',
label: '단가',
width: 120,
align: 'right',
itemRender: ({ values }) => <>{values.unitPrice.toLocaleString()}원</>,
},
{ key: 'stockQty', label: '재고', width: 80, align: 'right', itemRender: ({ values }) => <>{values.stockQty}개</> },
{ key: 'warehouse', label: '창고', 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()를 수행해두어야 깔끔하게 병합됩니다.