TanStack Table + TanStack Virtual + React Query로 50,000행 × 200열 데이터 그리드를 60FPS로 가상화하기
BI 대시보드 작업을 하다가 컬럼이 150개, 행이 5만 건을 넘는 순간 처음으로 브라우저가 진짜로 멈추는 걸 경험했습니다. 저도 처음엔 "pagination 쓰면 되지 않나?" 싶었는데, 분석가들이 "전체 데이터를 한 화면에서 보면서 가로 스크롤도 해야 한다"고 하더군요. DOM에 셀 수백만 개를 한꺼번에 꽂으면 어떤 브라우저도 버티질 못합니다.
이 글에서는 TanStack Table v8 + TanStack Virtual v3 + React Query v5를 조합해 행과 열을 동시에 가상화하는 패턴을 실제 코드와 함께 풀어봅니다. 세 라이브러리가 서로 어떻게 맞물리는지, 그리고 실무에서 자주 맞닥뜨리는 함정까지 솔직하게 다룹니다. 읽고 나면 50,000행 × 200열 규모의 그리드를 60FPS로 스크롤하는 컴포넌트를 직접 만들어볼 수 있게 됩니다.
이 글을 읽기 위한 전제 지식: React Hooks(useState, useEffect, useRef, useMemo) 기본, async/await. React Query나 TanStack Table을 처음 접하더라도 괜찮습니다.
목차
- 핵심 개념 — 세 라이브러리의 역할 분담
- 가상화의 핵심 원리
- 예시 1: 행 가상화 + 무한 스크롤
- 예시 2: 행·열 동시 가상화 (예시 1 확장)
- 예시 3: Sticky 헤더 + 고정 열 (예시 2 확장)
- 장단점 분석 + 흔한 실수
- 마치며
핵심 개념
세 라이브러리의 역할 분담
이 패턴이 강력한 이유는 각 라이브러리가 정확히 자기 일만 담당한다는 점입니다. 억지로 한 라이브러리에 모든 걸 욱여넣지 않아요.
| 라이브러리 | 담당 영역 | 핵심 API |
|---|---|---|
@tanstack/react-query v5 |
서버 데이터 페칭·캐싱·동기화 | useInfiniteQuery |
@tanstack/react-table v8 |
테이블 상태·로직 (정렬·필터·선택 등) + row model 구성 | useReactTable, getCoreRowModel |
@tanstack/react-virtual v3 |
DOM 가상화 (실제 렌더링 범위 제어) | useVirtualizer |
Headless UI란? 마크업이나 스타일을 강요하지 않고 로직과 상태만 제공하는 설계 방식입니다. TanStack Table은
<table>태그 한 줄도 렌더링해주지 않지만, 그 덕분에 shadcn/ui, MUI, 자체 디자인 시스템 어디에나 자유롭게 얹을 수 있습니다.
가상화의 핵심 원리
가상화의 핵심은 단순합니다. 화면에 보이는 것만 DOM에 존재시키는 것입니다.
뷰포트 높이가 800px이고 행 높이가 48px이라면, 실제 DOM에 존재하는 행은 약 17개 정도입니다. 나머지 49,983개 행은 CSS transform: translateY()로 공간만 차지하고 실제 DOM 노드는 없습니다. 스크롤하면 TanStack Virtual이 getVirtualItems()로 현재 뷰포트에 들어온 인덱스를 계산하고, React가 해당 행만 렌더링합니다.
데이터 흐름을 정리하면 이렇습니다.
전체 데이터: 50,000행
↓
React Query: 서버에서 50개씩 청크로 페칭 → 페이지 배열로 관리
↓
TanStack Table: flatData를 받아 정렬·필터 적용 → row model 구성
↓
TanStack Virtual: 현재 보이는 행 인덱스 17개만 추출
↓
실제 DOM: <tr> 17개만 존재한 가지 짚고 넘어갈 게 있습니다. row model을 구성하는 건 TanStack Table이고, React Query는 데이터만 공급합니다. 처음 이 패턴을 접하면 React Query가 테이블 상태까지 관여한다고 오해하기 쉬운데, 둘의 역할이 명확하게 분리되어 있습니다.
실전 적용
예시 1: 행 가상화 + 무한 스크롤
가장 많이 쓰이는 패턴입니다. 스크롤 끝에 닿으면 다음 페이지를 자동으로 불러오는 무한 스크롤과 가상화를 결합합니다. 예시 2, 3은 이 코드를 기반으로 확장됩니다.
먼저 타입부터 정의합니다. TypeScript strict 환경에서 처음부터 타입을 잡아두면 나중에 컴파일 에러로 고생할 일이 줄어듭니다.
// types.ts
type User = {
id: string
name: string
email: string
department: string
joinedAt: string
}
type FetchUsersResponse = {
rows: User[]
nextCursor: number | null
totalCount: number
}
async function fetchUsers({
page,
limit,
}: {
page: number
limit: number
}): Promise<FetchUsersResponse> {
const res = await fetch(`/api/users?page=${page}&limit=${limit}`)
if (!res.ok) throw new Error('fetch failed')
return res.json()
}// VirtualizedTable.tsx
import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from '@tanstack/react-table'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useMemo, useEffect } from 'react'
const FETCH_SIZE = 50
const columns: ColumnDef<User>[] = [
{ accessorKey: 'name', header: '이름', size: 150 },
{ accessorKey: 'email', header: '이메일', size: 200 },
{ accessorKey: 'department', header: '부서', size: 120 },
{ accessorKey: 'joinedAt', header: '입사일', size: 120 },
]
export function VirtualizedTable() {
const tableContainerRef = useRef<HTMLDivElement>(null)
// 1. React Query: 서버에서 페이지 단위로 데이터 페칭
const { data, fetchNextPage, hasNextPage, isFetching } =
useInfiniteQuery({
queryKey: ['users'],
queryFn: ({ pageParam }) =>
fetchUsers({ page: pageParam as number, limit: FETCH_SIZE }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
})
// 2. 페이지 배열을 flat한 단일 배열로 변환
const flatData = useMemo(
() => data?.pages.flatMap((page) => page.rows) ?? [],
[data]
)
const totalCount = data?.pages[0]?.totalCount ?? 0
// 3. TanStack Table: row model 구성
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
// 서버사이드 데이터를 쓸 때 이 두 옵션을 빠뜨리면 페이지 계산이 틀어집니다
manualPagination: true,
rowCount: totalCount,
})
const { rows } = table.getRowModel()
// 4. TanStack Virtual: 현재 보이는 행만 렌더링
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 48,
overscan: 10,
})
const virtualRows = rowVirtualizer.getVirtualItems()
// 5. 스크롤 끝 감지 → 다음 페이지 자동 페칭
useEffect(() => {
const lastItem = virtualRows.at(-1)
if (
lastItem &&
lastItem.index >= flatData.length - 1 &&
hasNextPage &&
!isFetching
) {
fetchNextPage()
}
}, [virtualRows, flatData.length, hasNextPage, isFetching, fetchNextPage])
const totalSize = rowVirtualizer.getTotalSize()
return (
<div
ref={tableContainerRef}
style={{ height: '600px', overflow: 'auto', position: 'relative' }}
>
<table style={{ display: 'grid' }}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} style={{ display: 'flex' }}>
{headerGroup.headers.map((header) => (
<th key={header.id} style={{ width: header.getSize() }}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody
style={{ height: `${totalSize}px`, position: 'relative' }}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<tr
key={row.id}
style={{
position: 'absolute',
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
width: '100%',
}}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
)
})}
</tbody>
</table>
{isFetching && <div>Loading more...</div>}
</div>
)
}코드 핵심 포인트:
| 구간 | 설명 |
|---|---|
flatData + useMemo |
pages.flatMap()으로 페이지 배열을 단일 행 배열로 펼칩니다. useMemo 없이 인라인으로 쓰면 매 렌더마다 새 배열이 생성되어 TanStack Table·Virtual 모두 불필요하게 재계산됩니다 |
manualPagination: true |
서버사이드 페이지네이션 사용 시 필수입니다. 없으면 TanStack Table이 클라이언트에서 페이지 수를 잘못 계산해 다음 페이지 fetch가 무한 루프에 빠지거나 중단될 수 있습니다 |
transform: translateY |
실제 DOM 위치 대신 CSS transform으로 행 위치를 지정합니다. 레이아웃 리플로우를 피해 성능이 훨씬 좋습니다 |
overscan: 10 |
뷰포트 밖으로 10개 행을 미리 렌더링합니다. 빠른 스크롤 시 흰 공백이 보이는 현상을 방지합니다 |
예시 2: 행·열 동시 가상화 (예시 1 확장)
컬럼이 50개를 넘어가면 수평 가상화도 필요해집니다. 실제로 200개 컬럼 그리드에서 가로 가상화를 빼면 수직 가상화 효과가 절반도 안 납니다.
예시 1에서 React Query와 flatData 구성 부분은 그대로 가져오고, 테이블·가상화 부분만 확장합니다. 이번에는 <table> 태그 대신 <div>를 씁니다. 이유는 잠시 후 인용구로 설명합니다.
헤더를 렌더링할 때 headers[virtualColumn.index] 형태의 인덱스 접근은 피하는 것이 좋습니다. hidden 컬럼이 있거나 컬럼 순서가 바뀌면 헤더와 셀이 어긋납니다. 저도 처음에 이걸 인덱스로 그냥 접근했다가 컬럼 숨기기 기능 붙이고 나서 헤더가 셀과 1칸씩 밀리는 버그를 만났는데, 꽤 찾기 어려운 버그였습니다. 컬럼 ID로 매핑하는 방식이 훨씬 안전합니다.
// BidirectionalVirtualizedTable.tsx
// React Query + flatData 부분은 예시 1과 동일 — 아래는 table/virtualizer 이후만 표시
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
type Header,
} from '@tanstack/react-table'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useMemo } from 'react'
export function BidirectionalVirtualizedTable({
flatData, // 예시 1의 flatData와 동일한 구조
totalCount,
}: {
flatData: User[]
totalCount: number
}) {
const tableContainerRef = useRef<HTMLDivElement>(null)
const table = useReactTable({
data: flatData,
columns, // 예: 200개 컬럼 (ColumnDef<User>[] 타입)
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
rowCount: totalCount,
defaultColumn: { size: 150 },
})
const { rows } = table.getRowModel()
const visibleColumns = table.getVisibleLeafColumns()
// 헤더를 컬럼 ID로 매핑 — 인덱스 접근 대신 ID 기반 lookup으로 안전하게
const headerByColumnId = useMemo(() => {
const leafHeaders =
table.getHeaderGroups().at(-1)?.headers ?? []
return new Map<string, Header<User, unknown>>(
leafHeaders.map((h) => [h.column.id, h])
)
}, [table])
// 행 virtualizer (수직)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 35,
overscan: 5,
})
// 열 virtualizer (수평)
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: visibleColumns.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: (index) => visibleColumns[index].getSize(),
overscan: 3,
})
const virtualRows = rowVirtualizer.getVirtualItems()
const virtualColumns = columnVirtualizer.getVirtualItems()
const totalRowHeight = rowVirtualizer.getTotalSize()
const totalColWidth = columnVirtualizer.getTotalSize()
return (
<div
ref={tableContainerRef}
style={{ height: '600px', width: '100%', overflow: 'auto' }}
>
<div
style={{
height: `${totalRowHeight}px`,
width: `${totalColWidth}px`,
position: 'relative',
}}
>
{/* 헤더 — position: sticky로 상단 고정 */}
<div
style={{
position: 'sticky',
top: 0,
zIndex: 1,
background: 'white',
}}
>
{virtualColumns.map((virtualColumn) => {
const col = visibleColumns[virtualColumn.index]
const header = headerByColumnId.get(col.id)
if (!header) return null
return (
<div
key={virtualColumn.key}
style={{
position: 'absolute',
left: virtualColumn.start,
width: virtualColumn.size,
height: 35,
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
)
})}
</div>
{/* 바디 — 행·열 교차점만 렌더링 */}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
const visibleCells = row.getVisibleCells()
return virtualColumns.map((virtualColumn) => {
const cell = visibleCells[virtualColumn.index]
return (
<div
key={`${virtualRow.index}-${virtualColumn.index}`}
style={{
position: 'absolute',
top: virtualRow.start + 35, // 헤더 높이 오프셋
left: virtualColumn.start,
width: virtualColumn.size,
height: virtualRow.size,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
)
})
})}
</div>
</div>
)
}왜
<table>태그 대신<div>를 쓰나요? 행·열 동시 가상화에서는 셀을position: absolute로 배치해야 합니다. 브라우저의<table>레이아웃 엔진은 absolute 포지셔닝과 잘 맞지 않아서, 이 경우에는 div 기반 그리드가 훨씬 안정적으로 동작합니다. 행만 가상화하는 예시 1은<table>+display: grid조합이 가능하지만, 양방향 가상화부터는<div>로 전환하는 것이 좋습니다.
동시 가상화의 렌더링 범위: 행 17개 × 열 8개 = 136개 셀만 DOM에 존재하는 방식입니다. 1,000행 × 200열(20만 셀)이라도 화면에는 여전히 136개 셀만 그려집니다.
예시 3: Sticky 헤더 + 고정 열 (예시 2 확장)
실제 BI 툴처럼 헤더는 항상 고정되고, 첫 번째 열(예: 이름, ID)도 스크롤에 고정되는 패턴입니다. 이 부분은 예시 2의 구조에서 고정 열 처리 로직만 추가하는 형태입니다.
position: sticky와 transform 기반 가상화가 같은 스크롤 컨테이너에 공존할 때 레이아웃이 깨지는 경우가 있습니다. 많은 분들이 이 지점에서 막히는 걸 봤는데, 핵심은 스크롤 컨테이너 계층 구조를 올바르게 설계하는 것입니다.
[스크롤 컨테이너: overflow: auto, position: relative]
└─ [전체 크기 div: width/height 총합, position: relative]
├─ [Sticky 헤더: position: sticky, top: 0, zIndex: 2]
│ └─ [가상 열 헤더들: position: absolute, left: ...]
│ ※ 이 안에 transform이 있어서는 안 됩니다
└─ [바디: position: relative, top: 헤더높이]
└─ [가상 셀들: position: absolute, top/left: ...]position: sticky가 동작하려면 해당 요소의 조상 중 transform이 적용된 요소가 없어야 합니다. transform이 있으면 그 요소가 새로운 컨테이닝 블록이 되면서 sticky가 작동을 멈춥니다.
// 예시 2의 BidirectionalVirtualizedTable에서 고정 열 처리 추가
// 컬럼 정의에 sticky 메타 추가
const columns: ColumnDef<User>[] = [
{
id: 'name',
header: '이름',
accessorKey: 'name',
size: 200,
meta: { sticky: true },
},
// ...나머지 컬럼들
]
// 렌더링 시 — 예시 2의 바디 렌더링 부분을 아래로 교체
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
const visibleCells = row.getVisibleCells()
return virtualColumns.map((virtualColumn) => {
const isSticky = virtualColumn.index === 0
const cell = visibleCells[virtualColumn.index]
return (
<div
key={`${virtualRow.index}-${virtualColumn.index}`}
style={{
// sticky 열은 absolute 대신 sticky 사용
position: isSticky ? 'sticky' : 'absolute',
top: isSticky
? virtualRow.start + 35
: virtualRow.start + 35,
left: isSticky ? 0 : virtualColumn.start,
zIndex: isSticky ? 2 : 0,
width: virtualColumn.size,
height: virtualRow.size,
background: isSticky ? 'white' : 'transparent',
// ※ transform을 sticky 요소에 직접 적용하지 않습니다
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
)
})
})}Sticky + Virtualization 핵심 주의: sticky 요소의 조상 체인 어디에도
transform이 있으면 안 됩니다. 스크롤 컨테이너 안에서 가상화된 행들이transform: translateY로 움직이더라도, sticky 요소 자체는position: sticky + top/left로만 위치를 제어해야 합니다.
장단점 분석
장점
실무에서 이 패턴을 선택하는 이유를 정리하면 이렇습니다.
| 항목 | 내용 |
|---|---|
| 극적인 메모리 절감 | 50,000행 기준 메모리 사용량 136 MB → 4.8 MB 수준 달성 가능 (TanStack Table PR #5927 기준, 5열 단순 데이터 환경) |
| 부드러운 스크롤 | DOM 노드를 뷰포트 범위로 제한해 60FPS 스크롤 유지 가능 |
| 유연한 디자인 통합 | Headless 설계로 shadcn/ui, MUI, 자체 디자인 시스템 어디에나 조합 가능 |
| 서버 상태와 자연스러운 통합 | React Query 캐싱·재요청·낙관적 업데이트가 가상화 스크롤과 자연스럽게 연결 |
| 경량 번들 | 세 라이브러리 합산 ~40KB. AG Grid 등 완성형 대비 훨씬 가볍습니다 |
| 프레임워크 독립 | Vue, Svelte, Solid, Angular에도 동일 패턴 적용 가능 |
솔직히 말하면, 이 중에서 실무에서 가장 체감이 큰 건 "디자인 자유도"입니다. 완성형 그리드 라이브러리는 커스텀 셀 렌더러 하나 만들려고 해도 문서를 한참 파야 하는데, 이 패턴은 셀이 그냥 React 컴포넌트라서 원하는 걸 마음껏 넣을 수 있습니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 동적 높이 행 | 가변 높이 셀은 measureElement 옵션으로 실측하지만 스크롤 위치 떨림(jitter) 발생 가능 |
가능하면 고정 높이를 권장합니다. 불가피하면 measureElement + scrollToIndex 조합으로 완화할 수 있습니다 |
| Overscan 과다 | overscan: 25 이상이면 중저사양 기기에서 렌더 오버헤드 증가 |
overscan: 10 내외로 유지하는 것이 적정합니다 |
| SEO 불리 | 가상화된 행은 DOM에 없어 크롤러가 데이터 인덱싱 불가 | 데이터 테이블이 SEO 대상이라면 SSR + 초기 렌더링 시 첫 페이지만 서버 렌더링하는 방식을 검토해볼 수 있습니다 |
| 접근성(a11y) 제한 | 스크린 리더는 전체 행이 DOM에 없어 탐색 제한 | aria-rowcount, aria-rowindex 등 ARIA 속성 추가 작업이 필요합니다 |
| 초기 구현 복잡도 | 세 라이브러리의 연결 접합점(scroll container ref 공유, 데이터 플래트닝 등) 구성에 학습 비용 존재 | 공식 예제를 시작점으로 삼는 것이 좋습니다 |
이 중에서 실무에서 가장 발목을 잡는 건 접근성 문제였습니다. 기능 구현 다 끝나고 QA에서 스크린 리더 이슈가 나오면 뒤늦게 ARIA 속성 전체를 다시 달아야 하는 상황이 생기니, 처음부터 aria-rowcount와 aria-rowindex를 고려해두는 것이 좋습니다.
실무에서 가장 흔한 실수
-
manualPagination: true와rowCount동시 누락 — 서버사이드 데이터를 쓰면서 이 옵션을 빠뜨리면 TanStack Table이 클라이언트에서 페이지 수를 잘못 계산해 다음 페이지 fetch가 무한 루프에 빠지거나 중단됩니다. 저도 처음에 이 부분을 놓쳐서 꽤 오래 헤맸습니다. -
스크롤 컨테이너 ref를 잘못 연결 —
rowVirtualizer와columnVirtualizer모두getScrollElement가 동일한 ref를 가리켜야 합니다. 서로 다른 컨테이너를 가리키면 스크롤 연산이 어긋납니다. -
flatData메모이제이션 누락 —pages.flatMap()결과를useMemo없이 인라인으로 사용하면 매 렌더마다 새 배열이 생성되어 TanStack Table과 Virtual이 불필요하게 재계산됩니다.
마치며
TanStack Table + TanStack Virtual + React Query 조합은 현재 React 생태계에서 대용량 테이블을 다루는 가장 실용적인 접근법 중 하나입니다. AG Grid 같은 완성형 솔루션이 더 나은 경우도 있습니다. 기능이 이미 내장되어 있어야 하거나, 팀에 프론트엔드 구현 리소스가 부족하거나, 엑셀 수준의 인터랙션이 필요하다면 AG Grid가 더 빠른 선택일 수 있습니다. 반면 디자인 자유도, 번들 크기, 커스텀 로직 확장성이 중요하다면 이 패턴이 훨씬 유연합니다. TanStack Virtual의 최신 다운로드 추세는 npm trends에서 직접 확인해볼 수 있습니다.
지금 바로 시작할 수 있는 3단계:
-
StackBlitz에서 공식 예제를 직접 실행해보세요. TanStack Table의 virtualized-infinite-scrolling 예제를 브라우저에서 바로 열고, 스크롤 중 DevTools의 Elements 탭을 보면 DOM 노드 수가 어떻게 유지되는지 눈으로 확인할 수 있습니다.
-
예시 1의 행 가상화 코드를 내 프로젝트 데이터에 먼저 적용해보세요.
pnpm add @tanstack/react-table @tanstack/react-virtual @tanstack/react-query설치 후,columns와fetchUsers타입을 내 API 응답에 맞게 바꾸는 것부터 시작하면 됩니다. 열 가상화는 그 다음 단계에서 추가해도 충분합니다. -
Chrome DevTools Performance 탭에서 가상화 전후를 직접 비교해보세요. 스크롤 중 DOM 노드 수와 메모리 사용량 그래프가 팀원 설득 자료로도 꽤 효과적입니다.
다음 글: TanStack Table의 컬럼 필터·정렬 상태를 TanStack Router의 URL 파라미터와 동기화해 북마크 가능한 데이터 그리드 만들기
참고 자료
- TanStack Table 가상화 가이드 (공식)
- TanStack Virtual 공식 사이트
- TanStack Table Virtualized Rows 공식 예제
- TanStack Table Virtualized Columns 공식 예제
- TanStack Table Virtualized Infinite Scrolling 공식 예제
- TanStack Virtual Virtualizer API 문서
- Building an Efficient Virtualized Table with TanStack Virtual and React Query with ShadCN | DEV Community
- Building Sticky Headers and Columns with TanStack Virtualizer | Medium
- React Virtualization Showdown: TanStack Virtualizer vs React-Window | Medium
- Making Tanstack Table 1000x faster with a 1 line change | JP Camara
- Infinite List with Tanstack: React Query & Virtual | Hashnode
- @tanstack/react-virtual vs react-virtualized vs react-window | npm trends