React Router 사용법
React Router 8.5 Declarative mode에서 route tree 매칭, 중첩 Outlet, URL 값, 내비게이션과 직접 진입의 책임 경계를 구현합니다.
React Router는 현재 URL을 라우트 트리(route tree)에 맞추고, 선택된 React element 트리를 렌더링하도록 이어 주는 라우팅 라이브러리입니다. React 자체가 라우터는 아니며, React Router도 서버의 문서 응답이나 HTTP 상태, 인증과 인가까지 대신하지는 않습니다.
이 글은 2026년 11월 기준 최신인 [email protected]의 Declarative mode, 곧 BrowserRouter와 Routes/Route를 쓰는 구성을 기준으로 합니다. 이 모드는 URL 매칭과 링크 이동, 활성 상태를 제공하지만, 라우트의 loader와 action, 대기 UI, 라우트 오류 처리를 함께 제공하는 Data mode나 Framework mode와는 맡는 범위가 다릅니다. 8.3부터 8.5까지의 변경은 이 글에서 쓰는 Declarative mode의 컴포넌트와 훅의 사용법을 바꾸지 않았습니다. 8.5.0에서 React의 <ViewTransition>을 쓰도록 사용 중단(deprecated)된 viewTransition 같은 뷰 전환 API는 Data mode와 Framework mode에서만 쓰는 기능이고, 나머지 변경도 대부분 그 두 모드와 서버·빌드 도구, 또는 드문 경로 패턴의 처리에 관한 것입니다(React Router 변경 기록).
설치와 라우터 연결
React Router 8의 Declarative mode는 react-router 패키지 하나로 씁니다. 예전의 react-router-dom은 8에서 따로 설치하지 않습니다. 이 버전은 React와 React DOM 19.2.7 이상, Node.js 22.22.0 이상을 요구하므로, 기존 프로젝트에서 따라 한다면 package.json의 버전을 먼저 확인합니다. 이 글은 첫 번째 React 앱 만들기의 컴포넌트와 JSX 구성을 알고 있다고 전제하며, element={<Home />}에 넣는 값은 렌더링할 React element이지 Home 함수 자체가 아니라는 점을 구별해 둡니다.
npm install react-router이 설치 명령은 실행하는 시점의 최신 패키지를 고릅니다. 이 글의 예제와 같은 버전으로 맞춰 보려면 실습 프로젝트에서 npm install [email protected]처럼 버전을 밝히고 lockfile을 확인합니다. 이제 src/main.jsx에서 BrowserRouter가 앱을 감싸도록 구성합니다.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);BrowserRouter는 브라우저 History API의 현재 위치(location)를 구독해 클라이언트 라우팅과 잇습니다. 일반적인 Link 이동은 history에 새 항목을 더하고(PUSH), replace 옵션은 현재 항목을 바꾸며(REPLACE), 브라우저의 뒤로·앞으로 이동은 기존 항목으로 옮겨 갑니다(POP).
HashRouter는 라우트의 위치를 URL의 해시 부분에 저장합니다. 해시는 서버 요청에 실려 가지 않으므로, 경로마다 진입 HTML로 바꿔 주기 어려운 환경에서 쓸 수 있습니다. 다만 기본 URL에서 앱 문서를 제공하는 일까지 호스트가 하지 않아도 되는 것은 아닙니다.
route tree 구성
Routes 안의 Route는 URL의 경로 조각(segment)과 렌더링할 element를 잇고, 중첩된 Route는 URL의 계층과 UI의 계층을 함께 나타냅니다. 아래 코드는 바깥 Route 하나가 RootLayout을 맡고, 그 안의 다섯 Route가 각 화면을 맡는 두 층의 트리입니다. products/new와 products/:id처럼 앞부분이 같은 두 경로를 함께 둔 것은, 뒤에서 어느 쪽이 선택되는지 비교하기 위해서입니다.
import { Route, Routes } from "react-router";
import RootLayout from "./layouts/RootLayout";
import Home from "./pages/Home";
import About from "./pages/About";
import ProductDetail from "./pages/ProductDetail";
import NewProduct from "./pages/NewProduct";
import NotFound from "./pages/NotFound";
export default function App() {
return (
<Routes>
<Route path="/" element={<RootLayout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="products/new" element={<NewProduct />} />
<Route path="products/:id" element={<ProductDetail />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}부모 element는 자식 element가 들어갈 자리인 Outlet을 렌더링해야 합니다. 아래 RootLayout은 모든 화면 위에 Navbar를 두고, 그 아래 <main> 안에 Outlet을 놓습니다. 그래서 주소가 바뀌어도 내비게이션은 그대로 남고, <main>의 내용만 일치한 자식 화면으로 바뀝니다.
import { Outlet } from "react-router";
import Navbar from "../components/Navbar";
export default function RootLayout() {
return (
<>
<Navbar />
<main>
<Outlet />
</main>
</>
);
}이 구성의 핵심은 다음과 같습니다. index 라우트는 부모 URL인 /에서 부모의 Outlet에 기본 자식을 렌더링합니다. 자식의 path는 부모의 경로를 자동으로 이어받으므로 products/:id는 /products/456과 맞습니다. 부모인 RootLayout은 선택된 가지(branch)에 계속 들어 있고, 일치한 자식 element가 Outlet 자리에 들어갑니다. 부모가 Outlet을 렌더링하지 않으면 자식 라우트가 맞아도 자식 element를 보여 줄 자리가 없습니다. 마지막으로 path="*"는 남은 경로를 모두 받는 splat 대체 라우트입니다.
pathname에서 중첩 element tree까지 — 상태 추적
현재 /products/456은 후보 branch 중 products/:id를 고르고 RootLayout Outlet에 ProductDetail과 params.id 문자열을 전달한다. 선언 순서 오해를 줄이도록 현재 location/선택/출력을 추적한다.
REACT ROUTING · URL & RESPONSIBILITY
pathname이 branch를 고르고 부모 Outlet이 자식을 렌더합니다
본문의 다섯 후보 가운데 /products/456은 동적 products/:id branch에 맞습니다. search와 hash는 같은 화면의 입력이며 pathname branch 선택과 구분합니다.
실제 ProductDetail · 문자열 ID와 데이터 조회
/products/456?tab=spec#buy
RootLayout → Outlet
→ ProductDetail
params.id = "456"
React 후드티 · 45,000원ID 형식 오류, 존재하지 않는 데이터, 일치하는 route가 없는 경우는 별도 경계입니다. /products/new는 정적 branch가 동적 :id보다 구체적입니다.
location.pathname/products/456이 route 매칭 입력이 됩니다.<Routes>가 다섯 branch를 비교합니다index·about·products/new·products/:id·*의 구체성을 비교하므로 단순한 첫 항목 선택이 아닙니다.
후보 element: Home · About · NewProduct · ProductDetail · NotFound.
/ + products/:id가 선택됩니다부모와 자식 match가 함께 남고
params.id는 문자열"456"입니다.중첩 element tree를 렌더링합니다
RootLayout이 유지되고ProductDetail이 부모의Outlet에 들어갑니다.
| URL 부분 | 예 | React Router API | route branch 선택 |
|---|---|---|---|
| pathname | /products/456 |
useParams() → { id: "456" } |
참여 · path 패턴과 비교 |
| search | ?tab=spec |
useSearchParams() |
같은 pathname branch의 보기 조건 |
| hash | #buy |
useLocation().hash |
branch 선택과 분리된 fragment |
pathname · /products/456
path 패턴과 비교하며, 동적 segment 값은 useParams()로 읽습니다.
search · ?tab=spec
useSearchParams()로 읽고 갱신합니다. pathname branch 자체를 고르는 값은 아닙니다.
hash · #buy
useLocation().hash로 읽는 fragment이며 pathname 매칭과 분리됩니다.
정적 branch는 동적 branch보다 구체적이고 splat은 fallback입니다. 경로 param·search·hash는 모두 외부 입력이므로 사용 전에 형식과 허용 범위를 검증합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | /products/456이 route 매칭 입력이 됩니다. |
| 원도표 조건·주석 | location.pathname: /products/456이 route 매칭 입력이 됩니다. |
| 원도표 설명 | <Routes>가 다섯 branch를 비교합니다 |
| 원도표 조건·주석 | <Routes>가 다섯 branch를 비교합니다. index·about·products/new·products/:id·*의 구체성을 비교하므로 단순한 첫 항목 선택이 아닙니다. 후보 element: Home · About · NewProduct · ProductDetail · NotFound. |
| 원도표 설명 | / + products/:id가 선택됩니다 |
| 원도표 설명 | 부모와 자식 match가 함께 남고 params.id는 문자열 "456"입니다. |
| 원도표 조건·주석 | / + products/:id가 선택됩니다. 부모와 자식 match가 함께 남고 params.id는 문자열 "456"입니다. |
| 원도표 설명 | RootLayout이 유지되고 ProductDetail이 부모의 Outlet에 들어갑니다. |
| 원도표 조건·주석 | 중첩 element tree를 렌더링합니다. RootLayout이 유지되고 ProductDetail이 부모의 Outlet에 들어갑니다. |
| 원도표 표 행 | URL 부분 | 예 | React Router API | route branch 선택 |
| 원도표 표 행 | pathname | /products/456 | useParams() → { id: "456" } | 참여 · path 패턴과 비교 |
| 원도표 표 행 | search | ?tab=spec | useSearchParams() | 같은 pathname branch의 보기 조건 |
| 원도표 표 행 | hash | #buy | useLocation().hash | branch 선택과 분리된 fragment |
| 원도표 설명 | path 패턴과 비교하며, 동적 segment 값은 useParams()로 읽습니다. |
| 원도표 설명 | useSearchParams()로 읽고 갱신합니다. pathname branch 자체를 고르는 값은 아닙니다. |
| 원도표 설명 | useLocation().hash로 읽는 fragment이며 pathname 매칭과 분리됩니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 location | pathname: /products/456 / 후보: 5개의 route branch | pathname은 /products/456이다 | 등록 순서가 첫 매칭의 선택 규칙은 아니다 |
| 구체 branch 선택 | branch: RootLayout + ProductDetail / params.id: 문자열 456 | products/:id가 현재 경로를 매칭한다 | path 매칭은 resource의 존재 검증이 아니다 |
| Outlet 표시 | 부모: RootLayout / child: ProductDetail | 부모 RootLayout의 Outlet에 ProductDetail을 렌더한다 | 부모가 Outlet을 빠뜨리면 자식의 표시 자리가 없다 |
pathname에서 중첩 element tree까지 — 상태 추적
현재 /products/456은 후보 branch 중 products/:id를 고르고 RootLayout Outlet에 ProductDetail과 params.id 문자열을 전달한다. 선언 순서 오해를 줄이도록 현재 location/선택/출력을 추적한다.
REACT ROUTING · URL & RESPONSIBILITY
pathname이 branch를 고르고 부모 Outlet이 자식을 렌더합니다
본문의 다섯 후보 가운데 /products/456은 동적 products/:id branch에 맞습니다. search와 hash는 같은 화면의 입력이며 pathname branch 선택과 구분합니다.
실제 ProductDetail · 문자열 ID와 데이터 조회
/products/456?tab=spec#buy
RootLayout → Outlet
→ ProductDetail
params.id = "456"
React 후드티 · 45,000원ID 형식 오류, 존재하지 않는 데이터, 일치하는 route가 없는 경우는 별도 경계입니다. /products/new는 정적 branch가 동적 :id보다 구체적입니다.
location.pathname/products/456이 route 매칭 입력이 됩니다.<Routes>가 다섯 branch를 비교합니다index·about·products/new·products/:id·*의 구체성을 비교하므로 단순한 첫 항목 선택이 아닙니다.
후보 element: Home · About · NewProduct · ProductDetail · NotFound.
/ + products/:id가 선택됩니다부모와 자식 match가 함께 남고
params.id는 문자열"456"입니다.중첩 element tree를 렌더링합니다
RootLayout이 유지되고ProductDetail이 부모의Outlet에 들어갑니다.
| URL 부분 | 예 | React Router API | route branch 선택 |
|---|---|---|---|
| pathname | /products/456 |
useParams() → { id: "456" } |
참여 · path 패턴과 비교 |
| search | ?tab=spec |
useSearchParams() |
같은 pathname branch의 보기 조건 |
| hash | #buy |
useLocation().hash |
branch 선택과 분리된 fragment |
pathname · /products/456
path 패턴과 비교하며, 동적 segment 값은 useParams()로 읽습니다.
search · ?tab=spec
useSearchParams()로 읽고 갱신합니다. pathname branch 자체를 고르는 값은 아닙니다.
hash · #buy
useLocation().hash로 읽는 fragment이며 pathname 매칭과 분리됩니다.
정적 branch는 동적 branch보다 구체적이고 splat은 fallback입니다. 경로 param·search·hash는 모두 외부 입력이므로 사용 전에 형식과 허용 범위를 검증합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | /products/456이 route 매칭 입력이 됩니다. |
| 원도표 조건·주석 | location.pathname: /products/456이 route 매칭 입력이 됩니다. |
| 원도표 설명 | <Routes>가 다섯 branch를 비교합니다 |
| 원도표 조건·주석 | <Routes>가 다섯 branch를 비교합니다. index·about·products/new·products/:id·*의 구체성을 비교하므로 단순한 첫 항목 선택이 아닙니다. 후보 element: Home · About · NewProduct · ProductDetail · NotFound. |
| 원도표 설명 | / + products/:id가 선택됩니다 |
| 원도표 설명 | 부모와 자식 match가 함께 남고 params.id는 문자열 "456"입니다. |
| 원도표 조건·주석 | / + products/:id가 선택됩니다. 부모와 자식 match가 함께 남고 params.id는 문자열 "456"입니다. |
| 원도표 설명 | RootLayout이 유지되고 ProductDetail이 부모의 Outlet에 들어갑니다. |
| 원도표 조건·주석 | 중첩 element tree를 렌더링합니다. RootLayout이 유지되고 ProductDetail이 부모의 Outlet에 들어갑니다. |
| 원도표 표 행 | URL 부분 | 예 | React Router API | route branch 선택 |
| 원도표 표 행 | pathname | /products/456 | useParams() → { id: "456" } | 참여 · path 패턴과 비교 |
| 원도표 표 행 | search | ?tab=spec | useSearchParams() | 같은 pathname branch의 보기 조건 |
| 원도표 표 행 | hash | #buy | useLocation().hash | branch 선택과 분리된 fragment |
| 원도표 설명 | path 패턴과 비교하며, 동적 segment 값은 useParams()로 읽습니다. |
| 원도표 설명 | useSearchParams()로 읽고 갱신합니다. pathname branch 자체를 고르는 값은 아닙니다. |
| 원도표 설명 | useLocation().hash로 읽는 fragment이며 pathname 매칭과 분리됩니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 location | pathname: /products/456 / 후보: 5개의 route branch | pathname은 /products/456이다 | 등록 순서가 첫 매칭의 선택 규칙은 아니다 |
| 구체 branch 선택 | branch: RootLayout + ProductDetail / params.id: 문자열 456 | products/:id가 현재 경로를 매칭한다 | path 매칭은 resource의 존재 검증이 아니다 |
| Outlet 표시 | 부모: RootLayout / child: ProductDetail | 부모 RootLayout의 Outlet에 ProductDetail을 렌더한다 | 부모가 Outlet을 빠뜨리면 자식의 표시 자리가 없다 |
위 그림은 경로(pathname)가 가지를 고르고 부모의 Outlet이 자식을 렌더링하는 과정을 보여 줍니다. location.pathname이 /products/456이면 Routes가 index, about, products/new, products/:id, *라는 다섯 후보의 구체성을 비교해 가장 잘 맞는 가지를 고르므로, 단순히 첫 항목을 고르는 방식이 아닙니다. 여기서는 / + products/:id가 선택되어 부모와 자식의 매치가 함께 남고, params.id는 문자열 "456"이 되며, RootLayout이 유지된 채 그 Outlet에 ProductDetail이 들어갑니다.
그림 위쪽의 예처럼 /products/456?tab=spec#buy로 들어오면 ProductDetail이 문자열 "456"으로 조회해 “React 후드티”와 45,000원을 보여 줍니다. 그림의 설명처럼 ID 형식이 틀린 경우, 데이터가 없는 경우, 일치하는 라우트가 없는 경우는 서로 다르게 처리되며, /products/new는 정적 가지가 동적인 :id보다 구체적이어서 그쪽으로 갑니다.
그림 아래의 표는 URL의 부분마다 라우트 매칭에 참여하는지와 읽는 API를 정리합니다. 경로는 path 패턴과 비교해 가지를 고르고, 동적 조각의 값은 useParams()로 { id: "456" }처럼 읽습니다. ?tab=spec 같은 쿼리(search)는 useSearchParams()로 읽고 갱신하는 같은 경로의 보기 조건이며, #buy 같은 해시는 useLocation().hash로 읽는 문서 조각으로 경로 매칭과 상관없습니다. 경로 매개변수와 쿼리, 해시는 모두 외부 입력이므로 쓰기 전에 형식과 허용 범위를 검증합니다.
“첫 번째 Route”가 아니라 가장 잘 맞는 branch
Routes는 위에서부터 처음 맞은 Route 하나를 고르는 목록이 아닙니다. 라우트 트리를 가지로 펼쳐 구체성을 비교하고, 현재 위치에 가장 잘 맞는 가지를 렌더링합니다. 예를 들어 /products/new에는 정적 조각인 products/new가 products/:id보다 구체적이고, /products/456에는 동적 가지가 맞으며, 다른 가지가 없는 /unknown/path는 * 대체 라우트로 갑니다. 선택의 결과는 잎(leaf) 하나가 아니라 부모부터 자식까지 이어진 매치의 가지입니다.
Declarative mode의 Route에 넣는 element는 React element입니다. 이 Routes 기반의 라우트들은 URL 매칭과 렌더링만 맡으며, 라우트 모듈의 loader와 action, 자동 코드 분할, 라우트의 ErrorBoundary에는 참여하지 않습니다.
더 깊이: 가지의 구체성을 점수로 매기는 방법
React Router 8.5.1의 소스를 보면, 라우트 트리를 가지로 펼친 뒤 각 가지의 전체 경로에 점수를 매기고 점수가 높은 가지부터 맞춰 봅니다. 점수는 경로를 /로 나눈 조각의 개수에서 시작해, 정적 조각 하나에 10점, :id 같은 동적 조각에 3점, 빈 조각에 1점을 더하고, index 라우트면 2점을 더하며 *가 들어 있으면 2점을 뺍니다. 이 규칙으로 계산하면 /products/new는 조각 3개에 1 + 10 + 10을 더해 24점, /products/:id는 3 + 1 + 10 + 3으로 17점이 되어, 선언 순서와 상관없이 정적 가지가 먼저 비교됩니다. path="*"만 있는 가지는 1점이라 거의 언제나 마지막까지 남습니다.
점수가 같으면 같은 부모 아래의 형제끼리만 선언 순서대로 앞의 것을 먼저 시도하고, 형제가 아닌 가지 사이에는 순서를 따지지 않습니다. 소스에는 :id.json처럼 동적 조각 뒤에 정적인 글자가 붙은 조각에 3.5점을 주는 규칙도 있는데, 8.2.0의 변경 기록은 이런 조각이 예전에는 정적 조각으로 잘못 계산되어 /sitemap.xml 같은 정적 라우트와 점수가 같아질 수 있던 문제를 고쳤다고 적고 있습니다. 이 숫자들은 문서화된 API가 아니라 구현의 세부 사항이므로, 앱은 숫자 자체가 아니라 정적 조각이 동적 조각보다, 동적 조각이 *보다 앞선다는 구체성의 규칙에 기대는 편이 안전합니다.
Link, NavLink, useNavigate
사용자가 누르는 일반적인 내비게이션은 Link나 NavLink로 만듭니다. 아래 Navbar는 앞의 RootLayout이 맨 위에 렌더링하는 컴포넌트이며, 라우트 트리에 있는 세 경로로 가는 링크를 둡니다. to에 적은 경로가 렌더링된 <a>의 href가 되므로, 링크 주소는 앞의 Route 경로와 맞아야 합니다.
import { NavLink } from "react-router";
const navClassName = ({ isActive }) =>
isActive ? "nav-link nav-link--active" : "nav-link";
export default function Navbar() {
return (
<nav aria-label="주요">
<NavLink to="/" end className={navClassName}>
Home
</NavLink>
<NavLink to="/about" className={navClassName}>
About
</NavLink>
<NavLink to="/products/123" className={navClassName}>
Product
</NavLink>
</nav>
);
}Link는 실제 <a href>를 렌더링하는 감싸개입니다. 알맞은 일반 클릭은 클라이언트 라우팅으로 처리하면서도, 키보드로 누르기와 링크 주소 복사, 새 탭에서 열기 같은 링크의 의미를 그대로 유지합니다. reloadDocument를 지정하면 브라우저의 문서 탐색을 씁니다.
NavLink는 Link에 활성 상태의 표현을 더합니다. 위 예제처럼 className에 콜백을 넘기면 콜백이 돌려준 문자열이 최종 클래스가 되므로, 활성 링크에는 nav-link--active가 적용됩니다. 활성 링크에는 aria-current="page"가 자동으로 붙고, style이나 children의 콜백에서도 isActive를 읽을 수 있습니다. end는 to="/tasks"처럼 하위 경로를 가진 링크가 /tasks/123에서도 활성으로 보이지 않게, 정확히 그 경로일 때만 활성이 되게 합니다. 다만 모든 URL이 /로 시작하므로 React Router는 to="/"인 NavLink를 예외로 다뤄 end가 없어도 루트에서만 활성으로 표시하며, 이 예제의 홈 링크에 붙인 end는 의도를 드러내는 표시일 뿐 결과를 바꾸지 않습니다(React Router의 NavLink). Declarative mode에서는 대기 중인 탐색 상태가 제공되지 않습니다.
useNavigate는 사용자의 링크 클릭이 아닌, 코드가 주도하는 이동에 씁니다. 폼 제출이 끝났을 때, 오래 쓰지 않아 로그아웃할 때, 시간이 끝난 퀴즈를 넘길 때가 대표적입니다.
import { useNavigate } from "react-router";
function CheckoutForm() {
const navigate = useNavigate();
async function handleSubmit(event) {
event.preventDefault();
await saveOrder();
navigate("/orders/complete", { replace: true });
}
return <form onSubmit={handleSubmit}>{/* 입력과 제출 버튼 */}</form>;
}이 CheckoutForm은 이동하는 시점을 설명하기 위한 일부 코드라서 saveOrder의 구현과 입력 검증, 실패 안내가 없으므로 그대로 실행할 수는 없습니다. saveOrder가 거부되면 await 다음의 이동은 실행되지 않으므로, 실제 주문 기능에는 실패 안내와 다시 시도하는 방법을 따로 구현해야 합니다. 또 사용자가 단순히 “홈으로 이동”을 누르는 UI라면, 버튼의 클릭 처리 함수에서 navigate("/")를 부르기보다 <Link to="/">홈</Link>가 링크의 의미와 브라우저의 기본 동작을 더 잘 지켜 줍니다.
params, search, hash
URL은 하나의 문자열이지만, 라우트 매칭과 화면의 입력에서는 역할을 나눠 읽습니다. 경로는 어떤 화면을 보여 줄지를 정하고, 쿼리는 같은 화면 안의 보기 조건을, 해시는 문서 안의 위치를 나타냅니다. 세 부분 모두 사용자가 주소창에서 바꿀 수 있는 외부 입력이라는 점은 같으므로, 아래 예제들은 값을 쓰기 전에 형식부터 확인합니다.
Path params
path="products/:id"의 :id는 경로 조각 하나를 받아 useParams() 결과의 id가 됩니다. 이 값은 언제나 문자열이므로, 아래 컴포넌트는 숫자만으로 된 문자열인지 먼저 정규식으로 확인하고, 통과한 뒤에야 products 객체의 키로 씁니다. 형식이 틀린 경우와 형식은 맞지만 데이터가 없는 경우를 서로 다른 문구로 나눈 것도 이 때문입니다.
import { useParams } from "react-router";
const products = {
"123": { name: "React 신발", price: 99000 },
"456": { name: "React 후드티", price: 45000 },
};
export default function ProductDetail() {
const { id } = useParams();
if (!id || !/^\d+$/.test(id)) {
return <p>올바르지 않은 제품 ID입니다.</p>;
}
const product = products[id];
if (!product) {
return <p>제품을 찾을 수 없습니다.</p>;
}
return (
<article>
<h1>{product.name}</h1>
<p>{product.price.toLocaleString()}원</p>
</article>
);
}useParams()의 값은 문자열이며, 라우트 구성과 현재 매치에 따라 없을 수도 있습니다. 매개변수의 이름은 같은 경로 안에서 겹치지 않게 정하고, 숫자나 식별자로 쓰기 전에 형식과 허용 범위를 검증해야 합니다. 또 “라우트가 없음”과 “라우트는 맞았지만 그 ID의 데이터가 없음”을 구분합니다. 앞의 것은 path="*" 대체 라우트가, 뒤의 것은 ProductDetail이 처리할 데이터 없음의 상태입니다.
여기서 /^\d+$/는 숫자 문자로만 이루어졌는지 검사할 뿐, 양수의 범위나 실제 제품이 있는지는 보장하지 않습니다. 이 예제의 ID는 숫자로 계산할 값이 아니라 문자열 키입니다. 그래서 "00123"을 Number(id)로 바꿔 "123"과 같은 제품으로 다루지 않습니다. 앞에 붙은 0을 허용할지, 같은 ID로 정규화할지는 데이터의 규칙에서 먼저 정합니다.
Search params
?q=react&page=2처럼 ? 뒤의 값은 useSearchParams()로 읽습니다. 돌려받은 setter를 호출하면 쿼리가 바뀌는 내비게이션이 일어납니다.
import { useSearchParams } from "react-router";
function ProductFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const sort = searchParams.get("sort") ?? "popular";
return (
<button onClick={() => setSearchParams({ sort: "price" })}>
현재 정렬: {sort}
</button>
);
}쿼리는 같은 경로의 화면에서 필터나 정렬, 페이지 같은 상태에 알맞습니다. 라우트 path의 동적 조각과는 다른 값이며, 필요한 기본값과 허용 값, 배열의 형식은 앱이 검증합니다.
Hash와 location
useLocation()은 pathname, search, hash, state, key를 담은 현재 위치를 돌려줍니다. #reviews 같은 해시는 문서 조각(fragment)이며, 경로의 가지를 고르는 값이 아닙니다. Link의 to에는 문자열뿐 아니라 다음처럼 pathname과 search, hash를 나눈 객체도 넘길 수 있습니다.
<Link
to={{
pathname: "/products/456",
search: "?tab=spec",
hash: "#buy",
}}
>
구매 정보
</Link>위치의 state는 브라우저 history에 저장되는 클라이언트의 상태입니다. 서버에서 읽을 수 없고 새 문서 응답에 남는 데이터도 아니므로, 공유하거나 되살려야 하는 상태는 URL이나 서버의 저장소에 둡니다.
내부 탐색과 직접 진입은 시작점이 다릅니다
내부 탐색과 직접 진입의 책임 경계 — 상태 추적
Link/history와 document GET의 다른 실행 경계가 같은 route matching에 합류한다. HTTP 404/200과 client splat UI가 별개임을 실제 URL 사례로 보강할 가치가 있다.
REACT ROUTING · URL & RESPONSIBILITY
같은 route tree에 도달해도 문서 응답의 책임은 다릅니다
현재 문서가 처리하는 내부 이동은 history와 location으로, 직접 진입은 host의 문서 응답으로 시작합니다. Declarative Routes는 실행 뒤 match와 element 렌더를 담당합니다.
본문 확인 URL · 직접 진입과 client fallback
/products/456?tab=spec#buy
→ 같은 상세 branch
/products/new → 정적 작성
/unknown/path → NotFound UI
(앱이 로드된 뒤)host가 entry HTML 200을 반환한 경우 client wildcard가 HTTP 상태를 바꾸지 않습니다. 제목·초점·알림·스크롤은 앱의 전환 정책으로 확인합니다.
앱 내부 탐색
Link·NavLink와 navigate(to)는 기본 PUSH 또는 명시한 REPLACE를 만듭니다. 뒤로·앞으로 이동이 현재 앱 문서의 entry에 머무르면 POP으로 location을 바꾸고 route branch를 다시 매칭합니다.
직접 진입 · 새로고침 · 새 탭
브라우저가 host/server에 문서를 먼저 요청합니다. client-only 정적 SPA rewrite는 알 수 없는 비자산 경로에도 entry HTML 200을 보낸 뒤 path="*" UI를 고를 수 있습니다. 실제 HTTP 404는 route-aware host나 SSR이 결정합니다.
| 경계 | React Router 기본 | 앱·host가 결정할 것 |
|---|---|---|
| 링크·활성 상태 | Link는 실제 anchor, NavLink는 활성 링크의 aria-current 값을 page로 설정 |
의미 있는 링크 문구와 useNavigate가 정말 비상호작용 이동인지 판단 |
| 초점·알림 | route 변경 뒤 UI에 관한 가정을 하지 않음 | 문서 제목, 주 콘텐츠 초점, 필요한 live-region 완료·오류 알림 |
| 스크롤 | Declarative mode는 location과 navigation primitives 제공 | 앱에서 복원 정책 구현, 또는 Data/Framework mode의 ScrollRestoration 사용 |
| 대기·오류 | <Routes>는 match와 element 렌더만 담당 |
앱 상태·React error boundary, 또는 Data/Framework mode의 pending·route error API |
| 404·보안 | path="*"는 앱 안의 unmatched UI를 선택 |
HTTP 상태·문서 fallback·API 분리·입력 검증·인증과 인가는 server 책임 |
링크·활성 상태
Link는 실제 anchor이고 NavLink는 활성 링크의 aria-current 값을 page로 설정합니다. 링크 문구와 이동 수단 선택은 앱 책임입니다.
초점·알림·스크롤
route 변경 뒤 제목, 주 콘텐츠 초점, 필요한 live-region 알림과 스크롤 정책을 앱이 설계합니다. Declarative mode는 앱에서 복원 정책을 구현하며, Data/Framework mode에서는 ScrollRestoration을 선택할 수 있습니다.
대기·오류
Declarative <Routes>는 match와 렌더만 담당합니다. 앱 상태와 React error boundary를 쓰거나 Data/Framework mode의 전용 API를 선택합니다.
404·보안
path="*"는 UI fallback입니다. HTTP 상태, 문서 fallback, API 분리, 입력 검증과 최종 인가는 host/server가 강제합니다.
client-only SPA의 path="*"는 unmatched UI를 선택할 뿐 이미 받은 entry 문서의 200 상태를 바꾸지 않습니다. HashRouter는 route location을 server로 전송되지 않는 hash에 두어 pathname fallback 부담을 피하지만, base URL에서 앱 문서를 제공하는 host 책임까지 없애지는 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | Link·NavLink와 navigate(to)는 기본 PUSH 또는 명시한 REPLACE를 만듭니다. 뒤로·앞으로 이동이 현재 앱 문서의 entry에 머무르면 POP으로 location을 바꾸고 route branch를 다시 매칭합니다. |
| 원도표 설명 | 브라우저가 host/server에 문서를 먼저 요청합니다. client-only 정적 SPA rewrite는 알 수 없는 비자산 경로에도 entry HTML 200을 보낸 뒤 path="*" UI를 고를 수 있습니다. 실제 HTTP 404는 route-aware host나 SSR이 결정합니다. |
| 원도표 표 행 | 경계 | React Router 기본 | 앱·host가 결정할 것 |
| 원도표 표 행 | 링크·활성 상태 | Link는 실제 anchor, NavLink는 활성 링크의 aria-current 값을 page로 설정 | 의미 있는 링크 문구와 useNavigate가 정말 비상호작용 이동인지 판단 |
| 원도표 표 행 | 초점·알림 | route 변경 뒤 UI에 관한 가정을 하지 않음 | 문서 제목, 주 콘텐츠 초점, 필요한 live-region 완료·오류 알림 |
| 원도표 표 행 | 스크롤 | Declarative mode는 location과 navigation primitives 제공 | 앱에서 복원 정책 구현, 또는 Data/Framework mode의 ScrollRestoration 사용 |
| 원도표 표 행 | 대기·오류 | <Routes>는 match와 element 렌더만 담당 | 앱 상태·React error boundary, 또는 Data/Framework mode의 pending·route error API |
| 원도표 표 행 | 404·보안 | path="*"는 앱 안의 unmatched UI를 선택 | HTTP 상태·문서 fallback·API 분리·입력 검증·인증과 인가는 server 책임 |
| 원도표 설명 | Link는 실제 anchor이고 NavLink는 활성 링크의 aria-current 값을 page로 설정합니다. 링크 문구와 이동 수단 선택은 앱 책임입니다. |
| 원도표 설명 | route 변경 뒤 제목, 주 콘텐츠 초점, 필요한 live-region 알림과 스크롤 정책을 앱이 설계합니다. Declarative mode는 앱에서 복원 정책을 구현하며, Data/Framework mode에서는 ScrollRestoration을 선택할 수 있습니다. |
| 원도표 설명 | Declarative <Routes>는 match와 렌더만 담당합니다. 앱 상태와 React error boundary를 쓰거나 Data/Framework mode의 전용 API를 선택합니다. |
| 원도표 설명 | path="*"는 UI fallback입니다. HTTP 상태, 문서 fallback, API 분리, 입력 검증과 최종 인가는 host/server가 강제합니다. |
| 원도표 설명 | client-only SPA의 path="*"는 unmatched UI를 선택할 뿐 이미 받은 entry 문서의 200 상태를 바꾸지 않습니다. HashRouter는 route location을 server로 전송되지 않는 hash에 두어 pathname fallback 부담을 피하지만, base URL에서 앱 문서를 제공하는 host 책임까지 없애지는 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 앱 내부 탐색 | 문서: 현재 문서 유지 / 현재: client location | Link와 navigate가 history와 location을 바꾼다 | 새 HTML 요청과 같은 사건이 아니다 |
| 직접 진입 비교 | host: GET /products/456 / HTTP: 응답 status | 주소 입력은 host의 200 또는 404 응답을 먼저 얻는다 | HTTP 404라면 client의 성공 경로와 구별한다 |
| route 결과 | UI: route 또는 NotFound / HTTP: 200 유지 가능 | HTML 200 뒤 매칭 또는 SPA의 wildcard UI를 고른다 | NotFound UI가 자동으로 HTTP 404를 만들지는 않는다 |
내부 탐색과 직접 진입의 책임 경계 — 상태 추적
Link/history와 document GET의 다른 실행 경계가 같은 route matching에 합류한다. HTTP 404/200과 client splat UI가 별개임을 실제 URL 사례로 보강할 가치가 있다.
REACT ROUTING · URL & RESPONSIBILITY
같은 route tree에 도달해도 문서 응답의 책임은 다릅니다
현재 문서가 처리하는 내부 이동은 history와 location으로, 직접 진입은 host의 문서 응답으로 시작합니다. Declarative Routes는 실행 뒤 match와 element 렌더를 담당합니다.
본문 확인 URL · 직접 진입과 client fallback
/products/456?tab=spec#buy
→ 같은 상세 branch
/products/new → 정적 작성
/unknown/path → NotFound UI
(앱이 로드된 뒤)host가 entry HTML 200을 반환한 경우 client wildcard가 HTTP 상태를 바꾸지 않습니다. 제목·초점·알림·스크롤은 앱의 전환 정책으로 확인합니다.
앱 내부 탐색
Link·NavLink와 navigate(to)는 기본 PUSH 또는 명시한 REPLACE를 만듭니다. 뒤로·앞으로 이동이 현재 앱 문서의 entry에 머무르면 POP으로 location을 바꾸고 route branch를 다시 매칭합니다.
직접 진입 · 새로고침 · 새 탭
브라우저가 host/server에 문서를 먼저 요청합니다. client-only 정적 SPA rewrite는 알 수 없는 비자산 경로에도 entry HTML 200을 보낸 뒤 path="*" UI를 고를 수 있습니다. 실제 HTTP 404는 route-aware host나 SSR이 결정합니다.
| 경계 | React Router 기본 | 앱·host가 결정할 것 |
|---|---|---|
| 링크·활성 상태 | Link는 실제 anchor, NavLink는 활성 링크의 aria-current 값을 page로 설정 |
의미 있는 링크 문구와 useNavigate가 정말 비상호작용 이동인지 판단 |
| 초점·알림 | route 변경 뒤 UI에 관한 가정을 하지 않음 | 문서 제목, 주 콘텐츠 초점, 필요한 live-region 완료·오류 알림 |
| 스크롤 | Declarative mode는 location과 navigation primitives 제공 | 앱에서 복원 정책 구현, 또는 Data/Framework mode의 ScrollRestoration 사용 |
| 대기·오류 | <Routes>는 match와 element 렌더만 담당 |
앱 상태·React error boundary, 또는 Data/Framework mode의 pending·route error API |
| 404·보안 | path="*"는 앱 안의 unmatched UI를 선택 |
HTTP 상태·문서 fallback·API 분리·입력 검증·인증과 인가는 server 책임 |
링크·활성 상태
Link는 실제 anchor이고 NavLink는 활성 링크의 aria-current 값을 page로 설정합니다. 링크 문구와 이동 수단 선택은 앱 책임입니다.
초점·알림·스크롤
route 변경 뒤 제목, 주 콘텐츠 초점, 필요한 live-region 알림과 스크롤 정책을 앱이 설계합니다. Declarative mode는 앱에서 복원 정책을 구현하며, Data/Framework mode에서는 ScrollRestoration을 선택할 수 있습니다.
대기·오류
Declarative <Routes>는 match와 렌더만 담당합니다. 앱 상태와 React error boundary를 쓰거나 Data/Framework mode의 전용 API를 선택합니다.
404·보안
path="*"는 UI fallback입니다. HTTP 상태, 문서 fallback, API 분리, 입력 검증과 최종 인가는 host/server가 강제합니다.
client-only SPA의 path="*"는 unmatched UI를 선택할 뿐 이미 받은 entry 문서의 200 상태를 바꾸지 않습니다. HashRouter는 route location을 server로 전송되지 않는 hash에 두어 pathname fallback 부담을 피하지만, base URL에서 앱 문서를 제공하는 host 책임까지 없애지는 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | Link·NavLink와 navigate(to)는 기본 PUSH 또는 명시한 REPLACE를 만듭니다. 뒤로·앞으로 이동이 현재 앱 문서의 entry에 머무르면 POP으로 location을 바꾸고 route branch를 다시 매칭합니다. |
| 원도표 설명 | 브라우저가 host/server에 문서를 먼저 요청합니다. client-only 정적 SPA rewrite는 알 수 없는 비자산 경로에도 entry HTML 200을 보낸 뒤 path="*" UI를 고를 수 있습니다. 실제 HTTP 404는 route-aware host나 SSR이 결정합니다. |
| 원도표 표 행 | 경계 | React Router 기본 | 앱·host가 결정할 것 |
| 원도표 표 행 | 링크·활성 상태 | Link는 실제 anchor, NavLink는 활성 링크의 aria-current 값을 page로 설정 | 의미 있는 링크 문구와 useNavigate가 정말 비상호작용 이동인지 판단 |
| 원도표 표 행 | 초점·알림 | route 변경 뒤 UI에 관한 가정을 하지 않음 | 문서 제목, 주 콘텐츠 초점, 필요한 live-region 완료·오류 알림 |
| 원도표 표 행 | 스크롤 | Declarative mode는 location과 navigation primitives 제공 | 앱에서 복원 정책 구현, 또는 Data/Framework mode의 ScrollRestoration 사용 |
| 원도표 표 행 | 대기·오류 | <Routes>는 match와 element 렌더만 담당 | 앱 상태·React error boundary, 또는 Data/Framework mode의 pending·route error API |
| 원도표 표 행 | 404·보안 | path="*"는 앱 안의 unmatched UI를 선택 | HTTP 상태·문서 fallback·API 분리·입력 검증·인증과 인가는 server 책임 |
| 원도표 설명 | Link는 실제 anchor이고 NavLink는 활성 링크의 aria-current 값을 page로 설정합니다. 링크 문구와 이동 수단 선택은 앱 책임입니다. |
| 원도표 설명 | route 변경 뒤 제목, 주 콘텐츠 초점, 필요한 live-region 알림과 스크롤 정책을 앱이 설계합니다. Declarative mode는 앱에서 복원 정책을 구현하며, Data/Framework mode에서는 ScrollRestoration을 선택할 수 있습니다. |
| 원도표 설명 | Declarative <Routes>는 match와 렌더만 담당합니다. 앱 상태와 React error boundary를 쓰거나 Data/Framework mode의 전용 API를 선택합니다. |
| 원도표 설명 | path="*"는 UI fallback입니다. HTTP 상태, 문서 fallback, API 분리, 입력 검증과 최종 인가는 host/server가 강제합니다. |
| 원도표 설명 | client-only SPA의 path="*"는 unmatched UI를 선택할 뿐 이미 받은 entry 문서의 200 상태를 바꾸지 않습니다. HashRouter는 route location을 server로 전송되지 않는 hash에 두어 pathname fallback 부담을 피하지만, base URL에서 앱 문서를 제공하는 host 책임까지 없애지는 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 앱 내부 탐색 | 문서: 현재 문서 유지 / 현재: client location | Link와 navigate가 history와 location을 바꾼다 | 새 HTML 요청과 같은 사건이 아니다 |
| 직접 진입 비교 | host: GET /products/456 / HTTP: 응답 status | 주소 입력은 host의 200 또는 404 응답을 먼저 얻는다 | HTTP 404라면 client의 성공 경로와 구별한다 |
| route 결과 | UI: route 또는 NotFound / HTTP: 200 유지 가능 | HTML 200 뒤 매칭 또는 SPA의 wildcard UI를 고른다 | NotFound UI가 자동으로 HTTP 404를 만들지는 않는다 |
위 그림은 같은 라우트 트리에 도착하더라도 내부 탐색과 직접 진입은 시작하는 곳이 다르다는 점을 보여 줍니다. URL을 옮길 때 지금의 앱 문서에서 처리되는 이동이라면, Link와 NavLink, useNavigate가 history에 PUSH나 REPLACE를 만들고, 뒤로·앞으로 이동이 현재 앱 문서의 항목 안에 머무르면 POP으로 위치를 바꾼 뒤 Routes가 가지를 다시 맞춰 element를 렌더링합니다. 주소를 입력하거나 새로고침하거나 새 탭으로 열면, 브라우저가 먼저 호스트나 서버에 GET /products/456 같은 문서를 요청합니다. 경로를 아는 호스트나 SSR이 없는 경로라고 판단하면 HTTP 404를 돌려주고, 그렇지 않으면 진입 문서가 200으로 오거나 SSR·하이드레이션으로 실행된 뒤 같은 라우트 매칭으로 이어집니다.
그림 위쪽의 예처럼 앱이 불러와진 뒤에는 /products/456?tab=spec#buy가 같은 상세 가지로, /products/new가 정적 작성 화면으로, /unknown/path가 NotFound UI로 갑니다. 그림의 설명처럼 호스트가 진입 HTML을 200으로 돌려줬다면 클라이언트의 와일드카드 라우트가 HTTP 상태를 바꾸지는 않으며, 제목과 포커스, 알림, 스크롤은 앱의 전환 방식으로 확인해야 합니다.
그림 아래의 표는 React Router가 기본으로 해 주는 일과 앱이나 호스트가 정해야 할 일을 나눕니다. Link는 실제 링크이고 NavLink는 활성 링크의 aria-current를 page로 설정하지만, 의미 있는 링크 문구와 navigate가 정말 링크가 아닌 이동인지는 앱이 판단합니다. React Router는 라우트가 바뀐 뒤의 UI에 대해 아무것도 가정하지 않으므로 문서 제목과 주 콘텐츠의 포커스, 필요한 완료·오류 알림은 앱이 정하고, Declarative mode는 위치와 내비게이션의 기본 도구만 제공하므로 스크롤 복원은 앱이 직접 구현하거나 Data·Framework mode의 ScrollRestoration을 씁니다. <Routes>는 매칭과 렌더링만 맡으므로 대기와 오류는 앱의 상태와 React의 에러 바운더리로, 또는 Data·Framework mode의 전용 API로 다룹니다. path="*"는 앱 안에서 맞지 않는 경로의 UI를 고를 뿐이고, HTTP 상태와 문서의 대체 응답, API의 분리, 입력 검증과 최종 인가는 호스트와 서버가 강제합니다.
정리하면, 앱이 이미 실행 중일 때 Link나 NavLink, useNavigate가 처리하는 이동은 현재 문서 안에서 history와 위치를 바꾸고 새 가지를 렌더링합니다. 이때 새 HTML 문서를 요청하지 않더라도, 라우트 컴포넌트의 코드나 화면의 데이터는 따로 네트워크 요청이 필요할 수 있습니다. 반면 주소창 입력이나 새로고침, 새 탭 열기, 외부 문서에서의 진입은 React Router가 실행되기 전에 호스트나 서버에 목적지 URL의 문서를 요청합니다.
그래서 호스트 쪽에서 해야 할 일이 있습니다. 클라이언트 라우팅 SPA의 유효한 앱 경로는 진입 HTML로 이어 줘야 하고, API와 정적 자산의 요청은 그 대체 응답에서 빼야 합니다. SSR이나 경로별 문서를 제공한다면 서버가 URL에 맞는 문서와 상태를 돌려줍니다. 클라이언트 전용 정적 SPA의 catch-all 재작성은 알 수 없는 경로에도 진입 HTML을 200 OK로 보낼 수 있으므로, 실제 HTTP 404가 필요하다면 라우트를 아는 호스트나 서버, SSR이나 사전 생성, 또는 별도의 상태 규칙이 정해야 합니다. <Route path="*" element={<NotFound />} />는 앱이 실행된 뒤 맞지 않는 경로의 UI를 고를 뿐, 이미 200 OK로 받은 진입 문서의 HTTP 상태를 거슬러 올라가 404로 바꾸지는 못합니다.
접근성, 스크롤, 대기, 오류
클라이언트 라우팅은 새 문서를 열지 않으므로, 브라우저가 문서를 탐색할 때 하던 전환 동작이 모두 저절로 되풀이되지는 않습니다. 링크는 가능하면 Link와 NavLink로 만들어 실제 링크의 의미를 유지하며, NavLink의 aria-current="page"는 현재 위치를 보조 기술에 알려 줍니다. 라우트가 바뀐 뒤에는 문서 제목을 갱신하고, 주 콘텐츠의 제목이나 main으로 포커스를 옮길지 설계합니다. 전환이 길거나 결과가 중요하다면 live region으로 로딩과 완료, 오류를 알립니다. React Router는 바뀐 UI가 무엇인지 알 수 없으므로 이런 방식을 대신 정해 주지 않습니다.
스크롤은 모든 이동에서 무조건 맨 위로 보내지 말고, 새 위치로의 이동과 뒤로·앞으로 이동의 복원, 쿼리만 바뀌는 전환, 해시 이동을 구분합니다. Declarative mode에서는 useLocation 등을 바탕으로 앱이 이 방식을 구현하며, ScrollRestoration은 Data·Framework 라우터용입니다. 대기 상태도 마찬가지로 Declarative mode의 NavLink에는 대기 상태가 없고 useNavigation은 데이터 라우터가 필요하므로, 이 구성에서는 데이터 요청 라이브러리나 컴포넌트의 상태로 대기 UI를 만들거나 Data·Framework mode를 고릅니다.
오류는 Routes와 Route element 구성에서 명시적인 비동기 오류 UI와 일반적인 React 에러 바운더리로 다룹니다. loader나 action의 오류, 라우트 모듈의 ErrorBoundary와 useRouteError가 필요하다면 Data·Framework mode를 씁니다. 보안 면에서는 경로 매개변수와 쿼리, 위치의 state, 클라이언트의 라우트 가드를 믿을 수 있는 값으로 보지 않으며, 서버는 모든 요청에서 입력 검증과 인증·인가를 다시 강제해야 합니다.
확인할 탐색 시나리오
라우팅은 링크를 한 번 눌러 보는 것으로 끝내지 않습니다. Link와 NavLink로 이동했을 때 URL과 활성 상태, 렌더링 결과가 일치하는지, 브라우저의 뒤로·앞으로 이동에서 이전의 위치와 화면이 되살아나는지 확인합니다. /products/456?tab=spec#buy를 주소창에 직접 입력하고 새로고침해도 진입 문서와 라우트 매칭이 정상인지, /products/new가 products/:id가 아니라 정적 라우트에 맞는지도 봅니다. 앱에 없는 URL에서는 대체 UI와 실제 HTTP 상태를 각각 확인하고, 키보드 이동과 현재 링크의 안내, 라우트가 바뀐 뒤의 제목·포커스·스크롤·로딩·오류 알림도 확인합니다.
이 글의 예제를 따라 만들 때는 Home, About, NewProduct, NotFound도 실제 파일로 준비해야 합니다. 홈은 다음처럼 시작하고, 나머지도 각 파일에서 자기 화면의 이름을 돌려주는 컴포넌트로 만듭니다.
export default function Home() {
return <h1>홈</h1>;
}About.jsx에는 About을, NewProduct.jsx에는 NewProduct를, NotFound.jsx에는 NotFound를 같은 방식으로 기본으로 내보냅니다. 별도의 API는 필요 없으며, 라우트가 고른 화면을 이름으로 구별하기 위한 준비입니다. ProductDetail.jsx와 RootLayout.jsx, Navbar.jsx에는 앞의 코드를 쓰고, index.html의 루트 요소와 Vite의 진입 파일은 앞에서 쓴 것을 그대로 둡니다. 이 글의 App.jsx는 라우트 자체에 집중하고 main.jsx에서 라우터를 한 번 제공하므로, 뒤의 블로그 실습처럼 App 자체가 BrowserRouter를 제공하는 구성과 섞어 라우터를 두 번 감싸지 않도록 주의합니다.
React Router 코드를 읽을 때는 컴포넌트의 이름을 외우기보다, 위치를 입력으로 가장 잘 맞는 가지를 고르고 중첩 element 트리를 렌더링하는 흐름과, 내부의 history 이동과 직접 문서 진입이라는 두 흐름을 나눠 보면 각자 맡는 일이 분명해집니다.
정리
- React Router 8의 Declarative mode는
react-router패키지의BrowserRouter,Routes,Route로 URL을 라우트 트리에 맞추고 element를 렌더링합니다. Routes는 위에서부터 처음 맞은 라우트가 아니라 가장 구체적으로 맞는 가지를 고르며, 부모는Outlet으로 자식이 들어갈 자리를 마련합니다.- 사용자가 누르는 이동은 실제 링크인
Link와NavLink로, 코드가 주도하는 이동만useNavigate로 만듭니다. - 경로 매개변수는
useParams(), 쿼리는useSearchParams(), 해시와state는useLocation()으로 읽으며, 모두 검증해야 할 외부 입력입니다. - 내부 탐색은 history와 위치를 바꾸지만, 직접 진입은 먼저 호스트가 문서를 돌려줘야 하며
path="*"는 HTTP 상태를 바꾸지 못합니다. - 제목과 포커스, 스크롤, 대기, 오류의 처리는 Declarative mode에서 앱이 직접 설계합니다.
연습 문제
1. 네 가지 상품 URL
앞의 상품 데이터와 라우트 트리를 그대로 씁니다. /products/new, /products/456?tab=spec#buy, /products/999, /missing에서 부모와 선택된 자식, 상세 데이터나 오류 상태를 각각 적어 보세요.
해설 보기
/products/new는 정적 가지로 RootLayout과 NewProduct를 렌더링합니다. /products/456?tab=spec#buy는 같은 부모 아래에서 ProductDetail을 고르고, 문자열 "456"으로 후드티를 조회해 “React 후드티”와 45,000원을 보여 줍니다. 쿼리와 해시는 이 가지를 다른 라우트로 바꾸지 않습니다.
/products/999도 상세 가지에 맞고 숫자 문자열의 형식도 통과하지만, 데이터에 없으므로 상세 컴포넌트의 “제품을 찾을 수 없습니다”가 나옵니다. /missing은 부모 아래 *의 NotFound입니다. 두 실패는 처리하는 곳이 다르므로 섞지 않습니다.
2. 정렬 버튼과 tab=spec
/products/456?tab=spec#buy에서 앞의 ProductFilters의 정렬 버튼을 누르면 tab=spec이 남을까요?
해설 보기
남지 않습니다. setSearchParams({ sort: "price" })는 쿼리 전체를 새 값으로 바꾸므로 쿼리는 ?sort=price만 남습니다. 다른 키를 유지해야 하는 필터는 다음 글처럼 현재 값을 복제한 뒤 바꾸려는 키만 갱신해야 합니다. setter를 호출하는 것만으로 어떤 값을 보존한다고 가정하지 마세요.
3. 00123과 abc
앞의 코드와 두 제품만 쓸 때, /products/00123과 /products/abc는 같은 오류 화면을 보여 줄까요? 각각 라우트 매칭과 형식 검사, 데이터 조회 가운데 어디까지 진행할까요?
해설 보기
다른 화면입니다. 00123은 동적 라우트에 맞고 숫자 문자의 형식도 통과하지만, 객체에 그 문자열 키가 없으므로 “제품을 찾을 수 없습니다”를 보여 줍니다. abc도 동적 라우트에는 맞지만 형식 검사에서 걸려 “올바르지 않은 제품 ID입니다”를 보여 주므로 조회까지 가지 않습니다.
정적 라우트가 없는 두 주소를 *가 먼저 처리한다고 생각하지 마세요. 둘 다 products/:id 가지가 맞은 뒤 컴포넌트 안에서 서로 다르게 처리됩니다.
4. PUSH와 REPLACE
history의 현재 항목이 /products/123입니다. Link로 /products/456에 이동한 뒤 navigate("/orders/complete", { replace: true })를 실행했습니다. 이 주소도 같은 SPA가 처리하고 이동에 오류가 없다고 가정하면, 뒤로 가기는 어디로 갈까요? 지금의 라우트 트리는 마지막 주소를 어떤 UI로 보여 줄까요?
해설 보기
PUSH 뒤의 history는 /products/123, /products/456 순서이고, REPLACE가 두 번째 항목만 /orders/complete로 바꿉니다. 그래서 뒤로 가기는 /products/123으로 갑니다.
다만 앞의 라우트 트리에는 orders/complete가 없으므로 지금 보이는 화면은 NotFound입니다. history 이동의 성공과 주문 완료 화면의 라우트 정의는 별개의 조건이며, 그 주소에서 직접 새로고침했을 때의 문서 응답도 호스트 설정에서 따로 확인해야 합니다.
다음 글 라우트 파라미터와 쿼리 문자열에서는 경로 매개변수와 쿼리 문자열을 더 깊이 다뤄, 여러 필터를 보존하며 URL을 갱신하는 방법을 살펴봅니다.