라우트 파라미터와 쿼리 문자열
pathname의 동적 세그먼트와 search 값을 구분하고, URL을 공유·복원 가능한 화면 상태의 원본으로 다룹니다.
URL은 주소인 동시에 사용자가 복사하고, 새로고침하고, 뒤로 가기로 되돌릴 수 있는 외부 상태입니다. React Router에서는 이 URL을 한 덩어리로 보지 않고 pathname, search, hash처럼 역할이 다른 부분으로 읽습니다.
/products/42?category=electronics&q=키보드를 예로 들면 /products/42는 route branch를 고르는 pathname이고, ? 뒤는 선택된 화면의 보기 조건인 search입니다. 다만 “경로는 항상 리소스 ID, 쿼리는 항상 선택 사항”이라는 규칙은 문법이 아니라 흔한 애플리케이션 설계 관례입니다. 어떤 값이 필수인지와 무엇을 식별하는지는 각 앱의 URL 계약으로 정해야 합니다.
React Router 8.3 · URL value contract
저장 위치는 “필수냐 선택이냐”가 아니라 route 선택과 복원 계약으로 정합니다. pathname과 search는 URL에 남지만 서로 다른 방식으로 router와 화면에 참여합니다.
| 위치 | router 동작 | 흔한 계약 | 읽기와 값 |
|---|---|---|---|
pathname · /products/42 |
path 패턴과 비교해 route branch 선택에 참여 |
계층·리소스 식별에 자주 사용하지만 앱이 결정 | useParams() · 문자열 또는 undefined |
search
|
pathname branch를 바꾸지 않고 선택된 화면의 입력 | 검색·필터·정렬·페이지에 자주 사용하지만 앱이 결정 | useSearchParams() · 문자열·반복 key 가능 |
| navigation state | 특정 history entry에 연결되고 주소에는 표시되지 않음 | 전환 출처처럼 복사한 링크에 없어도 되는 값 | useLocation().state · 직접 진입 때 없을 수 있음 |
| component state | route 매칭과 분리된 React 상태 | 열린 메뉴처럼 공유·새로고침 복원이 불필요한 값 | useState() · component 생명주기에 따름 |
pathname · route branch 입력
/products/:productId의 동적 값은 useParams()로 읽습니다. 리소스 식별에 쓰는 것은 흔한 관례이지 문법 보장은 아닙니다.
search · 선택된 화면의 입력
?category=chair는 useSearchParams()로 읽고 갱신합니다. 필수 여부와 기본값은 앱 계약입니다.
navigation state · entry에 연결
주소에 보이지 않으므로 복사한 링크나 직접 진입에서 복원을 가정하지 않습니다.
component state · 로컬 상호작용
공유와 새로고침 복원이 필요 없는 일시적 UI 상태에 적합합니다.
공유·직접 진입·새로고침·뒤로 가기로 같은 화면을 복원해야 한다면 pathname 또는 search를 검토합니다. URL 값은 모두 외부 문자열이므로 형식·허용 범위를 검증하고 비밀값은 넣지 않습니다.
동적 세그먼트로 경로 값 읽기
Route의 path에서 :productId처럼 콜론으로 시작하는 부분이 동적 세그먼트입니다. 아래 패턴에서 /products/42가 매치되면 productId 값은 문자열 "42"입니다. /products는 이 상세 route와 매치되지 않으므로 목록 route를 따로 둡니다.
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()는 현재 match에서 잡힌 동적 세그먼트를 읽습니다. 자식 route는 부모 route의 params도 상속합니다. 값은 디코딩된 문자열이며 route 구조에 따라 undefined일 수 있으므로, 숫자나 UUID처럼 사용할 형식과 실제 데이터 존재 여부를 따로 확인해야 합니다.
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>
);
}route가 매치되는 것과 서버에 리소스가 존재하는 것은 별개의 판단입니다. 또한 브라우저에서 param 형식을 검사하더라도 서버는 인증·인가와 데이터 검증을 다시 수행해야 합니다. 클라이언트의 * route가 보여 주는 “찾을 수 없음” 화면도 호스트가 직접 요청에 반환하는 HTTP 상태 코드와 같은 개념은 아닙니다.
useSearchParams로 보기 조건 읽고 갱신하기
search는 ?category=electronics&q=키보드처럼 ? 뒤에 오는 직렬화된 값입니다. React Router의 useSearchParams()는 현재 값인 URLSearchParams와 이를 갱신하는 setter를 반환합니다.
searchParams.get('q')는 첫 번째 값을 문자열 또는null로 반환합니다.- 같은 key를 여러 번 허용하는 계약이라면
searchParams.getAll('tag')처럼 모두 읽습니다. setSearchParams(next)는 단순한 로컬 상태 변경이 아니라 새 search를 향한 router navigation을 시작합니다.- 반환된
searchParams객체의 참조는 안정적이지만 객체 자체는 변경 가능합니다. 직접 바꿔 놓고 setter를 생략하면 화면 값과 주소가 어긋날 수 있으므로, 갱신할 때는 복제본을 만든 뒤 한 번의 setter 호출로 커밋합니다.
목록은 URL에서 읽은 값으로 매 렌더마다 계산할 수 있습니다. 이처럼 기존 props·상태에서 계산 가능한 값을 다시 useState에 저장하고 useEffect로 동기화할 필요는 없습니다.
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 navigation을 수행할 수 있습니다.
이 절의 <Routes> 예제는 Declarative Mode이므로 컴포넌트가 hooks로 URL 값을 읽습니다. Data 또는 Framework Mode에서 loader를 사용하는 앱이라면 loader의 params와 new URL(request.url).searchParams에서 같은 값을 읽고, 형식·허용 범위를 검증한 뒤 데이터 요청을 구성합니다. 두 mode의 API 위치는 달라도 URL을 신뢰하지 않고 파싱한다는 경계는 같습니다.
카테고리처럼 사용자가 하나의 선택을 확정하는 동작은 기본 PUSH로 남겨 뒤로 가기에서 이전 선택을 복원하게 할 수 있습니다. 반대로 검색어의 매 키 입력을 모두 history에 쌓지 않도록 예제는 { replace: true }를 사용합니다. 어느 동작을 PUSH와 REPLACE로 처리할지는 제품의 뒤로 가기 계약에 맞춰 정합니다.
React Router 8.3 · search params feedback
URL을 원본으로 읽고 렌더 결과를 파생합니다. 사용자 이벤트에서만 params 복제본을 만들고 setSearchParams로 navigation하면 공유·직접 진입·뒤로 가기가 같은 계약을 따릅니다.
현재 URL을 원본으로 둡니다
?category=electronics&q=keyboard가 직접 진입·공유·뒤로 가기의 입력입니다.필요한 값을 읽습니다
get은 첫 값, 반복 key 계약에는getAll을 사용합니다.정규화하고 검증합니다
빈 값·기본값·허용 목록·반복 key 정책을 도메인 값으로 바꿉니다.
렌더 입력을 파생합니다
URL에서 계산 가능한 목록을 별도 state와 Effect로 복제하지 않습니다.
controls와 결과를 렌더합니다
입력 값, pressed 상태, 결과 수가 모두 같은 URL 상태를 반영합니다.
이벤트에서 복제하고 커밋합니다
새 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와 화면 작업이 다시 실행될 수 있습니다.
URL 상태 계약 점검하기
pathname과 search에 무엇을 둘지는 “필수 대 선택” 한 줄로 결정할 수 없습니다. 다음 질문을 기준으로 계약을 검토합니다.
| 질문 | 설계 판단 |
|---|---|
| 이 값을 바꾸면 다른 route branch를 선택해야 하는가? | pathname의 정적·동적 segment 후보 |
| 같은 화면을 필터·정렬·검색·페이지 이동한 상태인가? | search 후보 |
| 링크를 복사하거나 새로고침해도 복원되어야 하는가? | pathname 또는 search에 직렬화 |
| 잠깐 열린 메뉴처럼 공유할 필요가 없는가? | 컴포넌트 state 또는 navigation state 검토 |
| 값이 없거나 잘못되었을 때의 기본값은 무엇인가? | parse·normalize·validate 규칙 명시 |
| 같은 key가 반복될 수 있는가? | 단일 값이면 get, 다중 값이면 getAll 정책 |
| 뒤로 가기가 어느 사용자 동작을 되돌려야 하는가? | navigation별 PUSH·REPLACE 정책 |
주소창에 있는 값은 신뢰할 수 없는 외부 문자열입니다. percent-encoding, 빈 값, 예상하지 못한 값, 반복 key, 매우 긴 입력을 테스트하고, 화면에서 숫자·열거형 등 도메인 타입으로 변환한 뒤 사용합니다. 경로와 쿼리는 주소창·브라우저 기록·서버 및 분석 로그 등에 노출될 수 있으므로 비밀번호, token 같은 비밀값을 넣지 않습니다.
다음 시나리오를 직접 확인하면 URL과 UI의 계약이 어긋나는 문제를 빨리 찾을 수 있습니다.
/products?category=electronics&q=키보드로 직접 진입했을 때 controls와 결과가 같은 값을 나타내는가?- 카테고리를 바꿨을 때
q가 보존되고, 검색어를 지웠을 때q만 삭제되는가? - 검색어 입력 뒤 뒤로 가기가 제품이 의도한 단위로 동작하는가?
category=unknown, 빈 값, 반복 key, 한글과 공백이 포함된 값이 정해 둔 정책대로 처리되는가?- 필터 controls에 label·pressed state가 있고 결과 변경이 보조 기술에 전달되는가?
- 상세 URL의 param 형식, 리소스 존재, 서버 인가를 서로 다른 경계에서 검사하는가?
React는 route schema나 배포 서버의 direct-entry fallback을 정하지 않습니다. React Router도 URL 값을 애플리케이션 도메인 타입으로 자동 검증하지 않습니다. 따라서 route 정의, 호스트 설정, parse·validation, 데이터 접근 권한을 각각의 책임 경계에서 설계해야 합니다.
라우트 파라미터와 쿼리 문자열은 모두 URL 상태입니다. pathname은 route branch 선택에 참여하고 search는 선택된 pathname 화면의 입력으로 읽히지만, 식별자·필수값·기본값·history 동작은 애플리케이션 계약입니다. 공유·새로고침·뒤로 가기로 복원되어야 하는 값은 URL에 두고, 읽을 때마다 외부 문자열로 검증하며, URL에서 계산할 수 있는 결과를 중복 state로 만들지 않는 것이 핵심입니다.
다음 절에서는 중첩 route와 Outlet을 사용해 선택된 부모·자식 element를 함께 구성합니다.