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

3/19/2025 ·impact

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

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 업그레이드 문서에 따르면 v7까지의 react-router-dom은 re-export 패키지였고, v8에서 이 패키지가 제거됐습니다. 그래서 v8에서는 RouterProvider처럼 DOM 전용인 것만 react-router/dom에서, 나머지는 react-router에서 가져옵니다. 같은 문서가 최소 요구 버전으로 react@19.2.7·react-dom@19.2.7 이상을 못 박아 뒀습니다. 아래 코드는 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 배포 문서도 같은 이야기를 합니다. 어떤 SPA든 배포할 때는 모든 URL이 클라이언트 빌드의 index.html로 가도록 호스트를 설정해야 하는데, 이를 기본으로 해 주는 호스트도 있지만 아닌 곳도 있다는 것입니다. 같은 문서가 예시로 든 방식은 _redirects 파일에 /* /index.html 200 한 줄을 넣는 것이고, 뒤이어 멀쩡한 라우트에서 404가 난다면 호스트 설정이 필요한 경우일 가능성이 높다고 덧붙입니다. 구체적인 설정 방법은 호스팅마다 다릅니다.

서버를 건드릴 수 없는 환경이라면 HashRouter가 있습니다. 공식 API 문서는 이 라우터가 위치를 URL의 해시 부분에 저장한다고 설명합니다. # 뒤쪽은 서버로 전송되지 않으니 서버가 보는 경로는 언제나 하나뿐이고, 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 업그레이드 가이드는 이 변화를 라우트 엘리먼트를 자식 위치에서 element라는 이름의 prop으로 옮긴 것이라고 설명합니다.

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

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

Routing 가이드는 부모의 path가 자식 경로에 자동으로 포함된다고 적습니다. 위 설정 하나로 /articles/articles/:id 두 주소가 생깁니다. 그리고 같은 문서에 따르면 자식 라우트는 부모 라우트의 <Outlet/> 자리에 그려집니다. 부모 컴포넌트가 자식이 그려질 자리를 직접 지정해야 한다는 뜻입니다.

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

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

부모에 Outlet이 없으면 주소는 바뀌는데 자식 화면이 나오지 않습니다. 중첩 라우팅에서 가장 흔한 증상입니다. 부모 URL에서도 기본 화면을 띄우고 싶다면 같은 문서가 설명하는 인덱스 라우트를 쓰면 됩니다. 부모의 URL에서 부모의 <Outlet/>에 렌더링되는 라우트로, path 대신 index prop으로 설정합니다.

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

Link, NavLink와 URL 데이터 다루기

화면 이동에는 a 태그 대신 Link를 씁니다. 공식 문서는 Link를 클라이언트 사이드 라우팅으로 이동하도록 <a href>를 감싼 컴포넌트라고 소개합니다. 생 a 태그를 그대로 쓰면 브라우저가 문서를 다시 요청하면서 앱이 통째로 재시작됩니다. 반대로 일부러 전체 새로고침이 필요한 자리에는 reloadDocument prop이 있습니다. 같은 문서가 링크를 클릭하면 클라이언트 사이드 라우팅 대신 문서 내비게이션을 쓴다고 적어 둔 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 문서는 /posts/:postId 패턴이 /posts/123에 매칭되면 params.postId"123"이 된다고 적습니다. 문자열입니다. useSearchParams 쪽은 현재 URLSearchParams와 이를 갱신하는 함수의 튜플을 돌려줍니다. 첫 번째 값은 문자열이 아니라 URLSearchParams 객체이고, 실제 값은 .get()으로 꺼냅니다. MDN은 이 메서드의 반환값을 해당 파라미터가 있으면 문자열, 없으면 null이라고 적어 두었습니다.

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

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

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

다만 이 함수형은 React setState의 큐잉을 따르지 않습니다. 문서도 이를 명시하면서 그 동작이 필요하면 setState를 직접 쓰라고 안내합니다. 한 틱 안에서 두 번 부르면 나중에 부른 것만 남는다는 뜻입니다.

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

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

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

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

이 라우트를 맨 아래 둬야 동작한다는 이야기가 따라다니는데, v6부터는 위치와 무관합니다. 업그레이드 가이드는 라우트를 순서대로 훑지 않고, 어떤 순서로 두든 현재 URL에 가장 잘 맞는 라우트를 골라낸다고 못 박습니다. v5의 Switch는 위에서 아래로 훑었으니 순서가 곧 우선순위였지만, Routes는 각 경로에 점수를 매겨 가장 잘 맞는 것을 고릅니다. 대신 경로를 적는 방식에는 제한이 하나 붙습니다. 같은 문서가 * 와일드카드는 경로 중간이 아니라 끝에서만 쓸 수 있다고 적어 뒀습니다. 그래도 맨 아래에 두는 관례가 남은 건 읽는 사람이 "여기가 마지막"이라고 파악하기 쉬워서지 동작 때문이 아닙니다.

이 라우트가 잡아 주는 건 앱이 이미 로드된 뒤에 나는 404뿐입니다. /about에서 새로고침했을 때 브라우저에 뜨는 404는 라우터가 실행되기도 전에 서버가 내는 응답입니다. 화면에 뜬 404가 내가 만든 NotFound 컴포넌트라면 라우트 설정 문제이고, 서버가 보낸 기본 페이지라면 앞에서 말한 fallback 문제입니다.

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

React Router 문서는 라우터를 세 가지 모드로 나눠 설명합니다. 지금까지 쓴 BrowserRouter + Routes 조합은 그중 Declarative 모드입니다. 같은 문서는 이 모드를 URL과 컴포넌트 매칭, 앱 안에서의 이동, 활성 상태 제공 같은 기본 라우팅 기능으로 정의하고, 데이터 로딩과 액션, pending 상태는 그 위 단계인 Data 모드가 loader·action·useFetcher로 더한다고 적습니다.

이 차이는 pending 쪽에서 금방 드러납니다. NavLink의 render prop에는 isActive 말고 isPending도 있는데, NavLink 문서pending은 Framework와 Data 모드에서만 쓸 수 있다고 단서를 답니다. Declarative 모드에서 "누른 링크가 로딩 중"을 표시하려면 그 상태를 직접 들고 있어야 한다는 뜻입니다.

SEO는 흔히 알려진 것보다 사정이 복잡합니다. Google 문서는 자바스크립트 웹 앱을 크롤 → 렌더 → 색인 세 단계로 처리한다고 설명하고, 렌더 단계에서 헤드리스 Chromium이 자바스크립트를 실행한다고 적습니다. <title>과 meta description을 자바스크립트로 바꾸는 것도 지원 범위 안입니다. 문제는 시점 쪽입니다. 렌더링은 큐에 들어가고, 같은 문서의 표현으로는 페이지가 그 큐에 몇 초 머물 수도 있지만 그보다 오래 걸릴 수도 있습니다. 색인이 며칠 늦어도 되는 페이지인지 아닌지부터 따져야 합니다.

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

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

마무리

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