본문으로 건너뛰기

안동민 개발노트

본문 시작

React Router 사용법

React Router 8.3 Declarative mode에서 route tree 매칭, 중첩 Outlet, URL 값, 내비게이션과 직접 진입의 책임 경계를 구현합니다.

React Router는 현재 URL을 route tree에 맞추고, 선택된 React element tree를 렌더링하도록 연결하는 라우팅 라이브러리입니다. React 자체가 라우터인 것은 아니며, 서버의 문서 응답·HTTP 상태·인증과 인가까지 대신하지도 않습니다.

이 절은 설치된 react-router@8.3.0Declarative mode, 즉 BrowserRouterRoutes/Route를 사용하는 구성을 기준으로 합니다. 이 모드는 URL 매칭, 링크 이동, active 상태를 제공하지만 route loader·action·pending UI·route error boundary를 함께 제공하는 Data/Framework mode와는 책임 범위가 다릅니다.

설치와 라우터 경계

React Router 8.3의 공식 Declarative mode 패키지를 설치합니다.

npm install react-router

src/main.jsx에서 BrowserRouter가 앱을 감싸도록 구성합니다.

src/main.jsx
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에 새 항목을 추가하고, replace 옵션은 현재 항목을 바꾸며, 브라우저 뒤로·앞으로 이동은 기존 항목으로 이동합니다.

HashRouter는 route location을 URL의 hash 부분에 저장합니다. hash는 서버 요청에 전송되지 않으므로 각 pathname을 entry HTML로 재작성하기 어려운 환경에서 사용할 수 있지만, base URL에서 앱 문서를 제공하는 host 책임까지 없어지는 것은 아닙니다.

route tree 구성

Routes 안의 Route는 URL segment와 렌더링할 element를 연결합니다. 중첩된 Route는 URL 계층과 UI 계층을 함께 표현합니다.

src/App.jsx
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을 렌더링해야 합니다.

src/layouts/RootLayout.jsx
import { Outlet } from "react-router";
import Navbar from "../components/Navbar";

export default function RootLayout() {
  return (
    <>
      <Navbar />
      <main>
        <Outlet />
      </main>
    </>
  );
}

위 구성의 핵심은 다음과 같습니다.

  • index route는 부모 URL인 /에서 부모의 Outlet에 기본 자식을 렌더링합니다.
  • 자식 path는 부모 경로를 자동으로 상속합니다. 따라서 products/:id/products/456과 매칭됩니다.
  • 부모 RootLayout은 선택된 branch에 계속 포함되고, 일치한 자식 element가 Outlet 위치에 들어갑니다.
  • 부모가 Outlet을 렌더링하지 않으면 자식 route가 매칭되어도 자식 element를 표시할 자리가 없습니다.
  • path="*"는 남은 pathname을 받는 splat fallback입니다.
React Router가 pathname에 가장 잘 맞는 route branch를 고르고 부모 element의 Outlet에 자식 element를 렌더링하는 과정

React Router 8.3 · route tree matching

Routes는 작성 순서의 첫 Route가 아니라 pathname에 가장 잘 맞는 branch를 고릅니다. 선택된 부모·자식 element가 함께 렌더링되고, 자식은 부모의 Outlet 위치에 들어갑니다.

pathname과 route branch의 중첩 매칭 트리 pathname products 456을 Routes가 네 branch 후보와 비교한다. 루트와 products 동적 segment branch가 선택되어 RootLayout의 Outlet에 ProductDetail이 렌더링되고 params id는 456이 된다. location.pathname /products/456 <Routes> · branch ranking 전체 branch 가운데 최적 매치 선택 / + index Home / + products/new 정적 · NewProduct / + products/:id 동적 · 선택 / + * splat · NotFound RootLayout → <Outlet /> ProductDetail · params.id = "456"
  1. location.pathname

    /products/456이 route 매칭 입력이 됩니다.

  2. <Routes>가 branch를 순위화합니다

    정적 segment, 동적 segment, splat의 구체성을 비교하므로 단순한 첫 항목 선택이 아닙니다.

  3. / + products/:id가 선택됩니다

    부모와 자식 match가 함께 남고 params.id는 문자열 "456"입니다.

  4. 중첩 element tree를 렌더링합니다

    RootLayout이 유지되고 ProductDetail이 부모의 Outlet에 들어갑니다.

URL 구성 요소별 매칭 참여 여부와 읽기 API
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는 모두 외부 입력이므로 사용 전에 형식과 허용 범위를 검증합니다.

“첫 번째 Route”가 아니라 가장 잘 맞는 branch

Routes는 위에서부터 처음 맞은 Route 하나를 고르는 목록이 아닙니다. route tree를 branch로 펼쳐 구체성을 비교하고 현재 location에 가장 잘 맞는 branch를 렌더링합니다.

예를 들어 /products/new에는 정적 segment인 products/newproducts/:id보다 구체적입니다. /products/456에는 동적 branch가 맞고, 다른 branch가 없는 /unknown/path* fallback으로 갑니다. 선택 결과는 한 leaf만이 아니라 부모부터 자식까지의 match branch입니다.

Declarative Routeelement는 React element입니다. 이 Routes 기반 route들은 URL 매칭과 렌더링을 담당하며, route module의 loader·action·자동 code splitting·route ErrorBoundary에는 참여하지 않습니다.

사용자가 누르는 일반적인 내비게이션은 Link 또는 NavLink로 만듭니다.

src/components/Navbar.jsx
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>를 렌더링하는 wrapper입니다. 적합한 일반 클릭은 클라이언트 라우팅으로 처리하면서도 키보드 활성화, 링크 주소 복사, 새 탭 열기 같은 anchor 의미를 유지합니다. reloadDocument를 지정하면 브라우저의 문서 탐색을 사용합니다.

NavLinkLink에 active 표현을 더합니다. 위 예제처럼 className callback을 전달하면 callback이 반환한 문자열이 최종 class가 되므로, 활성 링크에는 nav-link--active가 적용됩니다. 활성 링크의 aria-current="page"는 자동으로 설정되며, style·children callback에서도 isActive를 읽을 수 있습니다. Declarative mode에서는 pending 탐색 상태가 제공되지 않습니다.

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>;
}

단순히 사용자가 “홈으로 이동”을 누르는 UI라면 button의 click handler에서 navigate("/")를 호출하는 것보다 <Link to="/">홈</Link>가 링크 의미와 브라우저 기본 동작을 더 잘 보존합니다.

params, search, hash

URL은 하나의 문자열이지만 route 매칭과 화면 입력에서는 역할을 나누어 읽습니다.

Path params

path="products/:id":id는 한 pathname segment를 받아 useParams() 결과의 id가 됩니다.

src/pages/ProductDetail.jsx
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()의 값은 문자열이며 route 구성과 현재 match에 따라 없을 수도 있습니다. param 이름은 같은 path 안에서 고유하게 정하고, 숫자나 식별자로 사용하기 전에 형식과 허용 범위를 검증해야 합니다.

“route가 없음”과 “route는 맞았지만 해당 ID의 데이터가 없음”도 구분합니다. 전자는 path="*" fallback이고, 후자는 ProductDetail이 처리할 resource-not-found 상태입니다.

Search params

?q=react&page=2처럼 ? 뒤의 값은 useSearchParams()로 읽습니다. 반환된 setter를 호출하면 search가 바뀌는 navigation이 발생합니다.

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>
  );
}

search는 같은 pathname 화면의 필터·정렬·페이지 같은 상태에 적합합니다. route path의 dynamic segment와 같은 값이 아니며, 필요한 기본값·허용값·배열 형식은 앱이 검증합니다.

Hash와 location

useLocation()pathname, search, hash, state, key를 포함한 현재 location을 반환합니다. #reviews 같은 hash는 fragment이며 pathname branch를 고르는 값이 아닙니다.

Linkto에는 문자열뿐 아니라 다음처럼 pathname·search·hash를 나눈 객체도 전달할 수 있습니다.

<Link
  to={{
    pathname: "/products/456",
    search: "?tab=spec",
    hash: "#buy",
  }}
>
  구매 정보
</Link>

location state는 browser history에 저장되는 클라이언트 상태입니다. 서버에서 읽을 수 없고 새 문서 응답의 영속 데이터도 아니므로, 공유하거나 복원해야 하는 상태는 URL이나 서버 저장소를 사용합니다.

내부 탐색과 직접 진입은 경계가 다릅니다

실행 중인 앱의 클라이언트 탐색과 주소 입력 또는 새로고침의 문서 탐색이 서로 다른 경계를 거쳐 같은 route matching으로 합류하는 흐름

React Router 8.3 · navigation boundary

내부 이동은 browser history를, 직접 진입은 host/server를 먼저 통과합니다. 둘 다 앱이 실행된 뒤에는 현재 location을 같은 route tree에 맞추지만, 문서 응답·HTTP 상태는 라우터가 대신할 수 없습니다.

클라이언트 탐색과 문서 탐색의 분기 및 합류 URL 이동이 실행 중인 앱에서 처리되면 Link 또는 navigate와 History를 거쳐 route matching으로 간다. 주소 입력이나 새로고침이면 host 요청을 거치며, route-aware host나 SSR이 404로 판정하면 HTTP 404를 반환한다. client-only SPA rewrite 또는 유효한 route는 진입 문서를 실행한 뒤 route matching으로 가고, SPA의 unmatched route는 path splat UI를 선택하더라도 HTTP 상태는 200일 수 있다. URL 이동 시작 현재 앱 문서에서 처리되는 이동인가? YES NO 클라이언트 탐색 Link · NavLink · useNavigate browser history 갱신 PUSH · REPLACE · POP 문서 탐색 주소 입력 · 새로고침 · 새 탭 host / server 요청 GET /products/456 HTTP 404 응답인가? route-aware host · SSR만 route 판정 YES NO HTTP 404 route-aware host · SSR 상태 진입 문서 실행 entry HTML 200 · SSR · hydrate location → Routes match branch · render element

앱 내부 탐색

Link·NavLinknavigate(to)는 기본 PUSH 또는 명시한 REPLACE를 만듭니다. 뒤로·앞으로와 navigate(delta)POP으로 현재 location을 바꾸고 route branch를 다시 매칭합니다.

직접 진입 · 새로고침 · 새 탭

브라우저가 host/server에 문서를 먼저 요청합니다. client-only 정적 SPA rewrite는 알 수 없는 비자산 경로에도 entry HTML 200을 보낸 뒤 path="*" UI를 고를 수 있습니다. 실제 HTTP 404는 route-aware host나 SSR이 결정합니다.

라우팅 API가 제공하는 기본값과 애플리케이션이 설계할 책임
경계 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 <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·useNavigate가 처리하는 이동은 현재 문서 안에서 history와 location을 바꾸고 새 branch를 렌더링합니다. 이때 새 HTML 문서를 요청하지 않더라도 route component의 코드나 화면 데이터는 별도 네트워크 요청이 필요할 수 있습니다.

반면 주소창 입력, 새로고침, 새 탭 열기, 외부 문서에서의 진입은 React Router가 실행되기 전에 host/server에 목적 URL의 문서를 요청합니다.

  • client-routed SPA의 유효한 앱 경로는 entry HTML로 연결해야 합니다.
  • API와 정적 asset 요청은 SPA fallback에서 제외해야 합니다.
  • SSR이나 경로별 문서를 제공한다면 server가 URL에 맞는 문서와 상태를 반환합니다.
  • client-only 정적 SPA의 catch-all rewrite는 알 수 없는 비자산 경로에도 entry HTML을 200 OK로 보낼 수 있습니다.
  • 실제 HTTP 404가 필요하면 route를 아는 host/server, SSR·사전 생성 또는 별도의 상태 정책이 결정해야 합니다.

<Route path="*" element={<NotFound />} />는 앱이 실행된 뒤 unmatched UI를 고릅니다. 이미 200 OK로 받은 client-only entry 문서의 HTTP 상태를 이 component가 소급해 404로 바꾸지는 못합니다. 검색 엔진과 공유 링크에 정확한 상태가 중요하다면 route-aware server/host 또는 SSR·사전 생성 경계를 함께 설계합니다.

접근성, 스크롤, pending, error 책임

클라이언트 라우팅은 새 문서를 열지 않으므로 브라우저가 문서 탐색 때 하던 모든 전환 동작이 자동으로 반복되지는 않습니다.

  • 링크: 가능한 경우 Link·NavLink를 사용해 실제 anchor 의미를 유지합니다. NavLinkaria-current="page"는 현재 위치를 보조 기술에 전달합니다.
  • 초점과 알림: route 변경 뒤 문서 제목을 갱신하고, 주 콘텐츠 heading이나 main에 초점을 옮길지 설계합니다. 전환이 길거나 결과가 중요하면 live region으로 loading·완료·오류를 알립니다. React Router는 바뀐 UI를 알 수 없으므로 이 정책을 가정하지 않습니다.
  • 스크롤: 모든 이동을 무조건 맨 위로 보내지 말고 새 위치, 뒤로·앞으로 복원, search-only 전환, hash 이동을 구분합니다. Declarative mode에서는 useLocation 등을 바탕으로 앱이 정책을 구현합니다. ScrollRestoration은 Data/Framework router용입니다.
  • pending: Declarative mode의 NavLink에는 isPending이 없고 useNavigation은 data router가 필요합니다. 이 구성에서는 data fetching library나 component 상태로 pending UI를 만들거나 Data/Framework mode를 선택합니다.
  • error: Routes/Route element 구성에서는 명시적인 비동기 오류 UI와 일반 React error boundary를 둡니다. loader/action 오류와 route-module ErrorBoundary·useRouteError가 필요하면 Data/Framework mode를 사용합니다.
  • 보안: param·search·location state와 클라이언트 route guard는 신뢰 경계가 아닙니다. 서버는 모든 요청에서 입력 검증과 인증·인가를 다시 강제해야 합니다.

확인할 탐색 시나리오

라우팅은 링크 한 번만 눌러 보고 끝내지 않습니다.

  1. LinkNavLink로 이동했을 때 URL, active 상태, 렌더링 결과가 일치하는지 확인합니다.
  2. 브라우저 뒤로·앞으로 이동에서 이전 location과 화면이 복원되는지 확인합니다.
  3. /products/456?tab=spec#buy를 주소창에 직접 입력하고 새로고침해도 entry 문서와 route match가 정상인지 확인합니다.
  4. /products/newproducts/:id가 아니라 정적 route에 매칭되는지 확인합니다.
  5. 앱에 없는 URL의 fallback UI와 실제 HTTP 상태를 각각 확인합니다.
  6. 키보드 이동, 현재 링크 알림, route 변경 뒤 제목·초점·스크롤·loading·error 알림을 확인합니다.

React Router 코드를 읽을 때는 컴포넌트 이름을 외우기보다 location 입력 → 최적 route branch → 중첩 element tree내부 history 이동 → 직접 문서 진입의 두 흐름을 분리하면 책임 경계가 선명해집니다.