기본 DataGrid (Basic)
기본적인 컬럼 설정, 커스텀 셀 렌더러(itemRender), 좌측 열 고정(Frozen Columns), 행 클릭 이벤트 처리를 실무 예제로 학습합니다.
import * as React from 'react';
import { BGrid, type BGridColumn, type BGridDataItem, type BGridSortParam } from 'beautiful-grid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
type FulfillmentPriority = 'URGENT' | 'HIGH' | 'NORMAL';
type FulfillmentStatus = 'ON_HOLD' | 'PICKING' | 'PACKED' | 'SHIPPED';
interface FulfillmentOrder {
orderNo: string;
priority: FulfillmentPriority;
status: FulfillmentStatus;
customer: string;
product: string;
orderedQty: number;
availableQty: number;
warehouse: string;
promisedAt: string;
amount: number;
}
const fulfillmentOrders: BGridDataItem<FulfillmentOrder>[] = [
{
values: {
orderNo: 'SO-260822-1048',
priority: 'URGENT',
status: 'ON_HOLD',
customer: 'ACME 리테일',
product: '산업용 센서 A-100',
orderedQty: 18,
availableQty: 6,
warehouse: '이천 DC',
promisedAt: '2026-08-22 14:00',
amount: 12600000,
},
},
{
values: {
orderNo: 'SO-260822-1049',
priority: 'HIGH',
status: 'PICKING',
customer: '한빛 모빌리티',
product: '제어 모듈 CM-8',
orderedQty: 8,
availableQty: 8,
warehouse: '평택 DC',
promisedAt: '2026-08-22 15:30',
amount: 5840000,
},
},
{
values: {
orderNo: 'SO-260822-1050',
priority: 'NORMAL',
status: 'PACKED',
customer: '오로라 시스템즈',
product: '게이트웨이 GW-20',
orderedQty: 24,
availableQty: 31,
warehouse: '이천 DC',
promisedAt: '2026-08-22 17:00',
amount: 9120000,
},
},
{
values: {
orderNo: 'SO-260822-1051',
priority: 'URGENT',
status: 'ON_HOLD',
customer: '세림 테크',
product: '서보 드라이브 SD-4',
orderedQty: 12,
availableQty: 4,
warehouse: '부산 DC',
promisedAt: '2026-08-22 13:30',
amount: 10800000,
},
},
{
values: {
orderNo: 'SO-260822-1052',
priority: 'HIGH',
status: 'PICKING',
customer: '미래 자동화',
product: 'PLC 확장 모듈 X2',
orderedQty: 30,
availableQty: 30,
warehouse: '평택 DC',
promisedAt: '2026-08-22 18:00',
amount: 7650000,
},
},
{
values: {
orderNo: 'SO-260822-1053',
priority: 'NORMAL',
status: 'SHIPPED',
customer: '대성 로보틱스',
product: '엔코더 EC-12',
orderedQty: 15,
availableQty: 22,
warehouse: '이천 DC',
promisedAt: '2026-08-22 11:00',
amount: 4350000,
},
},
{
values: {
orderNo: 'SO-260822-1054',
priority: 'URGENT',
status: 'ON_HOLD',
customer: '뉴웨이브 에너지',
product: '인버터 IV-75',
orderedQty: 10,
availableQty: 0,
warehouse: '부산 DC',
promisedAt: '2026-08-22 16:00',
amount: 18900000,
},
},
{
values: {
orderNo: 'SO-260822-1055',
priority: 'HIGH',
status: 'PACKED',
customer: '정우 정밀',
product: '리니어 스케일 LS-9',
orderedQty: 6,
availableQty: 9,
warehouse: '평택 DC',
promisedAt: '2026-08-22 19:00',
amount: 3960000,
},
},
{
values: {
orderNo: 'SO-260822-1056',
priority: 'NORMAL',
status: 'PICKING',
customer: '에이스 팩토리',
product: '비전 카메라 VC-3',
orderedQty: 14,
availableQty: 14,
warehouse: '이천 DC',
promisedAt: '2026-08-23 09:00',
amount: 11200000,
},
},
{
values: {
orderNo: 'SO-260822-1057',
priority: 'HIGH',
status: 'ON_HOLD',
customer: '태성 이노텍',
product: '안전 라이트커튼 LC-5',
orderedQty: 20,
availableQty: 13,
warehouse: '부산 DC',
promisedAt: '2026-08-22 20:00',
amount: 6800000,
},
},
{
values: {
orderNo: 'SO-260822-1058',
priority: 'NORMAL',
status: 'PACKED',
customer: '비전 솔루션',
product: 'HMI 패널 H7',
orderedQty: 5,
availableQty: 11,
warehouse: '평택 DC',
promisedAt: '2026-08-23 10:30',
amount: 4750000,
},
},
{
values: {
orderNo: 'SO-260822-1059',
priority: 'HIGH',
status: 'PICKING',
customer: '글로벌 메카',
product: '토크 센서 TS-2',
orderedQty: 16,
availableQty: 16,
warehouse: '이천 DC',
promisedAt: '2026-08-23 12:00',
amount: 8320000,
},
},
];
const priorityView: Record<FulfillmentPriority, { label: string; className: string }> = {
URGENT: { label: '긴급', className: 'bg-rose-100 text-rose-700' },
HIGH: { label: '높음', className: 'bg-amber-100 text-amber-700' },
NORMAL: { label: '보통', className: 'bg-slate-100 text-slate-600' },
};
const statusView: Record<FulfillmentStatus, { label: string; className: string }> = {
ON_HOLD: { label: '출고 보류', className: 'bg-rose-100 text-rose-700' },
PICKING: { label: '피킹 중', className: 'bg-blue-100 text-blue-700' },
PACKED: { label: '포장 완료', className: 'bg-violet-100 text-violet-700' },
SHIPPED: { label: '출고 완료', className: 'bg-emerald-100 text-emerald-700' },
};
const initialColumns: BGridColumn<FulfillmentOrder>[] = [
{ key: 'orderNo', label: '주문번호', width: 145 },
{
key: 'priority',
label: '우선순위',
width: 90,
align: 'center',
itemRender: ({ value }) => {
const view = priorityView[value as FulfillmentPriority];
return (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${view.className}`}>
{view.label}
</span>
);
},
},
{
key: 'status',
label: '처리상태',
width: 105,
align: 'center',
itemRender: ({ value }) => {
const view = statusView[value as FulfillmentStatus];
return (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${view.className}`}>
{view.label}
</span>
);
},
},
{ key: 'customer', label: '고객사', width: 145 },
{ key: 'product', label: '상품', width: 185 },
{
key: 'orderedQty',
label: '주문수량',
width: 95,
align: 'right',
itemRender: ({ value }) => <>{Number(value).toLocaleString()}개</>,
},
{
key: 'availableQty',
label: '가용재고',
width: 95,
align: 'right',
itemRender: ({ value, values }) => (
<strong className={values.availableQty < values.orderedQty ? 'text-rose-600' : 'text-emerald-700'}>
{Number(value).toLocaleString()}개
</strong>
),
},
{ key: 'warehouse', label: '출고센터', width: 105, align: 'center' },
{ key: 'promisedAt', label: '출고 약속일', width: 155, align: 'center' },
{
key: 'amount',
label: '주문금액',
width: 135,
align: 'right',
itemRender: ({ value }) => <strong>{Number(value).toLocaleString()}원</strong>,
},
];
function BasicExample() {
const containerRef = React.useRef<HTMLDivElement>(null);
const { width: containerWidth, height: containerHeight } = useContainerSize(containerRef);
const [columns, setColumns] = React.useState(initialColumns);
const [sortParams, setSortParams] = React.useState<BGridSortParam[]>([]);
const [checkedRowKeys, setCheckedRowKeys] = React.useState<React.Key[]>([]);
const [focusedOrderNo, setFocusedOrderNo] = React.useState(fulfillmentOrders[0].values.orderNo);
const sortedOrders = React.useMemo(() => {
const [sort] = sortParams;
if (!sort?.key) return fulfillmentOrders;
const key = sort.key as keyof FulfillmentOrder;
return [...fulfillmentOrders].sort((a, b) => {
const left = a.values[key];
const right = b.values[key];
const result =
typeof left === 'number' && typeof right === 'number'
? left - right
: String(left).localeCompare(String(right), 'ko');
return sort.orderBy === 'asc' ? result : -result;
});
}, [sortParams]);
return (
<div className='flex min-h-0 flex-col gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600'>
<div>
<strong className='text-slate-900'>주문 출고 예외 관리</strong>
<span className='ml-2'>재고 부족과 마감 임박 주문을 한 화면에서 우선 처리합니다.</span>
</div>
<span aria-live='polite'>
검토 선택 {checkedRowKeys.length}건 · 현재 주문 {focusedOrderNo}
</span>
</div>
<DataGridContainer ref={containerRef} style={{ height: 400 }}>
<BGrid<FulfillmentOrder>
width={containerWidth}
height={containerHeight}
headerHeight={36}
itemHeight={18}
data={sortedOrders}
columns={columns}
rowKey='orderNo'
frozenColumnIndex={3}
showLineNumber
rowChecked={{
checkedRowKeys,
onChange: (_indexes, rowKeys) => setCheckedRowKeys(rowKeys),
}}
sort={{ sortParams, onChange: setSortParams }}
cellSelectionOptions={{ enabled: true }}
cellNavigationOptions={{ enabled: true, defaultActiveCell: { rowIndex: 0, columnIndex: 0 } }}
onChangeColumns={(_columnIndex, info) => setColumns(info.columns)}
onClick={({ item }) => setFocusedOrderNo(item.orderNo)}
/>
</DataGridContainer>
</div>
);
}
export default BasicExample;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. 언제 사용하며 무엇을 배울 수 있나요?
실무 비즈니스 시스템에서 가장 많이 쓰이는 형태는 주문 목록, 거래처 원장, 회원 관리 대장처럼 다양한 데이터 포맷(통화, 날짜, 상태 뱃지, 태그 등)을 깔끔하게 표현하고, 주요 키 컬럼(주문번호, 고객명 등)을 좌측에 고정시켜 가로 스크롤 시에도 항상 보이게 만드는 패턴입니다.
이 페이지에서는 다음과 같은 핵심 기법을 배웁니다:
- 커스텀 셀 렌더링 (
itemRender): 원시 데이터를 뱃지, 링크, 통화 포맷팅된 UI로 변환하기 - 틀고정 컬럼 (
frozenColumnIndex): 좌측 1~N개 열을 고정하여 가로 스크롤 시에도 뷰포트에 유지하기 - 컬럼 정렬 (
align) 및 너비 제어: 텍스트(left), 숫자(right), 코드/날짜(center) 정렬 규칙 - 행 클릭 상호작용 (
onClick): 사용자가 행을 클릭했을 때 상세 모달이나 팝업 열기
2. 실무 완성형 샘플 코드 (주문 내역 관리)
아래 예제는 이커머스 주문 관리 화면을 가정한 완성된 컴포넌트입니다:
import React, { useState } from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
interface OrderItem {
orderNo: string;
customerName: string;
productName: string;
orderDate: string;
amount: number;
status: 'PENDING' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED';
}
const statusBadgeStyles: Record<string, { bg: string; color: string; label: string }> = {
PENDING: { bg: '#fef3c7', color: '#92400e', label: '결제완료' },
SHIPPED: { bg: '#e0f2fe', color: '#075985', label: '배송중' },
DELIVERED: { bg: '#dcfce7', color: '#166534', label: '배송완료' },
CANCELLED: { bg: '#fee2e2', color: '#991b1b', label: '주문취소' },
};
export default function OrderListGrid() {
const [selectedOrder, setSelectedOrder] = useState<OrderItem | null>(null);
// 1. 컬럼 구성
const columns: BGridColumn<OrderItem>[] = [
{
key: 'orderNo',
label: '주문번호',
width: 130,
align: 'center',
itemRender: ({ values }) => (
<span style={{ fontWeight: 600, color: '#2563eb', cursor: 'pointer' }}>
{values.orderNo}
</span>
),
},
{
key: 'customerName',
label: '주문자명',
width: 120,
align: 'left',
},
{
key: 'productName',
label: '상품명',
width: 250,
align: 'left',
},
{
key: 'amount',
label: '결제금액',
width: 130,
align: 'right',
itemRender: ({ values }) => (
<span style={{ fontWeight: 600 }}>
{values.amount.toLocaleString('ko-KR')}원
</span>
),
},
{
key: 'status',
label: '주문상태',
width: 110,
align: 'center',
itemRender: ({ values }) => {
const badge = statusBadgeStyles[values.status];
return (
<span style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: 600,
backgroundColor: badge.bg,
color: badge.color,
}}>
{badge.label}
</span>
);
},
},
{
key: 'orderDate',
label: '주문일시',
width: 160,
align: 'center',
},
];
// 2. 데이터 구성
const data: BGridDataItem<OrderItem>[] = [
{ values: { orderNo: 'ORD-2026-001', customerName: '이민호', productName: '무선 기계식 키보드', amount: 159000, status: 'DELIVERED', orderDate: '2026-08-15 14:22' } },
{ values: { orderNo: 'ORD-2026-002', customerName: '박지영', productName: '4K 모니터 27인치', amount: 489000, status: 'SHIPPED', orderDate: '2026-08-16 09:15' } },
{ values: { orderNo: 'ORD-2026-003', customerName: '최동욱', productName: '인체공학 버티컬 마우스', amount: 69000, status: 'PENDING', orderDate: '2026-08-17 11:40' } },
{ values: { orderNo: 'ORD-2026-004', customerName: '정수연', productName: 'USB-C 멀티허브', amount: 45000, status: 'CANCELLED', orderDate: '2026-08-17 13:02' } },
];
return (
<div>
<BGrid<OrderItem>
width={800}
height={320}
columns={columns}
data={data}
rowKey="orderNo"
frozenColumnIndex={2} // 주문번호, 주문자명 2개 컬럼 좌측 고정
headerHeight={36}
itemHeight={32}
onClick={({ item, index }) => {
setSelectedOrder(item);
console.log(`선택된 행 index: ${index}`, item);
}}
/>
{selectedOrder && (
<div style={{ marginTop: 12, padding: 12, backgroundColor: '#f1f5f9', borderRadius: 8, fontSize: 13 }}>
선택된 주문: <strong>{selectedOrder.orderNo}</strong> ({selectedOrder.customerName} 고객님 / {selectedOrder.amount.toLocaleString()}원)
</div>
)}
</div>
);
}
3. 핵심 속성(Props) 상세 해설
| 속성명 | 타입 | 기본값 | 실무 설명 |
|---|---|---|---|
columns |
BGridColumn<T>[] |
[] (필수) |
테이블 헤더와 열의 너비, 정렬, 렌더러를 정의하는 컬럼 설정 배열입니다. |
data |
BGridDataItem<T>[] |
[] (필수) |
행 데이터 배열입니다. 각 항목은 { values: T } 형태로 래핑되어야 합니다. |
frozenColumnIndex |
number |
0 |
지정된 인덱스 미만의 컬럼들을 좌측에 틀고정(Frozen)하여 가로 스크롤 시 고정 렌더링합니다. (예: 2면 0번, 1번 열 고정) |
headerHeight |
number |
30 |
컬럼 헤더 영역의 높이(px)입니다. 폰트 크기나 다단 헤더 여부에 맞춰 조정합니다. |
itemHeight |
number |
15 |
셀 콘텐츠 영역의 기준 높이(px)입니다. 가상 스크롤 계산에 사용됩니다. |
itemPadding |
number |
7 |
행에 더해지는 세로 여백 계산값입니다. 실제 높이는 현재 테마와 함께 확인하세요. |
onClick |
(params) => void |
undefined |
셀 클릭 시 호출됩니다. { item: T, index, columnIndex, column }을 받습니다. |
4. 커스텀 셀 렌더러 (itemRender) 완벽 활용법
BGridColumn.itemRender는 단순 텍스트 출력을 넘어, 리액트 컴포넌트를 셀 내부에 자유롭게 렌더링할 수 있는 강력한 함수입니다.
콜백 매개변수 구조:
itemRender?: (params: {
item: BGridDataItem<T>; // 전체 행 래퍼 ({ values, status, checked })
values: T; // 실제 비즈니스 행 데이터 객체
value: any; // 해당 컬럼 key에 매핑된 단일 셀 값
column: BGridColumn<T>; // 현재 컬럼 설정 객체
index: number; // 현재 표시 행 인덱스
columnIndex: number; // 컬럼 인덱스
handleSave?: (value: any) => void; // 편집 모드 시 저장 트리거
handleCancel?: () => void; // 편집 모드 시 취소 트리거
}) => React.ReactNode;
실무 추천 패턴:
- 금액/숫자 표기:
values.amount.toLocaleString() - 상태 뱃지:
values.status에 따른 태그 렌더링 - 액션 버튼: 행별 삭제/수정 버튼 배치 (버튼 클릭 시
e.stopPropagation()을 호출하여onClick행 선택과 이벤트 충돌 방지)
5. 실무 팁 & 주의사항 (Gotchas)
[!TIP] 좌측 열 고정 시 가로 스크롤 성능: BeautifulGrid의 고정 컬럼(
frozenColumnIndex)은 고정 영역과 일반 영역을 별도 컴포넌트로 렌더링합니다. 셀 렌더러가 복잡하거나 컬럼 수가 많다면 목표 브라우저와 데이터 규모에서 스크롤 동기화를 확인하세요.
[!WARNING] 셀 내부 이벤트 전파 주의:
itemRender내부에서<button>이나<input>을 클릭할 때 그리드의 행 선택 이벤트(onClick)가 함께 발생하는 것을 원치 않는다면, 핸들러에서event.stopPropagation()을 반드시 호출하세요.