행 선택 및 체크박스 (Row Selection)
체크박스 다중 선택, 라디오 단일 선택, 전체 선택(Indeterminate) 및 제어 컴포넌트 상태 연동 방법을 학습합니다.
import * as React from 'react';
import { AXDataGrid, AXDGColumn, AXDGDataItem, AXDGItemRenderProps, AXDGSortParam, toMoney } from '@axboot/datagrid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
import { Progress } from 'antd';
import { Key, useState } from 'react';
interface Props {}
interface IListItem {
nation: string;
awpc: number;
wpc: number;
man: number;
woman: number;
ratio: number;
ratioMan: number;
ratioWoman: number;
}
const rawData = [
['대한민국(15+ LFS)', 44504, 28186, 16090, 12097, 63, 73, 53],
['아르메니아(15~75 LFS)', 2204, 1563, 780, 783, 70, 76, 66],
['아제르바이잔(15+ LFS)', 0, 5190, 2664, 2526, 66, 69, 63],
['부탄(15+ LFS)', 482, 320, 169, 151, 66, 71, 61],
['브루나이(15+ LFS)', 370, 238, 144, 94, 64, 72, 54],
['캄보디아(15+ LFS)', 11515, 8756, 4481, 4274, 76, 82, 69],
['키프로스(15+ LFS)', 712, 448, 236, 212, 63, 68, 57],
['조지아(15+ LFS)', 3037, 1911, 1025, 886, 62, 72, 54],
['홍콩(15+ LFS)', 6573, 3988, 1990, 1998, 60, 67, 55],
['인도네시아(15+ LFS)', 200485, 136808, 82760, 54048, 68, 82, 53],
['이란(15+ LFS)', 61658, 26940, 21707, 5233, 43, 70, 17],
['이스라엘(15+ LFS)', 6494, 4124, 2149, 1974, 63, 67, 59],
['일본(15+ LFS)', 110271, 68377, 37997, 30380, 62, 71, 53],
['카자흐스탄(15+ LFS)', 13131, 9203, 0, 0, 70, 0, 0],
['키르기스스탄(15+ LFS)', 4288, 2755, 1620, 1136, 64, 77, 51],
['레바논(15+ LFS)', 3677, 1798, 1230, 567, 48, 70, 29],
['마카오(16+ LFS)', 0, 395, 193, 202, 0, 74, 66],
['말레이시아(15~64 LFS)', 22685, 15582, 9503, 6078, 68, 80, 55],
['몰디브(15+ HIES)', 317, 202, 116, 86, 63, 78, 50],
['몽골(15+ LFS)', 2106, 1326, 706, 620, 63, 70, 55],
['파키스탄(15+ LFS)', 120220, 62030, 47845, 14185, 51, 79, 23],
['필리핀(15+ LFS )', 73008, 43399, 26527, 16872, 59, 72, 46],
['카타르(15+ LFS)', 2393, 2108, 1823, 285, 88, 95, 57],
['사우디아라비아(15+ LFS)', 0, 0, 0, 0, 57, 80, 24],
['싱가포르(15+ LFS)', 3422, 2329, 1251, 1077, 68, 75, 61],
['스리랑카(15+ LFS)', 16424, 8581, 5550, 3032, 52, 72, 34],
['대만(15+ LFS)', 20188, 11946, 6631, 5315, 59, 67, 51],
['태국(15+ LFS)', 56575, 37885, 20611, 17274, 67, 75, 59],
['튀르키예(15+ LFS)', 61468, 32524, 21855, 10669, 52, 72, 34],
['아랍에미리트(15+ LFS)', 9432, 7565, 5693, 1871, 80, 92, 57],
['베트남(15+ LFS)', 73394, 55507, 29068, 26440, 75, 81, 70],
];
const list = rawData.map((data, index) => {
return {
values: {
nation: data[0],
awpc: data[1],
wpc: data[2],
man: data[3],
woman: data[4],
ratio: data[5],
ratioMan: data[5],
ratioWoman: data[5],
},
};
});
const numRender = (item: AXDGItemRenderProps<IListItem>) => <>{toMoney(item.value)}</>;
function CheckedExample(props: Props) {
const [checkedKeys, setCheckedKeys] = useState<Key[]>();
const [sortParams, setSortParams] = React.useState<AXDGSortParam[]>([]);
const [columns, setColumns] = React.useState<AXDGColumn<IListItem>[]>([
{
key: 'nation',
label: 'Nation',
width: 150,
},
{
key: 'awpc',
label: <>{'active population'}</>,
width: 150,
align: 'right',
itemRender: numRender,
},
{ key: 'wpc', label: 'population', width: 100, align: 'right', itemRender: numRender },
{ key: 'man', label: 'Man', width: 100, align: 'right', itemRender: numRender },
{ key: 'woman', label: 'Woman', width: 100, align: 'right', itemRender: numRender },
{
key: 'ratio',
label: 'Ratio',
width: 150,
align: 'right',
itemRender: item => {
return <Progress size={'small'} percent={item.values.ratio} style={{ margin: 0 }} />;
},
},
{ key: 'ratioMan', label: 'Man', width: 100, align: 'right' },
{ key: 'ratioWoman', label: 'Woman', width: 100, align: 'right' },
]);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width: containerWidth, height: containerHeight } = useContainerSize(containerRef);
const sortedList = React.useMemo(() => {
let i = 0,
l = sortParams.length;
return list
.sort((a, b) => {
for (i = 0; i < l; i++) {
const sortInfo = sortParams[i];
if (sortInfo.key === undefined) {
continue;
}
let valueA = a.values[sortInfo.key as keyof IListItem],
valueB = b.values[sortInfo.key as keyof IListItem];
if (typeof valueA !== typeof valueB) {
valueA = '' + valueA;
valueB = '' + valueB;
}
if (valueA < valueB) {
return sortInfo.orderBy === 'asc' ? -1 : 1;
} else if (valueA > valueB) {
return sortInfo.orderBy === 'asc' ? 1 : -1;
}
}
return 0;
})
.slice() as AXDGDataItem<IListItem>[];
}, [sortParams]);
return (
<DataGridContainer ref={containerRef}>
<AXDataGrid<IListItem>
width={containerWidth}
height={containerHeight}
headerHeight={35}
data={sortedList}
columns={columns}
onChangeColumns={(columnIndex, { width, columns }) => {
console.log('onChangeColumnWidths', columnIndex, width, columns);
setColumns(columns);
}}
rowChecked={{
isRadio: true,
checkedRowKeys: checkedKeys,
onChange: (ids, keys, selectedAll) => {
console.log('onChange rowSelection', ids, keys, selectedAll);
setCheckedKeys(keys);
},
}}
sort={{
sortParams,
onChange: sortParams => {
console.log('onChange: sortParams', sortParams);
setSortParams(sortParams);
},
}}
showLineNumber
rowKey={'nation'}
/>
</DataGridContainer>
);
}
export default CheckedExample;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.
*
* AXDataGrid'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 > .axdg-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. 언제 사용하며 왜 필요한가요?
관리자 화면에서 가장 빈번한 작업 중 하나는 **“체크박스로 여러 항목을 선택한 뒤 일괄 삭제, 일괄 승인, 일괄 엑셀 다운로드”**를 수행하는 일입니다.
AXBOOT DataGrid는 다음과 같은 행 선택 기능을 기본 제공합니다:
- 다중 선택 (Checkbox): 여러 행을 체크박스로 선택
- 단일 선택 (Radio): 오직 하나의 행만 선택 가능하도록 강제
- 전체 선택 삼중 상태 (Tri-state): 전체 선택(
true), 전체 해제(false), 일부만 선택됨(indeterminate)을 헤더 체크박스에 자동 반영 - 고유 키 기반 선택 (
checkedRowKeys): 가상 스크롤이나 정렬/필터링 후에도 선택 상태를 안정적으로 유지
2. 실무 완성형 예제: 결제 승인 대기 목록 일괄 처리
아래 코드는 체크박스 선택 상태를 React 상태(checkedKeys)와 동기화하고, 선택된 항목들을 일괄 승인 처리하는 실무 예제입니다:
import React, { useState } from 'react';
import { AXDataGrid, type AXDGColumn, type AXDGDataItem } from '@axboot/datagrid';
interface PaymentItem {
id: string;
applicant: string;
department: string;
purpose: string;
amount: number;
requestDate: string;
}
export default function PaymentApprovalGrid() {
// 선택된 행의 ID 목록 상태 관리 (Controlled State)
const [checkedKeys, setCheckedKeys] = useState<string[]>(['REQ-002']);
const [data, setData] = useState<AXDGDataItem<PaymentItem>[]>([
{ values: { id: 'REQ-001', applicant: '강현우', department: '영업1팀', purpose: '고객사 미팅 교통비', amount: 35000, requestDate: '2026-08-16' } },
{ values: { id: 'REQ-002', applicant: '송유진', department: '개발기획팀', purpose: '클라우드 서버 사용료', amount: 890000, requestDate: '2026-08-16' } },
{ values: { id: 'REQ-003', applicant: '임재원', department: '인사총무팀', purpose: '사무용품 구매', amount: 120000, requestDate: '2026-08-17' } },
{ values: { id: 'REQ-004', applicant: '오세훈', department: '마케팅팀', purpose: '온라인 광고 집행비', amount: 1500000, requestDate: '2026-08-17' } },
]);
const columns: AXDGColumn<PaymentItem>[] = [
{ key: 'id', label: '신청번호', width: 100, align: 'center' },
{ key: 'applicant', label: '신청자', width: 100, align: 'center' },
{ key: 'department', label: '소속부서', width: 130 },
{ key: 'purpose', label: '지출목적', width: 220 },
{
key: 'amount',
label: '신청금액',
width: 130,
align: 'right',
itemRender: ({ values }) => <strong>{values.amount.toLocaleString()}원</strong>,
},
{ key: 'requestDate', label: '신청일자', width: 120, align: 'center' },
];
// 일괄 승인 핸들러
const handleApproveBatch = () => {
if (checkedKeys.length === 0) {
alert('승인할 항목을 1개 이상 선택해주세요.');
return;
}
const selectedItems = data.filter(d => checkedKeys.includes(d.values.id));
const totalAmount = selectedItems.reduce((sum, item) => sum + item.values.amount, 0);
const confirmed = confirm(
`선택된 ${selectedItems.length}건 (총 ${totalAmount.toLocaleString()}원)을 일괄 승인하시겠습니까?`
);
if (confirmed) {
// 승인된 데이터 목록에서 제외
setData(prev => prev.filter(d => !checkedKeys.includes(d.values.id)));
setCheckedKeys([]);
alert('정상적으로 승인 처리되었습니다.');
}
};
return (
<div>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
선택된 항목: <strong>{checkedKeys.length}</strong> / {data.length}건
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => setCheckedKeys(data.map(d => d.values.id))}
style={{ padding: '6px 12px', border: '1px solid #cbd5e1', background: '#fff', borderRadius: 4, cursor: 'pointer' }}
>
전체 선택
</button>
<button
onClick={() => setCheckedKeys([])}
style={{ padding: '6px 12px', border: '1px solid #cbd5e1', background: '#fff', borderRadius: 4, cursor: 'pointer' }}
>
선택 해제
</button>
<button
onClick={handleApproveBatch}
style={{ padding: '6px 16px', background: '#16a34a', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 'bold', cursor: 'pointer' }}
>
일괄 승인 ({checkedKeys.length})
</button>
</div>
</div>
<AXDataGrid<PaymentItem>
width={780}
height={300}
columns={columns}
data={data}
rowKey="id"
rowChecked={{
checkedIndexes: [], // 또는 checkedRowKeys 사용
checkedRowKeys: checkedKeys,
onChange: (checkedIndexes, checkedRowKeys, checkedAll) => {
console.log('선택 상태 변경:', { checkedIndexes, checkedRowKeys, checkedAll });
setCheckedKeys(checkedRowKeys);
},
}}
showLineNumber={true}
/>
</div>
);
}
3. rowChecked 옵션 완벽 가이드
rowChecked prop을 객체로 넘겨주면 헤더 및 행 좌측에 전용 선택 컨트롤(체크박스/라디오)이 자동 생성됩니다.
interface AXDGRowChecked<T> {
// true이면 단일 선택 라디오 UI를 사용
isRadio?: boolean;
// 인덱스 기반 선택 목록 (Uncontrolled 또는 인덱스 제어 시)
checkedIndexes?: number[];
// 고유 키 기반 선택 목록 (Controlled 상태 관리 시 추천)
checkedRowKeys?: React.Key[];
// 선택 상태 변경 시 콜백
onChange: (
checkedIndexes: number[],
checkedRowKeys: React.Key[],
checkedAll: boolean | 'indeterminate'
) => void;
}
4. 실무 팁 & 주의사항 (Gotchas)
[!TIP] 1. 인덱스(
checkedIndexes) 대신 키(checkedRowKeys)를 사용하세요: 사용자가 컬럼 정렬(Sort)을 바꾸거나 검색 필터를 적용하면 행의 인덱스(0, 1, 2…)는 계속 바뀝니다.rowKey="id"와checkedRowKeys를 사용하면 정렬이나 필터가 변경되어도 선택한 데이터가 정확하게 유지됩니다.
[!NOTE] 2. 단일 선택 라디오(
isRadio: true) 사용 시:rowChecked={{ isRadio: true, checkedRowKeys: [selectedId], onChange: (_, keys) => setSelectedId(keys[0]) }}형태로 설정하면 라디오 버튼 UI로 전환됩니다.