Next.js nuqs 쓰는 이유: URL 쿼리 상태를 useState처럼 다루기

nuqs는 URL 쿼리 문자열을 useState처럼 읽고 쓰게 해 주는 라이브러리다. Next.js 기본 도구로는 직접 챙겨야 하는 문자열 변환, 쿼리 조립, 서버 갱신 여부 선택, 업데이트 빈도 조절을 파서와 훅 하나로 묶어 준다.

Next.js

상태를 URL에 두는 이유

  1. 상품 목록의 검색어, 정렬 기준, 페이지 번호 같은 상태를 useState에 두면 그 값은 컴포넌트 메모리에만 있다.

  2. 그래서 새로고침하면 값이 초기화되고, 주소를 복사해 보내도 받은 사람에게는 같은 검색어와 페이지가 전해지지 않는다.

  3. 같은 값을 ?q=shoes&page=2처럼 URL 쿼리 문자열에 두면 주소 자체가 상태를 담는다.

  4. 주소가 상태를 담으므로 새로고침, 링크 공유, 북마크 뒤에도 URL에 담긴 검색어, 정렬, 페이지 상태를 복원할 수 있다.

  5. 다만 복원되는 것은 URL에 담긴 값뿐이고, 그 값으로 가져오는 데이터나 사용자 권한까지 같아지지는 않는다.

  6. Next.js App Router는 쿼리 문자열을 다루는 도구로 useSearchParams와 useRouter를 준다.

Next.js 기본 도구로 쿼리 상태를 다룰 때

  1. useSearchParams는 현재 쿼리 문자열을 읽기 전용 URLSearchParams 객체로 돌려준다.

    // 주소가 /products?q=shoes&page=2일 때
    const searchParams = useSearchParams()
    
    searchParams.get('q') // 'shoes'
    searchParams.get('page') // '2': 숫자도 문자열로 나온다
    searchParams.get('sort') // null: 주소에 없는 키는 null이다
    searchParams.set('page', '3') // Next.js 16 기준 ReadonlyURLSearchParamsError를 던진다
  2. 이 객체를 고쳐도 브라우저 주소창은 바뀌지 않고 React도 화면을 다시 그리지 않는다.

  3. 그래서 Next.js는 set 같은 고치는 메서드를 막아 두고, 값을 바꾸려면 URL 자체를 바꾸게 한다.

  4. URL을 바꾸는 방법은 라우터 이동인 router.replace·router.push와, 브라우저 API인 window.history.replaceState·pushState 두 갈래다.

  5. Next.js는 history 호출도 라우터에 통합해 useSearchParams와 동기화하므로, 두 방법 모두 바뀐 쿼리가 화면에 반영된다.

  6. 어느 쪽을 쓰든 기본 도구로 쿼리 상태를 다루면 아래 다섯 가지 불편함이 생긴다.

  7. 첫째로, 쿼리 값은 항상 문자열이라 숫자, 불리언, 배열이 필요할 때마다 변환과 예외 처리를 컴포넌트마다 반복한다.

    // 주소: ?inStock=false&tags=sale,new
    const inStock = searchParams.get('inStock') === 'true' // 'false'도 빈 문자열이 아니어서 truthy이므로 문자열 비교로 바꾼다
    const tags = (searchParams.get('tags') ?? '').split(',') // ['sale', 'new']: 배열은 직접 쪼갠다
  8. 둘째로, router.replace나 history.replaceState에 넘긴 쿼리 문자열은 기존 쿼리 전체를 덮어쓰므로, 한 키만 바꾸려 해도 매번 기존 쿼리를 복사해 조립해야 한다.

    // 현재 주소: ?q=shoes&sort=price&page=2에서 page만 3으로 바꾸고 싶다
    router.replace('?page=3') // 결과 ?page=3: 넘긴 문자열이 쿼리 전체를 덮어써서 q와 sort가 사라진다
    
    // 그래서 기존 쿼리를 복사한 뒤 page만 고쳐서 넘긴다
    const params = new URLSearchParams(searchParams.toString())
    params.set('page', '3')
    router.replace(`?${params.toString()}`) // 결과 ?q=shoes&sort=price&page=3
  9. 셋째로, 라우터 이동은 동적 페이지에서 Server Component 결과를 서버에 다시 요청할 수 있어서, 정렬처럼 클라이언트에서만 쓰는 쿼리도 불필요한 _rsc 요청을 만들 수 있다.

  10. 실제 요청 수는 프리페치와 클라이언트 캐시에 따라 달라지므로, 클릭 수와 늘 같지는 않다.

  11. history.replaceState를 직접 쓰면 이 요청은 피하지만, 서버 컴포넌트가 값을 알아야 하는 쿼리는 라우터 이동으로 바꿔야 하므로 쿼리마다 어느 쪽을 쓸지 골라 코드를 나눠야 한다.

  12. 넷째로, 검색창처럼 키 입력마다 URL을 바꾸면 History API 호출이 몰리고, 브라우저는 짧은 시간에 너무 많이 호출하면 호출을 막는다.

  13. 예를 들어 Safari는 짧은 시간 안에 100번을 넘는 history.replaceState 호출에 SecurityError를 던진다(기준 시간은 WebKit 버전에 따라 10초나 30초로 보고된다).

  14. 직접 디바운스 훅을 붙이면 호출 수는 줄지만, URL이 늦게 바뀌는 동안 입력창에 보일 값을 따로 useState로 들고 URL과 맞추는 코드가 검색창마다 붙는다.

  15. 다섯째로, 서버 컴포넌트도 searchParams로 같은 쿼리를 읽는데 여기서도 값이 문자열이나 문자열 배열이라, 클라이언트와 같은 변환 코드를 서버 쪽에 한 벌 더 짜야 하고 두 쪽 규칙이 어긋날 수 있다.

  16. nuqs는 이 다섯 가지를 차례로 파서, 자기 키만 고치는 상태 업데이트 함수(setter), shallow 옵션, URL 업데이트 큐, 서버용 로더로 맡는다.

nuqs란

https://nuqs.dev

https://github.com/47ng/nuqs

  1. nuqs는 쿼리 문자열의 한 키를 React 상태처럼 쓰게 해 주는 훅 라이브러리이고, GitHub README는 스스로를 URL 쿼리 문자열에 저장되는 useState라고 소개한다.

  2. 핵심 API는 useQueryState(key, parser)이며, useState처럼 [값, 업데이트 함수] 쌍을 돌려준다.

    const [q, setQ] = useQueryState('q') // ?q=shoes면 q는 'shoes', 키가 없으면 null
    setQ('boots') // 주소가 ?q=boots로 바뀌고 q도 'boots'가 된다
    setQ(null) // 주소에서 q 키를 지운다
  3. 이 글은 nuqs 2.10.2와 Next.js App Router를 기준으로 하며, nuqs 2.x는 Next.js 14.2.0 이상을 지원한다.

  4. nuqs는 프레임워크마다 URL을 바꾸는 방법이 달라서, Next.js App Router에서는 루트 레이아웃을 NuqsAdapter로 감싸 Next.js용 구현을 연결한다.

    // app/layout.tsx
    import { NuqsAdapter } from 'nuqs/adapters/next/app'
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        <html lang="ko">
          <body>
            {/* 이 아래의 useQueryState가 Next.js의 라우터와 History API를 쓰도록 연결한다 */}
            <NuqsAdapter>{children}</NuqsAdapter>
          </body>
        </html>
      )
    }
  5. 페이지 번호를 읽고 하나 올리는 코드를 기본 도구와 nuqs로 나란히 쓰면 아래와 같다.

    // before: Next.js 기본 도구 (컴포넌트 본문 안)
    const router = useRouter()
    const searchParams = useSearchParams()
    const parsed = parseInt(searchParams.get('page') ?? '')
    const page = Number.isNaN(parsed) ? 1 : parsed // 첫째: 문자열을 숫자로 바꾸고 기본값을 직접 채운다
    
    function goNext() {
      const params = new URLSearchParams(searchParams.toString()) // 둘째: 다른 키를 지키려고 복사한다
      params.set('page', String(page + 1))
      router.replace(`?${params.toString()}`) // 셋째: 동적 페이지면 서버 요청이 생길 수 있다
    }
    
    // after: nuqs (컴포넌트 본문 안)
    const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)) // 변환과 기본값은 파서가 맡는다
    
    function goNext() {
      setPage(page + 1) // 다른 키는 그대로 두고, 기본으로 서버 요청 없이 주소만 고친다
    }
  6. nuqs 쪽 코드에는 문자열 변환, 쿼리 복사, URL을 바꿀 API 선택을 다루는 줄이 없다.

해결1: 파서가 문자열 변환을 맡는다

  1. Next.js 기본 도구에서는 쿼리 값이 항상 문자열이라 숫자, 불리언, 배열로 바꾸는 코드를 컴포넌트마다 반복했다.

  2. nuqs에서는 이 변환을 파서가 맡으며, 파서는 URL의 문자열을 값으로 바꾸는 parse와 값을 다시 문자열로 바꾸는 serialize를 한 쌍으로 묶은 객체다.

    parseAsInteger.parse('3') // 3: 주소에서 읽은 문자열을 숫자로 바꾼다
    parseAsInteger.serialize(3) // '3': 숫자를 주소에 쓸 문자열로 바꾼다
  3. parseAsInteger의 parse는 parseInt로 숫자를 만들고, 결과가 NaN이면 null을 돌려준다.

  4. null이 나오면 withDefault(1)로 정한 기본값이 대신 쓰인다.

  5. 같은 파서에 여러 주소를 넣으면 page 값은 아래처럼 정해진다.

    const [page] = useQueryState('page', parseAsInteger.withDefault(1))
    
    // ?page=3      -> 3
    // ?page=abc    -> 1: parse가 null을 돌려줘 기본값을 쓴다
    // page 키 없음 -> 1: 읽을 문자열이 없어 기본값을 쓴다
    // ?page=-3     -> -3: 범위는 검사하지 않는다
    // ?page=2abc   -> 2: parseInt가 앞쪽 숫자만 읽는다
  6. 기본값이 있으면 값이 null일 수 없으므로 TypeScript도 page를 number로 추론한다.

  7. 반대 방향으로, 업데이트 함수로 기본값과 같은 값을 넣으면 clearOnDefault 옵션(2.x 기본값 true) 때문에 page 키가 URL에서 지워진다.

    // 현재 주소: ?q=shoes&page=2
    setPage(3) // ?q=shoes&page=3
    setPage(1) // ?q=shoes: 기본값 1과 같아서 page 키를 지운다
  8. 이미 주소에 있던 ?page=1을 읽기만 할 때는 지우지 않는다.

  9. 숫자 말고 다른 타입은 다른 내장 파서가 맡는다.

    parseAsBoolean // ?inStock=true -> true, ?inStock=yes -> false: 'true'와 같은지만 본다
    parseAsArrayOf(parseAsString) // ?tags=sale,new -> ['sale', 'new']: 쉼표로 나눈다
    parseAsStringLiteral(['asc', 'desc'] as const) // ?sort=asc -> 'asc', ?sort=up -> null: 목록에 없는 값은 버린다
  10. 앞 예시의 -3과 2abc처럼 parseAsInteger는 숫자 변환만 하고 범위는 검사하지 않는다.

  11. 범위 검사가 필요하면 createParser로 범위를 벗어난 값에 null을 돌려주는 파서를 직접 만든다.

    import { createParser } from 'nuqs'
    
    const parseAsPage = createParser({
      parse: (v) => {
        const n = parseInt(v)
        // 1보다 작거나 NaN이면 null을 돌려줘 withDefault의 기본값으로 떨어지게 한다
        return n >= 1 ? n : null
      },
      serialize: (n: number) => String(n), // 값을 주소에 쓸 문자열로 바꾼다
    })
    
    const [page] = useQueryState('page', parseAsPage.withDefault(1)) // ?page=-3 -> 1
  12. 형식이 복잡한 값은 파서 결과를 Zod 같은 스키마 라이브러리로 한 번 더 검사할 수도 있다.

해결2: 업데이트 함수는 자기 키만 고친다

  1. Next.js 기본 도구에서는 router.replace나 history.replaceState에 넘긴 문자열이 쿼리 전체를 덮어써서, 한 키를 바꿀 때마다 기존 쿼리를 복사해 조립했다.

  2. nuqs의 업데이트 함수는 이 복사와 조립을 안에서 대신한다.

  3. 2.10.2 소스를 보면 nuqs는 URL을 쓰기 직전에 현재 주소의 쿼리를 통째로 가져오고, 그 위에 바뀐 키만 set하거나 delete한다.

  4. 그래서 나머지 키는 손대지 않은 채로 남는다.

    // 현재 주소: ?q=shoes&sort=price&page=2
    const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
    setPage(3) // ?q=shoes&sort=price&page=3: q와 sort는 그대로 남는다
  5. 같은 순간에 여러 업데이트 함수를 불러도 nuqs는 바뀐 키를 큐에 모았다가 한 번의 URL 쓰기로 합친다.

  6. 여러 키를 처음부터 묶어 다루고 싶으면 useQueryStates를 쓴다.

    const [{ q, page }, setParams] = useQueryStates({
      q: parseAsString.withDefault(''),
      page: parseAsInteger.withDefault(1),
    })
    
    // 검색어를 바꾸면서 페이지를 1로 되돌린다. page는 기본값과 같아 주소에서 지워진다
    setParams({ q: 'boots', page: 1 })

해결3: shallow 옵션으로 서버 갱신 여부를 고른다

  1. Next.js 기본 도구에서는 라우터 이동을 쓰면 불필요한 _rsc 요청이 생길 수 있었고, history.replaceState를 쓰면 쿼리마다 어느 쪽을 쓸지 직접 골라야 했다.

  2. nuqs의 shallow 옵션은 기본값이 true이고, 이때 URL만 고치고 서버에는 알리지 않는다.

  3. Next.js App Router 어댑터 소스를 보면 shallow: true일 때 router.replace를 부르지 않고 브라우저의 history API만 직접 부른다.

  4. 이때 부를 메서드는 history 옵션이 정하며, 기본값 'replace'면 history.replaceState로 현재 기록을 덮어써 뒤로 가기 기록이 늘지 않고, 'push'면 history.pushState로 기록을 하나씩 쌓는다.

  5. 즉 shallow는 서버를 다시 렌더링할지를, history는 방문 기록을 어떻게 남길지를 정하는 별개의 옵션이다.

  6. Next.js는 history 호출을 useSearchParams와 동기화하므로, useQueryState 값은 바로 바뀌지만 서버 요청은 생기지 않는다.

  7. 스크롤도 기본으로 움직이지 않는다(scroll 기본값 false).

  8. 대가는 서버 컴포넌트가 바뀐 쿼리를 모른다는 것이다.

  9. 페이지 컴포넌트가 searchParams로 쿼리를 읽어 데이터를 가져온다면 그 값은 갱신되지 않는다.

  10. 이럴 때는 shallow: false를 주며, 그러면 어댑터가 history 호출 뒤에 router.replace(url, { scroll: false })를 불러 서버 컴포넌트를 다시 렌더링하게 한다.

    // 컴포넌트 본문 안: page가 바뀌면 서버 컴포넌트도 새 page로 다시 그려진다
    const [page, setPage] = useQueryState(
      'page',
      parseAsInteger.withDefault(1).withOptions({ shallow: false }),
    )
  11. 서버 요청 없이 URL을 바꾸는 방법 자체는 기본 도구에도 있고, nuqs는 그 선택을 키마다 옵션 하나로 바꾸게 해 준다.

해결4: URL 쓰기를 모아서 내보낸다

  1. Next.js 기본 도구에서는 키 입력마다 History API를 부르면 브라우저 제한에 걸릴 수 있었고, 직접 디바운스를 붙이면 입력창용 상태를 따로 들어야 했다.

  2. nuqs는 업데이트 함수가 불릴 때마다 URL을 바로 쓰지 않고, 업데이트를 큐에 모았다가 일정 간격으로 내보낸다.

  3. 그래서 업데이트 함수를 부른 횟수와 실제 History API 호출 횟수는 다를 수 있다.

  4. 2.10.2 소스 기준 기본 간격은 50ms이고, Safari 17 이상은 120ms, 그 이전 Safari는 320ms를 쓴다.

  5. 간격 안에 여러 번 값을 바꾸면 첫 업데이트는 바로 URL에 쓰이고, 그 뒤 값들은 모였다가 마지막 값으로 한 번 쓰인다.

  6. 이때 늦춰지는 것은 URL 쓰기뿐이고 훅이 돌려주는 값은 호출 즉시 바뀌므로, 입력창용 useState를 따로 두지 않아도 입력창이 밀리지 않는다.

  7. 검색어처럼 shallow: false로 서버 요청까지 일으키는 값은 limitUrlUpdates: debounce(300)을 주어 입력이 멈춘 뒤 한 번만 URL과 서버를 갱신하게 할 수 있다.

    import { debounce, parseAsString, useQueryState } from 'nuqs'
    
    // 컴포넌트 본문 안에서 부른다
    const [q, setQ] = useQueryState(
      'q',
      parseAsString.withDefault('').withOptions({
        shallow: false, // 서버 컴포넌트가 q로 검색 결과를 가져온다
        limitUrlUpdates: debounce(300), // 키우면 서버 요청이 줄고 결과가 늦게 뜨며, 줄이면 그 반대가 된다
      }),
    )
  8. limitUrlUpdates와 debounce는 2.5.0부터 있고, 그 전에는 throttleMs를 썼다.

해결5: 서버 컴포넌트도 같은 파서로 읽는다

  1. Next.js 기본 도구에서는 서버 컴포넌트가 읽는 쿼리도 문자열이라, 클라이언트와 같은 변환 코드를 서버 쪽에 한 벌 더 짜야 했다.

  2. Next.js 15부터 페이지는 searchParams를 Promise<{ [key: string]: string | string[] | undefined }>로 받고, Promise를 풀면 키마다 문자열, 같은 키가 반복되면 문자열 배열, 없으면 undefined가 들어 있다.

  3. nuqs는 같은 파서 객체로 서버에서 쿼리를 읽는 createLoader와 createSearchParamsCache를 준다.

  4. 서버와 클라이언트가 함께 쓰는 파서 파일은 'use client' 없이 두고, 파서를 nuqs가 아닌 nuqs/server에서 가져온다.

  5. 2.10.2 소스에서 nuqs 진입 파일은 첫 줄이 "use client"라서, 여기서 가져온 파서는 서버 컴포넌트에서 실제 함수로 쓸 수 없기 때문이다.

    // app/products/search-params.ts ('use client' 없음)
    import { createLoader, parseAsInteger, parseAsString } from 'nuqs/server' // 서버에서도 실행되도록 nuqs/server에서 가져온다
    
    // 서버와 클라이언트가 함께 쓰는 쿼리 규칙
    export const productParsers = {
      q: parseAsString.withDefault(''),
      page: parseAsInteger.withDefault(1),
    }
    
    // 서버 컴포넌트에서 searchParams를 같은 규칙으로 읽는 함수
    export const loadProductParams = createLoader(productParsers)
  6. 로더는 Promise인 searchParams를 그대로 받는다.

    // app/products/page.tsx
    import type { SearchParams } from 'nuqs/server'
    import { loadProductParams } from './search-params'
    
    export default async function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {
      // 클라이언트의 useQueryStates(productParsers)와 같은 타입, 같은 기본값을 얻는다
      const { q, page } = await loadProductParams(searchParams)
      // q와 page로 데이터를 가져와 그린다
      return <p>{q} 검색 결과 {page}페이지</p>
    }
  7. 클라이언트에서는 같은 productParsers를 useQueryStates(productParsers)에 넘기므로 기본값이나 타입을 바꿀 때 한 곳만 고치면 된다.

  8. 페이지보다 깊은 서버 컴포넌트에서 값을 읽어야 하면 createSearchParamsCache로 페이지에서 한 번 파싱하고 하위 컴포넌트에서 get으로 꺼낸다.

정리

  1. 검색어, 필터, 페이지 번호를 URL에 두면 새로고침과 링크 공유 뒤에도 URL에 담긴 상태를 복원할 수 있다.
  2. Next.js 기본 도구만 쓰면 문자열 변환, 쿼리 복사와 조립, 라우터 이동과 history API 중 무엇을 쓸지 고르는 일, History API 호출 빈도, 서버 컴포넌트의 중복 변환을 컴포넌트마다 직접 챙겨야 한다.
  3. 문자열 변환은 파서가 맡아, useQueryState가 타입 있는 값과 기본값을 돌려준다.
  4. 쿼리 복사와 조립은 업데이트 함수가 맡아, 현재 쿼리 위에 자기 키만 고친다.
  5. 서버 갱신 여부는 shallow 옵션이 정하며, 기본값 true는 history API만 불러 서버 요청을 건너뛰고 false는 router.replace로 서버 컴포넌트까지 갱신한다.
  6. 호출 빈도는 URL 업데이트 큐가 브라우저 제한 안으로 맞추고, 필요하면 debounce로 서버 요청까지 줄인다.
  7. nuqs/server에서 가져온 같은 파서 객체를 createLoader에 넘기면 서버 컴포넌트도 클라이언트와 같은 규칙으로 쿼리를 읽는다.
  8. 다만 parseAsInteger 같은 내장 파서는 범위를 검사하지 않으므로, 범위가 중요한 값은 직접 만든 파서나 스키마 검증으로 걸러야 한다.

댓글