zod 태그의 최신글

프론트엔드

URL을 상태로 관리한 방법: TanStack Router

인턴 기간 동안 백오피스 프로젝트에서 React Router 기반 레거시 코드를 마이그레이션 하는 작업을 맡았다. 단순히 마이그레이션 하기 보다는, 기존 JavaScript의 상태를 TypeScript로 마이그레이션 하면서 어떻게 런타임과 컴파일 타임 둘다 안정성을 잡을 수 있을지에 대한 고민이 많았다. 레거시 React Router 구조 기존 구조에서는 많은 필터 값을 url에 올리지 않고, 내부 상태로만 관리하고 있었다. 여기에서의 문제점은 사용자가 새로고침 시, 설정해놓은 필터 값들이 사라진다는 것이다. 따라서 UX를 향상하기 위해 사용자가 필터 값들을 새로고침 시에도 상태가 변경되지 않게 URL에 올리기로 결정하였다. URL로 상태를 옮기며 마주한 문제 ① 타입 안정성 저하 예를 들어 이런 URL이 들어올 수 있다 page, size는 number 타입이어야 하지만 실제로 사용자가 임의로 주소창에 입력한 값은 string이다. 사용자가 직접 값을 주입하였을 때, 서버로 값이 들어갈 수 있다는 점에서 라우터 단의 타입 관리가 필요해보였다. ② 상태의 기준점 모호 URL에 필터값을 올리는 방식을 채택하였을 때 필터 상태가 다음과 같이 여러 군데에 흩어져 있는 문제가 있다. useState로 관리되는 내부 상태 서버 요청 시 사용하는 query 객체 URL search 파라미터 따라서 이 기준점을 어디에 잡아야할지도 논의 및 합의가 필요한 부분이었다. 라우터 단에서 상태를 정규화하기 TanStack Router는 React Router와 달리 라우터 단에서 validateSearch를 제공한다. search 파라미터를 읽는 것과 동시에, 컴파일 및 런타임에서 zod로 검증 및 정규화를 수행할 수 있다. 라우터 단에서 다음의 기능을 수행하였다. 허용된 값만 통과 타입 자동 추론 실패 시 기본값 정규화 잘못된 쿼리는 서버에 전달되지 않음 상태 업데이트 규칙을 통일하기 URL 쿼리 값 정규화 문제는 해결하였지만, 가장 큰 문제는 필터가 있는 화면에서 코드가 계속 반복된다는 점이었다. 부분 업데이트 로직 기본값 처리 URL과 useState, URL과 서버 상태의 동기화 로직 이런 로직들이 페이지마다 반복되고 있었으며, 개발자마다 다른 로직을 사용하여 코드를 알아보기에도 쉽지 않았다. 이 문제는 useSearchParams 커스텀 훅을 만들어 해결하였다. useSearchParams Custom Hook 이 훅의 목적은 단순하다. search는 항상 타입 안전해야 하고 업데이트 방식은 팀 내에서 동일해야 한다. ① URL에 들어갈 수 있는 타입 제한 Date, 객체, Map 같은 값은 애초에 타입 단계에서 차단했다. search는 URL 직렬화 가능한 원시값만 가진다는 규칙을 강제했다. ② 업데이트 방식을 통일 이 구조로 인해서 페이지에서 search를 다루는 방식이 완전 동일해졌다. 부분 업데이트는 applySearch 전체 리셋은 resetSearch undefined는 무시 defaultSearch는 항상 기준점 적용 이후의 변화 이 커스텀 훅을 사용함으로써 다음의 변화가 생겼다. 프론트엔드 개발자 3명이 동일한 navigation 규칙 사용 필터 로직 중복 코드 약 40% 감소 공유, 새로고침, 뒤로가기 상황에서 상태 유지 신규 페이지 추가 시, navigation 로직 추가 간편화 회고 URL을 상태로 쓰는 건 생각보다 까다롭다. 페이지 내부 상태로만 관리할 때는 "내가 넣은 값만 존재한다"는 가정이 가능하지만, URL로 올리는 순간부터는 “누군가 아무 값이나 넣을 수 있다” 는 전제가 시작된다. 그래서 오히려 상태가 더 불안정해질 수도 있다. 재미있게도, 이런 종류의 문제는 결국 라이브러리 선택보다 팀 규칙의 문제다. 정규화 지점이 어디인지, 기본값이 무엇인지, 업데이트가 어떤 규칙을 따르는지. 이걸 문서가 아니라 코드로 강제했을 때 팀 생산성이 가장 크게 달라졌다. 전체 useSearchParams 코드 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() *

2026년 02월 12일 03:56