TanStack Table v8 서버 사이드 데이터 그리드 — manualPagination·manualSorting·manualFiltering로 페이지네이션·정렬·필터를 서버에 위임하는 패턴
수십만 건짜리 주문 목록이나 사용자 테이블을 다루다 보면, 클라이언트 사이드 정렬·필터링은 사실상 의미가 없다는 걸 금방 깨닫게 됩니다. 첫 로딩에 전체 데이터를 내려받고, 그걸 메모리에 올려 정렬하는 건 현실적으로 불가능하니까요. 그래서 대부분의 프로젝트는 결국 서버가 페이지네이션·정렬·필터링을 전담하는 구조로 이행합니다. 문제는 이걸 깔끔하게 연결하는 패턴을 처음 짤 때 꽤 헤맨다는 거예요. 저도 그랬습니다.
TanStack Table v8은 이 문제를 세 가지 옵션(manualPagination, manualSorting, manualFiltering)으로 정리해줍니다. 핵심은 TanStack Table을 "표시 전용 레이어"로 만들고, 상태 변경은 TanStack Query가 자동으로 서버 재조회로 연결하는 구조입니다. 이 글에서는 세 옵션을 하나의 흐름으로 연결하는 방법, 필터 변경 시 페이지 자동 리셋 처리, URL 상태 동기화까지 실무에서 반드시 마주치는 상황들을 순서대로 살펴보겠습니다.
핵심 개념
헤드리스 데이터 그리드와 "표시 전용 레이어"
헤드리스 데이터 그리드(Headless Data Grid): UI 렌더링에는 관여하지 않고, 정렬·페이지네이션·필터링 같은 테이블 동작 로직만 제공하는 라이브러리 패턴. 마크업과 스타일은 개발자가 100% 직접 제어합니다.
TanStack Table v8은 이 패턴의 대표 구현체입니다. React, Vue, Solid, Svelte 등 어떤 프레임워크에도 종속되지 않는 어댑터 구조라서, 한 번 패턴을 익혀두면 스택이 바뀌어도 그대로 재활용할 수 있습니다.
기본값에서 TanStack Table은 넘겨받은 전체 데이터를 클라이언트에서 직접 정렬하고 페이지를 나눕니다. 그런데 아래 세 옵션을 true로 설정하는 순간, 테이블 인스턴스는 계산을 포기하고 전달된 데이터를 그냥 보여주는 역할만 합니다.
| 옵션 | 기본값 | 역할 |
|---|---|---|
manualPagination |
false |
클라이언트 페이지 분할 모델 비활성화 |
manualSorting |
false |
클라이언트 정렬 모델 비활성화 |
manualFiltering |
false |
클라이언트 필터 모델 비활성화 |
셋 다 true로 놓으면 "지금 페이지 몇 번인지, 어떤 컬럼 기준으로 정렬 중인지, 어떤 필터가 걸렸는지"는 모두 외부 상태로 관리하게 됩니다. 그 상태가 바뀔 때마다 서버에 재조회 요청을 보내는 건 TanStack Query가 담당합니다.
Controlled State 패턴 — 상태를 테이블 바깥에서 관리하기
서버 사이드 그리드의 핵심은 Controlled State 패턴입니다. React의 useState로 만든 외부 상태를 테이블에 주입하고, 테이블에서 상태가 변경될 때(onPaginationChange, onSortingChange, onColumnFiltersChange) 다시 외부 상태를 갱신합니다.
이 상태들을 TanStack Query의 queryKey에 포함시키면, 상태가 변경될 때마다 자동으로 서버 재조회가 트리거됩니다. TanStack Query는 queryKey가 변경될 때마다 queryFn을 재실행하므로, 상태 연결만 제대로 해두면 별도의 useEffect나 이벤트 핸들러 없이도 데이터가 자연스럽게 갱신됩니다.
흐름을 의사코드로 표현하면 이렇습니다.
[사용자 인터랙션]
→ 컬럼 헤더 클릭(정렬) / 필터 입력 / 페이지 이동
→ onSortingChange / onColumnFiltersChange / onPaginationChange 호출
→ 외부 state 업데이트
→ queryKey 변경 감지
→ TanStack Query가 queryFn 재실행
→ 서버에서 새 데이터 수신
→ 테이블 리렌더링columnFilters vs globalFilter — 어떤 걸 써야 할까
컬럼 필터링 관련해서 처음 헷갈리는 부분이 여기입니다. TanStack Table에는 두 가지 필터 상태가 있습니다.
columnFilters: 특정 컬럼 단위로 각각 필터 조건을 지정합니다.{ id: string; value: unknown }[]배열 구조.globalFilter: 모든 컬럼을 대상으로 하는 단일 검색값. 통합 검색바 UI에 사용합니다.
이 글에서는 columnFilters 기반의 컬럼별 필터링을 다룹니다. "이름, 이메일, 상태 중 아무 컬럼이나 검색되는 통합 검색바"가 필요하다면 globalFilter와 onGlobalFilterChange를 별도로 연결하면 됩니다.
한 가지 짚어두고 싶은 부분이 있습니다. manualFiltering: true를 설정하면 TanStack Table의 클라이언트 filterFns는 실행되지 않습니다. filterFns는 manualFiltering: false인 클라이언트 필터링 모드에서만 동작하고, 서버 사이드 모드에서는 어떤 filterFn을 지정해도 실행되지 않습니다. 저도 처음에 이 부분을 놓쳐서 커스텀 filterFn을 열심히 작성하고 왜 동작 안 하나 한참 헤맸던 기억이 있습니다.
실전 적용
예시 1: 필터·정렬·페이지를 서버에 연결하는 기본 구조
완성된 useReactTable 설정과 JSX 렌더링을 함께 살펴보겠습니다. 헤더 클릭이 정렬 요청으로, 상태 변경이 서버 재조회로 이어지는 전체 흐름을 한 번에 확인할 수 있습니다.
import { useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import {
useReactTable,
getCoreRowModel,
flexRender,
type SortingState,
type ColumnFiltersState,
type PaginationState,
} from '@tanstack/react-table';
export function UserTable() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// 세 상태가 모두 queryKey에 포함 → 어느 하나라도 바뀌면 자동 재조회
const { data, isLoading } = useQuery({
queryKey: ['users', pagination, sorting, columnFilters],
queryFn: () => fetchUsers({ pagination, sorting, columnFilters }),
placeholderData: keepPreviousData,
});
const table = useReactTable({
data: data?.rows ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableMultiSort: false, // 멀티 소팅 미지원 시 명시 권장
rowCount: data?.totalCount, // v8.13.0+: pageCount 자동 계산
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: (updater) => {
setSorting(updater);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
},
onColumnFiltersChange: (updater) => {
setColumnFilters(updater);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
},
});
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: header.column.getCanSort() ? 'pointer' : 'default' }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' ? ' ↑' : ''}
{header.column.getIsSorted() === 'desc' ? ' ↓' : ''}
</th>
))}
</tr>
))}
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={columns.length}>로딩 중...</td></tr>
) : (
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>
);
}헤더의 onClick={header.column.getToggleSortingHandler()}가 핵심입니다. 이 핸들러가 호출되면 onSortingChange가 실행되고, setSorting이 상태를 바꾸고, queryKey가 변경되어 서버 재조회가 자동으로 이어집니다. UI 클릭 한 번이 서버 요청으로 이어지는 전체 흐름이 여기서 완성됩니다.
sorting 배열은 여러 정렬 조건을 담을 수 있는 구조입니다. 멀티 소팅을 지원하지 않는다면 enableMultiSort: false를 명시해두는 것을 권장합니다. API 변환 시에는 아래처럼 처리할 수 있습니다.
// 단일 정렬 파라미터 변환 (enableMultiSort: false 시)
// 범용 형식: ?sortBy=name&sortOrder=asc
// Spring Boot Pageable 형식: ?sort=name,asc (백엔드 스타일에 맞게 조정)
const sortBy = sorting[0]?.id;
const sortOrder = sorting[0]?.desc ? 'desc' : 'asc';
// columnFilters 배열 → API 파라미터 객체로 변환
const filterParams = columnFilters.reduce((acc, filter) => {
// 주의: filter.value가 배열([Date, Date] 등 범위 필터)인 경우
// URLSearchParams에 직접 넣으면 '[object Object]'로 직렬화됩니다
// 복합 값은 JSON.stringify 또는 커스텀 직렬화가 필요합니다
acc[filter.id] = filter.value as string;
return acc;
}, {} as Record<string, string>);
const fetchUsers = async ({
pagination,
sorting,
columnFilters,
}: {
pagination: PaginationState;
sorting: SortingState;
columnFilters: ColumnFiltersState;
}) => {
const params = new URLSearchParams({
page: String(pagination.pageIndex),
size: String(pagination.pageSize),
...(sortBy && { sortBy, sortOrder }),
...filterParams,
});
const res = await fetch(`/api/users?${params}`);
return res.json() as Promise<{ rows: User[]; totalCount: number }>;
};v8.13.0 이전 프로젝트를 유지보수 중이라면, rowCount 대신 pageCount를 직접 계산해 넘겨야 합니다(Math.ceil(total / pageSize)). v8.13.0부터는 rowCount만 넘기면 테이블이 현재 pageSize를 기준으로 pageCount를 자동 산출합니다. 두 값을 혼용하면 충돌이 생기니 버전을 먼저 확인하고 하나만 사용하는 것을 권장합니다.
| 처리 대상 | 페이지 리셋 필요 여부 | 이유 |
|---|---|---|
onColumnFiltersChange |
필수 | 필터 조건이 바뀌면 결과 수가 달라져 기존 페이지가 없을 수 있음 |
onSortingChange |
권장 | 기술적으론 괜찮지만 UX 일관성을 위해 리셋 권장 |
onPaginationChange |
불필요 | 페이지 이동이 목적이므로 리셋 불필요 |
솔직히 이 리셋 처리가 가장 많이 놓치는 부분입니다. autoResetPageIndex 옵션이 있긴 한데, manualPagination: true일 때 기대와 다르게 동작한다는 버그 리포트(#5611, #4797)가 있어서 위처럼 명시적으로 처리하는 게 안전합니다.
예시 2: 텍스트 필터 디바운싱으로 API 과호출 방지
텍스트 입력 필터에 디바운싱을 적용하지 않으면 키 입력마다 서버 요청이 발생합니다. "김철수"를 검색하는 데 7번의 API 호출이 생기는 셈입니다. 실무에서 자주 맞닥뜨리는 상황인데, columnFilters 상태를 그대로 쓰는 대신 디바운스된 버전을 queryKey에 넘기는 방식으로 해결할 수 있습니다.
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// 컴포넌트 내부
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// UI에는 즉각 반응하되, 서버 요청은 300ms 후에만 발생
const debouncedFilters = useDebounce(columnFilters, 300);
const { data } = useQuery({
queryKey: ['users', pagination, sorting, debouncedFilters],
queryFn: () => fetchUsers({
pagination,
sorting,
columnFilters: debouncedFilters, // 디바운스된 값만 서버로
}),
placeholderData: keepPreviousData, // 재조회 중 이전 데이터 유지
});
const table = useReactTable({
data: data?.rows ?? [],
state: {
pagination,
sorting,
columnFilters, // UI 상태는 즉각 반영
},
onColumnFiltersChange: (updater) => {
setColumnFilters(updater);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
},
// ...나머지 옵션
});placeholderData: keepPreviousData는 새 데이터를 불러오는 동안 이전 데이터를 그대로 보여주는 옵션입니다. 필터를 바꿀 때마다 테이블이 빈 화면으로 깜빡이는 현상을 막아주는데, 한번 써보면 없이는 못 쓸 정도로 체감 차이가 큽니다.
예시 3: URL 상태 동기화 — 필터·정렬·페이지를 북마크 가능하게
팀원들과 그리드를 논의하다 보면 어느 순간부터 "이 필터 상태 URL로 공유할 수 있으면 좋겠는데?"라는 요청이 꼭 나옵니다. tanstack-table-search-params 라이브러리를 사용하면 직렬화 로직을 직접 짜지 않아도 됩니다.
import { useTableSearchParams } from 'tanstack-table-search-params';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
export function UserTable() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// URL 파라미터와 양방향 동기화되는 상태 + 핸들러를 한 번에 받아옵니다
const stateAndOnChanges = useTableSearchParams({
push: (url) => router.push(url),
pathname,
searchParams,
});
const { data } = useQuery({
queryKey: ['users', stateAndOnChanges.state],
queryFn: () => fetchUsers(stateAndOnChanges.state),
placeholderData: keepPreviousData,
});
const table = useReactTable({
data: data?.rows ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
manualSorting: true,
rowCount: data?.totalCount,
...stateAndOnChanges, // state + onPaginationChange + onSortingChange + onColumnFiltersChange 주입
});
// 렌더링은 예시 1과 동일
}이 방식을 사용하면 https://example.com/users?page=2&sortBy=name&sortOrder=asc&status=active 형태로 URL이 자동 관리됩니다. 팀원에게 URL만 공유하면 동일한 필터·정렬 상태가 복원됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| UI 완전 제어 | 마크업과 스타일을 100% 직접 관리. 디자인 시스템 통합이 자연스럽습니다 |
| 경량 번들 | 코어 약 10~20KB. 사용하지 않는 기능은 번들에 포함되지 않습니다 |
| 프레임워크 무관 | React, Vue, Solid, Svelte, 바닐라 JS에서 동일한 API를 사용합니다 |
| MIT 라이선스 | AG Grid Enterprise 같은 라이선스 비용이 없습니다 |
| 타입 안전성 | TypeScript 기반으로 컬럼 정의부터 필터 값까지 타입 추론이 됩니다 |
| TanStack 생태계 시너지 | TanStack Query, TanStack Router와 자연스럽게 통합됩니다 |
단점 및 주의사항
초기 구현 비용은 Shadcn UI DataTable 보일러플레이트로 상당히 줄일 수 있는데, 진짜 발목을 잡는 건 여전히 복합 필터 표현 문제입니다. AND/OR 조건이 필요한 순간 기본 구조만으로는 한계에 부딪힙니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 초기 구현 비용 | 필터 UI, 셀 에디터 등을 직접 만들어야 합니다 | Shadcn UI DataTable 패턴을 보일러플레이트로 활용합니다 |
| 가상화 미포함 | 수만 행 렌더링 시 DOM 성능 문제 발생 가능 | @tanstack/react-virtual 별도 조합이 필요합니다 |
autoResetPageIndex 버그 |
manualPagination: true일 때 기대와 다르게 동작 |
onColumnFiltersChange에서 pageIndex를 명시적으로 0으로 리셋합니다 |
| 필터 직렬화 부담 | columnFilters 배열을 API 파라미터로 변환하는 로직 필요 |
tanstack-table-search-params 또는 nuqs 사용을 권장합니다 |
| 복합 필터 표현의 한계 | AND/OR 조건을 기본 ColumnFiltersState로 표현하기 어렵습니다 |
커스텀 필터 상태를 별도 설계합니다 |
가상화(Virtualization): DOM에 모든 행을 렌더링하는 대신, 현재 화면에 보이는 행만 렌더링하는 기법입니다. 서버 사이드 모드에서는 이미 페이지당 소수의 행만 받아오므로 대부분 필요 없지만, 한 페이지에 수백~수천 행을 표시하는 경우라면 고려해볼 수 있습니다.
실무에서 가장 흔한 실수
- 필터 변경 시 페이지를 리셋하지 않음 — 가장 자주 발생하는 버그입니다. 필터와 정렬이 바뀔 때
pageIndex를 0으로 명시적으로 초기화하지 않으면, 서버는 존재하지 않는 페이지를 달라는 요청을 받고 빈 화면이 뜰 수 있습니다. - 텍스트 필터에 디바운싱 미적용 — 글자 하나 입력할 때마다 API가 호출됩니다. 300ms 디바운스는 거의 필수 옵션이라고 봐도 됩니다.
filter.value배열을 URLSearchParams에 그대로 전달 — 날짜 범위 필터처럼filter.value가[Date, Date]형태일 때URLSearchParams에 그대로 넣으면[object Object]로 직렬화됩니다. 복합 값 필터는 별도 직렬화 처리가 필요합니다.rowCount와pageCount를 혼용 — v8.13.0 이전 방식과 혼용해서 둘 다 넘기면 충돌이 발생합니다. 버전을 먼저 확인하고 하나만 사용하는 것을 권장합니다.
마치며
이 패턴을 팀에 도입하면 생기는 가장 큰 변화는, "테이블 버그인가요, 서버 문제인가요?"라는 질문이 줄어든다는 겁니다. TanStack Table은 표시만 하고, 데이터 처리 책임은 서버에 명확하게 위임하기 때문에 문제가 생겼을 때 디버깅 범위가 훨씬 좁아집니다. TanStack Table v8의 세 가지 manual* 옵션은 단순한 설정 플래그가 아니라, 클라이언트와 서버의 역할 경계를 선언하는 아키텍처적 결정입니다.
지금 바로 시작해볼 수 있는 3단계:
pnpm add @tanstack/react-table @tanstack/react-query로 패키지를 설치한 뒤, 기존 클라이언트 사이드 테이블에서manualPagination: true만 먼저 추가해볼 수 있습니다. 페이지네이션이 서버로 이관되는 흐름을 가장 쉽게 체험할 수 있는 첫 발입니다.manualFiltering: true를 추가하고onColumnFiltersChange를setPagination리셋과 함께 연결해볼 수 있습니다. 컬럼 헤더에<input>을 하나 달아 필터 값이 API 파라미터로 변환되는 것을 직접 확인해보시면 좋습니다.pnpm add tanstack-table-search-params를 설치하고useTableSearchParams훅을 연결하면, 필터·정렬·페이지 상태가 URL에 자동 반영됩니다. 팀원에게 URL 하나 보내는 것만으로 디버깅 컨텍스트를 공유할 수 있게 됩니다.
참고 자료
- TanStack Table v8 — Pagination Guide | 공식
- TanStack Table v8 — Column Filtering Guide | 공식
- TanStack Table v8 — Sorting Guide | 공식
- TanStack Table v8 — Table State (React) Guide | 공식
- TanStack Table v8 — Pagination APIs | 공식
- TanStack Table v8 — Column Filtering APIs | 공식
- React TanStack Table Query Router Search Params Example | 공식
- Server Side Pagination, Column Filtering and Sorting With TanStack Table and Query Library | Medium
- Server-side Pagination and Sorting with TanStack Table and React | Medium
- Server-Side Table Operations Made Simple: React + TanStack + Spring Boot | Medium
- TanStack Table vs AG Grid: Complete Comparison 2025 | Simple Table
- tanstack-table-search-params — URL 파라미터 동기화 라이브러리 | GitHub
- Building a Library to Sync TanStack Table State with URL Parameters | DEV.to
- GitHub Issue #4797 — Manual Pagination doesn't reset PageIndex when Manual Column Filtering
- GitHub Issue #5611 — autoResetPageIndex 동작 이슈
- TanStack Table v8 Complete Semantic Data Grid Demo | DEV.to