본문 바로가기

React

React에서 TanStack Table로 고성능 데이터 테이블 구현하기

대용량 데이터를 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-virtual

3. 컬럼 정의와 기본 테이블

컬럼은 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 DataTable

4. 정렬·필터·페이징 상태 관리 베스트프랙티스

상태는 모두 외부에서 제어 가능한 형태로 유지합니다. 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로 매우 빠르고 확장 가능한 데이터 테이블을 구현할 수 있습니다.