데이터와 컬럼 정의 (Data & Columns)
TypeScript 제네릭을 활용한 안전한 컬럼 매핑, 점 표기법 중첩 키 접근, 폭(Width)과 정렬(Align) 규칙을 심층 분석합니다.
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. 개요 및 타입 안정성
BGridColumn<T>과 BGridDataItem<T>는 셀 렌더러와 콜백에서 행 값의 타입을 전달합니다. 다만 현재 BGridColumn.key는 string | string[]이므로 존재하지 않는 필드명을 컴파일러가 자동으로 차단하지는 않습니다. 컬럼 key와 실제 데이터 필드가 일치하는지 애플리케이션 코드와 테스트에서 확인해야 합니다.
2. 컬럼 key의 2가지 지정 방식
1) 단순 문자열 키 (1차원 속성)
{ key: 'username', label: '사용자명', width: 120 }
2) 배열 점 경로 키 (중첩 객체 접근)
데이터가 { company: { address: { city: '서울' } } } 처럼 중첩된 경우:
{ key: ['company', 'address', 'city'], label: '도시', width: 120 }