본문 바로가기

라우트 파라미터와 쿼리 문자열

pathname의 동적 세그먼트와 search 값을 구분하고, URL을 공유·복원 가능한 화면 상태의 원본으로 다룹니다.

URL은 주소이면서, 사용자가 복사하고 새로고침하고 뒤로 가기로 되돌릴 수 있는 바깥의 상태이기도 합니다. React Router에서는 이 URL을 한 덩어리로 보지 않고 pathname, search, hash처럼 역할이 다른 부분으로 나눠 읽습니다.

/products/42?category=electronics&q=키보드를 예로 들면, /products/42는 라우트의 가지를 고르는 경로(pathname)이고, ? 뒤는 선택된 화면의 보기 조건인 쿼리(search)입니다. 다만 “경로는 언제나 리소스의 ID이고 쿼리는 언제나 선택 사항”이라는 규칙은 문법이 아니라 흔한 애플리케이션 설계의 관례일 뿐입니다. 어떤 값이 꼭 필요하고 무엇을 식별하는지는 앱마다 URL을 어떻게 쓸지 정하는 규칙으로 결정해야 합니다.

공유와 복원 계약에 맞춰 화면 값을 저장합니다

REACT ROUTING · URL & RESPONSIBILITY

공유와 복원 계약에 맞춰 화면 값을 저장합니다

pathname은 route branch 선택에, search는 선택된 화면의 필터·검색 입력에 참여합니다. navigation state와 component state는 복사한 URL의 일부가 아닙니다.

실제 제품 검색 · 주소만으로 같은 목록을 복원

/products
?category=electronics&q=키보드
category: electronics
query: 키보드
id "2" · 키보드 / 결과 1개

category가 unknown이면 화면에서는 전체 범주로 해석하지만 주소를 자동으로 고치지는 않습니다. 비밀값은 주소·history·로그에 노출될 수 있는 URL에 넣지 않습니다.

화면 값의 위치별 router 동작과 애플리케이션 계약
위치 router 동작 흔한 계약 읽기와 값
pathname · /products/42 path 패턴과 비교해 route branch 선택에 참여 계층·리소스 식별에 자주 사용하지만 앱이 결정 useParams() · 문자열 또는 undefined

search

?q=키보드

pathname branch를 바꾸지 않고 선택된 화면의 입력 검색·필터·정렬·페이지에 자주 사용하지만 앱이 결정 useSearchParams() · 문자열·반복 key 가능
navigation state 특정 history entry에 연결되고 주소에는 표시되지 않음 전환 출처처럼 복사한 링크에 없어도 되는 값 useLocation().state · 직접 진입 때 없을 수 있음
component state route 매칭과 분리된 React 상태 열린 메뉴처럼 공유·새로고침 복원이 불필요한 값 useState() · component 생명주기에 따름

pathname · route branch 입력

/products/:productId의 동적 값은 useParams()로 읽습니다. 리소스 식별에 쓰는 것은 흔한 관례이지 문법 보장은 아닙니다.

search · 선택된 화면의 입력

?q=키보드는 useSearchParams()로 읽고 갱신합니다. 필수 여부와 기본값은 앱 계약입니다.

navigation state · entry에 연결

useLocation().state로 읽는 특정 history entry의 값입니다. 주소에 보이지 않으므로 복사한 링크나 직접 진입에서 복원을 가정하지 않습니다.

component state · 로컬 상호작용

useState()로 관리하며 component 생명주기에 따릅니다. 열린 메뉴처럼 공유와 새로고침 복원이 필요 없는 일시적 UI 상태에 적합합니다.

공유·직접 진입·새로고침·뒤로 가기로 같은 화면을 복원해야 한다면 pathname 또는 search를 검토합니다. URL 값은 모두 외부 문자열이므로 형식·허용 범위를 검증하고 비밀값은 넣지 않습니다.

위 표는 화면의 값을 어디에 둘지에 따라 라우터가 어떻게 다루는지와 흔히 어떻게 쓰는지를 비교합니다. /products/42 같은 경로는 path 패턴과 비교되어 라우트 가지를 고르는 데 참여하며, 계층이나 리소스를 식별하는 데 자주 쓰지만 그렇게 쓸지는 앱이 정합니다. 그 동적 값은 useParams()로 읽으며 문자열이거나 undefined입니다. ?q=키보드 같은 쿼리는 경로의 가지를 바꾸지 않고 선택된 화면의 입력이 되며, 검색과 필터, 정렬, 페이지에 자주 쓰고 useSearchParams()로 읽습니다. 값은 문자열이고 같은 키가 반복될 수 있습니다.

표의 아래 두 줄은 URL이 아닌 곳입니다. 내비게이션 상태(navigation state)는 특정 history 항목에 붙어 주소에는 보이지 않으므로, 전환의 출처처럼 복사한 링크에 없어도 되는 값에 쓰고 useLocation().state로 읽으며, 직접 진입할 때는 없을 수 있습니다. 컴포넌트 state는 라우트 매칭과 상관없는 React의 상태로, 열린 메뉴처럼 공유하거나 새로고침 뒤에 되살릴 필요가 없는 값에 쓰고 컴포넌트의 생명주기를 따릅니다.

그림 위쪽의 예는 이 글의 제품 검색입니다. /products?category=electronics&q=키보드라는 주소만으로 카테고리 electronics와 검색어 키보드를 되살려, id "2"인 키보드 하나를 결과로 보여 줍니다. 그림의 설명처럼 category가 unknown이면 화면에서는 전체 카테고리로 해석하지만 주소를 자동으로 고치지는 않습니다. 그림의 마지막 설명이 강조하듯, 공유나 직접 진입, 새로고침, 뒤로 가기로 같은 화면을 되살려야 한다면 경로나 쿼리를 검토하고, URL의 값은 모두 바깥에서 온 문자열이므로 형식과 허용 범위를 검증하며, 주소와 history, 로그에 드러날 수 있는 URL에는 비밀 값을 넣지 않습니다.


동적 세그먼트로 경로 값 읽기

Route의 path에서 :productId처럼 콜론으로 시작하는 부분이 동적 세그먼트(dynamic segment)입니다. 아래 패턴에서 /products/42가 맞으면 productId의 값은 문자열 "42"입니다. /products는 이 상세 라우트와 맞지 않으므로 목록 라우트를 따로 둡니다.

src/App.jsx (일부)
import { Route, Routes } from 'react-router';
import ProductDetail from './pages/ProductDetail';
import ProductList from './pages/ProductList';

export default function AppRoutes() {
  return (
    <Routes>
      <Route path="/products" element={<ProductList />} />
      <Route path="/products/:productId" element={<ProductDetail />} />
      <Route path="*" element={<p>요청한 화면을 찾을 수 없습니다.</p>} />
    </Routes>
  );
}

useParams()는 현재 매치에서 잡힌 동적 세그먼트를 읽으며, 자식 라우트는 부모 라우트의 매개변수도 이어받습니다. 값은 디코딩된 문자열이고 라우트 구조에 따라 undefined일 수 있으므로, 숫자나 UUID처럼 쓸 형식과 실제 데이터가 있는지를 따로 확인해야 합니다.

src/pages/ProductDetail.jsx
import { useParams } from 'react-router';

const productData = {
  '123': { name: 'React 신발', description: 'React 개발자를 위한 신발' },
  '456': { name: '리액트 후드티', description: 'React 로고 후드티' },
};

export default function ProductDetail() {
  const { productId } = useParams();
  const hasValidShape = productId !== undefined && /^\d+$/.test(productId);
  const product = hasValidShape ? productData[productId] : undefined;

  if (!product) {
    return (
      <main>
        <h1>제품을 찾을 수 없습니다</h1>
        <p>제품 번호의 형식과 존재 여부를 확인해 주세요.</p>
      </main>
    );
  }

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>제품 ID: {productId}</p>
    </main>
  );
}

라우트가 맞는 것과 서버에 그 리소스가 있는 것은 서로 다른 판단입니다. 또 브라우저에서 매개변수의 형식을 검사하더라도, 서버는 인증과 인가, 데이터 검증을 다시 해야 합니다. 클라이언트의 * 라우트가 보여 주는 “찾을 수 없음” 화면도, 호스트가 직접 요청에 돌려주는 HTTP 상태 코드와는 다른 개념입니다.


useSearchParams로 보기 조건 읽고 갱신하기

쿼리는 ?category=electronics&q=키보드처럼 ? 뒤에 오는 직렬화된 값입니다. React Router의 useSearchParams()는 현재 값인 URLSearchParams와 그 값을 갱신하는 setter를 돌려줍니다. searchParams.get('q')는 첫 번째 값을 문자열이나 null로 돌려주고, 같은 키를 여러 번 허용하기로 정했다면 searchParams.getAll('tag')처럼 모두 읽습니다.

setSearchParams(next)는 단순히 로컬 상태를 바꾸는 것이 아니라, 새 쿼리로 가는 라우터 내비게이션을 시작합니다. 또 돌려받은 searchParams 객체는 참조가 안정적이지만 객체 자체는 바꿀 수 있습니다. 직접 바꿔 놓고 setter를 부르지 않으면 화면의 값과 주소가 어긋날 수 있으므로, 갱신할 때는 복제본을 만든 뒤 setter를 한 번 호출해 반영합니다.

목록은 URL에서 읽은 값으로 렌더할 때마다 계산할 수 있습니다. 이렇게 이미 있는 props나 상태에서 계산할 수 있는 값을 다시 useState에 저장하고 useEffect로 맞출 필요는 없습니다.

src/pages/ProductList.jsx
import { Link, useSearchParams } from 'react-router';

const allProducts = [
  { id: '1', name: '노트북', category: 'electronics' },
  { id: '2', name: '키보드', category: 'electronics' },
  { id: '3', name: '책상', category: 'furniture' },
  { id: '4', name: '의자', category: 'furniture' },
];

const categoryOptions = [
  { value: '', label: '전체' },
  { value: 'electronics', label: '전자제품' },
  { value: 'furniture', label: '가구' },
];
const allowedCategories = new Set(categoryOptions.map(({ value }) => value));

export default function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  const rawCategory = searchParams.get('category') ?? '';
  const category = allowedCategories.has(rawCategory) ? rawCategory : '';
  const query = searchParams.get('q') ?? '';
  const normalizedQuery = query.trim().toLocaleLowerCase();

  const filteredProducts = allProducts.filter((product) => {
    const matchesCategory = !category || product.category === category;
    const matchesQuery = product.name.toLocaleLowerCase().includes(normalizedQuery);
    return matchesCategory && matchesQuery;
  });

  function updateParam(name, value, options) {
    const next = new URLSearchParams(searchParams);
    if (value) next.set(name, value);
    else next.delete(name);
    setSearchParams(next, options);
  }

  return (
    <main>
      <h1>제품 목록</h1>

      <fieldset>
        <legend>카테고리</legend>
        {categoryOptions.map((option) => (
          <button
            key={option.value || 'all'}
            type="button"
            aria-pressed={category === option.value}
            onClick={() => updateParam('category', option.value)}
          >
            {option.label}
          </button>
        ))}
      </fieldset>

      <label htmlFor="product-search">제품 검색</label>
      <input
        id="product-search"
        type="search"
        value={query}
        onChange={(event) =>
          updateParam('q', event.target.value, { replace: true })
        }
      />

      <p role="status" aria-live="polite">
        검색 결과 {filteredProducts.length}개
      </p>
      <ul>
        {filteredProducts.map((product) => (
          <li key={product.id}>
            <Link to={`/products/${product.id}`}>{product.name}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

이 예제는 허용된 category만 쓰고 나머지는 안전한 기본값으로 해석합니다. 이 정규화는 렌더의 입력을 정하는 일일 뿐 주소를 자동으로 다시 쓰지는 않습니다. 정해진 하나의 주소(canonical URL)로 맞춰야 한다면, loader나 분명한 사용자 동작에서 검증된 값으로 replace 내비게이션을 할 수 있습니다.

/products?category=electronics&q=키보드에서는 허용된 카테고리가 electronics, 정규화한 검색어가 키보드입니다. 첫 조건은 제품 1과 2를 남기고, 두 번째 조건까지 만족하는 것은 제품 2 하나뿐입니다. 두 조건을 OR가 아니라 AND로 묶었기 때문입니다. 검색어를 지우면 category는 그대로 남고 제품 1과 2가 보입니다.

이 목록의 ID 1, 2, 3, 4와 앞의 상세 데이터의 ID 123·456은 서로 다른 작은 예제입니다. 두 파일을 하나의 실습 앱으로 붙이려면 목록 링크의 ID와 상세 조회의 데이터를 같은 규칙으로 맞춰야 합니다. 지금 목록의 /products/2 링크는 라우트에는 맞지만 앞의 productData에는 없으므로 제품 없음 상태가 됩니다. 라우팅의 성공과 데이터 연결의 성공은 따로 확인합니다.

이 글의 <Routes> 예제는 Declarative mode이므로 컴포넌트가 훅으로 URL의 값을 읽습니다. Data mode나 Framework mode에서 loader를 쓰는 앱이라면 loader의 params와 new URL(request.url).searchParams에서 같은 값을 읽고, 형식과 허용 범위를 검증한 뒤 데이터 요청을 만듭니다. 두 모드는 API의 위치만 다를 뿐, URL을 믿지 않고 파싱한다는 점은 같습니다.

카테고리처럼 사용자가 하나의 선택을 확정하는 동작은 기본 PUSH로 남겨, 뒤로 가기에서 이전 선택을 되살리게 할 수 있습니다. 반대로 검색어를 한 글자씩 입력할 때마다 history에 쌓지 않도록 예제는 { replace: true }를 씁니다. 어떤 동작을 PUSH로, 어떤 동작을 REPLACE로 처리할지는 제품에서 뒤로 가기가 무엇을 되돌려야 하는지에 맞춰 정합니다.

여러 key를 한 동작으로 적용하기

updateParam('category', 'furniture')와 updateParam('q', '의자')를 같은 이벤트에서 잇달아 호출하면, 두 호출이 같은 렌더의 searchParams를 복제할 수 있습니다. 그러면 두 번째 내비게이션이 첫 번째 변경을 덮어쓸 수 있으며, setter에 함수를 넘기더라도 React의 state setter처럼 같은 순간의 변경을 쌓아 주는 큐가 아닙니다. “필터 적용”처럼 하나의 사용자 동작이라면 변경을 한 복제본에 모아 한 번에 반영합니다. 다음 함수를 앞의 ProductList 안에 더해 버튼에서 호출할 수 있습니다.

function applyFilters(nextCategory, nextQuery) {
  if (!allowedCategories.has(nextCategory)) return;

  const next = new URLSearchParams(searchParams);
  if (nextCategory) next.set('category', nextCategory);
  else next.delete('category');
  if (nextQuery) next.set('q', nextQuery);
  else next.delete('q');
  setSearchParams(next);
}

이 함수는 UI가 제공하는 문자열 두 개를 받아, tab 같은 다른 키는 그대로 두고 category의 중복 값은 set으로 하나로 정리합니다. 하나의 확정 동작을 기본 PUSH로 남기므로, 뒤로 가기는 중간의 필터가 아니라 적용하기 전의 조건으로 돌아갑니다. 여러 번 호출한 setter의 결과를 기다렸다가 합치는 코드가 아니라는 점에 주의하세요.

URL에서 렌더와 navigation으로 이어지는 search params 흐름 — 상태 추적

원 search에서 검증된 category/query로 목록을 계산하고 새 URLSearchParams 한 번의 navigation 뒤 UI가 달라진다. 다른 key 보존/중복 category 정리/뒤로 복원을 실제 주소 값으로 추적할 수 있다.

URL에서 목록을 계산하고 이벤트에서 search를 갱신합니다

REACT ROUTING · URL & RESPONSIBILITY

URL에서 목록을 계산하고 이벤트에서 search를 갱신합니다

렌더 중에는 URL을 읽어 controls·결과를 파생합니다. 다음 사용자 이벤트에서 params 복제본을 수정하고 setter를 한 번 호출하면 navigation 뒤의 URL을 다시 읽습니다.

실제 검색 · 다른 key를 보존하며 q만 변경

?category=electronics&q=키보드
→ 키보드 1개
q 지우기 → ?category=electronics
→ 전자제품 2개
category 선택 → PUSH
q 매 입력 → REPLACE

updateParam은 현재 params를 복제하고 지정한 key만 set/delete합니다. 미지원 category의 화면 기본값 해석은 URL 쓰기와 별개입니다.

search params의 읽기, 파생 렌더, 갱신 흐름현재 location search를 URLSearchParams로 읽고 정규화와 검증을 거쳐 목록을 파생하고 controls와 결과를 렌더링한다. 사용자 입력은 새 URLSearchParams에 반영한 뒤 setSearchParams를 한 번 호출하여 navigation하고, 갱신된 URL이 다시 흐름의 입력이 된다. 현재 URL · 원본 상태?category=electronics&q=키보드search params 읽기searchParams.get(...) · getAll(...)정규화 · 검증기본값 · whitelist · 반복 key 정책렌더 입력 파생allProducts.filter(...)controls · 결과 렌더로컬 복사 없이 같은 URL 값을 표시다음 사용자 이벤트 · 커밋new URLSearchParams(searchParams)setSearchParams(next, options)NAVIGATION
search params의 읽기, 파생 렌더, 갱신 흐름현재 location search를 URLSearchParams로 읽고 정규화와 검증을 거쳐 목록을 파생하고 controls와 결과를 렌더링한다. 사용자 입력은 새 URLSearchParams에 반영한 뒤 setSearchParams를 한 번 호출하여 navigation하고, 갱신된 URL이 다시 흐름의 입력이 된다.현재 URL · 원본 상태?category=electronics&q=키보드search params 읽기searchParams.get(...)getAll(...)정규화 · 검증기본값 · whitelist반복 key 정책렌더 입력 파생allProducts.filter(...)controls · 결과 렌더로컬 복사 없이같은 URL 값을 표시다음 이벤트 · 커밋new URLSearchParams(searchParams)setSearchParams(next, options)NAVIGATION
  1. 현재 URL을 원본으로 둡니다

    ?category=electronics&q=키보드가 직접 진입·공유·뒤로 가기의 입력입니다.

  2. 필요한 값을 읽습니다

    get은 첫 값, 반복 key 계약에는 getAll을 사용합니다.

  3. 정규화하고 검증합니다

    빈 값·기본값·허용 목록·반복 key 정책을 도메인 값으로 바꿉니다.

  4. 렌더 입력을 파생합니다

    URL에서 계산 가능한 목록을 별도 state와 Effect로 복제하지 않습니다.

  5. controls와 결과를 렌더합니다

    입력 값, pressed 상태, 결과 수가 모두 같은 URL 상태를 반영합니다.

  6. 이벤트에서 복제하고 커밋합니다

    새 params에 변경을 모아 setter를 한 번 호출하면 navigation 후 갱신된 URL부터 다시 읽습니다.

History는 사용자 동작 단위로

카테고리 확정은 기본 PUSH로 이전 선택을 남길 수 있습니다. 검색어 매 키 입력은 { replace: true }로 같은 entry를 교체할 수 있습니다.

안전한 갱신 경계

searchParams는 안정된 참조지만 변경 가능한 객체입니다. 복제본을 수정하고 setSearchParams로 커밋하며 URL에 비밀값을 넣지 않습니다.

setter는 search를 향한 router navigation입니다. Data·Framework Mode의 loader도 params와 request URL의 search를 검증해 데이터를 구성하며, navigation에서 loader와 화면 작업이 다시 실행될 수 있습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명?category=electronics&q=키보드가 직접 진입·공유·뒤로 가기의 입력입니다.
원도표 조건·주석현재 URL을 원본으로 둡니다. ?category=electronics&q=키보드가 직접 진입·공유·뒤로 가기의 입력입니다.
원도표 설명get은 첫 값, 반복 key 계약에는 getAll을 사용합니다.
원도표 조건·주석필요한 값을 읽습니다. get은 첫 값, 반복 key 계약에는 getAll을 사용합니다.
원도표 조건·주석정규화하고 검증합니다. 빈 값·기본값·허용 목록·반복 key 정책을 도메인 값으로 바꿉니다.
원도표 조건·주석렌더 입력을 파생합니다. URL에서 계산 가능한 목록을 별도 state와 Effect로 복제하지 않습니다.
원도표 조건·주석controls와 결과를 렌더합니다. 입력 값, pressed 상태, 결과 수가 모두 같은 URL 상태를 반영합니다.
원도표 조건·주석이벤트에서 복제하고 커밋합니다. 새 params에 변경을 모아 setter를 한 번 호출하면 navigation 후 갱신된 URL부터 다시 읽습니다.
원도표 설명카테고리 확정은 기본 PUSH로 이전 선택을 남길 수 있습니다. 검색어 매 키 입력은 { replace: true }로 같은 entry를 교체할 수 있습니다.
원도표 설명searchParams는 안정된 참조지만 변경 가능한 객체입니다. 복제본을 수정하고 setSearchParams로 커밋하며 URL에 비밀값을 넣지 않습니다.
원도표 설명setter는 search를 향한 router navigation입니다. Data·Framework Mode의 loader도 params와 request URL의 search를 검증해 데이터를 구성하며, navigation에서 loader와 화면 작업이 다시 실행될 수 있습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
현재 searchcategory: electronics / q: 키보드category=electronics와 q=키보드로 현재 조건을 읽는다URL이 조건의 원본이며 별도로 중복 state를 두지 않는다
파생 목록result: id 2·키보드 / count: 1검증과 정규화 뒤 id 2의 키보드 한 개를 표시한다unknown category는 화면의 기본 해석이며 주소의 자동 교정은 아니다
한 번의 갱신다른 key: 보존 / history: 확정 동작 PUSH새 URLSearchParams에 조건을 함께 반영하고 한 번 navigation한다뒤로 가기는 중간 setter가 아닌 적용 전 조건으로 돌아간다

URL에서 렌더와 navigation으로 이어지는 search params 흐름 — 상태 추적

원 search에서 검증된 category/query로 목록을 계산하고 새 URLSearchParams 한 번의 navigation 뒤 UI가 달라진다. 다른 key 보존/중복 category 정리/뒤로 복원을 실제 주소 값으로 추적할 수 있다.

URL에서 목록을 계산하고 이벤트에서 search를 갱신합니다

REACT ROUTING · URL & RESPONSIBILITY

URL에서 목록을 계산하고 이벤트에서 search를 갱신합니다

렌더 중에는 URL을 읽어 controls·결과를 파생합니다. 다음 사용자 이벤트에서 params 복제본을 수정하고 setter를 한 번 호출하면 navigation 뒤의 URL을 다시 읽습니다.

실제 검색 · 다른 key를 보존하며 q만 변경

?category=electronics&q=키보드
→ 키보드 1개
q 지우기 → ?category=electronics
→ 전자제품 2개
category 선택 → PUSH
q 매 입력 → REPLACE

updateParam은 현재 params를 복제하고 지정한 key만 set/delete합니다. 미지원 category의 화면 기본값 해석은 URL 쓰기와 별개입니다.

search params의 읽기, 파생 렌더, 갱신 흐름현재 location search를 URLSearchParams로 읽고 정규화와 검증을 거쳐 목록을 파생하고 controls와 결과를 렌더링한다. 사용자 입력은 새 URLSearchParams에 반영한 뒤 setSearchParams를 한 번 호출하여 navigation하고, 갱신된 URL이 다시 흐름의 입력이 된다. 현재 URL · 원본 상태?category=electronics&q=키보드search params 읽기searchParams.get(...) · getAll(...)정규화 · 검증기본값 · whitelist · 반복 key 정책렌더 입력 파생allProducts.filter(...)controls · 결과 렌더로컬 복사 없이 같은 URL 값을 표시다음 사용자 이벤트 · 커밋new URLSearchParams(searchParams)setSearchParams(next, options)NAVIGATION
search params의 읽기, 파생 렌더, 갱신 흐름현재 location search를 URLSearchParams로 읽고 정규화와 검증을 거쳐 목록을 파생하고 controls와 결과를 렌더링한다. 사용자 입력은 새 URLSearchParams에 반영한 뒤 setSearchParams를 한 번 호출하여 navigation하고, 갱신된 URL이 다시 흐름의 입력이 된다.현재 URL · 원본 상태?category=electronics&q=키보드search params 읽기searchParams.get(...)getAll(...)정규화 · 검증기본값 · whitelist반복 key 정책렌더 입력 파생allProducts.filter(...)controls · 결과 렌더로컬 복사 없이같은 URL 값을 표시다음 이벤트 · 커밋new URLSearchParams(searchParams)setSearchParams(next, options)NAVIGATION
  1. 현재 URL을 원본으로 둡니다

    ?category=electronics&q=키보드가 직접 진입·공유·뒤로 가기의 입력입니다.

  2. 필요한 값을 읽습니다

    get은 첫 값, 반복 key 계약에는 getAll을 사용합니다.

  3. 정규화하고 검증합니다

    빈 값·기본값·허용 목록·반복 key 정책을 도메인 값으로 바꿉니다.

  4. 렌더 입력을 파생합니다

    URL에서 계산 가능한 목록을 별도 state와 Effect로 복제하지 않습니다.

  5. controls와 결과를 렌더합니다

    입력 값, pressed 상태, 결과 수가 모두 같은 URL 상태를 반영합니다.

  6. 이벤트에서 복제하고 커밋합니다

    새 params에 변경을 모아 setter를 한 번 호출하면 navigation 후 갱신된 URL부터 다시 읽습니다.

History는 사용자 동작 단위로

카테고리 확정은 기본 PUSH로 이전 선택을 남길 수 있습니다. 검색어 매 키 입력은 { replace: true }로 같은 entry를 교체할 수 있습니다.

안전한 갱신 경계

searchParams는 안정된 참조지만 변경 가능한 객체입니다. 복제본을 수정하고 setSearchParams로 커밋하며 URL에 비밀값을 넣지 않습니다.

setter는 search를 향한 router navigation입니다. Data·Framework Mode의 loader도 params와 request URL의 search를 검증해 데이터를 구성하며, navigation에서 loader와 화면 작업이 다시 실행될 수 있습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명?category=electronics&q=키보드가 직접 진입·공유·뒤로 가기의 입력입니다.
원도표 조건·주석현재 URL을 원본으로 둡니다. ?category=electronics&q=키보드가 직접 진입·공유·뒤로 가기의 입력입니다.
원도표 설명get은 첫 값, 반복 key 계약에는 getAll을 사용합니다.
원도표 조건·주석필요한 값을 읽습니다. get은 첫 값, 반복 key 계약에는 getAll을 사용합니다.
원도표 조건·주석정규화하고 검증합니다. 빈 값·기본값·허용 목록·반복 key 정책을 도메인 값으로 바꿉니다.
원도표 조건·주석렌더 입력을 파생합니다. URL에서 계산 가능한 목록을 별도 state와 Effect로 복제하지 않습니다.
원도표 조건·주석controls와 결과를 렌더합니다. 입력 값, pressed 상태, 결과 수가 모두 같은 URL 상태를 반영합니다.
원도표 조건·주석이벤트에서 복제하고 커밋합니다. 새 params에 변경을 모아 setter를 한 번 호출하면 navigation 후 갱신된 URL부터 다시 읽습니다.
원도표 설명카테고리 확정은 기본 PUSH로 이전 선택을 남길 수 있습니다. 검색어 매 키 입력은 { replace: true }로 같은 entry를 교체할 수 있습니다.
원도표 설명searchParams는 안정된 참조지만 변경 가능한 객체입니다. 복제본을 수정하고 setSearchParams로 커밋하며 URL에 비밀값을 넣지 않습니다.
원도표 설명setter는 search를 향한 router navigation입니다. Data·Framework Mode의 loader도 params와 request URL의 search를 검증해 데이터를 구성하며, navigation에서 loader와 화면 작업이 다시 실행될 수 있습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
현재 searchcategory: electronics / q: 키보드category=electronics와 q=키보드로 현재 조건을 읽는다URL이 조건의 원본이며 별도로 중복 state를 두지 않는다
파생 목록result: id 2·키보드 / count: 1검증과 정규화 뒤 id 2의 키보드 한 개를 표시한다unknown category는 화면의 기본 해석이며 주소의 자동 교정은 아니다
한 번의 갱신다른 key: 보존 / history: 확정 동작 PUSH새 URLSearchParams에 조건을 함께 반영하고 한 번 navigation한다뒤로 가기는 중간 setter가 아닌 적용 전 조건으로 돌아간다

위 그림은 URL에서 목록을 계산하고, 이벤트에서 쿼리를 갱신하는 흐름을 여섯 단계로 보여 줍니다. 현재 URL이 원본 상태가 되어 직접 진입과 공유, 뒤로 가기의 입력이 되고, searchParams.get(...)이나 getAll(...)로 필요한 값을 읽습니다. 읽은 값은 기본값과 허용 목록, 반복 키의 규칙으로 정규화하고 검증해 도메인의 값으로 바꾸며, 그 값으로 allProducts.filter(...)처럼 렌더의 입력을 계산합니다. URL에서 계산할 수 있는 목록을 별도의 state와 Effect로 복제하지 않고, 입력의 값과 버튼의 눌림 상태, 결과의 개수가 모두 같은 URL 상태를 반영하도록 렌더링합니다.

다음 사용자 이벤트에서는 new URLSearchParams(searchParams)로 복제본을 만들어 변경을 모으고, setSearchParams(next, options)를 한 번 호출해 내비게이션합니다. 그러면 갱신된 URL에서부터 다시 읽기가 시작됩니다. 그림 위쪽의 예처럼 ?category=electronics&q=키보드에서는 키보드 하나가 보이고, 검색어를 지우면 ?category=electronics가 되어 전자제품 두 개가 보이며, 카테고리 선택은 PUSH로, 검색어의 한 글자 입력은 REPLACE로 처리합니다.

그림 아래 칸은 갱신할 때 지킬 점을 정리합니다. history는 사용자 동작의 단위로 남겨, 카테고리의 확정은 기본 PUSH로 이전 선택을 남기고 검색어 입력은 { replace: true }로 같은 항목을 바꿀 수 있습니다. searchParams는 참조가 안정적이지만 바꿀 수 있는 객체이므로 복제본을 고쳐 setter로 반영하고, URL에는 비밀 값을 넣지 않습니다. 또 setter는 쿼리로 가는 라우터 내비게이션이라서, Data·Framework mode라면 내비게이션에서 loader와 화면의 작업이 다시 실행될 수 있습니다.


URL 상태 설계 점검하기

경로와 쿼리에 무엇을 둘지는 “필수냐 선택이냐”라는 한 가지 기준으로 정할 수 없습니다. 다음 질문으로 설계를 검토합니다.

질문설계 판단
이 값을 바꾸면 다른 라우트 가지를 골라야 하는가?경로의 정적·동적 세그먼트 후보
같은 화면을 필터·정렬·검색·페이지 이동한 상태인가?쿼리 후보
링크를 복사하거나 새로고침해도 되살아나야 하는가?경로나 쿼리에 직렬화
잠깐 열린 메뉴처럼 공유할 필요가 없는가?컴포넌트 state나 내비게이션 상태 검토
값이 없거나 잘못되었을 때의 기본값은 무엇인가?파싱·정규화·검증 규칙 명시
같은 키가 반복될 수 있는가?하나의 값이면 get, 여러 값이면 getAll
뒤로 가기가 어떤 사용자 동작을 되돌려야 하는가?내비게이션마다 PUSH·REPLACE 결정

주소창에 있는 값은 믿을 수 없는 바깥의 문자열입니다. 퍼센트 인코딩, 빈 값, 예상하지 못한 값, 반복되는 키, 아주 긴 입력을 시험해 보고, 화면에서는 숫자나 열거형 같은 도메인 타입으로 바꾼 뒤 씁니다. 경로와 쿼리는 주소창과 브라우저 기록, 서버와 분석 도구의 로그에 드러날 수 있으므로 비밀번호나 토큰 같은 비밀 값을 넣지 않습니다.

쿼리의 +는 URLSearchParams가 읽을 때 공백으로 해석합니다. 그래서 q=C++를 직접 이어 붙이면 문자열 "C "가 됩니다. + 문자를 검색하려면 q=C%2B%2B를 쓰거나 new URLSearchParams({ q: "C++" }).toString()으로 직렬화합니다. 직렬화는 문자를 안전하게 나타내는 일일 뿐, 허용되는 값인지 검증하는 일을 대신하지 않습니다.

다음 상황을 직접 확인해 보면 URL과 UI가 어긋나는 문제를 빨리 찾을 수 있습니다. /products?category=electronics&q=키보드로 직접 들어왔을 때 컨트롤과 결과가 같은 값을 보여 주는지, 카테고리를 바꿨을 때 q가 남고 검색어를 지웠을 때 q만 지워지는지, 검색어를 입력한 뒤 뒤로 가기가 의도한 단위로 동작하는지 봅니다. category=unknown이나 빈 값, 반복되는 키, 한글과 공백이 들어간 값이 정해 둔 규칙대로 처리되는지, 필터 컨트롤에 라벨과 눌림 상태가 있고 결과의 변화가 보조 기술에 전달되는지, 상세 URL에서 매개변수의 형식과 리소스의 존재, 서버의 인가를 서로 다른 단계에서 검사하는지도 확인합니다.

React는 라우트의 구조나 배포 서버의 직접 진입 대체 응답을 정하지 않고, React Router도 URL의 값을 애플리케이션의 도메인 타입으로 자동 검증하지 않습니다. 그래서 라우트의 정의와 호스트의 설정, 파싱과 검증, 데이터의 접근 권한을 각각 알맞은 곳에서 설계해야 합니다.


정리

  • 경로의 동적 세그먼트는 라우트 가지를 고르는 데 참여하고 useParams()로 읽으며, 쿼리는 선택된 화면의 입력으로 useSearchParams()로 읽습니다.
  • URL의 값은 모두 디코딩된 바깥의 문자열이므로 형식과 허용 범위를 검증하고, 라우트가 맞는 것과 데이터가 있는 것을 구별합니다.
  • URL에서 계산할 수 있는 목록은 별도의 state로 복제하지 않고 렌더할 때마다 계산합니다.
  • 쿼리를 갱신할 때는 searchParams를 복제해 바꿀 키만 고친 뒤 setter를 한 번 호출하고, 하나의 동작에서 setter를 여러 번 부르지 않습니다.
  • 확정하는 동작은 PUSH로, 한 글자씩 바뀌는 입력은 REPLACE로 history에 남기는 식으로 뒤로 가기의 단위를 정합니다.
  • 쿼리의 +는 공백으로 읽히므로 URLSearchParams로 직렬화하고, URL에는 비밀 값을 넣지 않습니다.

연습 문제

1. 공백만 있는 검색어

목록의 네 제품과 updateParam을 그대로 씁니다. category=furniture&q=%20%20에서는 어떤 ID가 남을까요?

해설 보기

검색어는 디코딩하면 공백 두 개이고, trim()한 뒤에는 빈 문자열입니다. 모든 이름이 빈 문자열을 포함하므로 카테고리 조건만 남아, 가구인 ID 3과 4가 보입니다.

2. 지원하지 않는 카테고리

category=unknown&q=키보드에서는 컨트롤과 결과가 어떻게 보일까요? 주소의 unknown은 자동으로 지워질까요?

해설 보기

지원하지 않는 카테고리는 전체로 해석되므로 “전체” 버튼이 눌린 상태가 되고, 검색어에 맞는 ID 2가 결과로 보입니다. 화면에 쓸 입력을 정규화했을 뿐 URL을 다시 쓴 것은 아니므로, 주소에는 unknown이 그대로 남습니다.

3. 반복되는 키와 검색어 지우기

category=electronics&category=furniture&q=키보드&tab=spec에서는 어떤 카테고리를 읽을까요? 이 상태에서 검색어를 빈 문자열로 바꾸면 어떤 키가 남고, history에는 새 항목이 생길까요?

해설 보기

get('category')는 첫 값인 electronics를 읽으므로 ID 2가 남습니다. 검색어를 지우면 복제본에서 q만 지우므로 두 개의 category 값과 tab=spec은 그대로 남습니다.

검색어의 처리 함수는 { replace: true }를 넘기므로 새 항목을 만들지 않고 현재 history 항목을 바꿉니다. 이어서 카테고리 버튼으로 값을 확정하면, set이 같은 키의 중복을 하나로 정리하고 기본 PUSH로 이동합니다.

4. 한 번에 필터 적용하기

현재 쿼리가 category=electronics&q=키보드&tab=spec일 때 applyFilters('furniture', '의자')를 한 번 실행합니다. 남는 키와 결과 ID, 뒤로 가기로 돌아갈 history 항목을 적어 보세요.

해설 보기

다음 쿼리는 category=furniture&q=의자&tab=spec에 해당하며, tab=spec은 그대로 남습니다. 두 조건을 모두 만족하는 것은 ID 4입니다.

PUSH로 항목 하나를 만들므로, 뒤로 가기는 적용하기 전의 조건인 category=electronics&q=키보드&tab=spec으로 돌아갑니다. 두 키를 서로 다른 setter로 바꿔 중간의 상태가 남는 일은 생기지 않습니다.

5. C++ 검색하기

q=C++와 q=C%2B%2B에서 get('q')는 각각 무엇을 돌려줄까요?

해설 보기

q=C++는 +가 공백으로 읽혀 "C "가 되고, q=C%2B%2B는 "C++"가 됩니다. 디코딩한 문자열과 주소에 나타낼 직렬화된 문자열을 구별하세요. 어떤 값이 허용되는 검색어인지는 앱이 따로 정해야 합니다.

다음 글 중첩 라우팅 소개에서는 중첩된 라우트와 Outlet으로 선택된 부모와 자식의 element를 함께 구성하는 방법을 다룹니다.