TanStack Table로 React 데이터 테이블 구현하기

TanStack Table로 React 데이터 테이블 구현하기

React로 관리자 페이지나 대시보드를 만들다 보면 테이블 UI는 거의 빠지지 않고 등장합니다. 데이터를 표로 보여주는 것까진 괜찮은데, 정렬이나 필터링, 페이지네이션까지 붙이려면 코드가 금세 복잡해지죠 😅

예전에는 이런 걸 React Table이라는 라이브러리로 해결했는데요. 이 라이브러리가 v8에서 완전히 새로 설계되면서 이름도 TanStack Table로 바뀌었습니다.

TanStack QueryTanStack Start로 유명한 TanStack 생태계의 일원인데, “headless UI”라는 재밌는 접근 방식을 쓰고 있습니다. 테이블의 로직(정렬, 필터링, 페이지네이션 등)만 제공하고 UI는 전적으로 개발자에게 맡기는 거죠. 그래서 어떤 디자인 시스템이든, 어떤 CSS 프레임워크든 마음대로 조합할 수 있습니다.

이번 포스팅에서는 TanStack Table의 핵심 개념을 이해하고 React에서 정렬, 필터링, 페이지네이션을 갖춘 테이블을 차근차근 만들어 보겠습니다.

프로젝트 셋업

먼저 React 프로젝트에 TanStack Table을 설치합니다.

bun add @tanstack/react-table
npm install @tanstack/react-table

이 글에서는 직원 목록을 보여주는 테이블을 예제로 사용하겠습니다. 간단한 타입과 샘플 데이터를 먼저 준비합니다.

data.ts
export type Employee = {
  id: number;
  name: string;
  department: string;
  position: string;
  salary: number;
  joinDate: string;
};

export const employees: Employee[] = [
  {
    id: 1,
    name: "김지수",
    department: "개발팀",
    position: "시니어 개발자",
    salary: 6500,
    joinDate: "2020-03-15",
  },
  {
    id: 2,
    name: "이민호",
    department: "디자인팀",
    position: "리드 디자이너",
    salary: 5800,
    joinDate: "2019-07-22",
  },
  {
    id: 3,
    name: "박서연",
    department: "개발팀",
    position: "주니어 개발자",
    salary: 4200,
    joinDate: "2023-01-10",
  },
  {
    id: 4,
    name: "최영훈",
    department: "마케팅팀",
    position: "마케팅 매니저",
    salary: 5500,
    joinDate: "2021-05-03",
  },
  {
    id: 5,
    name: "정하은",
    department: "개발팀",
    position: "풀스택 개발자",
    salary: 6000,
    joinDate: "2021-11-18",
  },
  {
    id: 6,
    name: "한소율",
    department: "인사팀",
    position: "인사 담당자",
    salary: 4800,
    joinDate: "2022-04-25",
  },
  {
    id: 7,
    name: "오준혁",
    department: "개발팀",
    position: "데브옵스 엔지니어",
    salary: 6200,
    joinDate: "2020-09-07",
  },
  {
    id: 8,
    name: "윤채원",
    department: "디자인팀",
    position: "UX 리서처",
    salary: 5200,
    joinDate: "2022-08-14",
  },
  {
    id: 9,
    name: "임도현",
    department: "마케팅팀",
    position: "콘텐츠 마케터",
    salary: 4500,
    joinDate: "2023-06-01",
  },
  {
    id: 10,
    name: "강서윤",
    department: "개발팀",
    position: "프론트엔드 개발자",
    salary: 5700,
    joinDate: "2021-02-28",
  },
];

컬럼 정의

TanStack Table을 쓸 때 가장 먼저 해야 하는 일은 컬럼을 정의하는 겁니다. 각 컬럼이 데이터의 어떤 필드를 보여줄지, 헤더에는 뭘 표시할지, 셀은 어떻게 그릴지를 지정해야 합니다.

columns.tsx
import { createColumnHelper } from "@tanstack/react-table";
import type { Employee } from "./data";

const columnHelper = createColumnHelper<Employee>();

export const columns = [
  columnHelper.accessor("name", {
    header: "이름",
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor("department", {
    header: "부서",
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor("position", {
    header: "직책",
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor("salary", {
    header: "연봉(만원)",
    cell: (info) => info.getValue().toLocaleString(),
  }),
  columnHelper.accessor("joinDate", {
    header: "입사일",
    cell: (info) => info.getValue(),
  }),
];

createColumnHelper()는 타입 안전하게 컬럼을 정의할 수 있게 도와주는 유틸리티입니다. accessor()에 데이터 객체의 키를 넘기면 알아서 해당 필드 값을 꺼내 옵니다. header는 테이블 상단에 표시할 텍스트이고, cell은 각 행의 셀을 어떻게 그릴지 정하는 함수입니다.

연봉 컬럼처럼 toLocaleString()으로 천 단위 구분자를 넣는 것도 cell 함수 안에서 자유롭게 할 수 있습니다.

기본 테이블 렌더링

컬럼과 데이터가 준비됐으니 이제 테이블을 화면에 그려봅시다. TanStack Table의 핵심은 useReactTable 훅입니다.

BasicTable.tsx
import {
  useReactTable,
  getCoreRowModel,
  flexRender,
} from "@tanstack/react-table";
import { employees } from "./data";
import { columns } from "./columns";

function BasicTable() {
  const table = useReactTable({
    data: employees,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <th key={header.id}>
                {flexRender(
                  header.column.columnDef.header,
                  header.getContext(),
                )}
              </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>
  );
}

useReactTable()data, columns, getCoreRowModel()을 넘기면 테이블 인스턴스가 만들어집니다. getCoreRowModel()은 원본 데이터를 행(row) 모델로 변환하는 가장 기본적인 함수로, 모든 테이블에 반드시 필요합니다.

flexRender()는 컬럼 정의에서 지정한 headercell을 실제 React 엘리먼트로 변환해 주는 렌더링 헬퍼입니다. 문자열이면 그대로 보여주고, 함수면 적절한 context를 인자로 넘겨 호출합니다.

테이블 인스턴스의 주요 메서드를 정리하면 이렇습니다.

  • getHeaderGroups() — 헤더 그룹 배열을 반환합니다. 중첩 헤더가 없으면 하나의 그룹만 나옵니다
  • getRowModel() — 현재 상태(필터, 정렬, 페이징)가 적용된 행 배열을 반환합니다
  • getVisibleCells() — 각 행에서 보이는 셀만 반환합니다. 컬럼 숨김 기능과 연동됩니다

여기까지 만들면 데이터가 표로 출력되긴 하지만, 아직 정적인 표일 뿐입니다. 이제부터 정렬, 필터링, 페이지네이션을 하나씩 추가해 보겠습니다.

정렬 기능 추가

테이블에서 가장 흔하게 기대하는 기능이 헤더 클릭으로 정렬하는 거죠. TanStack Table에서는 getSortedRowModel()을 추가하고 정렬 상태를 관리하면 됩니다.

SortableTable.tsx
import { useState } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  flexRender,
} from "@tanstack/react-table";
import type { SortingState } from "@tanstack/react-table";
import { employees } from "./data";
import { columns } from "./columns";

function SortableTable() {
  const [sorting, setSorting] = useState<SortingState>([]);

  const table = useReactTable({
    data: employees,
    columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <th
                key={header.id}
                onClick={header.column.getToggleSortingHandler()}
                style={{ cursor: "pointer", userSelect: "none" }}
              >
                {flexRender(
                  header.column.columnDef.header,
                  header.getContext(),
                )}
                {{ asc: " 🔼", desc: " 🔽" }[
                  header.column.getIsSorted() as string
                ] ?? ""}
              </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>
  );
}

뭐가 달라졌는지 살펴봅시다.

useStatesorting 상태를 만들어서 useReactTablestateonSortingChange에 넘겨줬습니다. TanStack Table이 정렬 상태를 추적하되, 상태 자체는 React가 관리하는 구조입니다.

getSortedRowModel()을 추가하면 테이블이 sorting 상태에 따라 행을 자동으로 재정렬합니다. 이 함수를 추가하지 않으면 정렬 상태가 바뀌어도 행의 순서는 그대로입니다.

헤더의 onClickgetToggleSortingHandler()를 연결하면, 클릭할 때마다 “정렬 없음 → 오름차순 → 내림차순 → 정렬 없음” 순서로 순환합니다. getIsSorted()는 현재 정렬 방향을 "asc", "desc", 또는 false로 알려주기 때문에 이걸로 정렬 표시 아이콘을 보여줄 수 있습니다.

필터링 기능 추가

이번엔 특정 조건으로 데이터를 걸러내는 필터링을 붙여봅시다. TanStack Table은 컬럼별 필터와 전역 필터를 모두 지원하는데, 여기서는 전역 필터를 구현해 보겠습니다. 입력 필드 하나로 모든 컬럼을 한꺼번에 검색하는 방식입니다.

FilterableTable.tsx
import { useState } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  flexRender,
} from "@tanstack/react-table";
import type { SortingState } from "@tanstack/react-table";
import { employees } from "./data";
import { columns } from "./columns";

function FilterableTable() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState("");

  const table = useReactTable({
    data: employees,
    columns,
    state: { sorting, globalFilter },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

  return (
    <div>
      <input
        type="text"
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="이름, 부서, 직책으로 검색..."
        style={{ marginBottom: "12px", padding: "8px", width: "300px" }}
      />
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  onClick={header.column.getToggleSortingHandler()}
                  style={{ cursor: "pointer", userSelect: "none" }}
                >
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext(),
                  )}
                  {{ asc: " 🔼", desc: " 🔽" }[
                    header.column.getIsSorted() as string
                  ] ?? ""}
                </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>
  );
}

getFilteredRowModel()을 추가하고 globalFilter 상태를 연결하면 끝입니다. 검색어를 입력하면 모든 컬럼에서 해당 텍스트를 포함하는 행만 표시됩니다.

검색 입력란에 “개발”이라고 치면 개발팀 소속이거나 직책에 “개발자”가 들어간 직원만 걸러지는 식입니다. 기본 필터 함수가 문자열 포함 여부를 알아서 확인해 주니까 별도의 필터 로직을 짤 필요가 없죠.

만약 컬럼별로 개별 필터를 적용하고 싶다면 columnFilters 상태를 사용하면 됩니다.

const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);

const table = useReactTable({
  // ...
  state: { sorting, columnFilters },
  onColumnFiltersChange: setColumnFilters,
  getFilteredRowModel: getFilteredRowModel(),
});

각 컬럼의 column.setFilterValue()를 호출하면 해당 컬럼에만 필터가 적용됩니다. 부서 드롭다운으로 특정 부서만 보기, 연봉 범위로 필터링하기 같은 기능을 이렇게 구현할 수 있습니다.

페이지네이션 추가

데이터가 수십, 수백 건이 넘어가면 한 페이지에 전부 보여줄 수는 없겠죠. 페이지네이션도 지금까지와 같은 패턴입니다. getPaginationRowModel()을 추가하고 상태를 연결하면 됩니다.

PaginatedTable.tsx
import { useState } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
} from "@tanstack/react-table";
import type { SortingState } from "@tanstack/react-table";
import { employees } from "./data";
import { columns } from "./columns";

function PaginatedTable() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState("");

  const table = useReactTable({
    data: employees,
    columns,
    state: { sorting, globalFilter },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: {
      pagination: { pageSize: 5 },
    },
  });

  return (
    <div>
      <input
        type="text"
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="이름, 부서, 직책으로 검색..."
        style={{ marginBottom: "12px", padding: "8px", width: "300px" }}
      />
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  onClick={header.column.getToggleSortingHandler()}
                  style={{ cursor: "pointer", userSelect: "none" }}
                >
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext(),
                  )}
                  {{ asc: " 🔼", desc: " 🔽" }[
                    header.column.getIsSorted() as string
                  ] ?? ""}
                </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
        style={{
          marginTop: "12px",
          display: "flex",
          gap: "8px",
          alignItems: "center",
        }}
      >
        <button
          onClick={() => table.firstPage()}
          disabled={!table.getCanPreviousPage()}
        >
          {"<<"}
        </button>
        <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>
        <button
          onClick={() => table.lastPage()}
          disabled={!table.getCanNextPage()}
        >
          {">>"}
        </button>
      </div>
    </div>
  );
}

getPaginationRowModel()을 추가하면 getRowModel()이 현재 페이지에 해당하는 행만 반환합니다. initialStatepagination.pageSize로 한 페이지에 표시할 행 수를 설정했고, 기본값은 10입니다.

테이블 인스턴스가 제공하는 페이지네이션 메서드는 직관적입니다. firstPage(), previousPage(), nextPage(), lastPage()로 페이지를 이동하고, getCanPreviousPage()getCanNextPage()로 이동 가능 여부를 확인합니다. getPageCount()는 전체 페이지 수를 반환하는데, 필터링 결과에 따라 자동으로 재계산됩니다.

여기서 재밌는 건 정렬, 필터링, 페이지네이션이 알아서 맞물려 돌아간다는 겁니다. 검색어를 입력하면 필터링된 결과를 기준으로 정렬이 적용되고, 페이지 수도 필터링 결과에 맞게 조정됩니다. 이게 가능한 이유는 TanStack Table이 내부적으로 행 모델 파이프라인을 관리하기 때문인데, 다음 섹션에서 좀 더 자세히 살펴보겠습니다.

행 모델 파이프라인

TanStack Table이 데이터를 처리하는 내부 구조를 알아두면 커스터마이징할 때 훨씬 수월합니다.

원본 데이터가 테이블에 들어오면 먼저 getCoreRowModel()이 이를 행(row) 객체로 변환합니다. 그 뒤에는 우리가 추가한 행 모델들이 파이프라인처럼 순서대로 적용됩니다.

원본 데이터
  → getCoreRowModel()     (행 객체로 변환)
  → getFilteredRowModel() (조건에 맞는 행만 통과)
  → getSortedRowModel()   (정렬 기준으로 재정렬)
  → getPaginationRowModel() (현재 페이지만 잘라냄)
  → 화면에 렌더링

각 단계는 전부 선택 사항입니다. 필터링만 필요하면 getFilteredRowModel()만, 정렬만 필요하면 getSortedRowModel()만 넣으면 됩니다.

이런 구조라서 나중에 행 그룹핑(getGroupedRowModel())이나 행 확장(getExpandedRowModel()) 같은 고급 기능을 추가해도 기존 코드를 거의 건드릴 필요가 없습니다.

서버 사이드 처리

지금까지는 모든 데이터를 클라이언트에서 들고 있는 상황이었습니다. 그런데 데이터가 수천, 수만 건이면 어떨까요? 당연히 서버에서 정렬하고 필터링해서 보내줘야 합니다. TanStack Table은 이런 서버 사이드 시나리오도 깔끔하게 처리할 수 있습니다.

function ServerSideTable() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [pagination, setPagination] = useState({
    pageIndex: 0,
    pageSize: 20,
  });

  // 서버에서 데이터를 가져올 때 정렬, 페이지 정보를 파라미터로 전달
  const { data, totalCount } = useFetchEmployees({
    sorting,
    page: pagination.pageIndex,
    size: pagination.pageSize,
  });

  const table = useReactTable({
    data: data ?? [],
    columns,
    state: { sorting, pagination },
    onSortingChange: setSorting,
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    manualSorting: true,
    manualPagination: true,
    pageCount: Math.ceil((totalCount ?? 0) / pagination.pageSize),
  });

  // ... 렌더링은 클라이언트 사이드와 동일
}

manualSortingmanualPaginationtrue로 설정하는 게 핵심입니다. 이렇게 하면 TanStack Table은 데이터를 직접 정렬하거나 자르지 않고 상태 관리만 맡습니다. 실제 데이터 처리는 서버 API가 하고, 받아온 결과를 data로 넘기면 되는 거죠.

getSortedRowModel()이나 getPaginationRowModel()이 빠져 있는 게 보이시나요? 서버에서 이미 정렬하고 페이지를 잘라서 보내주니까 클라이언트에서 또 할 필요가 없습니다. pageCount만 전체 개수로 계산해서 넘기면 페이지네이션 UI가 제대로 동작합니다.

TanStack Query와 함께 사용하면 서버 데이터 가져오기가 더 깔끔해집니다.

컬럼 가시성 토글

관리자 화면을 쓰다 보면 “이 컬럼은 안 보여줘도 되는데”라는 요구사항이 꼭 나옵니다. TanStack Table은 컬럼 가시성을 토글하는 기능도 기본으로 갖고 있습니다.

function TableWithColumnToggle() {
  const [columnVisibility, setColumnVisibility] = useState({});

  const table = useReactTable({
    data: employees,
    columns,
    state: { columnVisibility },
    onColumnVisibilityChange: setColumnVisibility,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <div>
      <div style={{ marginBottom: "12px" }}>
        {table.getAllLeafColumns().map((column) => (
          <label key={column.id} style={{ marginRight: "12px" }}>
            <input
              type="checkbox"
              checked={column.getIsVisible()}
              onChange={column.getToggleVisibilityHandler()}
            />{" "}
            {typeof column.columnDef.header === "string"
              ? column.columnDef.header
              : column.id}
          </label>
        ))}
      </div>
      {/* 테이블 렌더링 */}
    </div>
  );
}

columnVisibility 상태는 { salary: false, joinDate: false } 같은 형태로, false인 컬럼이 숨겨집니다. getAllLeafColumns()로 전체 컬럼 목록을 가져와서 체크박스를 만들고, getToggleVisibilityHandler()를 연결하면 토글 UI가 완성됩니다.

별도의 필터링이나 조건문 없이, TanStack Table이 getVisibleCells()에서 숨겨진 컬럼을 자동으로 제외합니다. 기존 렌더링 코드를 전혀 수정하지 않아도 되는 거죠.

Headless UI의 장점

TanStack Table이 “headless”라는 건 UI를 전혀 제공하지 않는다는 뜻입니다. <table> 대신 <div>로 그리드를 짜도 되고, Tailwind CSS든 styled-components든 마음대로 스타일링할 수 있습니다.

그럼 이게 왜 좋을까요?

Material UI의 Table 컴포넌트나 Ant Design Table 같은 라이브러리는 기본 스타일이 다 정해져 있어서 빠르게 만들기엔 좋지만, 디자이너가 특별한 테이블을 원하면 오버라이드 지옥에 빠지기 쉽습니다. TanStack Table은 애초에 이런 문제가 없습니다. 로직과 UI가 완전히 분리돼 있으니까요.

테이블을 카드 형태로 보여주고 싶다고요? 그냥 JSX만 바꾸면 됩니다. getRowModel().rows를 카드 컴포넌트로 렌더링해도 정렬, 필터링, 페이지네이션은 그대로 잘 동작합니다.

마치며

이번 포스팅에서는 TanStack Table의 headless UI 철학부터 시작해서 정렬, 필터링, 페이지네이션, 컬럼 가시성, 서버 사이드 처리까지 살펴봤습니다.

테이블 로직과 UI를 깔끔하게 분리해 주는 덕에 어떤 디자인이든 유연하게 적용할 수 있고, 행 모델 파이프라인 구조라서 기능을 붙이고 떼는 것도 간단합니다.

이 글에서 다루지 못한 행 선택, 행 확장, 그룹핑과 집계, 컬럼 리사이징, 가상화 같은 기능도 지원하니 필요하면 공식 문서를 참고해 보세요. 다음에 프로젝트에서 데이터 테이블을 만들 일이 생기면 한번 써보시길 권합니다.

더 자세한 내용은 TanStack Table 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord