대용량 데이터를 React에서 빠르게 렌더링하고, 정렬·필터·페이징을 유연하게 제어하려면 TanStack Table(v8)이 가장 실용적입니다. 가상 스크롤과 서버 사이드 처리까지 결합하면 수십만 행도 부드럽게 다룰 수 있습니다. 아래 예제와 체크리스트로 바로 적용해보세요.
1. TanStack Table을 선택하는 이유
헤드리스(Headless) 접근으로 DOM과 스타일 제약이 없고, 상태를 완전 제어할 수 있습니다. 정렬·필터·페이징·행 선택·그룹핑 등을 플러그인 없이 구성할 수 있으며, @tanstack/react-virtual로 가상 스크롤을 결합하면 렌더 비용을 최소화합니다.
2. 설치와 기본 준비
테이블과 가상 스크롤 패키지를 함께 설치합니다.
// 설치
npm i @tanstack/react-table @tanstack/react-virtual
// or
yarn add @tanstack/react-table @tanstack/react-virtual3. 컬럼 정의와 기본 테이블
컬럼은 accessorKey 기반으로 정의하고, useReactTable로 테이블 인스턴스를 생성합니다. 데이터와 컬럼은 useMemo로 안정화하여 불필요한 렌더를 줄입니다.
import React, { useMemo, useState } from 'react'
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
} from '@tanstack/react-table'
function DataTable({ rows }) {
const data = useMemo(() => rows, [rows])
const columns = useMemo(() => [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: '이름' },
{ accessorKey: 'email', header: '이메일' },
{
accessorKey: 'createdAt',
header: '가입일',
cell: info => new Date(info.getValue()).toLocaleDateString(),
},
], [])
const [sorting, setSorting] = useState([])
const [globalFilter, setGlobalFilter] = useState('')
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 })
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter, pagination },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<div>
<input
placeholder="검색..."
value={globalFilter ?? ''}
onChange={e => setGlobalFilter(e.target.value)}
aria-label="테이블 검색"
/>
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id} scope="col">
<button
onClick={header.column.getToggleSortingHandler()}
aria-label={`정렬: ${String(header.column.id)}`}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' 🔼', desc: ' 🔽' }[header.column.getIsSorted()] ?? null}
</button>
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>이전</button>
<span>페이지 {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>다음</button>
<select
value={table.getState().pagination.pageSize}
onChange={e => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 50, 100].map(size => (
<option key={size} value={size}>{size} / 페이지</option>
))}
</select>
</div>
</div>
)
}
export default DataTable4. 정렬·필터·페이징 상태 관리 베스트프랙티스
상태는 모두 외부에서 제어 가능한 형태로 유지합니다. onSortingChange 등 콜백을 연결하면 서버 사이드로도 쉽게 전환할 수 있습니다. columns/data는 의존성이 변하지 않도록 useMemo로 감싸고, accessorKey는 안정적인 키를 사용합니다.
5. 대용량에 강한 가상 스크롤 구현
수천~수십만 행에서 전체 DOM을 만들지 않고 필요한 부분만 렌더링합니다. @tanstack/react-virtual을 결합하면 성능이 비약적으로 향상됩니다.
import React, { useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'
function VirtualTable({ rows, columns }) {
const data = useMemo(() => rows, [rows])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
const parentRef = useRef(null)
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 36, // 행 높이 추정값(px)
overscan: 10, // 앞뒤로 추가 렌더링할 버퍼
})
const virtualItems = rowVirtualizer.getVirtualItems()
const totalSize = rowVirtualizer.getTotalSize()
return (
<div ref={parentRef} style={{ maxHeight: 480, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} scope="col">
{flexRender(h.column.columnDef.header, h.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody style={{ position: 'relative' }}>
<tr style={{ height: totalSize }}>
<td colSpan={table.getAllLeafColumns().length} />
</tr>
{virtualItems.map(vi => {
const row = table.getRowModel().rows[vi.index]
return (
<tr
key={row.id}
style={{
position: 'absolute',
transform: `translateY(${vi.start}px)`,
width: '100%',
}}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
export default VirtualTable포인트: estimateSize는 실제 행 높이에 맞게 조정하고, overscan으로 스크롤 시 빈 화면을 방지합니다. tbody를 position: relative로 두고 가상 아이템을 absolute로 배치합니다.
6. 서버 사이드 페이징·정렬·필터 연동
대용량 API와 연동할 때는 manual 옵션을 활성화하고, 상태 변경 시마다 서버에 요청합니다.
import React, { useEffect, useMemo, useState } from 'react'
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table'
function ServerTable() {
const columns = useMemo(() => [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: '이름' },
{ accessorKey: 'email', header: '이메일' },
], [])
const [data, setData] = useState([])
const [sorting, setSorting] = useState([])
const [globalFilter, setGlobalFilter] = useState('')
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 })
const [pageCount, setPageCount] = useState(-1) // unknown at first
const [loading, setLoading] = useState(false)
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter, pagination },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
manualSorting: true,
manualFiltering: true,
manualPagination: true,
pageCount,
getCoreRowModel: getCoreRowModel(),
})
useEffect(() => {
const params = new URLSearchParams()
params.set('page', String(pagination.pageIndex))
params.set('size', String(pagination.pageSize))
if (globalFilter) params.set('q', globalFilter)
if (sorting.length) {
params.set('sort', sorting.map(s => `${s.id}:${s.desc ? 'desc' : 'asc'}`).join(','))
}
setLoading(true)
fetch(`/api/users?${params.toString()}`)
.then(r => r.json())
.then(res => {
setData(res.rows)
setPageCount(Math.ceil(res.total / pagination.pageSize))
})
.finally(() => setLoading(false))
}, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter])
return (
<div>
<input
placeholder="검색..."
value={globalFilter ?? ''}
onChange={e => setGlobalFilter(e.target.value)}
/>
{loading && <p>로딩 중...</p>}
<table>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id}>
<button onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
{{ asc: ' 🔼', desc: ' 🔽' }[h.column.getIsSorted()] ?? null}
</button>
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>이전</button>
<span>페이지 {table.getState().pagination.pageIndex + 1} / {pageCount > 0 ? pageCount : '-'}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>다음</button>
</div>
</div>
)
}
export default ServerTable포인트: manualSorting/manualFiltering/manualPagination을 true로 설정하고, API의 total을 활용하여 pageCount를 계산합니다. 정렬 파라미터는 id:asc|desc 형태로 서버와 합의합니다.
7. 접근성(A11y)·UX·성능 팁
- th에 scope="col"을 지정하고, 헤더 상호작용에 버튼을 사용합니다. aria-label로 정렬 의도를 전달합니다.
- 열 정의와 포맷터는 useMemo로 감싸고, 불변 데이터 구조를 사용합니다.
- 가상 스크롤 시 estimateSize를 실제 높이에 맞춰 조정하고, overscan을 8~12 수준으로 테스트합니다.
- 셀 내부에서 무거운 컴포넌트를 렌더링할 경우 React.memo로 감싼 셀 렌더러를 사용합니다.
- 서버 사이드에서는 pageIndex/pageSize/sorting/globalFilter를 쿼리로 일관되게 전송하고, 에러/로딩 상태를 사용자에게 명확히 알립니다.
8. 체크리스트
- columns/data를 useMemo로 안정화했나요?
- 정렬·필터·페이징을 외부 상태로 제어하고 있나요?
- 대용량 데이터에서 @tanstack/react-virtual로 가상 스크롤을 적용했나요?
- 서버와 정렬/필터 파라미터 포맷을 합의했나요?
- ARIA와 키보드 내비게이션을 고려했나요?
위 구성만 지켜도 TanStack Table로 매우 빠르고 확장 가능한 데이터 테이블을 구현할 수 있습니다.
'React' 카테고리의 다른 글
| React와 tRPC를 활용한 타입 안전 API 통신 구축하기 (0) | 2026.07.16 |
|---|---|
| React 앱에서 Feature Flag 시스템 설계 및 적용하기 (0) | 2026.07.16 |
| React 앱에서 주기적인 데이터 동기화 스케줄러 구축하기 (0) | 2026.07.15 |
| React에서 다중 파일 드래그 앤 드롭 업로드 및 진행률 표시하기 (0) | 2026.07.14 |
| React 앱에서 AR(증강현실) 콘텐츠 뷰어 구현하기 (0) | 2026.07.14 |