React Router 사용법 정리: 중첩 라우팅과 URL 파라미터

3/19/2025 ·impact

React로 만든 화면이 두 개를 넘어가는 순간, 주소와 컴포넌트를 연결하는 규칙이 필요해집니다. react-router-dom은 브라우저 주소를 읽어 어떤 컴포넌트를 그릴지 정하고, 문서 전체를 다시 내려받지 않은 채 화면만 갈아 끼웁니다.

SPA에서 라우팅이 하는 일

전통적인 웹은 링크를 누를 때마다 서버가 새 HTML을 내려주고 브라우저가 화면을 처음부터 다시 그렸습니다. Single Page Application(SPA)은 문서 하나를 받아 둔 뒤 자바스크립트가 주소 변화를 감지해 필요한 부분만 바꿉니다. 사용자가 URL을 입력하거나 링크를 클릭했을 때 그 URL을 해석해 알맞은 컴포넌트를 보여주는 과정, 이것이 라우팅입니다.

React 라우팅이 다루는 범위는 대체로 네 가지입니다.

  • 동적 페이지 전환 — 문서 전체를 다시 받지 않고 화면만 교체
  • URL 파라미터와 쿼리 스트링 — 화면에 필요한 값을 주소에 실어 보내기
  • 중첩 라우팅(Nested Routing) — 공통 레이아웃은 그대로, 안쪽 영역만 교체
  • 브라우저 히스토리 제어 — 뒤로 가기와 앞으로 가기

네 번째 항목이 실무에서 자주 밀립니다. 라우터 없이 useState 값으로 화면을 전환하면 뒤로 가기를 눌렀을 때 앱 밖으로 나가버리고, 지금 보고 있는 화면의 주소를 복사해 남에게 보낼 수도 없습니다. 새로고침하면 첫 화면으로 돌아가는 것도 같은 이유입니다. 화면 상태를 주소창에 올려 둔다는 점이 라우팅과 일반적인 상태 관리를 가르는 지점입니다.

설치와 BrowserRouter 설정

설치는 한 줄입니다. 다만 @types/react-router-dom을 같이 깔라는 안내가 아직 검색 결과에 남아 있는데, v6부터는 필요 없습니다.

npm install react-router-dom

react-router-dom 6.30.0의 package.json에는 "types": "./dist/index.d.ts"가 들어 있습니다. 타입 정의가 패키지 안에 동봉돼 있으니 별도 @types 패키지를 겹쳐 깔 이유가 없습니다.

패키지 이름 자체는 그 뒤로 한 번 정리됐습니다. v7 → v8 업그레이드 문서는 "React Router v8 removes the react-router-dom re-export package. In v8, you should import DOM-specific APIs from react-router/dom and everything else from react-router"라고 적습니다. v7까지의 react-router-dom이 re-export 패키지였다는 뜻이고, v8에서는 RouterProvider처럼 DOM 전용인 것만 react-router/dom에서, 나머지는 react-router에서 가져옵니다. 같은 문서가 최소 요구 버전으로 react@19.2.7·react-dom@19.2.7 이상을 못 박아 뒀으니 React 버전부터 봐야 합니다. 아래 코드는 v6·v7 기준 import 경로를 그대로 씁니다. 그 문서가 훅 쪽 변경으로 든 것은 useMatches()matches[i].dataloaderData로 바뀐 건인데, 이 글에 나오는 API는 거기 걸리지 않아 import 경로만 갈아 끼우면 됩니다.

다음은 앱 최상단을 라우터로 감싸는 단계입니다. BrowserRouter는 브라우저 History API를 사용해 # 없는 /about 형태의 주소를 만듭니다.

// index.tsx
import { BrowserRouter } from 'react-router-dom';

ReactDOM.createRoot(document.getElementById('root')!).render(
 <BrowserRouter>
 <App />
 </BrowserRouter>
);

대신 배포할 때 조건이 하나 붙습니다. 사용자가 /about에서 새로고침하면 브라우저는 그 경로를 서버에 그대로 요청하는데, 정적 호스팅에는 그런 파일이 없으니 404가 돌아옵니다. React Router의 SPA 배포 문서도 같은 이야기를 합니다. "Common to deploying any SPA, you'll need to configure your host to direct all URLs to the index.html of the client build. Some hosts do this by default, but others don't." 같은 문서가 예시로 든 방식은 _redirects 파일에 /* /index.html 200 한 줄을 넣는 것이고, 뒤이어 "If you're getting 404s at valid routes for your app, it's likely you need to configure your host"이라고 덧붙입니다. 구체적인 설정 방법은 호스팅마다 다릅니다.

서버를 건드릴 수 없는 환경이라면 HashRouter가 있습니다. 공식 API 문서는 이 라우터를 "stores the location in the hash portion of the URL so it is not sent to the server"라고 설명합니다. # 뒤쪽은 서버로 전송되지 않으니 서버가 보는 경로는 언제나 하나뿐이고, rewrite 설정 없이도 새로고침이 깨지지 않습니다. 대신 주소에 #이 남습니다.

Route 매칭과 중첩 라우팅

라우터 안에서는 RoutesRoute로 경로와 컴포넌트를 연결합니다. 고정된 경로와 동적 세그먼트를 같은 자리에 섞어 쓸 수 있습니다.

// App.tsx
import { Routes, Route } from 'react-router-dom';

function App() {
 return (
 <Routes>
 <Route path="/" element={<Home />} />
 <Route path="/about" element={<About />} />
 <Route path="/profiles/:username" element={<Profile />} />
 </Routes>
 );
}

path에 붙은 :username이 동적 세그먼트입니다. /profiles/jeff로 들어오면 Profile이 렌더링되고, jeff라는 값을 컴포넌트 안에서 꺼내 쓸 수 있습니다. element에 컴포넌트 자체가 아니라 JSX 엘리먼트를 넘긴다는 점은 처음에 자주 걸립니다. v5 → v6 업그레이드 가이드는 이 변화를 "moving your route element from the child position to a named element prop"이라고 설명합니다.

중첩 라우팅은 공통 레이아웃을 유지한 채 안쪽 영역만 바꾸고 싶을 때 씁니다. 헤더와 사이드바는 그대로 두고 본문만 갈아 끼우는 화면이 여기 해당합니다.

// 중첩 라우팅 설정하기
<Routes>
 <Route path="articles" element={<Articles />}>
 <Route path=":id" element={<Article />} />
 </Route>
</Routes>

Routing 가이드는 "The path of the parent is automatically included in the child"라고 적습니다. 위 설정 하나로 /articles/articles/:id 두 주소가 생깁니다. 그리고 같은 문서의 한 줄, "Child routes are rendered through the <Outlet/> in the parent route". 부모 컴포넌트가 자식이 그려질 자리를 직접 지정해야 한다는 뜻입니다.

// Articles.tsx
import { Outlet } from 'react-router-dom';

function Articles() {
 return (
 <div>
 <h1>게시글 목록</h1>
 <Outlet />
 </div>
 );
}

부모에 Outlet이 없으면 주소는 바뀌는데 자식 화면이 나오지 않습니다. 중첩 라우팅에서 가장 흔한 증상이니 화면이 비어 보이면 여기부터 봅니다. 부모 URL 자체에도 기본 자식을 그리고 싶다면 같은 문서가 설명하는 인덱스 라우트가 있습니다. "Index routes render into their parent's <Outlet/> at their parent's URL"이고, path 대신 index prop으로 설정합니다.

라우팅은 어떤 화면을 보여줄지까지만 정합니다. 여러 화면이 같은 데이터를 함께 봐야 하는 단계가 오면 별도의 상태 관리가 필요한데, 그 이야기는 Recoil로 React 상태를 관리하는 방법에 따로 적어 두었습니다.

Link, NavLink와 URL 데이터 다루기

화면 이동에는 a 태그 대신 Link를 씁니다. 공식 문서는 Link를 "a progressively enhanced <a href> wrapper to enable navigation with client-side routing"이라고 소개합니다. 생 a 태그를 그대로 쓰면 브라우저가 문서를 다시 요청하면서 앱이 통째로 재시작됩니다. 반대로 일부러 전체 새로고침이 필요한 자리에는 reloadDocument prop이 있습니다. 같은 문서가 "will use document navigation instead of client side routing when the link is clicked"라고 적어 둔 prop입니다.

// Link 활용 예시
<Link to="/about">소개 페이지</Link>

// NavLink로 활성화된 링크 스타일 적용하기
<NavLink
 to="/articles/1"
 style={({ isActive }) => (isActive ? {color:'green', fontWeight:'bold'} : undefined)}
>
 게시글 1
</NavLink>

NavLinkLink를 감싸 활성 상태를 넘겨주는 컴포넌트입니다. className, style, children을 함수로 주면 isActive를 받습니다. 함수를 안 써도 되는 경우가 더 많습니다. 문서에 따르면 매칭된 링크에는 active 클래스가 붙고 aria-current="page"도 자동으로 적용되니, CSS만 만지면 되는 상황이라면 a.active { ... } 한 줄로 끝납니다. 뷰 트랜지션이 진행 중일 때 붙는 transitioning 클래스도 같은 방식입니다.

주소에 담긴 값은 두 훅으로 읽습니다. 경로 안에 박힌 값은 useParams, ? 뒤의 쿼리 스트링은 useSearchParams 담당입니다.

// 파라미터 접근
import { useParams } from 'react-router-dom';
const { username } = useParams();

// 쿼리 스트링 접근
import { useSearchParams } from 'react-router-dom';
const [searchParams, setSearchParams] = useSearchParams();
let detail = searchParams.get("detail");

// 쿼리 변경 예시
setSearchParams({ detail: "true", page: "2" });

반환값의 생김새를 헷갈리기 쉽습니다. useParams 문서는 "Assuming a route pattern like /posts/:postId is matched by /posts/123 then params.postId will be "123""라고 적습니다. 문자열입니다. useSearchParams 쪽은 "A tuple of the current URLSearchParams and a function to update them"을 돌려줍니다. 첫 번째 값은 문자열이 아니라 URLSearchParams 객체이고, 실제 값은 .get()으로 꺼냅니다. MDN은 이 메서드의 반환값을 "A string if the given search parameter is found; otherwise, null"이라고 적어 두었습니다.

정리하면 주소에서 꺼낸 값은 숫자가 아닙니다. 페이지 번호처럼 숫자로 다룰 값은 변환을 거쳐야 하고, "true" 같은 문자열을 불리언처럼 쓰려면 비교문을 직접 씁니다. 값이 아예 없는 경우와 빈 문자열이 온 경우도 갈라서 봐야 합니다.

setSearchParams에 객체를 넘기면 쿼리 스트링이 통째로 그 값으로 바뀝니다. 정렬 조건은 두고 페이지 번호만 바꾸고 싶다면 함수형을 씁니다.

setSearchParams((searchParams) => {
 searchParams.set("page", "2");
 return searchParams;
});

다만 이 함수형은 React setState의 큐잉을 따르지 않습니다. 문서가 "Multiple calls to setSearchParams in the same tick will not build on the prior value"라고 명시하고, 그 동작이 필요하면 setState를 직접 쓰라고 안내합니다. 한 틱 안에서 두 번 부르면 뒤엣것만 남는다는 뜻입니다.

목록 화면의 검색어와 페이지 번호를 쿼리 스트링에 올려 두면 새로고침해도 조건이 남고, 그 값을 그대로 API 요청에 실어 보낼 수 있습니다. 이 요청을 받는 서버 쪽을 직접 만들어 보고 싶다면 FastAPI로 만드는 Todo List 앱 글이 따로 있습니다.

NotFound 라우트와 서버 404 구분하기

정의하지 않은 경로로 들어온 사용자에게 빈 화면을 보여주지 않으려면 와일드카드 라우트를 추가합니다.

// NotFound 라우팅 추가
<Route path="*" element={<NotFound />} />

이 라우트를 맨 아래 둬야 동작한다는 이야기가 따라다니는데, v6부터는 위치와 무관합니다. 업그레이드 가이드는 "Routes are chosen based on the best match instead of being traversed in order"라고 설명하고, 이어서 "You may put your routes in whatever order you wish and the router will automatically detect the best route for the current URL"이라고 못 박습니다. v5의 Switch는 위에서 아래로 훑었으니 순서가 곧 우선순위였지만, Routes는 각 경로에 점수를 매겨 가장 잘 맞는 것을 고릅니다. 대신 위치 제한이 하나 붙습니다. 같은 문서가 "A * wildcard may be used only at the end of a path, not in the middle"이라고 적어 뒀습니다. 그래도 맨 아래에 두는 관례가 남은 건 읽는 사람이 "여기가 마지막"이라고 파악하기 쉬워서지 동작 때문이 아닙니다.

이 라우트가 담당하는 범위는 앱이 이미 로드된 뒤의 404까지입니다. /about에서 새로고침했을 때 브라우저에 뜨는 404는 라우터가 실행되기도 전에 서버가 내는 응답입니다. 화면에 뜬 404가 내가 만든 NotFound 컴포넌트인지 서버가 보낸 기본 페이지인지를 구분하면 원인이 갈립니다. 앞은 라우트 설정 문제, 뒤는 앞에서 말한 fallback 문제입니다.

클라이언트 라우팅이 못 하는 것

React Router 문서는 자기 자신을 세 모드로 나눠 설명합니다. 이 글의 BrowserRouter + Routes 조합은 그중 Declarative 모드입니다. 같은 문서는 이 모드를 "basic routing features like matching URLs to components, navigating around the app, and providing active states"로 정의하고, 데이터 로딩과 액션, pending 상태는 그 위 단계인 Data 모드가 loader·action·useFetcher로 더한다고 적습니다.

pending 쪽이 의외로 빨리 체감됩니다. NavLink의 render prop에는 isActive 말고 isPending도 있는데, NavLink 문서가 "Note that pending is only available with Framework and Data modes"라고 단서를 답니다. Declarative 모드에서 "누른 링크가 로딩 중"을 표시하려면 그 상태를 직접 들고 있어야 한다는 뜻입니다.

SEO는 흔히 알려진 것보다 결이 복잡합니다. Google 문서는 자바스크립트 웹 앱을 크롤 → 렌더 → 색인 세 단계로 처리한다고 설명하고, 렌더 단계에서 헤드리스 Chromium이 자바스크립트를 실행한다고 적습니다. <title>과 meta description을 자바스크립트로 바꾸는 것도 지원 범위 안입니다. 문제는 시점 쪽입니다. 렌더링은 큐에 들어가고, 같은 문서의 표현으로 "The page may stay on this queue for a few seconds, but it can take longer than that". 색인이 며칠 늦어도 되는 페이지인지 아닌지가 갈림길입니다.

성능은 측정해서 판단합니다. LCP는 뷰포트에 보이는 가장 큰 이미지·텍스트 블록·비디오가 렌더된 시점을 재는 지표입니다. web.dev는 좋음 기준을 "2.5 seconds or less"로 잡되, 페이지 로드의 75번째 백분위수에서 모바일과 데스크톱을 나눠 재라고 안내합니다. 4초를 넘어가면 나쁨 구간입니다. 클라이언트 렌더링에서는 그 큰 요소가 번들을 받아 실행한 뒤에야 그려지니 값이 밀리기 쉽고, 실제 측정에서 나쁘게 나오는지부터 보고 움직이면 됩니다.

SEO와 초기 로딩이 실제로 걸릴 때 서버 렌더링 이야기가 나옵니다. 판단 기준 자체는 단순합니다. 로그인 이후에만 보이는 대시보드나 관리자 화면은 색인 대상이 아니니 위 두 가지 어느 쪽에도 걸리지 않습니다. 그리고 갈아탈 곳이 꼭 다른 프레임워크인 것도 아닙니다. 같은 모드 문서가 React Router의 Framework 모드 기능으로 "SPA, SSR, and static rendering strategies"를 들고 있어서, 쓰던 라우터 안에서 단계를 올리는 길도 남아 있습니다.

마무리

여기까지가 주소와 컴포넌트를 잇는 최소 단위입니다. 코드보다 먼저 막히는 쪽은 대개 서버 fallback 설정입니다. 로컬에서 잘 되던 새로고침이 배포 후에 깨지는 건 라우트 설정보다 호스팅의 rewrite 규칙이 비어 있어서인 경우가 많습니다.