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 입니다.

JAVASCRIPT
// History API — 서버 요청 없이 URL 변경
window.history.pushState(state, '', '/about');
// URL이 /about으로 변경되지만, 서버에 요청이 가지 않음

// 뒤로가기/앞으로가기 시 이벤트 발생
window.addEventListener('popstate', (event) => {
  console.log('URL이 변경됨:', window.location.pathname);
  // React Router가 이 이벤트를 감지하여 적절한 컴포넌트를 렌더링
});

React Router의 동작 흐름을 정리하면 다음과 같습니다.

PLAINTEXT
1. 사용자가 <Link to="/about">을 클릭
2. React Router가 history.pushState('/about') 호출
3. URL이 /about으로 변경 (서버 요청 없음)
4. Router가 URL 변경을 감지
5. /about에 매칭되는 컴포넌트를 렌더링

BrowserRouter vs HashRouter

JSX
// 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를 사용할 때 서버 설정이 필요한 이유입니다.

PLAINTEXT
사용자가 직접 https://example.com/about을 입력하면
→ 서버가 /about에 대한 파일을 찾음
→ 파일이 없으므로 404
→ 해결: 모든 경로를 index.html로 리다이렉트

기본 사용법

Route 설정

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

동적 라우트

JSX
<Routes>
  <Route path="/users/:userId" element={<UserProfile />} />
</Routes>

function UserProfile() {
  const { userId } = useParams();
  return <div>사용자 ID: {userId}</div>;
}

Nested Routes (중첩 라우트)

레이아웃을 공유하면서 하위 경로를 렌더링합니다.

JSX
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과 렌더링의 관계입니다.

PLAINTEXT
/                    → Layout > Home
/products            → Layout > Products > ProductList
/products/123        → Layout > Products > ProductDetail
/settings/profile    → Layout > Settings > Profile

네비게이션

Link와 NavLink

JSX
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 — 데이터 미리 가져오기

JSX
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 — 데이터 변경

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

코드 스플리팅

JSX
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 RoutesOutlet으로 레이아웃 공유하는 계층 구조
loader/actionv6.4+ — 데이터 페칭과 폼 처리를 라우트 수준에서 관리
코드 스플리팅lazy + Suspense로 라우트별 번들 분리

라우팅이 단순한 URL 매칭을 넘어 데이터 로딩과 폼 처리까지 담당하는 방향으로 발전하고 있습니다.

댓글 로딩 중...