홈으로 돌아가기

Next.js App Router에서 상태의 소스로서 URL

Next.js App Router에서 URL이 페이지 상태의 단일 소스가 됩니다. server components가 searchParams를 읽고 렌더링 전에 콘텐츠를 구성합니다. 클라이언트 폼이 추가 useEffect 없이 브라우저 히스토리와 동기화됩니다.

Next.js App Router에서 URL이 데이터 제어
Advertisement 728x90

Next.js App Router에서 URL을 단일 진실의 원천으로 사용하기

Next.js App Router에서는 URL이 페이지 상태의 최종 결정 요소가 됩니다. 로컬 useState를 주소 표시줄과 동기화하는 대신, 서버 렌더링된 페이지는 searchParams를 읽고 즉시 콘텐츠를 생성합니다. 이로 인해 입력 필드, 데이터, 탐색 간의 불일치가 사라집니다.

제품 검색의 경우 이렇게 작동합니다: URL에 ?q=phone이 포함 → 서버가 파라미터 추출 → API 호출 → 바로 표시할 수 있는 목록 렌더링. 뒤로/앞으로 탐색 및 직접 링크도 예측 가능하게 동작합니다.

클라이언트 측 접근 방식의 일반적인 실수

기존의 React 코드에서 useEffect를 사용하면 여러 개의 진실 원천이 생깁니다:

Google AdInline article slot
"use client";

import { useEffect, useState } from "react";

export default function GoodsPage() {
  const [q, setQ] = useState("");
  const [items, setItems] = useState([]);

  useEffect(() => {
    async function load() {
      const res = await fetch(`/api/goods?q=${encodeURIComponent(q)}`);
      const data = await res.json();
      setItems(data.products);
    }

    load();
  }, [q]);

  return (
    <>
      <input value={q} onChange={e => setQ(e.target.value)} />
      <div>{items.map(item => <div key={item.id}>{item.title}</div>)}</div>
    </>
  );
}

결과:

  • 직접 링크는 상태를 복원하지 못함
  • 페이지 새로 고침 시 데이터 초기화
  • 뒤로/앞으로 탐색이 UX를 해침
  • 입력값과 URL 간 수동 동기화 필요

서버 측 모델: 렌더링 전 데이터 확보

서버 컴포넌트는 서버에서 searchParams를 읽고 하이드레이션 전에 데이터를 가져옵니다:

  • URL이 필터링 파라미터를 저장
  • 서버가 searchParams 파싱
  • 캐싱된 요청 실행
  • 미리 로드된 props로부터 UI 렌더링

제품용 유니버설 데이터 계층:

Google AdInline article slot
// src/app/_data/dummyjson.js
const API_BASE = "https://dummyjson.com";

async function fetchJson(url, fetchOptions = {}) {
  const res = await fetch(url, fetchOptions);

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    const err = new Error(`DummyJSON error: ${res.status} ${res.statusText}. ${text}`);
    err.status = res.status;
    throw err;
  }

  return res.json();
}

export async function getProducts({ q = "", limit = 12, skip = 0 } = {}) {
  const safeQ = String(q).trim();
  const qs = new URLSearchParams({
    limit: String(limit),
    skip: String(skip),
  });

  const url = safeQ
    ? `${API_BASE}/products/search?${qs.toString()}&q=${encodeURIComponent(safeQ)}`
    : `${API_BASE}/products?${qs.toString()}`;

  return fetchJson(url, {
    next: { revalidate: 60 },
  });
}

검색 로직은 격리되어 있어 페이지 컴포넌트는 깔끔하고 집중력 있게 유지됩니다.

서버 측 페이지 구현하기

// src/app/(app)/goods/page.js
import Link from "next/link";
import { getProducts } from "@/app/_data/goodsApi";
import GoodsSearchBar from "@/app/_ui/GoodsSearchBar";
import GoodsGridMotionClient from "@/app/_ui/GoodsGridMotionClient";

export default async function GoodsPage({ searchParams }) {
  const sp = await searchParams;
  const q = typeof sp?.q === "string" ? sp.q : "";

  const data = await getProducts({ q, limit: 12, skip: 0 });

  const isEmpty = !data?.products?.length;
  const hasFilter = !!String(q || "").trim();

  return (
    <div>
      <h1>제품 목록</h1>
      <GoodsSearchBar initialQuery={q} />

      {isEmpty ? (
        <div>
          <h2>결과 없음</h2>

          {hasFilter ? (
            <p>"{q}"와 일치하는 제품이 없습니다</p>
          ) : (
            <p>목록이 비어 있습니다</p>
          )}

          <Link href="/goods">모든 제품 보기</Link>
        </div>
      ) : (
        <GoodsGridMotionClient products={data.products} />
      )}
    </div>
  );
}

URL이 페이지 콘텐츠를 완전히 결정합니다.

URL 동기화를 통한 클라이언트 측 검색

입력은 여전히 클라이언트 측에 있지만, 제출 후에만 URL을 업데이트합니다:

Google AdInline article slot
"use client";

import { useEffect, useMemo, useState } from "react";

import { useRouter, useSearchParams } from "next/navigation";

function getFirstQ(sp) {
  const all = sp.getAll("q");
  return all[0] ?? "";
}

export default function GoodsSearchBar({ basePath = "/goods" }) {
  const router = useRouter();
  const sp = useSearchParams();

  const qFromUrl = getFirstQ(sp);
  const [value, setValue] = useState(qFromUrl);

  useEffect(() => {
    setValue(qFromUrl);
  }, [qFromUrl]);

  function buildHref(nextQ) {
    const q = nextQ.trim();
    return q ? `${basePath}?q=${encodeURIComponent(q)}` : basePath;
  }

  function onSubmit(e) {
    e.preventDefault();
    router.push(buildHref(value));
  }

  function onReset() {
    router.push(basePath);
  }

  const canSearch = useMemo(() => value.trim().length > 0, [value]);

  return (
    <form onSubmit={onSubmit}>
      <input
        value={value}
        onChange={e => setValue(e.target.value)}
        placeholder="예: 스마트폰"
      />

      <button type="submit" disabled={!canSearch}>
        검색
      </button>

      <button type="button" onClick={onReset}>
        초기화
      </button>
    </form>
  );
}

핵심 포인트:

  • useSearchParams()는 입력을 브라우저 히스토리와 동기화합니다
  • router.push()는 클라이언트 측 요청 없이 URL을 변경합니다
  • getFirstQ()는 중복 쿼리 파라미터 처리

이 아키텍처의 장점

  • 단일 진실의 원천: URL이 페이지 상태를 정의
  • 직접 링크: /goods?q=phone은 항상 동일한 결과 반환
  • 탐색: 뒤로/앞으로 탐색 시 정확한 상태 복원
  • 서버 렌더링: 하이드레이션 전에 데이터 로딩
  • 디버깅: 상태가 URL에 명확히 노출됨

URL 파라미터 사용을 피해야 할 상황

URL 파라미터에 적합한 경우:

  • 검색 쿼리
  • 정렬 및 필터링
  • 페이징
  • 뷰 모드

클라이언트 상태에 유지하는 것이 더 좋은 경우:

  • 드롭다운 및 모달
  • 애니메이션
  • 임시 입력값
  • 호버 또는 포커스 상태

피해야 할 세 가지 치명적 실수

  • 동기화되지 않은 이중 상태: useState와 URL을 조율 없이 혼용
  • 서버 컴포넌트 내부에서 클라이언트 측 fetch: useEffect를 통해
  • 폼 제출 후 입력을 주요 원천으로 삼기: URL이 아닌

가장 중요한 점:

  • App Router에서는 URL이 페이지 데이터를 정의해야 함
  • 서버 컴포넌트는 렌더링 전에 searchParams를 읽어야 함
  • 클라이언트 입력은 useSearchParams()와 동기화해야 함
  • 다중 파라미터는 getAll()로 처리해야 함

— Editorial Team

Advertisement 728x90

다음 읽기