
지금까지 React 프로젝트에서 라우팅을 구현할 때는 자연스럽게 BrowserRouter를 사용했다.
보통 main.tsx에서 앱 전체를 BrowserRouter로 감싸고, App.tsx 안에서 Routes와 Route를 사용해 페이지를 연결하는 방식이었다.
import { BrowserRouter } from "react-router";
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<App />
</BrowserRouter>,
);
import { Routes, Route } from "react-router";
const App = () => {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/members" element={<Members />} />
</Routes>
);
};
export default App;
이 방식은 처음 React Router를 배울 때 이해하기 쉽다.
BrowserRouter가 라우팅이 가능한 환경을 만들어주고, Routes 안에 Route를 나열하면서 URL과 컴포넌트를 연결하면 되기 때문이다.
그런데 이번에 React Router를 다시 사용하면서 createBrowserRouter와 RouterProvider를 사용하는 방식을 보게 되었다.
처음에는 단순히 문법만 다른 줄 알았는데, 알고 보니 라우팅을 바라보는 구조 자체가 조금 달랐다.
이번 글에서는 createBrowserRouter 방식이 무엇인지 정리해보려고 한다.
React는 SPA, 즉 Single Page Application을 만들 때 자주 사용된다.
SPA는 페이지를 이동할 때마다 HTML 문서를 새로 받아오는 방식이 아니라, 하나의 페이지 안에서 필요한 컴포넌트만 바꿔 렌더링하는 방식이다.
예를 들어 다음과 같은 URL이 있다고 해보자.
/ → Home
/login → Login
/members → MemberList
/members/1 → MemberDetail
사용자는 서로 다른 페이지로 이동한다고 느끼지만, 실제로는 React 앱 내부에서 URL에 맞는 컴포넌트를 렌더링하고 있는 것이다.
이때 URL과 컴포넌트를 연결해주는 라이브러리가 React Router다.
즉, React Router는 단순히 페이지를 이동시키는 도구라기보다는 현재 URL에 따라 어떤 UI를 보여줄지 결정하는 라우팅 도구라고 볼 수 있다.
내가 기존에 익숙했던 방식은 다음과 같다.
import { BrowserRouter } from "react-router";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<App />
</BrowserRouter>,
);
그리고 App.tsx에서 라우트를 선언한다.
import { Routes, Route } from "react-router";
const App = () => {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/members" element={<Members />} />
<Route path="/members/:id" element={<MemberDetail />} />
</Routes>
);
};
export default App;
이 구조에서 핵심은 다음과 같다.
BrowserRouter가 앱 전체에 라우팅 기능을 제공한다.Routes는 여러 개의 Route 중 현재 URL과 맞는 것을 찾아준다.Route는 특정 path와 렌더링할 컴포넌트를 연결한다.이 방식은 JSX로 라우트를 직접 작성하기 때문에 처음 보기에는 더 직관적이다.
하지만 페이지가 많아지고, 레이아웃이 중첩되고, 로그인 여부에 따라 접근을 막아야 하는 페이지가 생기면 App.tsx 안의 라우트 구조가 점점 복잡해질 수 있다.
이번에 새로 사용한 방식은 createBrowserRouter와 RouterProvider를 사용하는 방식이다.
import { createBrowserRouter, RouterProvider } from "react-router";
import RootLayout from "./layouts/RootLayout";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Members from "./pages/Members";
import MemberDetail from "./pages/MemberDetail";
const router = createBrowserRouter([
{
path: "/",
Component: RootLayout,
children: [
{
index: true,
Component: Home,
},
{
path: "login",
Component: Login,
},
{
path: "members",
Component: Members,
},
{
path: "members/:id",
Component: MemberDetail,
},
],
},
]);
const App = () => {
return <RouterProvider router={router} />;
};
export default App;
처음 봤을 때 가장 낯설었던 점은 Route를 JSX로 작성하지 않는다는 점이었다.
기존 방식에서는 다음처럼 썼다.
<Route path="/login" element={<Login />} />
하지만 createBrowserRouter 방식에서는 라우팅 정보를 객체 배열로 선언한다.
{
path: "login",
Component: Login,
}
즉, 라우팅 구조를 JSX가 아니라 객체 형태의 설정 정보로 관리하는 방식이다.

React Router 예시를 찾아보면 보통 이런 코드가 많이 나온다.
{
path: "login",
element: <Login />,
}
하지만 내가 사용한 방식은 다음과 같다.
{
path: "login",
Component: Login,
}
처음에는 element와 Component가 완전히 다른 기능인가 싶었는데, 둘 다 해당 경로에서 렌더링할 컴포넌트를 지정한다는 점은 비슷하다.
다만 작성 방식이 다르다.
// element 방식
{
path: "login",
element: <Login />,
}
// Component 방식
{
path: "login",
Component: Login,
}
element 방식은 JSX 요소를 직접 넘긴다.
반면 Component 방식은 컴포넌트 자체를 넘긴다.
그래서 Component를 사용할 때는 <Login />처럼 작성하지 않고 Login이라고 작성해야 한다.
// 올바른 방식
{
path: "login",
Component: Login,
}
// 잘못된 방식
{
path: "login",
Component: <Login />,
}
이 부분이 헷갈릴 수 있다.
Component는 말 그대로 컴포넌트 함수를 받는 속성이기 때문에 JSX 태그 형태가 아니라 컴포넌트 이름을 그대로 전달해야 한다.
두 방식의 차이를 정리하면 다음과 같다.
| 구분 | BrowserRouter 방식 | createBrowserRouter 방식 |
|---|---|---|
| 라우트 선언 방식 | JSX로 선언 | 객체 배열로 선언 |
| 주로 사용하는 API | BrowserRouter, Routes, Route | createBrowserRouter, RouterProvider |
| 렌더링 컴포넌트 지정 | element={<Page />} | Component: Page 또는 element: <Page /> |
| 중첩 라우팅 | Route 안에 Route 작성 | children 배열 사용 |
| 라우터 구조 분리 | App.tsx 안에 섞이기 쉬움 | router.tsx 같은 파일로 분리하기 좋음 |
| 확장성 | 단순 라우팅에 적합 | loader, action 같은 Data API까지 확장 가능 |
내가 느낀 가장 큰 차이는 라우팅을 컴포넌트 안에 작성하느냐, 라우팅 설정 객체로 분리하느냐였다.
BrowserRouter 방식은 처음 배우기 쉽고 직관적이다.
반면 createBrowserRouter 방식은 처음에는 낯설지만, 라우트 구조가 커질수록 전체 라우팅 트리를 한눈에 보기 좋다.
React Router를 사용하다 보면 단순히 /login, /signup처럼 독립적인 페이지도 있지만, 특정 레이아웃 안에서 여러 페이지가 바뀌는 경우도 많다.
예를 들어 마이페이지가 있다고 해보자.
/mypage
/mypage/members
/mypage/members/:id
이 페이지들은 모두 공통적으로 마이페이지 레이아웃을 사용할 수 있다.
이럴 때 중첩 라우팅을 사용할 수 있다.
const router = createBrowserRouter([
{
path: "/mypage",
Component: MyPageLayout,
children: [
{
index: true,
Component: MyPage,
},
{
path: "members",
Component: Members,
},
{
path: "members/:id",
Component: MemberDetail,
},
],
},
]);
여기서 중요한 점은 자식 route의 path에 /mypage/members처럼 전체 경로를 쓰지 않는다는 것이다.
부모 route의 path가 /mypage이기 때문에, 자식 route에서는 그 뒤에 붙을 경로만 작성하면 된다.
{
path: "members",
Component: Members,
}
이렇게 작성하면 실제 경로는 다음과 같이 만들어진다.
/mypage/members
만약 자식 route에 절대 경로처럼 /members를 작성하면, 부모 경로와 맞지 않아 에러가 날 수 있다.
중첩 라우팅에서는 자식 경로를 보통 상대 경로로 작성한다는 점을 기억해야 한다.
중첩 라우팅을 사용할 때 꼭 같이 등장하는 개념이 Outlet이다.
import { Outlet } from "react-router";
import Header from "../components/Header";
const MyPageLayout = () => {
return (
<>
<Header />
<Outlet />
</>
);
};
export default MyPageLayout;
처음에는 Outlet이 왜 필요한지 헷갈릴 수 있다.
부모 route에 children을 작성했다고 해서 자식 컴포넌트가 아무 위치에나 자동으로 렌더링되는 것은 아니다.
부모 컴포넌트 안에서 자식 route가 렌더링될 위치를 지정해야 하는데, 그 역할을 하는 것이 Outlet이다.
예를 들어 다음과 같은 라우트 구조가 있다고 해보자.
{
path: "/mypage",
Component: MyPageLayout,
children: [
{
index: true,
Component: MyPage,
},
{
path: "members",
Component: Members,
},
],
}
사용자가 /mypage/members로 이동하면 먼저 부모인 MyPageLayout이 렌더링된다.
그리고 MyPageLayout 안의 Outlet 위치에 Members 컴포넌트가 렌더링된다.
const MyPageLayout = () => {
return (
<>
<Header />
{/* 이 위치에 MyPage 또는 Members가 들어온다 */}
<Outlet />
</>
);
};
즉, Outlet은 중첩 라우팅에서 자식 페이지가 렌더링되는 자리라고 이해하면 된다.
중첩 라우팅을 사용하다 보면 index: true라는 속성도 자주 보인다.
{
path: "/members",
Component: MembersLayout,
children: [
{
index: true,
Component: MemberList,
},
{
path: ":id",
Component: MemberDetail,
},
],
}
이 구조에서 실제 URL은 다음과 같이 매칭된다.
/members → MemberList
/members/1 → MemberDetail
index: true는 부모 경로에 정확히 들어왔을 때 보여줄 기본 자식 route를 의미한다.
즉, /members로 들어왔을 때 MembersLayout의 Outlet 자리에 기본으로 렌더링되는 컴포넌트가 MemberList다.
만약 index: true가 없다면 /members에 들어왔을 때 부모 레이아웃만 보이고, Outlet에는 아무것도 렌더링되지 않을 수 있다.
그래서 중첩 라우팅에서 부모 경로 자체에 보여줄 기본 화면이 필요하다면 index: true를 사용하면 된다.
상세 페이지를 만들 때는 보통 URL에 따라 다른 데이터를 보여줘야 한다.
예를 들어 멤버 상세 페이지가 있다면 다음과 같은 URL이 만들어질 수 있다.
/members/1
/members/2
/members/3
각각 다른 멤버의 상세 정보지만, 페이지 구조 자체는 동일하다.
이럴 때 사용하는 것이 동적 라우팅이다.
{
path: "members/:id",
Component: MemberDetail,
}
여기서 :id는 고정된 문자열이 아니라 URL에 따라 달라지는 값이다.
컴포넌트 안에서는 useParams를 사용해 이 값을 꺼낼 수 있다.
import { useParams } from "react-router";
const MemberDetail = () => {
const { id } = useParams();
return <div>{id}번 멤버 상세 페이지</div>;
};
export default MemberDetail;
예를 들어 /members/3으로 이동하면 id 값은 "3"이 된다.
이렇게 하면 하나의 MemberDetail 컴포넌트를 여러 상세 페이지에서 재사용할 수 있다.
라우팅을 공부하다 보면 Link와 useNavigate도 같이 헷갈린다.
둘 다 페이지 이동을 할 수 있지만, 사용하는 상황이 조금 다르다.
Link는 사용자가 클릭해서 이동하는 UI를 만들 때 사용한다.
import { Link } from "react-router";
const MemberCard = ({ id, name }: { id: number; name: string }) => {
return (
<Link to={`/members/${id}`}>
<article>
<h3>{name}</h3>
</article>
</Link>
);
};
export default MemberCard;
카드 전체를 클릭해서 상세 페이지로 이동해야 한다면 div에 onClick을 붙일 수도 있지만, 실제로 페이지 이동 목적이라면 Link를 사용하는 것이 더 자연스럽다.
왜냐하면 Link는 HTML의 링크 의미를 살릴 수 있고, 사용자가 새 탭으로 열기 같은 기본 브라우저 동작도 사용할 수 있기 때문이다.
반면 useNavigate는 특정 로직이 끝난 뒤 코드로 이동시켜야 할 때 사용한다.
import { useNavigate } from "react-router";
const Login = () => {
const navigate = useNavigate();
const handleLogin = () => {
// 로그인 요청 성공 후
navigate("/mypage");
};
return <button onClick={handleLogin}>로그인</button>;
};
export default Login;
정리하면 다음과 같다.
| 상황 | 추천 방식 |
|---|---|
| 사용자가 클릭해서 페이지 이동 | Link |
| 로그인 성공 후 이동 | useNavigate |
| 폼 제출 성공 후 이동 | useNavigate |
| 단순 메뉴, 카드, 버튼형 링크 | 가능하면 Link |
중첩 라우팅을 사용하면 to="..", to="../members" 같은 상대 경로도 자주 보인다.
예를 들어 현재 URL이 다음과 같다고 해보자.
/mypage/members/1
여기서 Link를 이렇게 작성할 수 있다.
<Link to="..">뒤로가기</Link>
이 경우 현재 route 기준으로 한 단계 위로 이동한다.
즉, 보통 다음 경로로 이동한다.
/mypage/members
반면 다음처럼 작성하면:
<Link to="../members">멤버 목록</Link>
현재 route 구조에 따라 예상과 다르게 /members가 중복되거나 이상한 경로가 될 수 있다.
그래서 중첩 라우팅에서는 현재 route 기준으로 상대 경로가 계산된다는 점을 이해해야 한다.
명확하게 특정 경로로 이동시키고 싶다면 절대 경로를 사용할 수도 있다.
<Link to="/mypage/members">멤버 목록</Link>
정리하면 다음과 같다.
| 작성 방식 | 의미 |
|---|---|
to=".." | 현재 route 기준 한 단계 위 |
to="members" | 현재 route 아래의 members |
to="../members" | 한 단계 위로 간 뒤 members |
to="/members" | 루트 기준 members |
처음에는 상대 경로가 편해 보이지만, 중첩 구조가 복잡할 때는 오히려 헷갈릴 수 있다.
따라서 현재 위치와 route 구조를 정확히 알고 있을 때 상대 경로를 쓰고, 명확한 이동이 필요하면 절대 경로를 쓰는 것도 좋은 방법이라고 느꼈다.
내가 느낀 createBrowserRouter 방식의 장점은 다음과 같다.
라우트가 객체 배열로 정리되기 때문에 전체 페이지 구조를 보기 좋다.
const router = createBrowserRouter([
{
path: "/",
Component: RootLayout,
children: [
{ index: true, Component: Home },
{ path: "login", Component: Login },
{ path: "signup", Component: Signup },
{ path: "members", Component: Members },
{ path: "members/:id", Component: MemberDetail },
],
},
]);
페이지가 많아졌을 때 router.tsx 같은 파일로 분리해두면 앱의 URL 구조를 한 곳에서 관리할 수 있다.
공통 레이아웃을 부모 route로 두고, 그 안에서 바뀌는 페이지를 children으로 표현할 수 있다.
{
path: "/mypage",
Component: MyPageLayout,
children: [
{ index: true, Component: MyPage },
{ path: "members", Component: Members },
{ path: "members/:id", Component: MemberDetail },
],
}
이렇게 하면 Header, Sidebar처럼 여러 페이지에서 공유하는 UI를 레이아웃 컴포넌트에 두고, 실제 페이지 내용만 Outlet에서 바꿀 수 있다.
createBrowserRouter는 단순히 URL에 따라 컴포넌트를 보여주는 것뿐만 아니라, loader, action 같은 Data API와 함께 사용할 수 있다.
예를 들어 페이지가 렌더링되기 전에 필요한 데이터를 미리 가져오는 구조를 만들 수도 있다.
{
path: "members/:id",
Component: MemberDetail,
loader: async ({ params }) => {
const response = await fetch(`/api/members/${params.id}`);
return response.json();
},
}
이번 글에서는 Data API까지 깊게 다루지는 않지만, createBrowserRouter가 단순 라우팅보다 더 확장된 구조를 지원한다는 점은 기억해둘 만하다.