TanStack Table 필터·정렬을 URL search params에 동기화하기 — URL 상태 동기화 실전 패턴 (URL state sync)
어드민 화면을 만들 때 이런 상황 한 번쯤 겪어보셨을 겁니다. 운영팀에서 "이 필터 조합으로 매일 아침 확인해야 하는데, 북마크 가능하게 해줄 수 있나요?"라고 요청이 옵니다. QA 팀에서는 슬랙으로 "이 버그, 재현 방법 알려줄게요" 하더니 스크린샷 다섯 장을 보내옵니다. 저도 처음엔 useState로 필터 상태를 관리하고 localStorage에 백업하는 방식을 썼는데, 공유가 안 되고 새 탭에서 열면 초기화되는 문제가 계속 발목을 잡았습니다.
진짜 해결책은 URL을 직접 상태 저장소로 만드는 겁니다. URL 하나를 복사해서 동료에게 보내면 그 필터·정렬·페이지 상태 그대로 열립니다. 새로고침해도, 다른 탭에서 열어도, 슬랙으로 공유해도 전부 동일한 뷰가 복원됩니다.
이 글은 TanStack Table v8과 TanStack Router v1에 기본 경험이 있는 분을 대상으로 합니다. 설치나 기초 설정은 다루지 않고, URL 동기화 패턴에 집중합니다. 다룰 내용은 필터·정렬·페이지네이션 상태를 URL search params와 연결하는 전체 흐름, TanStack Query와의 서버 사이드 연동, 그리고 실무에서 자주 마주치는 함정들입니다.
핵심 개념
URL이 주인, 컴포넌트는 손님
전통적인 방식에서는 useState나 useReducer가 테이블 상태의 주인입니다. 컴포넌트가 마운트되면 초기값으로 시작하고, 언마운트되면 사라집니다. URL 동기화 패턴은 이 관계를 뒤집습니다.
동작 흐름은 아래 순서로 이어집니다.
- 사용자가 필터 변경
onColumnFiltersChange콜백 실행navigate()로 URL 업데이트useSearch()가 새 값 반환- 테이블 재렌더
URL이 유일한 상태 저장소가 되면 북마크·공유·새로고침 복원·뒤로가기가 자연스럽게 따라옵니다.
Single Source of Truth: 특정 상태를 오직 한 곳에서만 관리하는 설계 원칙. 여러 곳에 복사본이 생기면 동기화 문제가 생기는데, URL을 SSOT로 쓰면 URL 자체가 앱 상태가 됩니다.
TanStack Router의 Search Params가 다른 이유
기존 React Router나 Next.js의 useSearchParams는 search params를 단순 문자열로 다룹니다. ?page=2&sort=name처럼 파싱도 직접 해야 하고, 복잡한 객체를 넣으려면 JSON.stringify를 직접 써야 했습니다.
TanStack Router는 search params를 **1급 시민(first-class citizen)**으로 처리합니다. 라우트 정의 시점에 Zod 스키마로 타입을 선언하면, useSearch() 훅이 이미 파싱되고 타입이 붙은 객체를 반환합니다. 잘못된 URL로 접근해도 fallback 값으로 안전하게 처리됩니다.
// @tanstack/zod-adapter v1 기준 (버전에 따라 패키지명이 다를 수 있습니다)
import { z } from 'zod'
import { createFileRoute } from '@tanstack/react-router'
import { zodValidator, fallback } from '@tanstack/zod-adapter'
const tableSearchSchema = z.object({
columnFilters: z
.array(z.object({ id: z.string(), value: z.unknown() }))
.default([]),
sorting: z
.array(z.object({ id: z.string(), desc: z.boolean() }))
.default([]),
pageIndex: fallback(z.number().int().nonnegative(), 0),
pageSize: fallback(z.number().int().positive(), 20),
})
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(tableSearchSchema),
})fallback(): URL 파라미터가 스키마 검증에 실패했을 때 앱이 크래시하는 대신 지정한 기본값을 사용하도록 합니다. 외부에서 URL을 임의로 조작하는 경우를 방어하는 안전장치입니다.
Controlled State와 manualFiltering
TanStack Table은 기본적으로 내부에서 상태를 자체 관리합니다(uncontrolled). URL 동기화를 하려면 외부에서 제어하는 controlled 모드로 전환해야 합니다. state 옵션에 외부 값을 주입하고, on*Change 콜백에서 URL을 업데이트하는 방식입니다.
핵심 패턴은 세 가지입니다.
import {
useReactTable,
getCoreRowModel,
functionalUpdate,
} from '@tanstack/react-table'
import { useNavigate } from '@tanstack/react-router'
// on*Change 핸들러 공통 패턴
onColumnFiltersChange: (updater) => {
const next = functionalUpdate(updater, columnFilters)
navigate({
search: (prev) => ({ ...prev, columnFilters: next, pageIndex: 0 }),
replace: true,
})
},functionalUpdate:
on*Change콜백의updater는 새 값이 직접 올 수도 있고, 이전 상태를 받아 새 상태를 반환하는 함수로 올 수도 있습니다.functionalUpdate가 두 경우를 모두 처리해줍니다. 이걸 쓰지 않으면 간헐적으로 이전 상태로 덮어쓰는 버그가 생깁니다.
여기서 중요한 선택이 있습니다. manualFiltering: true를 사용하는지 여부가 전체 아키텍처에 영향을 줍니다.
manualFiltering: true(서버 사이드 필터링): 테이블은 필터 상태를 URL에만 저장하고, 실제 필터링은 서버에 위임합니다. 서버가 URL의 필터 파라미터를 보고 쿼리를 실행합니다.manualFiltering미설정 (클라이언트 사이드 필터링): 테이블이data배열을 직접 필터링합니다. 이 경우 URL 동기화 자체는 되지만, 서버 데이터를 함께 쓰면 서버에서 한 번, 클라이언트에서 또 한 번 필터가 적용되는 더블 필터링 문제가 생깁니다. 서버에서 이미 필터된 데이터를 가져왔는데 클라이언트에서 다시 한번 필터링하면 결과가 이상해질 수 있어서 주의가 필요합니다.
실전 적용
예시 1: TanStack Query와 결합한 서버 사이드 필터링
URL에 필터·정렬 상태가 있으면 서버 데이터 fetching에도 그대로 활용할 수 있습니다. TanStack Query의 queryKey에 search params를 포함시키면, URL이 바뀔 때마다 자동으로 리페치가 트리거됩니다.
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import {
useReactTable,
getCoreRowModel,
functionalUpdate,
type ColumnDef,
} from '@tanstack/react-table'
// 데이터 fetching 로직을 커스텀 훅으로 분리
function useProductsQuery() {
const { columnFilters, sorting, pageIndex, pageSize } = Route.useSearch()
return useQuery({
// queryKey에 search params 포함 — URL 변경 시 자동 리페치됩니다
queryKey: ['products', { columnFilters, sorting, pageIndex, pageSize }],
queryFn: () =>
fetchProducts({
filters: columnFilters,
sorting,
page: pageIndex,
limit: pageSize,
}),
placeholderData: (prev) => prev, // 페이지 전환 시 빈 화면 대신 이전 데이터 유지
})
}
interface Product {
id: string
name: string
status: string
}
const columns: ColumnDef<Product>[] = [
// ... 컬럼 정의
]
function ProductTable() {
const { data, isLoading, isFetching } = useProductsQuery()
const { columnFilters, sorting, pageIndex, pageSize } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const table = useReactTable({
data: data?.items ?? [],
columns,
rowCount: data?.total ?? 0,
getCoreRowModel: getCoreRowModel(),
state: {
columnFilters,
sorting,
pagination: { pageIndex, pageSize },
},
manualFiltering: true,
manualSorting: true,
manualPagination: true,
onColumnFiltersChange: (updater) => {
const next = functionalUpdate(updater, columnFilters)
navigate({
search: (prev) => ({ ...prev, columnFilters: next, pageIndex: 0 }),
replace: true,
})
},
onSortingChange: (updater) => {
const next = functionalUpdate(updater, sorting)
navigate({
search: (prev) => ({ ...prev, sorting: next }),
replace: true,
})
},
onPaginationChange: (updater) => {
const next = functionalUpdate(updater, { pageIndex, pageSize })
navigate({
search: (prev) => ({
...prev,
pageIndex: next.pageIndex,
pageSize: next.pageSize,
}),
replace: true,
})
},
})
return (
<div>
{isFetching && <div className="loading-indicator">로딩 중...</div>}
{/* 테이블 렌더링 */}
</div>
)
}| 코드 포인트 | 설명 |
|---|---|
queryKey: ['products', { ... }] |
필터 조합마다 별도 캐시 엔트리 생성 — 뒤로가기 시 캐시에서 즉시 복원됩니다 |
placeholderData: (prev) => prev |
페이지 이동 시 빈 화면 없이 이전 데이터를 보여줍니다 |
pageIndex: 0 리셋 |
필터가 바뀌면 1페이지로 돌아가는 자연스러운 UX 패턴입니다 |
replace: true |
히스토리 스택에 쌓지 않아 뒤로가기가 의도대로 동작합니다 |
예시 2: 텍스트 필터에 디바운싱 적용
텍스트 입력 필터를 완전히 URL에 묶으면 글자 하나 입력할 때마다 URL이 바뀌고 히스토리가 너무 빠르게 쌓입니다. 솔직히 저도 처음에 이걸 그냥 URL에 연결했다가 뒤로가기가 무용지물이 되는 경험을 했습니다. 입력 필드의 표시값만 로컬 state로 관리하고, 실제 필터 상태는 디바운스 후 URL로 쓰는 방식이 자연스럽습니다.
import { useState, useEffect } from 'react'
import { useDebounce } from 'use-debounce' // 또는 직접 구현 가능
function DebouncedFilterInput({
columnId,
placeholder,
}: {
columnId: string
placeholder: string
}) {
const { columnFilters } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
// 현재 URL에서 이 컬럼의 필터 값 읽기
const currentValue =
(columnFilters.find((f) => f.id === columnId)?.value as string) ?? ''
// 로컬 state로 입력값 관리 (타이핑 즉시 반응)
const [inputValue, setInputValue] = useState(currentValue)
const [debouncedValue] = useDebounce(inputValue, 300)
// 디바운스된 값이 바뀔 때만 URL 업데이트
useEffect(() => {
if (debouncedValue === currentValue) return
navigate({
search: (prev) => ({
...prev,
columnFilters: debouncedValue
? [
...prev.columnFilters.filter((f) => f.id !== columnId),
{ id: columnId, value: debouncedValue },
]
: prev.columnFilters.filter((f) => f.id !== columnId),
pageIndex: 0,
}),
replace: true,
})
}, [debouncedValue, currentValue, navigate, columnId])
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// exhaustive-deps 충족 + debouncedValue === currentValue 가드로 루프 방지
return (
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={placeholder}
/>
)
}왜 로컬 state를 따로 두나요? 입력 필드를 완전히 URL에 묶으면 300ms 디바운스 동안 입력값이 화면에 반응하지 않아 UX가 어색해집니다. 입력 필드 표시는 로컬 state가, 실제 필터 적용은 URL이 담당하는 역할 분리입니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 북마크·공유 | URL 하나로 필터·정렬 상태를 완전히 재현 — 슬랙 링크 하나로 동일한 뷰를 공유할 수 있습니다 |
| 새로고침 내구성 | localStorage, 세션 없이도 상태가 보존됩니다 |
| 뒤로가기 지원 | push 모드에서 브라우저 히스토리로 이전 필터 상태를 복원할 수 있습니다 |
| SSR 친화적 | 서버에서 URL 파라미터로 초기 데이터를 fetching해 첫 로딩 성능이 향상됩니다 |
| 분석 추적 | GA, Amplitude 등에서 URL만으로 사용자 필터 행동을 추적할 수 있습니다 |
| 타입 안전성 | TanStack Router + Zod 조합으로 컴파일 타임 타입 검증이 가능합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 무한 루프 위험 | URL 변경이 useSearch 재실행을 유발하고, on*Change 핸들러에서 참조가 달라진 같은 값으로 다시 navigate하면 루프가 발생합니다 |
replace: true + functionalUpdate 패턴 사용, 가드 조건(if debouncedValue === currentValue) 추가 |
| URL 길이 제한 | 브라우저마다 2,000~8,000자 제한 — 복잡한 필터 객체가 한계에 도달할 수 있습니다 | 복잡한 필터는 서버에 저장하고 URL엔 필터 ID만 담는 방식을 고려해볼 수 있습니다 |
| 직렬화 한계 | Date, RegExp, 중첩 객체는 JSON 직렬화 시 타입 손실이 생깁니다 |
Zod의 z.coerce.date() 또는 transform 활용, fallback으로 방어 |
| 히스토리 스택 | push 모드에서 타이핑마다 히스토리가 쌓입니다 |
텍스트 필터는 디바운싱 + replace: true 조합이 필요합니다 |
무한 루프가 발생하는 상황을 좀 더 구체적으로 보면, on*Change 핸들러에서 navigate를 호출할 때 새 배열/객체를 생성하면 useSearch가 참조가 다르다고 판단해 리렌더를 유발하고, 이 리렌더가 다시 핸들러를 실행하는 사이클이 만들어집니다. functionalUpdate + replace: true 조합이 이 사이클을 끊어줍니다.
replace vs push:
navigate({ replace: true })는 현재 히스토리 항목을 교체하고, 기본(push)은 새 항목을 추가합니다. 필터·정렬처럼 빠르게 바뀌는 상태는replace: true로, 페이지 이동처럼 뒤로가기가 의미 있는 경우는 push 모드를 활용하는 것이 자연스럽습니다.
실무에서 가장 흔한 실수
-
모든 상태를 URL에 넣으려는 욕심 — 컬럼 너비, 모달 열림 상태, 드래그 중인 행 인덱스 같은 UI 상태는 URL에 담지 않는 것이 일반적입니다. URL에는 공유·북마크가 의미 있는 상태만 담기는 게 맞습니다.
-
functionalUpdate없이 updater를 직접 사용 —on*Change콜백의 인자가 값인지 함수인지 확인하지 않고 직접 사용하면 간헐적으로 이전 상태로 덮어쓰는 버그가 생깁니다. TanStack Table의functionalUpdate를 항상 통해서 처리하는 것이 안전합니다. -
필터 변경 시 페이지 인덱스를 리셋하지 않음 — 3페이지에서 필터를 바꿨는데 3페이지 결과를 그대로 보여주면 사용자 입장에서 혼란스럽습니다.
onColumnFiltersChange에서pageIndex: 0을 함께 업데이트하는 패턴이 필요합니다.
마치며
이 패턴을 적용하고 나서 제가 느낀 건, functionalUpdate와 fallback()이 생각보다 많은 예외 상황을 조용히 막아주고 있다는 점입니다. 처음엔 "굳이 이런 게 필요한가?" 싶었는데, 외부에서 URL을 조작하거나 updater가 함수로 오는 케이스를 한 번씩 겪고 나서는 왜 있는지 바로 이해가 됐습니다. 이 패턴의 핵심은 URL이 단 하나의 상태 저장소 역할을 하도록 설계하는 것이고, functionalUpdate와 fallback은 그 설계를 안전하게 유지해주는 장치들입니다.
지금 바로 시작한다면 이 순서가 가장 부담이 적습니다.
-
라우트 스키마부터 — 기존 라우트 파일에
validateSearch: zodValidator(...)한 줄을 추가하고,sorting과pageIndex/pageSize만 담는 작은 스키마로 시작합니다. 복잡한 필터 스키마는 그 다음에 붙여도 됩니다. -
정렬 하나만 연결 —
useReactTable의state.sorting에useSearch()에서 읽은 값을 주입하고,onSortingChange에서navigate()를 호출하도록 바꿉니다. 컬럼 헤더를 클릭하면 URL이 바뀌는 것을 바로 확인할 수 있습니다. -
TanStack Query와 연결 —
queryKey에 search params 객체를 포함시키면, URL이 바뀔 때 서버 데이터를 자동으로 다시 불러오는 흐름이 완성됩니다. 이 시점부터 URL 하나로 필터 상태를 공유할 수 있게 됩니다.
직접 구현 대신 라이브러리 방식을 선호한다면, tanstack-table-search-params가 이 패턴을 훅으로 추상화해줍니다. Next.js Pages/App Router와 React Router도 지원합니다.
다음 글: TanStack Router의
loaderDeps와loader를 활용해 URL search params로 서버 사이드 초기 데이터를 prefetch하고, TanStack Query의staleTime과 조합해 SSR 친화적인 데이터 그리드 완성하기
참고 자료
- Search Params Guide | TanStack Router
- Validate Search Params with Schemas | TanStack Router
- Navigate with Search Parameters | TanStack Router
- Column Filtering Guide | TanStack Table
- Sorting Guide | TanStack Table
- Query Router Search Params Example | TanStack Table
- Search Params Are State | TanStack Blog
- tanstack-table-search-params | GitHub — 직접 구현 대신 라이브러리를 활용하고 싶다면 이 옵션도 있습니다
- Building a Library to Sync TanStack Table State with URL Parameters | DEV Community — 같은 접근법을 라이브러리로 만든 과정을 담은 글입니다
- Advanced React state management using URL parameters | LogRocket
- TanStack Router: Query Parameters & Validators | Leonardo Montini