프론트엔드
URL을 상태로 관리한 방법: TanStack Router
2026년 02월 12일 03:56
인턴 기간 동안 백오피스 프로젝트에서 React Router 기반 레거시 코드를 마이그레이션 하는 작업을 맡았다.
단순히 마이그레이션 하기 보다는, 기존 JavaScript의 상태를 TypeScript로 마이그레이션 하면서 어떻게 런타임과 컴파일 타임 둘다 안정성을 잡을 수 있을지에 대한 고민이 많았다.
레거시 React Router 구조
기존 구조에서는 많은 필터 값을 url에 올리지 않고, 내부 상태로만 관리하고 있었다.
여기에서의 문제점은 사용자가 새로고침 시, 설정해놓은 필터 값들이 사라진다는 것이다.
따라서 UX를 향상하기 위해 사용자가 필터 값들을 새로고침 시에도 상태가 변경되지 않게 URL에 올리기로 결정하였다.
URL로 상태를 옮기며 마주한 문제
① 타입 안정성 저하
예를 들어 이런 URL이 들어올 수 있다
https://example.com/test?page=hello&size=world
page, size는 number 타입이어야 하지만 실제로 사용자가 임의로 주소창에 입력한 값은 string이다.
사용자가 직접 값을 주입하였을 때, 서버로 값이 들어갈 수 있다는 점에서 라우터 단의 타입 관리가 필요해보였다.
② 상태의 기준점 모호
URL에 필터값을 올리는 방식을 채택하였을 때
필터 상태가 다음과 같이 여러 군데에 흩어져 있는 문제가 있다.
- useState로 관리되는 내부 상태
- 서버 요청 시 사용하는 query 객체
- URL search 파라미터
따라서 이 기준점을 어디에 잡아야할지도 논의 및 합의가 필요한 부분이었다.
라우터 단에서 상태를 정규화하기
TanStack Router는 React Router와 달리 라우터 단에서 validateSearch를 제공한다.
search 파라미터를 읽는 것과 동시에, 컴파일 및 런타임에서 zod로 검증 및 정규화를 수행할 수 있다.
validateSearch: (search) => {
const parsed = DaysPurchaseSearchSchema.safeParse(search);
return parsed.success ? parsed.data : {};
};
라우터 단에서 다음의 기능을 수행하였다.
- 허용된 값만 통과
- 타입 자동 추론
- 실패 시 기본값 정규화
- 잘못된 쿼리는 서버에 전달되지 않음
상태 업데이트 규칙을 통일하기
URL 쿼리 값 정규화 문제는 해결하였지만,
가장 큰 문제는 필터가 있는 화면에서 코드가 계속 반복된다는 점이었다.
- 부분 업데이트 로직
- 기본값 처리
- URL과 useState, URL과 서버 상태의 동기화 로직
이런 로직들이 페이지마다 반복되고 있었으며,
개발자마다 다른 로직을 사용하여 코드를 알아보기에도 쉽지 않았다.
이 문제는 useSearchParams 커스텀 훅을 만들어 해결하였다.
useSearchParams Custom Hook
이 훅의 목적은 단순하다.
search는 항상 타입 안전해야 하고 업데이트 방식은 팀 내에서 동일해야 한다.
① URL에 들어갈 수 있는 타입 제한
type Primitive = string | number | boolean | null | undefined
type PrimitiveOrArray = Primitive | readonly Primitive[]
Date, 객체, Map 같은 값은 애초에 타입 단계에서 차단했다. search는 URL 직렬화 가능한 원시값만 가진다는 규칙을 강제했다.
② 업데이트 방식을 통일
const { search, applySearch, resetSearch } =
useSearchParams<UiPostListQuery>({
defaultSearch,
Route,
});
이 구조로 인해서 페이지에서 search를 다루는 방식이 완전 동일해졌다.
- 부분 업데이트는 applySearch
- 전체 리셋은 resetSearch
- undefined는 무시
- defaultSearch는 항상 기준점
적용 이후의 변화
이 커스텀 훅을 사용함으로써 다음의 변화가 생겼다.
프론트엔드 개발자 3명이 동일한 navigation 규칙 사용
필터 로직 중복 코드 약 40% 감소
공유, 새로고침, 뒤로가기 상황에서 상태 유지
신규 페이지 추가 시, navigation 로직 추가 간편화
회고
URL을 상태로 쓰는 건 생각보다 까다롭다.
페이지 내부 상태로만 관리할 때는 "내가 넣은 값만 존재한다"는 가정이 가능하지만,
URL로 올리는 순간부터는 “누군가 아무 값이나 넣을 수 있다” 는 전제가 시작된다.
그래서 오히려 상태가 더 불안정해질 수도 있다.
재미있게도, 이런 종류의 문제는 결국 라이브러리 선택보다 팀 규칙의 문제다.
정규화 지점이 어디인지, 기본값이 무엇인지, 업데이트가 어떤 규칙을 따르는지.
이걸 문서가 아니라 코드로 강제했을 때 팀 생산성이 가장 크게 달라졌다.
전체 useSearchParams 코드
// useSerachParams Custom Hook
type Primitive = string | number | boolean | null | undefined
type PrimitiveOrArray = Primitive | readonly Primitive[]
/**
* SearchLike<T>
*
* `validateSearch`로 다루는 search 객체의 값 제약을 강제한다.
*
* ## 목적
* - `search`에 들어갈 값은 **URL로 직렬화 가능한 값만** 허용한다.
* - 리스트(배열) 파라미터도 지원한다.
*
* ## 제약
* - 각 key의 값은 `PrimitiveOrArray`만 가능하다.
* - 객체/함수/Date/Map 등 URL 직렬화에 부적절한 값은 타입 단계에서 차단한다.
*
* - Date는 string/number로 변환
* - 객체는 id만 넣거나 JSON.stringify(비추)
* - Map/Set은 배열이나 레코드 문자열로 변환
*
* @typeParam T - search 객체의 shape
*/
export type SearchLike<T> = {
[K in keyof T]: PrimitiveOrArray
}
/**
* Updater<T>
*
* TanStack Router의 `navigate({ search })`가 받는 형태를 따르는 업데이트 타입.
*
* - 값 자체로 교체하거나
* - 이전 값(prev)을 받아 다음 값을 계산하는 함수를 전달할 수 있다.
*
* @typeParam T - 업데이트 대상 타입
*/
type Updater<T> = T | ((prev: T) => T)
/**
* patch 객체를 search에 적용 가능한 형태로 정규화한다.
*
* ## 동작
* - `undefined`는 무시한다(해당 key를 건드리지 않음).
* - 그외의 값: 그대로 반영
*
* @typeParam T - search object shape
* @param patch - 부분 업데이트용 patch
* @returns 빈 값/빈 배열이 제거된 정규화 patch
*/
const normalizePatch = <T extends Record<string, PrimitiveOrArray>>(
patch: Partial<T>,
): Partial<T> => {
const entries = Object.entries(patch) as Array<[keyof T, T[keyof T]]>
const cleaned: Array<[keyof T, PrimitiveOrArray]> = []
for (const [key, value] of entries) {
if (value === undefined) continue
cleaned.push([key, value])
}
return Object.fromEntries(cleaned) as Partial<T>
}
/**
* useSearchParams 옵션
*
* @typeParam T - 페이지에서 사용하는 search의 shape
*/
interface UseSearchParamsOptions<T extends SearchLike<T>> {
/**
* search 초기값(리셋 시 사용할 기본값).
*
* - `resetSearch()` 호출 시 그대로 적용된다.
* - 가능하면 이 값도 `validateSearch`의 기본값과 일치시키는 것을 권장한다.
*/
defaultSearch: T
/**
* 이 페이지가 사용하는 TanStack Router의 Route 객체.
*
* ## 왜 필요한가?
* - `Route.useSearch()`는 `validateSearch` 결과(정규화된 search)를 반환해야 한다.
* - `Route.useNavigate()`를 사용해서 `navigate({ search })`로 이동해야
* `validateSearch` 파이프라인을 타고 안전하게 업데이트된다.
*
* ## 요구사항
* - `useSearch()`는 정규화된 search를 반환해야 한다.
* - `useNavigate()`는 `{ search }`를 받아 navigate를 수행해야 한다.
*/
Route: {
useSearch: () => T
useNavigate: () => (opts: { search?: Updater<Partial<T> | T> }) => void
}
}
/**
* useSearchParams
*
* `validateSearch`를 반드시 거쳐 search를 다루기 위한 유틸 훅.
*
* ## 제공 기능
* - `search`: 현재 라우트의 정규화된 search
* - `applySearch(patch)`: 부분 업데이트 (빈 값은 제거 의도로 처리)
* - `resetSearch()`: defaultSearch로 완전 리셋
*
* ## patch 적용 규칙 (`applySearch`)
* - `undefined` → 무시(해당 key 유지)
* - `null` / `''` → 제거 의도(정규화 과정에서 key 제외)
* - 배열 → 빈 요소 제거 후, 배열이 비면 제거 의도
*
* ## 예시
* ```ts
* const { search, applySearch, resetSearch } = useSearchParams({
* defaultSearch: { page: 1, keyword: '' },
* Route,
* })
*
* applySearch({ keyword: 'abc', page: 1 })
* applySearch({ keyword: '' }) // keyword 제거 의도
* applySearch({ tags: ['a', '', 'b'] }) // tags -> ['a','b']
* applySearch({ tags: [] }) // tags 제거 의도
* resetSearch()
* ```
*
* @typeParam T - search의 shape (각 값은 URL 직렬화 가능한 타입이어야 함)
* @param options - 훅 옵션
* @returns search와 updater 함수들
*/
export const useSearchParams = <T extends SearchLike<T>>({
defaultSearch,
Route,
}: UseSearchParamsOptions<T>) => {
const search = Route.useSearch()
const navigate = Route.useNavigate()
/**
* search를 부분 업데이트한다.
*
* @param patch - 변경할 key들의 부분 객체
*/
const applySearch = (patch: Partial<T>) => {
const normalized = normalizePatch<T>(patch)
navigate({
search: (prev) => {
return {
...prev,
...normalized,
}
},
})
}
/**
* search를 `defaultSearch`로 리셋한다.
*/
const resetSearch = () => {
navigate({
search: defaultSearch,
})
}
return { search, applySearch, resetSearch }
}
