데이터 내보내기 (Export CSV, Excel & Data)
외부 의존성 없이 현재 화면 데이터, 체크된 행, 원본 데이터를 Excel(.xlsx)이나 CSV로 다운로드하거나 외부 라이브러리 연동용 논리 모델을 추출합니다.
import { t } from './i18n';
import * as React from 'react';
import { Button, Space, Tag } from 'antd';
import { Download, FileSpreadsheet } from 'lucide-react';
import {
BGrid,
type BGridColumn,
type BGridDataItem,
type BGridRef,
} from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
interface Order {
orderNo: string;
customer: string;
product: string;
category: string;
price: number;
quantity: number;
status: string;
orderDate: string;
}
const customers = ['AxisJ Studio', 'Northwind', t('서울 물류', 'Seoul Logistics'), 'Mono Office', 'Global Tech'];
const products = [
t('클라우드 서버 패키지', 'Cloud Server Package'),
t('데이터베이스 라이선스', 'Database License'),
t('보안 컨설팅 서비스', 'Security Consulting'),
t('엔터프라이즈 모니터링', 'Enterprise Monitoring'),
t('네트워크 방화벽 장비', 'Network Firewall Unit'),
];
const categories = ['Infrastructure', 'Software', 'Security', 'DevOps', 'Hardware'];
const statuses = [t('결제 완료', 'Paid'), t('배송 중', 'Shipping'), t('처리 완료', 'Completed'), t('보류', 'Pending')];
const data: BGridDataItem<Order>[] = Array.from({ length: 40 }, (_, index) => {
const price = (index % 5 + 1) * 150000;
const quantity = (index % 4) + 1;
const month = String((index % 12) + 1).padStart(2, '0');
const day = String((index % 28) + 1).padStart(2, '0');
return {
values: {
orderNo: `ORD-2026-${String(index + 1).padStart(4, '0')}`,
customer: customers[index % customers.length],
product: products[index % products.length],
category: categories[index % categories.length],
price,
quantity,
status: statuses[index % statuses.length],
orderDate: `2026-${month}-${day}`,
},
};
});
const columns: BGridColumn<Order>[] = [
{
id: 'orderNo',
key: 'orderNo',
label: t('주문 번호', 'Order No'),
width: 140,
exportHeader: 'Order Number',
toolbox: true,
},
{
id: 'customer',
key: 'customer',
label: t('고객사', 'Customer'),
width: 140,
toolbox: true,
},
{
id: 'product',
key: 'product',
label: t('주문 품목', 'Product'),
width: 180,
toolbox: true,
},
{
id: 'category',
key: 'category',
label: t('분류', 'Category'),
width: 120,
toolbox: true,
},
{
id: 'price',
key: 'price',
label: t('단가', 'Unit Price'),
width: 120,
align: 'right',
toolbox: true,
itemRender: ({ value }) => `₩${Number(value).toLocaleString()}`,
getExportValue: ({ value }) => Number(value),
},
{
id: 'quantity',
key: 'quantity',
label: t('수량', 'Quantity'),
width: 80,
align: 'right',
toolbox: true,
},
{
id: 'status',
key: 'status',
label: t('상태', 'Status'),
width: 110,
align: 'center',
toolbox: true,
exportHeader: col => `${String(col.id).toUpperCase()} (STATUS)`,
},
{
id: 'orderDate',
key: 'orderDate',
label: t('주문 일자', 'Order Date'),
width: 120,
toolbox: true,
},
];
export default function ExportExample() {
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const gridRef = React.useRef<BGridRef<Order>>(null);
const [checkedRowKeys, setCheckedRowKeys] = React.useState<React.Key[]>([]);
const handleExportAllExcel = React.useCallback(() => {
gridRef.current?.exportExcel({
fileName: 'all-orders.xlsx',
rows: 'source',
columns: 'all',
});
}, []);
const handleExportAllCsv = React.useCallback(() => {
gridRef.current?.exportCsv({
fileName: 'all-orders.csv',
rows: 'source',
columns: 'all',
});
}, []);
const handleExportCheckedExcel = React.useCallback(() => {
gridRef.current?.exportExcel({
fileName: 'selected-orders.xlsx',
rows: 'checked',
columns: 'visible',
});
}, []);
const handleExportCheckedCsv = React.useCallback(() => {
gridRef.current?.exportCsv({
fileName: 'selected-orders.csv',
rows: 'checked',
columns: 'visible',
});
}, []);
return (
<div className='flex min-h-0 flex-col gap-3'>
<div className='flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700'>
<Space wrap>
<Button icon={<FileSpreadsheet size={14} />} type='primary' onClick={handleExportAllExcel}>
{t('전체 로드 데이터 Excel', 'Export all loaded rows (Excel)')}
</Button>
<Button icon={<Download size={14} />} onClick={handleExportAllCsv}>
{t('전체 로드 데이터 CSV', 'Export all loaded rows (CSV)')}
</Button>
<Button
icon={<FileSpreadsheet size={14} />}
disabled={checkedRowKeys.length === 0}
onClick={handleExportCheckedExcel}
>
{t('선택 행 Excel', 'Export checked rows (Excel)')} ({checkedRowKeys.length})
</Button>
<Button
icon={<Download size={14} />}
disabled={checkedRowKeys.length === 0}
onClick={handleExportCheckedCsv}
>
{t('선택 행 CSV', 'Export checked rows (CSV)')} ({checkedRowKeys.length})
</Button>
</Space>
<div className='flex items-center gap-2'>
<Tag color='blue'>{t('우클릭 메뉴 지원', 'Context Menu Supported')}</Tag>
<span className='text-xs text-slate-500'>
{t('툴박스 필터·정렬 및 컬럼 표시 설정 반영', 'Reflects toolbox filters, sorting, and visibility')}
</span>
</div>
</div>
<DataGridContainer ref={containerRef} minHeight={480}>
<BGrid<Order>
ref={gridRef}
width={width}
height={height}
columns={columns}
data={data}
rowKey='orderNo'
rowChecked={{
checkedRowKeys,
onChange: (_indexes, keys) => setCheckedRowKeys(keys),
}}
columnVisibility={true}
contextMenuOptions={{
items: () => [
{
id: 'export-all-excel',
label: t('전체 로드 행 Excel 내보내기', 'Export all loaded rows to Excel'),
icon: <FileSpreadsheet size={14} />,
onSelect: () => handleExportAllExcel(),
},
{
id: 'export-all-csv',
label: t('전체 로드 행 CSV 내보내기', 'Export all loaded rows to CSV'),
icon: <Download size={14} />,
onSelect: () => handleExportAllCsv(),
},
{
type: 'separator',
id: 'export-sep',
},
{
id: 'export-checked-excel',
label: t('체크된 행 Excel 내보내기', 'Export checked rows to Excel'),
icon: <FileSpreadsheet size={14} />,
disabled: checkedRowKeys.length === 0,
onSelect: () => handleExportCheckedExcel(),
},
{
id: 'export-checked-csv',
label: t('체크된 행 CSV 내보내기', 'Export checked rows to CSV'),
icon: <Download size={14} />,
disabled: checkedRowKeys.length === 0,
onSelect: () => handleExportCheckedCsv(),
},
],
}}
/>
</DataGridContainer>
</div>
);
}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는 별도의 외부 라이브러리(SheetJS, ExcelJS 등) 없이도 완전한 Export 기능을 제공합니다.
가상 스크롤(Virtual Scrolling)을 사용하는 그리드에서는 화면 DOM에 일부 행만 렌더링되므로, DOM을 탐색하여 내보내는 방식은 전체 데이터 유실을 유발합니다. BeautifulGrid의 Export 기능은 DOM에 일절 의존하지 않고 내부 논리 데이터 모델(store.data, store.sourceData, sourceIndexByVisibleIndex)을 직접 조회하여 신뢰할 수 있는 데이터를 추출합니다.
gridRef.current.exportExcel(options): 외부 라이브러리 없이 순수 JavaScript로 표준 OpenXML.xlsx파일 직렬화 및 브라우저 다운로드 트리거gridRef.current.exportCsv(options): CSV 직렬화 후 브라우저 파일 다운로드 트리거 (RFC 4180 준수, UTF-8 BOM, 수식 인젝션 방지 기본 지원)gridRef.current.getExportData(options): 컬럼 및 셀 2차원 배열 형태의 논리 데이터 모델 반환 (JSON, SheetJS, ExcelJS 등에 바로 활용 가능)
2. Row Scope (행 범위)
Export 시 대상 행 범위(rows)를 다음 세 가지 옵션 중 하나로 지정할 수 있습니다 (기본값: 'displayed').
rows 옵션 |
대상 데이터 | 설명 |
|---|---|---|
'displayed' |
현재 화면 논리 행 | 필터, 정렬, 트리 접힘이 반영된 현재 표시 행입니다. 가상 스크롤과 무관하게 전체 논리 행이 화면 표시 순서대로 내보내집니다. |
'checked' |
선택된 행 | rowChecked를 통해 체크된 행들을 sourceData 순서대로 내보냅니다. 필터로 인해 현재 화면에서 숨겨진 체크 행도 포함됩니다. |
'source' |
현재 로드된 원본 행 | 클라이언트에 로드되어 있는 sourceData 전체를 내보냅니다. |
// 현재 정렬/필터가 적용된 화면 데이터 내보내기
gridRef.current?.exportCsv({
fileName: 'orders.csv',
rows: 'displayed',
});
// 체크된 행만 내보내기
gridRef.current?.exportCsv({
fileName: 'checked-orders.csv',
rows: 'checked',
});
[!WARNING] 서버 사이드 페이징(Server-side Pagination) 안내
rows: 'source'는 전체 데이터베이스 데이터가 아니라 **현재 클라이언트에 로드되어 있는sourceData**를 의미합니다. BeautifulGrid는 내보내기 시 다른 페이지의 데이터를 임의로 서버에서 자동 fetch하지 않습니다. 전체 원본 데이터 추출은 백엔드 Export API 사용을 권장합니다.
3. Column Scope (컬럼 범위)
내보낼 컬럼 범위(columns)는 다음 중 하나로 지정할 수 있습니다 (기본값: 'visible').
'visible': 컬럼 숨김(Column Visibility)이 적용된 후 현재 표시되는 컬럼 목록'all': 현재 숨겨진 컬럼까지 모두 포함한 전체 원본 컬럼 목록 (피벗 모드에서는 피벗 컬럼 전체)readonly string[]: 지정된 컬럼 ID 배열 순서대로 내보내기 (존재하지 않는 ID는 무시)
// 숨겨진 컬럼을 포함하여 모든 컬럼 내보내기
gridRef.current?.exportCsv({
columns: 'all',
});
// 지정한 컬럼만 특정 순서로 내보내기
gridRef.current?.exportCsv({
columns: ['orderNo', 'customer', 'price'],
});
4. 컬럼별 Export 설정 (BGridColumn)
컬럼 정의에서 내보내기 제외, 헤더 이름 변환, 셀 값 변환을 세밀하게 제어할 수 있습니다.
const columns: BGridColumn<Order>[] = [
{
id: 'orderNo',
key: 'orderNo',
label: <span>주문 번호</span>,
exportHeader: 'Order Number', // ReactNode 라벨 대신 사용될 CSV 헤더 문자열
},
{
id: 'price',
key: 'price',
label: '단가',
getExportValue: ({ value }) => Number(value), // 숫자로 정규화
},
{
id: 'actions',
key: 'orderNo',
label: '작업',
exportable: false, // 모든 export scope에서 항상 제외
},
];
헤더 값 결정 규칙
column.exportHeader: 문자열 또는 컬럼 객체를 받는 함수column.label: 문자열 또는 숫자일 경우String(label)- 컬럼 식별자(
columnId)
셀 값 결정 규칙
itemRender의 React UI 결과물은 내보내기에 사용되지 않습니다. 원본 데이터 값(item.values)에서 nested key 경로를 읽은 rawValue를 바탕으로 getExportValue가 정의되어 있으면 그 반환값을, 그렇지 않으면 원본 rawValue를 사용합니다.
5. 컨텍스트 메뉴(Context Menu) 연동
contextMenuOptions를 설정하여 마우스 우클릭 시 Export 기능을 바로 실행할 수 있습니다.
<BGrid<Order>
ref={gridRef}
columns={columns}
data={data}
contextMenuOptions={{
items: () => [
{
id: 'export-all-excel',
label: '전체 로드 행 Excel 내보내기',
onSelect: () => {
gridRef.current?.exportExcel({
fileName: 'all-orders.xlsx',
rows: 'source',
columns: 'all',
});
},
},
{
id: 'export-all-csv',
label: '전체 로드 행 CSV 내보내기',
onSelect: () => {
gridRef.current?.exportCsv({
fileName: 'all-orders.csv',
rows: 'source',
columns: 'all',
});
},
},
{ type: 'separator', id: 'export-sep' },
{
id: 'export-checked-excel',
label: '체크된 행 Excel 내보내기',
disabled: checkedRowKeys.length === 0,
onSelect: () => {
gridRef.current?.exportExcel({
fileName: 'selected-orders.xlsx',
rows: 'checked',
columns: 'visible',
});
},
},
{
id: 'export-checked-csv',
label: '체크된 행 CSV 내보내기',
disabled: checkedRowKeys.length === 0,
onSelect: () => {
gridRef.current?.exportCsv({
fileName: 'selected-orders.csv',
rows: 'checked',
columns: 'visible',
});
},
},
],
}}
/>
6. Excel (.xlsx) 내보내기
별도의 외부 무거운 라이브러리(SheetJS, ExcelJS) 설치 없이 gridRef.current.exportExcel을 사용하여 표준 OpenXML Excel 파일(.xlsx)을 즉시 다운로드할 수 있습니다.
// 현재 화면에 표시된 데이터로 Excel 내보내기
gridRef.current?.exportExcel({
fileName: 'orders.xlsx',
sheetName: 'Orders', // 시트 탭 이름 (기본값: 'Sheet1', 최대 31자)
rows: 'displayed', // 'displayed' | 'checked' | 'source' (기본값: 'displayed')
columns: 'visible', // 'visible' | 'all' | string[] (기본값: 'visible')
includeHeader: true, // 헤더 행 포함 여부 (기본값: true)
});
숫자(number)와 불리언(boolean) 타입은 Excel 내에서 문자열이 아닌 실제 숫자/불리언 셀 타입으로 직렬화되어 합계 및 수식 계산이 가능합니다.
7. CSV 직렬화 및 보안 설정
exportCsv는 RFC 4180 호환 이스케이프, UTF-8 BOM, 수식 인젝션(CSV Injection) 방지를 기본 지원합니다.
gridRef.current?.exportCsv({
fileName: 'export.csv',
delimiter: ',', // 기본값 ',' (탭 구분 '\t', 세미콜론 ';' 등 지원)
newline: '\r\n', // 기본값 '\r\n'
bom: true, // 기본값 true (엑셀에서 한글 UTF-8 깨짐 방지)
preventFormulaInjection: true, // 기본값 true ('=', '+', '-', '@'로 시작하는 문자열 앞에 ' prefix)
});
8. 외부 엑셀 라이브러리(SheetJS / ExcelJS) 연동
XLSX 파일 생성이 필요할 때는 getExportData()를 호출하여 외부 라이브러리에 전달할 수 있습니다.
SheetJS (xlsx) 연동 예제
import * as XLSX from 'xlsx';
const exportData = gridRef.current?.getExportData({
rows: 'displayed',
columns: 'visible',
});
if (exportData) {
// 2차원 배열(aoa) 생성
const aoa = [
exportData.columns.map(c => c.header),
...exportData.rows.map(row => row.cells),
];
const ws = XLSX.utils.aoa_to_sheet(aoa);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
XLSX.writeFile(wb, 'orders.xlsx');
}