nuqs 2.5.0 업데이트 완전 정리 타입 안전 URL 상태 관리의 다음 단계

nuqs 2.5.0 업데이트 완전 정리 타입 안전 URL 상태 관리의 다음 단계

2024년 8월 22일, 타입 안전한 URL 상태 관리 라이브러리 'nuqs'가 2.5.0을 공개했는데요.

무려 일곱 번의 베타를 거치며 꽤 많은 변화가 쌓였고, 이번 릴리스는 기능과 성능, 에코시스템 호환성 세 축이 동시에 전진한 느낌이에요.

핵심은 'Debounce 기반의 URL 업데이트 제어', 'Key Isolation을 통한 렌더링 최적화', 'Standard Schema로 열리는 타입 안전 밸리데이션 통로', 그리고 'TanStack Router 지원 실험'이거든요.

이 글에서는 원문이 짚어준 포인트를 놓치지 않으면서, 실제 앱에 붙일 때 알아두면 좋은 설계 팁과 마이그레이션 포인트까지 곁들여보려 해요.

무엇이 달라졌나 nuqs 2.5.0의 전체 지도

nuqs 2.5.0은 고빈도 입력에 대응하는 URL 업데이트 제어를 한층 섬세하게 만들었고, 대규모 앱에서 흔한 '불필요 렌더링'을 근본부터 줄이는 장치를 더했는데요.

여기에 타입 정의의 단일 소스로부터 서버와 라우터까지 밸리데이션을 끌고 가는 'Standard Schema'가 추가되어 협업 비용이 크게 낮아진 게 체감 포인트죠.

Debounce로 다스리는 URL 업데이트 limitUrlUpdates의 등장

이번 버전의 하이라이트 중 하나가 'limitUrlUpdates' 옵션이에요.

이전에는 'throttleMs' 중심으로 제어했다면, 이제는 'debounce'와 'throttle' 두 방식을 상황에 맞춰 고를 수 있게 됐죠.

검색창처럼 마지막 입력만 의미 있는 시나리오에는 'debounce'가 잘 맞고, 슬라이더처럼 연속 피드백이 필요한 UI에는 'throttle'이 어울린다는 건 익숙한 감각일 거예요.

고빈도 입력마다 URL이 갱신되면 여러 문제가 연쇄로 터지는데요.

히스토리를 'push'로 쌓는 경우 브라우저 기록이 과도하게 불어나고, 쿼리 파라미터를 트리거로 API를 부르면 초당 수십 번의 호출이 찍히곤 하죠.

이럴 때 'limitUrlUpdates'가 안전망처럼 중간에서 트래픽을 고르게 깔아주면 UX와 부하가 동시에 안정됩니다.

import { useQueryState, debounce, throttle } from 'nuqs'

export default function SearchBox() {
  const [q, setQ] = useQueryState('q')

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQ(e.target.value, {
      limitUrlUpdates: debounce(400), // 마지막 입력 후 400ms 뒤에 1번만 반영
      history: 'push',
    })
  }

  return <input value={q || ''} onChange={onChange} />
}

만약 특정 순간에 즉시 반영이 필요하다면 'defaultRateLimit'로 레이트 제한을 원상 복구하는 것도 가능해요.

이건 버튼 제출처럼 '지금은 지연 없이 가자'가 명확한 상호작용에서 유용하죠.

import { useQueryState, debounce, defaultRateLimit } from 'nuqs'

const [q, setQ] = useQueryState('q', {
  limitUrlUpdates: debounce(1000),
})

function onSubmit(value: string) {
  setQ(value, { limitUrlUpdates: defaultRateLimit }) // 이 시점은 즉시 반영
}

이전 옵션인 'throttleMs'에서 넘어올 때는 의미를 그대로 옮기기보다, 컴포넌트의 의도를 기준으로 'debounce'와 'throttle'을 선택해주는 편이 좋아요.

검색과 자동완성에는 'debounce', 드래그와 스크러빙에는 'throttle' 같은 역할 분담이 명확하면 유지보수도 쉬워지죠.

Key Isolation으로 잡는 불필요 렌더링 파도

'Key Isolation'은 nuqs 2.5.0에 새롭게 들어온 렌더링 최적화인데요.

요약하면 '내가 구독한 URL 키가 변할 때만 컴포넌트가 다시 그려지도록' 관찰 범위를 좁혀주는 기능이죠.

기존에는 어느 파라미터가 바뀌든 nuqs를 쓰는 컴포넌트 전반이 렌더를 타기 쉬웠고, 페이지가 커질수록 미묘한 지연과 잔 떨림이 쌓이곤 했거든요.

아래 예제를 보면 감이 와요.

function ProductFilters() {
  const [category, setCategory] = useQueryState('category', parseAsString)
  const [sort, setSort] = useQueryState('sort', parseAsString)

  // category나 sort가 바뀔 때만 렌더
  console.log('ProductFilters rendered')

  return (
    <div>
      <select value={category || ''} onChange={(e) => setCategory(e.target.value)}>
        <option value="">전체</option>
        <option value="electronics">전자기기</option>
      </select>
      <select value={sort || ''} onChange={(e) => setSort(e.target.value)}>
        <option value="">정렬</option>
        <option value="price">가격순</option>
      </select>
    </div>
  )
}

URL이 '/?category=electronics'로 바뀌면 과거에는 nuqs를 쓰는 다양한 컴포넌트가 함께 흔들렸는데요.

이제는 'category'에 관심 있는 컴포넌트만 재렌더링되고, 다른 컴포넌트는 손대지 않은 것처럼 그대로 유지돼요.

프레임워크 호환성도 넓은 편이라 React SPA, React Router, Remix, TanStack Router에서 모두 효과를 볼 수 있죠.

설정이 따로 필요한 것도 아니라서, 업그레이드만으로 실익을 챙기기 쉬운 영역입니다.

Standard Schema로 여는 단일 소스 밸리데이션

'Standard Schema'는 2.5.0에서 도입된 밸리데이션 인터페이스인데요.

핵심은 'nuqs의 파서 정의에서 바로 재사용 가능한 밸리데이터를 뽑아 다른 도구들과 공유한다'는 아이디어죠.

TypeScript 생태계의 밸리데이션 라이브러리들이 이 스펙을 공통 분모로 삼으면, 도구 간 타입 안전성을 손쉽게 연결할 수 있어요.

import {
  createStandardSchemaV1,
  parseAsInteger,
  parseAsString,
} from 'nuqs' // 혹은 'nuqs/server'

export const searchParams = {
  searchTerm: parseAsString.withDefault(''),
  maxResults: parseAsInteger.withDefault(10),
  category: parseAsString,
}

export const validateSearchParams = createStandardSchemaV1(searchParams)

이렇게 만든 'validateSearchParams'는 tRPC나 TanStack Router 같은 곳에서도 곧바로 입력 검증으로 재사용할 수 있어요.

한 번 정의하면 프론트의 URL 상태와 백엔드 API가 같은 타입 정의를 바라보게 되니, 모델 불일치로 인한 버그 밀도가 눈에 띄게 줄어들죠.

tRPC와의 통합 같은 타입으로 끝까지 가져가기

tRPC에서는 프로시저 입력으로 Standard Schema를 그대로 꽂을 수 있는데요.

프론트에서 쓰던 검색 파라미터 정의가 서버에서 바로 타입 안전한 입력으로 이어지는 그림이 만들어집니다.

import { router, publicProcedure } from './trpc'
import { validateSearchParams } from './search-params'

export const appRouter = router({
  search: publicProcedure
    .input(validateSearchParams)
    .query(async ({ input }) => {
      const { searchTerm, maxResults, category } = input
      return await searchProducts({
        query: searchTerm,
        limit: maxResults,
        category,
      })
    }),

  getProductCategories: publicProcedure
    .query(async () => {
      return await getCategories()
    }),
})

여기서 중요한 건 '단일 소스'라는 개념인데요.

URL 파라미터의 파서 정의가 사실상 도메인 모델의 일부가 되고, 이 모델이 라우터와 API 레이어로 올라가며 그대로 검증틀을 형성하죠.

Zod, Valibot, ArkType 같은 라이브러리들이 Standard Schema를 매개로 상호 운용되면, 팀은 더 적은 어댑터로 더 넓은 호환성을 얻게 됩니다.

TanStack Router 지원 실험 nuqs는 어떻게 끼어드나

nuqs 2.5.0은 TanStack Router에 대한 실험적 지원을 포함하고 있어요.

다만 이건 'TanStack Router 자체를 대체하려는' 목적이 아니라, nuqs를 사용하는 외부 라이브러리와의 호환성을 위해 문턱을 낮추는 성격에 가까워요.

TanStack Router 앱을 쓰는 입장에서는 기본 제공 API가 이미 강력하니, nuqs 도입이 필수라는 의미는 아니죠.

설정은 익숙한 어댑터 패턴으로 시작해요.

// app/root.tsx
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router'
import { Outlet } from '@tanstack/react-router'

export default function App() {
  return (
    <NuqsAdapter>
      <Outlet />
    </NuqsAdapter>
  )
}

라우트 정의에서 'createStandardSchemaV1'로 URL 검색 파라미터 밸리데이션을 결합할 수 있어요.

import { createFileRoute } from '@tanstack/react-router'
import { useQueryState, parseAsString, parseAsInteger, createStandardSchemaV1 } from 'nuqs'

const searchParams = {
  q: parseAsString.withDefault(''),
  page: parseAsInteger.withDefault(1),
}

export const Route = createFileRoute('/search')({
  validateSearch: createStandardSchemaV1(searchParams, { partialOutput: true }),
  component: SearchPage,
})

function SearchPage() {
  const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>페이지: {page}</p>
    </div>
  )
}

지원 방향은 계속 보조적 기능으로 이어질 예정이고, 세부 진행은 공식 공지를 참고하는 게 안전해요.

참고 링크는 다음과 같고, 최신 논의가 올라오면 함께 따라가면 됩니다.

https://x.com/nuqs47ng/status/1959166202036490354

기타 개선 더 정교해진 타입과 더 작은 번들

문자열 리터럴 파서에 'as const'를 붙이면 더 정확한 유니언 타입으로 추론돼요.

'parseAsStringLiteral'에 'const' 수식자를 적용하면 상태 값의 타입이 'string'이 아니라 구체 리터럴 유니언으로 내려가죠.

// 이전
const statusParser = parseAsStringLiteral(['draft', 'published', 'archived'])
// 타입: string

// 이후
const statusParser = parseAsStringLiteral(['draft', 'published', 'archived'] as const)
// 타입: 'draft' | 'published' | 'archived'

const [status, setStatus] = useQueryState('status', statusParser)

'NuqsAdapter'는 앱 전역 기본 옵션을 설정할 수 있게 확장되었는데요.

히스토리 처리나 스크롤, 기본값 클리어 정책을 한 번에 통일하면 제품 전체 UX가 고르게 맞춰집니다.

import { NuqsAdapter } from 'nuqs/adapters/next/app'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <NuqsAdapter
          defaultOptions={{
            history: 'push',
            shallow: false,
            scroll: true,
            clearOnDefault: true,
          }}
        >
          {children}
        </NuqsAdapter>
      </body>
    </html>
  )
}

번들 사이즈는 런타임 의존성을 비우면서도 더 줄었는데요.

gzipped 기준 'nuqs@2.4.x'가 약 6.2KB였다면 'nuqs@2.5.0'은 약 5.4KB로 13% 가까이 줄었습니다.

트리 셰이킹도 강화되어 사용하지 않는 파서는 번들에서 자연스럽게 빠지고, 필요한 조각만 담긴 모듈이 만들어지죠.

import { parseAsString, parseAsInteger } from 'nuqs'
// parseAsFloat, parseAsBoolean 등은 사용하지 않으면 번들에서 제외

API도 다듬어졌는데요.

'defaultRateLimit'가 추가되어 커스텀 레이트 제한을 상황별로 무력화하고 기본 동작으로 빠르게 되돌릴 수 있어요.

'urlKeys' 타입 추론이 개선되어 파서 정의를 기준으로 안전한 키 매핑을 강제하는 것도 특징이죠.

import { type UrlKeys } from 'nuqs'

const parsers = {
  latitude: parseAsFloat.withDefault(35.6762),
  longitude: parseAsFloat.withDefault(139.6503),
}

const urlKeys: UrlKeys<typeof parsers> = {
  latitude: 'lat',
  longitude: 'lng',
}

Next.js 15.5에서 선보인 'typed routes' 흐름에 맞춰, 'createTypedLink()' 유틸 제안도 올라왔어요.

nuqs 자체 기능 변화는 아니지만, 타입 안전 링크 생성과 URL 상태의 결합을 더 단단히 만드는 방향으로 읽으면 좋아요.

마이그레이션 가이드 throttleMs에서 limitUrlUpdates로

먼저 의도를 정리하는 게 좋아요.

'throttleMs'는 일정 간격으로 연속 업데이트를 허용하는 전략이라 실시간 피드백에 어울렸고, 'limitUrlUpdates'는 'debounce'와 'throttle' 중 하나를 선택하는 포괄 인터페이스죠.

기존 코드에서 'throttleMs: n'을 쓰던 곳은 'limitUrlUpdates: throttle(n)'으로 옮기면 의미가 보존돼요.

반대로 검색창처럼 빠르게 입력되는 텍스트 필드는 'limitUrlUpdates: debounce(n)'로 바꾸면 히스토리 폭증과 API 남발을 깔끔히 억제할 수 있죠.

SSR을 쓴다면 주의할 점도 있는데요.

URL 상태가 클라이언트에서 지연 반영될 때 서버 렌더된 초기 콘텐츠와의 불일치가 잠깐 보일 수 있어요.

이럴 땐 'default 값'과 'withDefault'를 명시해 초기 하이드레이션 상태를 고정하고, 상호작용 이후에만 지연 반영되도록 설계를 명료하게 가져가면 안전하죠.

성능과 DX를 높이는 운영 팁 실전에서 부딪히는 것들

Key Isolation은 기본으로 작동하니 추가 설정은 필요 없는데요.

다만 상위에서 '모든 키'를 구독하는 헬퍼를 만들어 무심코 끼워 넣으면 고립의 이점을 스스로 지우게 돼요.

컴포넌트가 실제로 쓰는 키만 구독하도록 훅을 좁히는 습관이 성능을 방어하는 첫 단추죠.

Debounce와 Throttle은 이벤트 소스의 특성을 꾸준히 관찰하는 게 중요해요.

자동완성처럼 네트워크 왕복이 있는 컴포넌트는 서버 SLA와 타임아웃을 고려해 디바운스 시간을 잡고, 슬라이더는 애니메이션 프레임과 어긋나지 않는 스로틀 간격으로 균형을 맞추면 매끄럽죠.

Standard Schema는 타입 정의의 '단일 소스화'가 핵심이라 버전 관리를 신중하게 가져가야 해요.

Schema 변경이 들어가면 클라이언트와 서버, 라우터 모두에 릴리스 노트를 공유하고, e2e에서 URL 파라미터의 경계값을 테스트로 고정해두면 사고가 줄어들죠.

정리 nuqs 2.5.0이 주는 실제 이득

이번 릴리스는 URL 상태의 빈번한 변화에 제동을 걸어 성능과 기록을 지켜내고, 키 단위로 렌더링을 고립시켜 대규모 화면의 잔 떨림을 줄여주는데요.

여기에 Standard Schema가 연결되면서 타입은 한 번 정의해 여러 계층에서 재사용되고, TanStack Router 지원 실험으로 호환성의 범위도 넓어졌죠.

번들 다이어트와 트리 셰이킹 개선으로 기본 체력도 올라갔으니, 앱 전체의 체감이 한 단계 나아지는 업데이트라고 보면 맞아요.

원문 기반 코드와 설정 다시 보기

Debounce와 Throttle은 'limitUrlUpdates' 한 자리에 모였고, 'defaultRateLimit'으로 예외 구간을 빠르게 벗어날 수 있는데요.

Key Isolation은 특별한 설정 없이도 '관심 있는 키만 다시 그린다'는 기본 원칙을 지켜줘요.

Standard Schema는 'createStandardSchemaV1'로 뽑아 tRPC와 라우터에서 동일한 타입으로 입력을 검증하고, TanStack Router는 어댑터와 라우트 설정을 통해 nuqs와 자연스럽게 연결되죠.

나머지 개선은 'as const' 기반의 정확한 유니언 추론, 전역 기본 옵션, 더 작은 번들, 더 공격적인 트리 셰이킹, 안전한 urlKeys 타입으로 요약됩니다.