type-safe한 routes를 사용하기 feat.nuqs,zod

Nextjs 14, app router를 사용하는 회사 프로젝트에서 url routes를 활용해 UI를 변경해주거나 페이지 전환을 해주는 케이스를 발견했다. pathname과 querystring을 조합해 경로를 만들거나 조건에 따라 다른 경로로 이동시켜주는 로직이었다. 이참에 좀더 라우트를 type-safe하게 관리하는 방법들을 공부해보기로 했다.

음악 목록을 인기순/최신순, 장르별로 보여주는 예시 코드를 작성해보자.


function MyComponent() {
// 클라이언트에서 window 직접 사용 → SSR 위험, 인코딩/디코딩 수동 처리 필요
  const pathname = window.location.pathname
  const search = window.location.search // "?filter=popular&category=music"

  // 쿼리 직접 파싱 (간단하게)
  const params = new URLSearchParams(search)
  const filter = params.get('filter') // string | null
  const category = params.get('category')

  const handleClick = () => {
    const newFilter = 'latest'
    const newCategory = 'sports'

    // 수동 인코딩, 조합 필요
    const query = `?filter=${encodeURIComponent(newFilter)}&category=${encodeURIComponent(newCategory)}`

    window.location.href = pathname + query
  }

  return (
    <div>
      <p>Filter: {filter}</p>
      <p>Category: {category}</p>
      <button onClick={handleClick}>Change</button>
      ...
    </div>
  )
}

이 코드의 문제점은 뭘까?

⚠️window.location.search 직접 사용 방식의 문제점

window.location.search 를 직접 접근해 사용하고, 문자열을 하드코딩해 직접 조합하는 이 방식은

⚠️SSR 환경에서 사용 불가능하다.

⚠️URLSearchParmas 쿼리 직접 파싱 방식의 문제점

타입 불안정 (모든 값은 string | null)

⚠️문자열 직접 조합, 수동 인코딩의 문제점

직접 조합 → 인코딩 누락 가능성

⚠️중복 코드와 반복 로직으로, 재사용이 어렵다는 문제점

⚠️타입검증이 어렵다는 문제점

타입 검증 없음 → 잘못된 값 허용

⚠️테스트가 어려움

Zod를 활용해 리팩토링해보자.

Zod란?

Zod의 특징은?

// constants.ts
export const FILTER_OPTIONS = ['popular', 'latest'] as const
export type FilterOption = (typeof FILTER_OPTIONS)[number]

// schemas.ts
import { z } from 'zod'
import { FILTER_OPTIONS } from './constants'

// 쿼리 스키마 정의
export const QuerySchema = z.object({
  filter: z.enum(FILTER_OPTIONS).optional(),
  category: z.string().optional(),
})
import { QuerySchema } from './schemas'

function MyComponent() {
  const params = new URLSearchParams(window.location.search)

  // zod 안전 검증
  const rawQuery = {
    filter: params.get('filter') ?? undefined,
    category: params.get('category') ?? undefined,
  }

  const parseResult = QuerySchema.safeParse(rawQuery)

  const filter = parseResult.success ? parseResult.data.filter : 'popular'
  const category = parseResult.success ? parseResult.data.category : undefined

  const handleClick = () => {
    const newFilter = 'latest'
    const newCategory = 'sports'

    // 여전히 문자열 직접 조합
    const query = `?filter=${encodeURIComponent(newFilter)}&category=${encodeURIComponent(newCategory)}`

    window.location.href = window.location.pathname + query
  }

  return (
    <div>
      <p>Filter: {filter}</p>
      <p>Category: {category}</p>
      <button onClick={handleClick}>Change</button>
      ...
    </div>
  )
}

이렇게 하면 위에서 말한 문제점 일부를 개선할 수 있다.

✅ Zod로 개선

Nuqs를 활용해 한 번 더 리팩토링해보자.

Nuqs란?
import { QuerySchema } from './schemas'
import { useQueryStates } from 'nuqs'
import { parseAsZod } from 'nuqs/zod'

export default function MyComponent() {
  // Nuqs + Zod 통합 parser 사용해 자동 파싱 + 직렬화 + 상태관리
  const [query, setQuery] = useQueryStates(parseAsZod(QuerySchema))

  const { filter, category } = query

  return (
    <div>
      <p>Filter: {filter}</p>
      <p>Category: {category}</p>

      <button onClick={() => setQuery({ filter: 'latest', category: 'sports' })}>
        Change
      </button>
      ...
    </div>
  )
}

parseAsZod(QuerySchema) 가 해결하는 문제
useQueryStates(...) 가 해결하는 문제

서버 ↔ 클라이언트 쿼리 구조 일치

서버컴포넌트에서도 쿼리파라미터가 필요하면 searchParams를 props로 받으면 되는데..
이런 문제가 발생할 수 있다.

서버 컴포넌트 (page.tsx)

const filter = searchParams.filter ?? 'popular' // 문자열 그대로

클라이언트 컴포넌트(client.tsx)

const [query, setQuery] = useQueryStates({
  filter: parseAsEnum(['popular', 'latest']).withDefault('latest'),
})

문제점

해결 방법 : "쿼리 파서 구조를 일치"시켜서 서버/클라이언트 공통화

// lib/parsers.ts
// Nuqs 파서 정의
import { parseAsString, parseAsEnum } from 'nuqs'
import { QuerySchema } from './schemas'

export const pageParsers = {
  filter: parseAsEnum(FILTTER_OPTIONS).withDefault('popular'),
  category: parseAsString.optional(),
}
// app/page.tsx
// Server Component (SSR)에서 searchParams 받기
import { pageParsers } from '@/lib/parsers'
import { PageClient } from './client'

export default function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  // SSR-safe 쿼리 파싱
  const filter = pageParsers.filter.parse(searchParams.filter ?? null)
  const category = pageParsers.category?.parse?.(searchParams.category ?? null)

  return <PageClient filter={filter} category={category} />
}
// app/client.tsx
// Client Component에서 동일 파서 재사용 가능
'use client'

import { useQueryStates } from 'nuqs'
import { pageParsers } from '@/lib/parsers'

export function PageClient(props: { filter: string; category?: string }) {
  const [{ filter, category }, setQuery] = useQueryStates(pageParsers)

  return (
    <div>
      <h1>Filter: {filter}</h1>
      <h2>Category: {category}</h2>

      <button onClick={() => setQuery({ filter: 'latest' })}>
        Change
      </button>
      ...
    </div>
  )
}

이렇게 pageParsers 를 서버/클라이언트에서 재사용하면 쿼리 구조(키, 타입, 기본값, 유효성)가 완전히 동일하게 유지된다. 굳!