기본 DataGrid (Basic)

기본적인 컬럼 설정, 커스텀 셀 렌더러(itemRender), 좌측 열 고정(Frozen Columns), 행 클릭 이벤트 처리를 실무 예제로 학습합니다.

#columns#itemRender#frozen-columns#onClick#align
검토일: 2026-08-17
LIVE DEMO기본 DataGrid (Basic) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
GitHub
import { t } from './i18n';
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: t('ACME 리테일', 'ACME Retail'),
      product: t('산업용 센서 A-100', 'Industrial Sensor A-100'),
      orderedQty: 18,
      availableQty: 6,
      warehouse: t('이천 DC', 'Icheon DC'),
      promisedAt: '2026-08-22 14:00',
      amount: 12600000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1049',
      priority: 'HIGH',
      status: 'PICKING',
      customer: t('한빛 모빌리티', 'Hanbit Mobility'),
      product: t('제어 모듈 CM-8', 'Control Module CM-8'),
      orderedQty: 8,
      availableQty: 8,
      warehouse: t('평택 DC', 'Pyeongtaek DC'),
      promisedAt: '2026-08-22 15:30',
      amount: 5840000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1050',
      priority: 'NORMAL',
      status: 'PACKED',
      customer: t('오로라 시스템즈', 'Aurora Systems'),
      product: t('게이트웨이 GW-20', 'Gateway GW-20'),
      orderedQty: 24,
      availableQty: 31,
      warehouse: t('이천 DC', 'Icheon DC'),
      promisedAt: '2026-08-22 17:00',
      amount: 9120000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1051',
      priority: 'URGENT',
      status: 'ON_HOLD',
      customer: t('세림 테크', 'Serim Tech'),
      product: t('서보 드라이브 SD-4', 'Servo Drive SD-4'),
      orderedQty: 12,
      availableQty: 4,
      warehouse: t('부산 DC', 'Busan DC'),
      promisedAt: '2026-08-22 13:30',
      amount: 10800000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1052',
      priority: 'HIGH',
      status: 'PICKING',
      customer: t('미래 자동화', 'Mirae Automation'),
      product: t('PLC 확장 모듈 X2', 'PLC Expansion Module X2'),
      orderedQty: 30,
      availableQty: 30,
      warehouse: t('평택 DC', 'Pyeongtaek DC'),
      promisedAt: '2026-08-22 18:00',
      amount: 7650000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1053',
      priority: 'NORMAL',
      status: 'SHIPPED',
      customer: t('대성 로보틱스', 'Daesung Robotics'),
      product: t('엔코더 EC-12', 'Encoder EC-12'),
      orderedQty: 15,
      availableQty: 22,
      warehouse: t('이천 DC', 'Icheon DC'),
      promisedAt: '2026-08-22 11:00',
      amount: 4350000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1054',
      priority: 'URGENT',
      status: 'ON_HOLD',
      customer: t('뉴웨이브 에너지', 'New Wave Energy'),
      product: t('인버터 IV-75', 'Inverter IV-75'),
      orderedQty: 10,
      availableQty: 0,
      warehouse: t('부산 DC', 'Busan DC'),
      promisedAt: '2026-08-22 16:00',
      amount: 18900000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1055',
      priority: 'HIGH',
      status: 'PACKED',
      customer: t('정우 정밀', 'Jungwoo Precision'),
      product: t('리니어 스케일 LS-9', 'Linear Scale LS-9'),
      orderedQty: 6,
      availableQty: 9,
      warehouse: t('평택 DC', 'Pyeongtaek DC'),
      promisedAt: '2026-08-22 19:00',
      amount: 3960000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1056',
      priority: 'NORMAL',
      status: 'PICKING',
      customer: t('에이스 팩토리', 'Ace Factory'),
      product: t('비전 카메라 VC-3', 'Vision Camera VC-3'),
      orderedQty: 14,
      availableQty: 14,
      warehouse: t('이천 DC', 'Icheon DC'),
      promisedAt: '2026-08-23 09:00',
      amount: 11200000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1057',
      priority: 'HIGH',
      status: 'ON_HOLD',
      customer: t('태성 이노텍', 'Taesung Innotek'),
      product: t('안전 라이트커튼 LC-5', 'Safety Light Curtain LC-5'),
      orderedQty: 20,
      availableQty: 13,
      warehouse: t('부산 DC', 'Busan DC'),
      promisedAt: '2026-08-22 20:00',
      amount: 6800000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1058',
      priority: 'NORMAL',
      status: 'PACKED',
      customer: t('비전 솔루션', 'Vision Solution'),
      product: t('HMI 패널 H7', 'HMI Panel H7'),
      orderedQty: 5,
      availableQty: 11,
      warehouse: t('평택 DC', 'Pyeongtaek DC'),
      promisedAt: '2026-08-23 10:30',
      amount: 4750000,
    },
  },
  {
    values: {
      orderNo: 'SO-260822-1059',
      priority: 'HIGH',
      status: 'PICKING',
      customer: t('글로벌 메카', 'Global Mecca'),
      product: t('토크 센서 TS-2', 'Torque Sensor TS-2'),
      orderedQty: 16,
      availableQty: 16,
      warehouse: t('이천 DC', 'Icheon DC'),
      promisedAt: '2026-08-23 12:00',
      amount: 8320000,
    },
  },
];

const priorityView: Record<FulfillmentPriority, { label: string; className: string }> = {
  URGENT: { label: t('긴급', 'Urgent'), className: 'bg-rose-100 text-rose-700' },
  HIGH: { label: t('높음', 'High'), className: 'bg-amber-100 text-amber-700' },
  NORMAL: { label: t('보통', 'Normal'), className: 'bg-slate-100 text-slate-600' },
};

const statusView: Record<FulfillmentStatus, { label: string; className: string }> = {
  ON_HOLD: { label: t('출고 보류', 'Dispatch Pending'), className: 'bg-rose-100 text-rose-700' },
  PICKING: { label: t('피킹 중', 'Picking in Progress'), className: 'bg-blue-100 text-blue-700' },
  PACKED: { label: t('포장 완료', 'Packaging Completed'), className: 'bg-violet-100 text-violet-700' },
  SHIPPED: { label: t('출고 완료', 'Dispatch Completed'), className: 'bg-emerald-100 text-emerald-700' },
};

const initialColumns: BGridColumn<FulfillmentOrder>[] = [
  { key: 'orderNo', label: t('주문번호', 'Order Number'), width: 145 },
  {
    key: 'priority',
    label: t('우선순위', 'Priority'),
    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: t('처리상태', 'Processing Status'),
    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: t('고객사', 'Client'), width: 145 },
  { key: 'product', label: t('상품', 'Product'), width: 185 },
  {
    key: 'orderedQty',
    label: t('주문수량', 'Order Quantity'),
    width: 95,
    align: 'right',
    itemRender: ({ value }) => <>{Number(value).toLocaleString()}{t('개', 'ea')}</>,
  },
  {
    key: 'availableQty',
    label: t('가용재고', 'Available Stock'),
    width: 95,
    align: 'right',
    itemRender: ({ value, values }) => (
      <strong className={values.availableQty < values.orderedQty ? 'text-rose-600' : 'text-emerald-700'}>
        {Number(value).toLocaleString()}{t('개', 'ea')}
      </strong>
    ),
  },
  { key: 'warehouse', label: t('출고센터', 'Dispatch Center'), width: 105, align: 'center' },
  { key: 'promisedAt', label: t('출고 약속일', 'Promised Dispatch Date'), width: 155, align: 'center' },
  {
    key: 'amount',
    label: t('주문금액', 'Order Amount'),
    width: 135,
    align: 'right',
    itemRender: ({ value }) => <strong>{Number(value).toLocaleString()}{t('원', 'KRW')}</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'>{t('주문 출고 예외 관리', 'Order Dispatch Exception Management')}</strong>
          <span className='ml-2'>{t('재고 부족과 마감 임박 주문을 한 화면에서 우선 처리합니다.', 'Handle out-of-stock and impending deadline orders with priority on one screen.')}</span>
        </div>
        <span aria-live='polite'>
          {t('검토 선택', 'Selected for review')} {checkedRowKeys.length}{t('건 · 현재 주문', ' items · Current Order')} {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={containerWidth > 0 && containerWidth < 640 ? 1 : 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;

1. 언제 사용하며 무엇을 배울 수 있나요?

실무 비즈니스 시스템에서 가장 많이 쓰이는 형태는 주문 목록, 거래처 원장, 회원 관리 대장처럼 다양한 데이터 포맷(통화, 날짜, 상태 뱃지, 태그 등)을 깔끔하게 표현하고, 주요 키 컬럼(주문번호, 고객명 등)을 좌측에 고정시켜 가로 스크롤 시에도 항상 보이게 만드는 패턴입니다.

이 페이지에서는 다음과 같은 핵심 기법을 배웁니다:

  1. 커스텀 셀 렌더링 (itemRender): 원시 데이터를 뱃지, 링크, 통화 포맷팅된 UI로 변환하기
  2. 틀고정 컬럼 (frozenColumnIndex): 좌측 1~N개 열을 고정하여 가로 스크롤 시에도 뷰포트에 유지하기
  3. 컬럼 정렬 (align) 및 너비 제어: 텍스트(left), 숫자(right), 코드/날짜(center) 정렬 규칙
  4. 행 클릭 상호작용 (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: '13px',
            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)

[!WARNING] 모바일 화면에서의 틀고정 (frozenColumnIndex) 반응형 처리: 모바일 기기처럼 화면 폭이 좁은 환경에서는 고정 컬럼 수가 많을 경우 고정 영역이 화면의 대부분을 가려 가로 스크롤이 작동하지 않는 것처럼 오해할 수 있습니다. 모바일 환경에서는 containerWidth < 640 ? 1 : 3처럼 화면 폭에 맞춰 frozenColumnIndex를 1개로 줄이거나 고정을 해제(0)하는 반응형 구성을 권장합니다.

[!TIP] 좌측 열 고정 시 가로 스크롤 성능: BeautifulGrid의 고정 컬럼(frozenColumnIndex)은 고정 영역과 일반 영역을 별도 컴포넌트로 렌더링합니다. 셀 렌더러가 복잡하거나 컬럼 수가 많다면 목표 브라우저와 데이터 규모에서 스크롤 동기화를 확인하세요.

[!WARNING] 셀 내부 이벤트 전파 주의: itemRender 내부에서 <button>이나 <input>을 클릭할 때 그리드의 행 선택 이벤트(onClick)가 함께 발생하는 것을 원치 않는다면, 핸들러에서 event.stopPropagation()을 반드시 호출하세요.