검색 파라미터는 곧 상태다 ("Search Params Are State")

okorion·2025년 12월 3일

1. 핵심 메시지

  • 검색 파라미터(search params)는 그냥 문자열이 아니라 애플리케이션 상태다.

  • 지금까지는:

    • 임의의 URLSearchParams 파싱
    • 여기저기 흩어진 유틸·훅
    • 라우트와 분리된 검증/타입 정의
      → 즉, 2급 시민 취급을 받아왔다.
  • 글의 주장:

    • 검색 파라미터를 진짜 “상태”로 취급하면 라우팅·타입·검증·리액티브 모델까지 한 번에 정리할 수 있다.
    • 그걸 라우터 레이어에서 해결하는 예시로 TanStack Router를 제시.

2. 기존 방식의 문제: 읽을 때도, 쓸 때는 더

2-1. 읽기(파싱) 단계의 한계

전형적인 패턴:

const schema = z.object({
  sort: z.enum(['asc', 'desc']),
})

const raw = Object.fromEntries(new URLSearchParams(location.href))
const result = schema.safeParse(raw)

문제점:

  • 매번 이런 코드 반복

  • 라우트와 스키마가 분리 → 어디가 진실인지 알 수 없음

  • URLSearchParams는:

    • 문자열만 지원
    • 중첩 구조 없음
    • 배열/타입 변환 직접 구현 필요
  • 파라미터가 조금만 복잡해져도 스케일이 안 맞는다.

Nuqs 같은 라이브러리들이 이 “읽기” 부분을 많이 개선했지만,
글이 보는 진짜 문제는 쓰기(write) 쪽이다.

2-2. 쓰기 단계의 난장판

이런 코드를 떠올려 보면:

<Link to="/dashboards/overview" search={{ sort: 'asc' }} />

실제로는:

  • sort가 이 라우트에서 유효한 값인지 알 수 없다.
  • 다른 컴포넌트가 같은 라우트에 다른 스키마/기대값을 쓸 수도 있다.
  • 어디도 “여기가 이 라우트의 검색 파라미터 계약(Schema)”라고 말해주지 않는다.

결국:

  • 검증을 여러 군데서 따로 함
  • 디폴트 값·타입·포맷이 곳곳에서 서서히 틀어짐
  • URL은 여전히 “신뢰할 수 없는 문자열”로 남는다.

3. 로컬 추상화의 한계: Nuqs 패턴

Nuqs 같은 도구:

  • Zod 기반 파싱
  • 타입 추론
  • 컴포넌트 단위에서 읽고/쓰는 useSearchParam 훅 제공

장점:

  • 특정 컴포넌트 안에서의 로컬 DX는 좋아진다.

하지만 한계:

  • 스키마가 컴포넌트 단위로 중복 정의
  • 라우트 전체의 계약이 아니라, “각자 자기 파라미터만 안다”
  • 라우트가 진화했을 때(파라미터 추가/변경),
    어디를 고쳐야 하는지 전역적으로 보장 못 함

즉, 로컬 추상화는 “편의성”은 주지만,
전역적인 조정·일관성(coordination)은 제공하지 못한다.


4. 검색 파라미터 스키마를 “라우터”에 묶기

4-1. TanStack Router의 접근

핵심 아이디어:

“검색 파라미터 스키마를 라우트 정의 안에 집어넣고,
라우터가 단일 소스 오브 트루스를 가진다.”

예시:

export const Route = createFileRoute('/dashboards/overview')({
  validateSearch: z.object({
    sort: z.enum(['asc', 'desc']),
    filter: z.string().optional(),
  }),
})

이렇게 하면:

  • 이 라우트 /dashboards/overview에 대한 검색 파라미터 계약이 여기 한 곳에만 존재한다.
  • 이후 네비게이션 코드는:
<Link
  to="/dashboards/overview"
  search={{ sort: 'asc' }} // 타입 추론 + 검증
/>
  • search에 잘못된 값(예: sort: 'up')을 넘기면 타입 에러.
  • 불완전한 상태·유효하지 않은 값이 URL에 들어가는 것을 컴파일 타임에 막는다.

4-2. 상태 업데이트도 “리듀서 스타일”로

검색 파라미터 부분 업데이트:

navigate({
  search: (prev) => ({ ...prev, page: prev.page + 1 }),
})

특징:

  • 트랜잭션(원자적) 업데이트

  • 라우터의 리액티브 모델과 결합되어

    • 해당 파라미터를 사용하는 컴포넌트만 리렌더
    • “URL이 바뀌었으니 전부 리렌더” 같은 비효율 감소

5. 계층적 스키마: Parent/Child 라우트 조합

검색 파라미터 스키마를 라우트에 묶으면,
상위/하위 라우트 간의 일관성도 보장할 수 있다.

5-1. 부모 라우트에서 공통 검색 스키마 정의

// routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
  validateSearch: z.object({
    sort: z.enum(['asc', 'desc']).default('asc'),
  }),
})

5-2. 자식 라우트에서 확장만 허용

// routes/dashboard/$dashboardId.tsx
export const Route = createFileRoute('/dashboard/$dashboardId')({
  validateSearch: z.object({
    filter: z.string().optional(),
    // ✅ sort는 부모에게서 자동 상속
  }),
})

요청: /dashboard/123?sort=desc&filter=active

  • 부모: sort 검증
  • 자식: filter 검증
  • 둘이 합쳐서 하나의 일관된 검색 파라미터 스키마가 된다.

만약 자식에서 sort를 다른 타입으로 다시 정의하면?

validateSearch: z.object({
  sort: z.boolean(), // ❌ 부모의 'asc' | 'desc'와 충돌
  filter: z.string().optional(),
})

타입 에러로 바로 잡힌다.

이 구조 덕분에:

  • 상위 라우트가 관리하는 공통 파라미터(page, sort 등)를
    하위 라우트들이 마음대로 깨지 못한다.
  • 중첩 라우트의 검색 파라미터가 컴포저블 + 안전해진다.

6. “검색 파라미터 = 상태”로 다루면 생기는 효과

정리하면, 라우터 레벨에서 검색 파라미터를 스키마와 함께 관리하면:

  1. 타입 세이프티

    • 잘못된 값·누락된 값이 URL에 들어갈 수 없다.
  2. 계약(Contract)

    • 라우트가 “이 URL에서는 이런 search state를 기대한다”는 계약을 명시.
  3. 일관성

    • 디폴트·형식·타입이 라우트 단위로 통일.
  4. 리액티브 모델

    • 검색 파라미터 변경에 따른 UI 업데이트를 정밀하게 제어.
  5. 조정(Co-ordination)

    • 여러 컴포넌트·페이지가 같은 검색 파라미터를 안전하게 공유.

7. 결론

  • 검색 파라미터는 문자열 덩어리가 아니라 상태다.

  • 상태라면:

    • 타입이 있어야 하고
    • 검증이 되어야 하고
    • 소유자가 있어야 하고
    • 라우팅 컨텍스트와 연결되어야 한다.
  • 그걸 앱 곳곳의 훅/유틸에 흩어놓지 말고,
    라우터 안으로 끌고 들어와 단일 소스 오브 트루스로 관리하는 게 맞다.

검색 파라미터를 여전히 “문자열 파싱 대상” 정도로 취급하면:

  • 상태가 새고(leak),
  • 깨지고(break),
  • 그걸 우회하는 코드만 쌓인다.

애초에 처음부터 “상태”로 다루는 설계로 출발해야 한다는 게 이 글의 요지다.


원문 - Search Params Are State

profile
Tech Blog

0개의 댓글