중급

React 강좌 08강 — React Router v6: SPA 라우팅 완전 정복

🎯 학습 목표

  • React Router v6의 Routes/Route 중첩 구조와 Outlet을 이해한다
  • useParams, useNavigate, useLocation을 각각 코드로 활용할 수 있다
  • Protected Route(인증 라우트)를 직접 구현할 수 있다
  • React.lazy와 Suspense로 코드 스플리팅을 구현한다
  • SPA 라우팅 원리(History API)를 이해한다

📖 핵심 개념 1 — SPA 라우팅 원리

전통적인 멀티 페이지 앱(MPA)에서는 링크를 클릭할 때마다 서버에서 새 HTML 페이지를 내려받습니다. SPA(Single Page Application)는 브라우저의 History API를 활용해 페이지 전체를 새로 받지 않고, JavaScript로 URL과 UI를 동시에 변경합니다. React Router는 이 작업을 추상화한 라이브러리입니다.

// 설치
// npm install react-router-dom

// main.jsx — BrowserRouter로 앱 감싸기
import { BrowserRouter } from 'react-router-dom';

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

📖 핵심 개념 2 — 중첩 라우트와 Outlet

// App.jsx — 라우트 구조 정의
import { Routes, Route, Link, Navigate } from 'react-router-dom';
import { lazy, Suspense } from 'react';

// 코드 스플리팅: 각 페이지를 필요할 때만 로드
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));
const NotFound = lazy(() => import('./pages/NotFound'));

function App() {
  return (
    <Suspense fallback={<div>페이지 로딩 중...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />

        {/* 중첩 라우트: /dashboard, /dashboard/profile, /dashboard/settings */}
        <Route path="/dashboard" element={<DashboardLayout />}>
          {/* index: /dashboard 접근 시 기본으로 표시 */}
          <Route index element={<Dashboard />} />
          <Route path="profile" element={<Profile />} />
          <Route path="settings" element={<Settings />} />
        </Route>

        {/* 인증 필요 라우트 */}
        <Route
          path="/admin"
          element={
            <RequireAuth>
              <AdminPage />
            </RequireAuth>
          }
        />

        {/* 404 처리 */}
        <Route path="*" element={<NotFound />} />
      </Routes>
    </Suspense>
  );
}

// DashboardLayout — 중첩 라우트의 공통 레이아웃
function DashboardLayout() {
  return (
    <div className="dashboard">
      <nav>
        {/* Link는 a 태그 역할 — 페이지 새로고침 없이 이동 */}
        <Link to="/dashboard">대시보드 홈</Link>
        <Link to="/dashboard/profile">프로필</Link>
        <Link to="/dashboard/settings">설정</Link>
      </nav>
      {/* Outlet: 중첩된 자식 Route의 컴포넌트가 여기에 렌더링됨 */}
      <main><Outlet /></main>
    </div>
  );
}

📖 핵심 개념 3 — useParams, useNavigate, useLocation

import { useParams, useNavigate, useLocation } from 'react-router-dom';

// useParams — URL 파라미터 읽기
// Route: <Route path="/posts/:postId/comments/:commentId" element={...} />
function PostDetail() {
  const { postId, commentId } = useParams();
  // URL이 /posts/42/comments/7 이면 postId="42", commentId="7"

  return <p>게시글 {postId}의 댓글 {commentId}</p>;
}

// useNavigate — 프로그래밍 방식 이동
function LoginForm() {
  const navigate = useNavigate();

  async function handleLogin(e) {
    e.preventDefault();
    const success = await doLogin();

    if (success) {
      navigate('/dashboard');         // 이동
      // navigate('/dashboard', { replace: true }); // 히스토리 교체 (뒤로가기 방지)
      // navigate(-1);                 // 뒤로 가기
      // navigate(1);                  // 앞으로 가기
    }
  }

  return <form onSubmit={handleLogin}>...</form>;
}

// useLocation — 현재 URL 정보
function NavLink({ to, children }) {
  const location = useLocation();
  const isActive = location.pathname === to;

  // location.pathname: "/dashboard/profile"
  // location.search: "?tab=recent"
  // location.hash: "#section2"
  // location.state: navigate로 전달한 state

  return (
    <Link
      to={to}
      style={{ fontWeight: isActive ? 'bold' : 'normal' }}
    >
      {children}
    </Link>
  );
}

📖 핵심 개념 4 — Protected Route

// hooks/useAuth.js
function useAuth() {
  // 실제로는 Context나 Zustand에서 인증 상태를 가져옴
  const token = localStorage.getItem('authToken');
  return { isAuthenticated: !!token };
}

// components/RequireAuth.jsx
import { Navigate, useLocation } from 'react-router-dom';

function RequireAuth({ children }) {
  const { isAuthenticated } = useAuth();
  const location = useLocation();

  if (!isAuthenticated) {
    // 로그인 페이지로 리디렉션
    // state에 현재 위치를 저장 → 로그인 후 원래 페이지로 돌아가기 위해
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

// LoginPage.jsx — 로그인 후 원래 페이지로 이동
function LoginPage() {
  const navigate = useNavigate();
  const location = useLocation();

  // RequireAuth가 저장한 원래 경로, 없으면 대시보드로
  const from = location.state?.from?.pathname || '/dashboard';

  async function handleLogin() {
    await doLogin();
    navigate(from, { replace: true }); // 원래 페이지로 이동
  }

  return <button onClick={handleLogin}>로그인</button>;
}

⚠️ 흔한 실수 (よくあるミス)

  • Link 대신 a 태그 사용: <a href="/about">는 페이지 전체를 새로 로드합니다. SPA에서는 반드시 <Link to="/about">를 사용하세요.
  • BrowserRouter를 여러 번 감싸기: 앱 전체를 하나의 BrowserRouter로 감싸야 합니다. 여러 번 감싸면 라우터 컨텍스트 충돌이 발생합니다.
  • useParams 타입 주의: useParams가 반환하는 값은 항상 문자열입니다. 숫자 ID가 필요하면 parseInt(postId, 10)으로 변환하세요.
  • 중첩 라우트에서 Outlet 누락: 부모 Route에 Outlet을 렌더링하지 않으면 자식 Route가 표시되지 않습니다.
  • v5와 v6 혼용: React Router v6에서 <Switch><Routes>로, exact prop은 불필요해졌습니다. v5 문법을 v6에서 사용하면 에러가 발생합니다.

💡 실무 팁

  • NavLink 활용: React Router의 NavLink는 현재 경로와 일치하면 자동으로 active 클래스를 추가합니다. 네비게이션 메뉴에 활용하면 편리합니다.
  • 코드 스플리팅 기본 적용: 각 페이지를 lazy()로 감싸는 것을 기본 패턴으로 사용하면 초기 번들 크기가 크게 줄어 첫 로드 속도가 개선됩니다.
  • useSearchParams: URL 쿼리 파라미터(?sort=asc&page=2)를 다루려면 useSearchParams 훅을 사용합니다. 검색, 필터, 페이지네이션 구현에 유용합니다.
  • 서버 설정 주의: BrowserRouter를 사용하면 서버에서 모든 경로를 index.html로 리다이렉트하도록 설정해야 합니다. 그렇지 않으면 URL 직접 입력 시 404가 발생합니다.