React Router 동작 원리 — SPA에서 URL이 바뀌는 진짜 방법
SPA에서 URL이 바뀌는데 왜 페이지가 새로고침되지 않을까요? React Router는 내부적으로 브라우저의 어떤 API를 활용하고 있을까요?
개념 정의
React Router는 React 애플리케이션에서 클라이언트 사이드 라우팅 을 구현하는 라이브러리입니다. URL에 따라 서로 다른 컴포넌트를 렌더링하면서, 전체 페이지를 새로고침하지 않는 SPA(Single Page Application) 경험을 제공합니다.
왜 필요한가
SPA는 하나의 HTML 파일에서 JavaScript가 동적으로 UI를 변경합니다. 하지만 사용자에게는 여전히 URL 기반의 네비게이션이 필요합니다.
- 뒤로가기/앞으로가기 버튼이 동작해야 합니다
- URL을 복사하여 특정 페이지를 공유할 수 있어야 합니다
- 북마크가 가능해야 합니다
- SEO를 위해 의미 있는 URL 구조가 필요합니다
내부 동작 — History API
pushState와 popstate
React Router의 핵심은 브라우저의 History API 입니다.
// History API — 서버 요청 없이 URL 변경
window.history.pushState(state, '', '/about');
// URL이 /about으로 변경되지만, 서버에 요청이 가지 않음
// 뒤로가기/앞으로가기 시 이벤트 발생
window.addEventListener('popstate', (event) => {
console.log('URL이 변경됨:', window.location.pathname);
// React Router가 이 이벤트를 감지하여 적절한 컴포넌트를 렌더링
});
React Router의 동작 흐름을 정리하면 다음과 같습니다.
1. 사용자가 <Link to="/about">을 클릭
2. React Router가 history.pushState('/about') 호출
3. URL이 /about으로 변경 (서버 요청 없음)
4. Router가 URL 변경을 감지
5. /about에 매칭되는 컴포넌트를 렌더링
BrowserRouter vs HashRouter
// BrowserRouter — 깔끔한 URL
// URL: https://example.com/about
// 필요: 서버에서 모든 경로를 index.html로 리다이렉트 설정
<BrowserRouter>
<Routes>
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
// HashRouter — 해시 기반 URL
// URL: https://example.com/#/about
// 장점: 서버 설정 불필요 (해시는 서버로 전송되지 않음)
<HashRouter>
<Routes>
<Route path="/about" element={<About />} />
</Routes>
</HashRouter>
BrowserRouter를 사용할 때 서버 설정이 필요한 이유입니다.
사용자가 직접 https://example.com/about을 입력하면
→ 서버가 /about에 대한 파일을 찾음
→ 파일이 없으므로 404
→ 해결: 모든 경로를 index.html로 리다이렉트
기본 사용법
Route 설정
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">홈</Link>
<Link to="/products">상품</Link>
<Link to="/about">소개</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
동적 라우트
<Routes>
<Route path="/users/:userId" element={<UserProfile />} />
</Routes>
function UserProfile() {
const { userId } = useParams();
return <div>사용자 ID: {userId}</div>;
}
Nested Routes (중첩 라우트)
레이아웃을 공유하면서 하위 경로를 렌더링합니다.
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="products" element={<Products />}>
<Route index element={<ProductList />} />
<Route path=":productId" element={<ProductDetail />} />
</Route>
<Route path="settings" element={<Settings />}>
<Route path="profile" element={<Profile />} />
<Route path="account" element={<Account />} />
</Route>
</Route>
</Routes>
);
}
function Layout() {
return (
<div>
<Header />
<main>
<Outlet /> {/* 여기에 자식 라우트가 렌더링됨 */}
</main>
<Footer />
</div>
);
}
function Products() {
return (
<div>
<h1>상품</h1>
<Outlet /> {/* ProductList 또는 ProductDetail이 렌더링됨 */}
</div>
);
}
URL과 렌더링의 관계입니다.
/ → Layout > Home
/products → Layout > Products > ProductList
/products/123 → Layout > Products > ProductDetail
/settings/profile → Layout > Settings > Profile
네비게이션
Link와 NavLink
import { Link, NavLink } from 'react-router-dom';
// 기본 링크
<Link to="/products">상품</Link>
// 활성 상태 스타일링
<NavLink
to="/products"
className={({ isActive }) => isActive ? 'nav-active' : ''}
>
상품
</NavLink>
// 프로그래매틱 네비게이션
function SearchForm() {
const navigate = useNavigate();
const handleSubmit = (e) => {
e.preventDefault();
navigate(`/search?q=${query}`);
// 또는 뒤로가기: navigate(-1)
// replace: navigate('/login', { replace: true })
};
}
loader와 action (v6.4+)
React Router v6.4부터 도입된 데이터 라우팅 패턴입니다.
loader — 데이터 미리 가져오기
import { createBrowserRouter, RouterProvider, useLoaderData } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/products/:productId',
element: <ProductDetail />,
loader: async ({ params }) => {
const response = await fetch(`/api/products/${params.productId}`);
if (!response.ok) throw new Response('Not Found', { status: 404 });
return response.json();
},
errorElement: <ErrorPage />,
},
]);
function ProductDetail() {
const product = useLoaderData(); // loader의 반환값
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
function App() {
return <RouterProvider router={router} />;
}
action — 데이터 변경
const router = createBrowserRouter([
{
path: '/products/:productId/review',
element: <ReviewForm />,
action: async ({ request, params }) => {
const formData = await request.formData();
await postReview(params.productId, {
rating: formData.get('rating'),
comment: formData.get('comment'),
});
return redirect(`/products/${params.productId}`);
},
},
]);
function ReviewForm() {
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post">
<input name="rating" type="number" min="1" max="5" />
<textarea name="comment" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '저장 중...' : '리뷰 작성'}
</button>
</Form>
);
}
코드 스플리팅
import { lazy, Suspense } from 'react';
const ProductPage = lazy(() => import('./pages/ProductPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
<Routes>
<Route
path="/products"
element={
<Suspense fallback={<PageSkeleton />}>
<ProductPage />
</Suspense>
}
/>
<Route
path="/settings"
element={
<Suspense fallback={<PageSkeleton />}>
<SettingsPage />
</Suspense>
}
/>
</Routes>
주의할 점
BrowserRouter에서 새로고침 시 404 에러
BrowserRouter는 History API를 사용하므로, /users/123 같은 URL로 직접 접근하면 서버가 해당 경로의 파일을 찾으려 합니다. 서버에서 ** 모든 경로를 index.html로 리다이렉트 **하는 설정(SPA fallback)이 필수입니다.
라우트 중첩 시 Outlet을 빠뜨리는 실수
부모 라우트에 <Outlet />을 렌더링하지 않으면 자식 라우트의 컴포넌트가 화면에 나타나지 않습니다. Nested Routes를 사용할 때 반드시 부모 레이아웃에 Outlet을 배치해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 핵심 원리 | History API의 pushState/popstate — 서버 요청 없이 URL 변경 |
| BrowserRouter | 깔끔한 URL — 서버 SPA fallback 설정 필요 |
| HashRouter | 설정 없이 동작 — URL에 # 포함 |
| Nested Routes | Outlet으로 레이아웃 공유하는 계층 구조 |
| loader/action | v6.4+ — 데이터 페칭과 폼 처리를 라우트 수준에서 관리 |
| 코드 스플리팅 | lazy + Suspense로 라우트별 번들 분리 |
라우팅이 단순한 URL 매칭을 넘어 데이터 로딩과 폼 처리까지 담당하는 방향으로 발전하고 있습니다.