본문 바로가기

React

React Router v6의 최신 기능과 사용법

React Router v6는 선언적 라우팅을 단순화하고, 데이터 로딩/변경을 라우트 계층으로 끌어올리는 Data Router 기능(v6.4+)을 도입했습니다. 이 글은 실무에서 바로 쓸 수 있는 최신 v6 사용법과 베스트 프랙티스를 정리합니다.

1. v6 핵심 변화 한눈에 보기

- Switch → Routes, Redirect → Navigate, exact 제거, component/render → element로 변경되었습니다.

- 중첩 라우팅 기본 탑재, 상대 경로 네비게이션 개선, 레이아웃은 Outlet으로 구성합니다.

- Data Router(createBrowserRouter, loader, action, errorElement, useLoaderData, useNavigation, defer/Await)로 라우트 단에서 데이터 로딩/뮤테이션과 에러 핸들링을 다룹니다.

- 라우트 단위 코드 스플리팅(lazy) 지원으로 초기 번들 크기를 줄일 수 있습니다.

2. 설치와 기본 세팅

// 1) 설치
// npm i react-router-dom

// 2) Data Router 기반 엔트리 (권장)
import React from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";

import RootLayout from "./layouts/RootLayout";
import Home from "./routes/Home";
import ErrorPage from "./routes/ErrorPage";

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <Home /> },
    ],
  },
]);

createRoot(document.getElementById("root")).render(
  <RouterProvider router={router} />
);

기존 BrowserRouter + Routes 방식도 가능하지만, Data Router로 통일하면 실무에서 데이터 흐름이 단순해집니다.

3. 레이아웃과 중첩 라우팅

// layouts/RootLayout.jsx
import { NavLink, Outlet } from "react-router-dom";

export default function RootLayout() {
  return (
    <div>
      <nav>
        <NavLink to="/" end>Home</NavLink>
        <NavLink to="/posts">Posts</NavLink>
      </nav>
      <main>
        <Outlet /> {/* 자식 라우트가 렌더링됩니다 */}
      </main>
    </div>
  );
}

중첩 경로에서 상대 네비게이션이 기본값입니다. 예: 자식 라우트에서 navigate("..")는 상위 경로로 이동합니다.

4. CRUD 예제로 보는 Data Router(loader/action)

// routes config
import { createBrowserRouter, redirect } from "react-router-dom";
import RootLayout from "./layouts/RootLayout";
import ErrorPage from "./routes/ErrorPage";
import PostsLayout from "./routes/PostsLayout";
import PostsList, { loader as postsLoader } from "./routes/PostsList";
import PostDetail, { loader as postLoader } from "./routes/PostDetail";
import NewPost, { action as createPostAction } from "./routes/NewPost";

export const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: "posts",
        element: <PostsLayout />,
        children: [
          { index: true, element: <PostsList />, loader: postsLoader },
          { path: ":postId", element: <PostDetail />, loader: postLoader },
          { path: "new", element: <NewPost />, action: createPostAction },
        ],
      },
    ],
  },
]);
// routes/PostsList.jsx
import { useLoaderData, Link } from "react-router-dom";

export async function loader() {
  const res = await fetch("/api/posts");
  if (!res.ok) throw new Response("Failed to load", { status: res.status });
  return res.json();
}

export default function PostsList() {
  const posts = useLoaderData();
  return (
    <div>
      <h2>Posts</h2>
      <Link to="new">새 글 작성</Link>
      {posts.map(p => (
        <div key={p.id}>
          <Link to={String(p.id)}>{p.title}</Link>
        </div>
      ))}
    </div>
  );
}
// routes/PostDetail.jsx
import { useLoaderData } from "react-router-dom";

export async function loader({ params }) {
  const res = await fetch(`/api/posts/${params.postId}`);
  if (res.status === 404) throw new Response("Not found", { status: 404 });
  if (!res.ok) throw new Error("Server error");
  return res.json();
}

export default function PostDetail() {
  const post = useLoaderData();
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.body}</p>
    </article>
  );
}
// routes/NewPost.jsx
import { Form, redirect, useNavigation } from "react-router-dom";

export async function action({ request }) {
  const formData = await request.formData();
  const payload = Object.fromEntries(formData);
  const res = await fetch("/api/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Response("Create failed", { status: res.status });
  const created = await res.json();
  return redirect(`/posts/${created.id}`); // 성공 시 상세로 이동
}

export default function NewPost() {
  const nav = useNavigation();
  const busy = nav.state === "submitting";
  return (
    <div>
      <h2>새 글 작성</h2>
      <Form method="post">
        <p><input name="title" placeholder="제목" required /></p>
        <p><textarea name="body" placeholder="내용" required /></p>
        <button type="submit" disabled={busy}>{busy ? "저장 중..." : "저장"}</button>
      </Form>
    </div>
  );
}

Form는 action으로 직접 POST를 보내며, 완료 후 관련 로더가 자동 재검증(revalidation)됩니다.

5. 에러 경계와 오류 처리

// routes/ErrorPage.jsx
import { useRouteError, isRouteErrorResponse } from "react-router-dom";

export default function ErrorPage() {
  const err = useRouteError();
  if (isRouteErrorResponse(err)) {
    return <div>에러 {err.status}: {err.statusText}</div>;
  }
  return <div>문제가 발생했습니다: {String(err?.message ?? err)}</div>;
}

loader/action에서 throw Response(...) 또는 Error를 던지면 해당 라우트 체인의 errorElement가 렌더링됩니다.

6. 로딩 상태 처리(useNavigation)

// 전역 로딩 인디케이터 예시 (레이아웃에서)
import { useNavigation, Outlet } from "react-router-dom";

export default function PostsLayout() {
  const nav = useNavigation();
  const pending = nav.state === "loading" || nav.state === "submitting";
  return (
    <div>
      {pending && <div style={{ height: 3, background: "#4f46e5" }} />}
      <Outlet />
    </div>
  );
}

라우트 전환/액션 진행 중 상태를 한 곳에서 다룰 수 있어 스피너 난립을 줄입니다.

7. 지연 로딩: 라우트 레벨 lazy

// lazy 라우트 구성 (권장: Data Router와 함께)
const router = createBrowserRouter([
  {
    path: "/",
    lazy: () => import("./routes/root.lazy"), // 모듈에서 loader/Component 등 제공
  },
]);
// routes/root.lazy.js
export async function loader() {
  return { msg: "hello" };
}

export function Component() {
  return <div>코드 스플리팅된 루트</div>;
}

export function ErrorBoundary({ error }) {
  return <div>에러: {String(error)}</div>;
}

route.lazy는 해당 경로에 진입할 때만 코드와 로더를 가져옵니다. React.Suspense를 별도로 감싸지 않아도 됩니다. 기존 React.lazy로 element를 lazy 처리하는 것도 가능합니다.

8. defer와 Await로 스트리밍 로딩

// routes/PostDetailDefer.jsx (예시)
import { defer } from "react-router-dom";

export async function loader({ params }) {
  const postPromise = fetch(`/api/posts/${params.postId}`).then(r => r.json());
  const commentsPromise = fetch(`/api/posts/${params.postId}/comments`).then(r => r.json());
  return defer({ post: postPromise, comments: commentsPromise });
}
import { useLoaderData, Await } from "react-router-dom";
import { Suspense } from "react";

export default function PostDetailDefer() {
  const data = useLoaderData();
  return (
    <div>
      <Suspense fallback={<p>게시글 로딩...</p>}>
        <Await resolve={data.post}>{post => <h2>{post.title}</h2>}</Await>
      </Suspense>
      <Suspense fallback={<p>댓글 로딩...</p>}>
        <Await resolve={data.comments}>{cs => cs.map(c => <p key={c.id}>{c.body}</p>)}</Await>
      </Suspense>
    </div>
  );
}

큰 데이터는 지연 스트리밍하여 첫 페인트를 빠르게 가져갈 수 있습니다.

9. 동적 세그먼트와 쿼리 파라미터

// useParams
import { useParams } from "react-router-dom";
const { postId } = useParams();

// useSearchParams
import { useSearchParams } from "react-router-dom";
const [sp, setSp] = useSearchParams();
const page = Number(sp.get("page") ?? 1);
const next = page + 1;
setSp({ page: String(next) }, { replace: true });

검색 파라미터를 상태처럼 다루며, 브라우저 히스토리 관리도 함께 처리합니다.

10. 라우트 보호(인증 가드)

// 인증 체크를 loader에서 수행
import { redirect } from "react-router-dom";

export async function protectedLoader({ request }) {
  const isAuthed = await fetch("/api/me", { headers: { Accept: "application/json" } })
    .then(r => r.ok);
  if (!isAuthed) return redirect(`/login?next=${new URL(request.url).pathname}`);
  return null;
}

// 라우트 구성
{
  path: "dashboard",
  loader: protectedLoader,
  element: <Dashboard />,
}

UI 컴포넌트에서 인증 여부를 판단하기보다, 라우터에서 접근 제어를 처리하면 깔끔합니다.

11. useRoutes와 JSX 기반 정의(대안)

import { useRoutes } from "react-router-dom";

export default function App() {
  const element = useRoutes([
    { path: "/", element: <Home /> },
    { path: "/about", element: <About /> },
  ]);
  return element;
}

useRoutes는 간단한 앱에 유용하지만, 데이터 로딩/에러/액션을 표준화하려면 Data Router를 권장합니다.

12. v5 → v6 마이그레이션 요약

- Switch → Routes

- Redirect → Navigate

- exact 제거(경로 매칭 개선)

- component/render → element: element에는 JSX 요소를 전달합니다.

- withRouter 제거 → useNavigate/useLocation/useParams 훅 사용

- useHistory → useNavigate

13. 실무 팁 체크리스트

- 데이터는 useEffect가 아닌 loader/action으로 옮겨 SSR/스트리밍/에러 경계를 일관 처리합니다.

- 액션 성공 시 redirect로 라우팅 흐름을 단순화합니다.

- 목록/상세가 같은 트리 안에 있으면 액션 이후 자동 재검증으로 캐시 동기화를 얻습니다.

- 에러는 Response(status)로 명시해 UX를 통제합니다(404/401/500 등).

- 라우트 lazy로 초기 번들을 줄이고, 상위 레이아웃은 가능하면 고정하여 전환 비용을 최소화합니다.

- 컨트롤 바/탑바 등 전역 로딩 UI는 useNavigation을 레이아웃에서 처리합니다.

이 가이드를 바탕으로 라우트 정의, 데이터 로딩/액션, 에러/로딩 상태를 라우터 중심으로 재구성해 보세요. 컴포넌트는 화면 표현에 집중하고, 라우터는 데이터 흐름과 전환을 책임질 때 개발 체감이 가장 크게 좋아집니다.