React Router로 SPA 라우팅: 중첩 라우트, Loader·Action, 보호 라우트, Error Boundary
이 글의 핵심
React Router의 기본 라우트 설정부터 중첩 라우트와 레이아웃, Loader·Action으로 데이터와 폼을 라우트 단위로 다루는 방법, 로그인 보호 라우트와 에러 처리를 다룹니다.
이 글의 핵심
React Router로 SPA 라우팅을 구현하는 글입니다. BrowserRouter, Loader, Action, Protected Routes, Nested Routes를 예제로 정리했습니다. 본문은 v6.4 이후의 Data Router API(createBrowserRouter) 기준이며, v7에서 바뀐 점은 해당 위치에 적었습니다.
실무에서 마주치는 문제들
페이지 전환이 필요해요
일반 링크로 페이지를 옮기면 브라우저가 HTML, CSS, JavaScript를 다시 받고 앱 상태(입력 중인 폼, 스크롤, 열린 메뉴)가 모두 초기화됩니다. SPA 라우터는 링크 클릭을 가로채 History API로 주소만 바꾸고, 바뀐 부분의 컴포넌트만 다시 렌더링합니다. 공통 레이아웃과 상태가 유지되므로 전환이 빠르고 자연스럽습니다.
인증 라우트가 필요해요
로그인이 필요한 페이지마다 컴포넌트 안에서 사용자를 확인하고 리다이렉트하면, 검사를 빠뜨린 페이지가 생기기 쉽습니다. 라우트 설정에서 보호할 영역을 한 번에 묶으면 새 페이지를 그 아래에 추가하는 것만으로 보호가 적용됩니다.
데이터 로딩이 복잡해요
컴포넌트의 useEffect에서 데이터를 불러오면 컴포넌트가 먼저 렌더링된 뒤에야 요청이 시작됩니다. 부모와 자식이 각자 데이터를 불러오면 부모 요청이 끝나고 자식이 렌더링된 뒤에야 자식 요청이 시작되는 요청 폭포(waterfall)가 생기고, 로딩·에러·경쟁 상태 처리 코드가 컴포넌트마다 반복됩니다. Loader는 URL이 바뀌는 순간 해당 경로의 모든 loader를 병렬로 실행하고, 데이터가 준비된 뒤 화면을 그립니다.
대가도 있습니다. Loader 방식은 데이터가 모두 준비될 때까지 이전 화면에 머무르므로, 느린 API가 하나 있으면 전환 전체가 늦어집니다. 또 loader는 캐시 기능이 없어서 같은 페이지로 돌아올 때마다 다시 요청합니다. 캐시와 백그라운드 갱신이 중요한 앱이라면 loader 안에서 TanStack Query의 캐시를 함께 쓰는 구성이 흔합니다.
React Router란?
핵심 특징
React Router는 React SPA 라우팅 라이브러리입니다. 주요 기능:
- 선언적 라우팅: JSX 기반
- Nested Routes: 레이아웃 공유
- Loader: 데이터 페칭
- Action: Form 처리
- Protected Routes: 인증 라우트
설치 및 기본 설정
설치
npm install react-router-dom
v7부터는 react-router-dom의 기능이 react-router 패키지로 합쳐져 npm install react-router 후 import { createBrowserRouter } from 'react-router'로 쓰는 것이 권장됩니다. react-router-dom도 호환을 위해 남아 있어서 이 글의 코드는 v7에서도 import 경로만 바꾸면 동작합니다.
기본 라우팅
// main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Root from './routes/root';
import Home from './routes/home';
import About from './routes/about';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{
index: true,
element: <Home />,
},
{
path: 'about',
element: <About />,
},
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
라우트를 JSX(<Route>)가 아니라 객체 배열로 정의하는 것이 Data Router의 방식입니다. 라우터가 렌더링 전에 전체 경로 구조를 알아야 loader와 action을 미리 실행할 수 있기 때문입니다. JSX가 익숙하다면 createRoutesFromElements(<Route ...>)로 JSX를 객체로 변환해도 됩니다. router는 컴포넌트 바깥에서 한 번만 만들어야 합니다. App 안에서 createBrowserRouter를 호출하면 렌더링마다 라우터가 새로 만들어져 상태가 초기화되고 loader가 반복 실행됩니다.
index: true는 부모 경로(/)와 정확히 일치할 때 보여 줄 기본 자식입니다. 자식 경로의 path에 앞 슬래시가 없는 것('about')은 부모 경로에 이어 붙는 상대 경로라는 뜻이라, 최종 주소는 /about이 됩니다.
배포할 때 가장 자주 겪는 문제는 직접 주소를 입력하거나 새로고침하면 404가 나는 것입니다. /about은 브라우저 안에서만 존재하는 경로라 서버에는 그런 파일이 없기 때문입니다. 개발 서버(Vite)는 알아서 index.html을 돌려주지만, Nginx나 정적 호스팅에서는 모든 경로를 index.html로 되돌리는 설정(try_files $uri /index.html;, Netlify의 _redirects, Vercel의 rewrites 등)을 따로 해 줘야 합니다.
Nested Routes
Layout
// routes/root.tsx
import { Outlet, Link } from 'react-router-dom';
export default function Root() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/blog">Blog</Link>
</nav>
<main>
<Outlet />
</main>
</div>
);
}
<Outlet />은 현재 주소와 일치하는 자식 라우트가 그려질 자리입니다. /about으로 이동하면 Root는 그대로 남아 있고 Outlet 자리의 내용만 Home에서 About으로 바뀌므로, 내비게이션 바 같은 공통 레이아웃이 다시 마운트되지 않습니다. 중첩은 몇 단계든 가능해서 /settings/profile처럼 설정 페이지 안에 탭 레이아웃을 한 번 더 두는 구조도 같은 방식으로 만듭니다. 부모 컴포넌트에 <Outlet />을 빠뜨리면 주소는 바뀌는데 화면에 자식이 나타나지 않는데, 에러가 나지 않아서 원인을 찾기 어려운 흔한 실수입니다.
<Link>는 <a> 태그를 렌더링하지만 클릭을 가로채 전체 페이지를 다시 불러오지 않습니다. 현재 위치에 따라 스타일을 바꾸고 싶다면 NavLink를 쓰면 활성 링크에 active 클래스와 aria-current="page"가 붙습니다. 예제의 /blog 링크는 라우트 설정에 없으므로 클릭하면 에러 화면이 나오는데, 이것도 7장의 Error Boundary가 처리합니다.
Loader
기본 Loader
// routes/posts.tsx
import { useLoaderData } from 'react-router-dom';
export async function loader() {
const response = await fetch('/api/posts');
const posts = await response.json();
return { posts };
}
export default function Posts() {
const { posts } = useLoaderData() as { posts: Post[] };
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
// main.tsx
{
path: 'posts',
element: <Posts />,
loader: postsLoader,
}
loader는 라우트 파일에서 함께 export하고, 라우트 설정에서 import Posts, { loader as postsLoader } from './routes/posts'처럼 이름을 바꿔 가져와 연결합니다. 라우터는 /posts로 이동이 시작되면 컴포넌트를 렌더링하기 전에 loader를 실행하고, 컴포넌트에서는 useLoaderData()로 결과를 꺼냅니다. 그래서 컴포넌트 안에 로딩 상태나 useEffect가 없고, posts는 항상 준비된 상태입니다. 이동 중에 로딩 표시가 필요하면 레이아웃에서 useNavigation().state === 'loading'을 확인해 상단 진행 표시줄 같은 전역 표시를 띄웁니다.
useLoaderData() as { posts: Post[] }처럼 타입 단언을 쓴 이유는 v6의 useLoaderData가 unknown을 반환하기 때문입니다. useLoaderData<typeof loader>()로 loader의 반환 타입을 추론하게 하는 방법도 있고, v7의 프레임워크 모드에서는 라우트별 타입이 자동으로 생성됩니다.
이 loader는 응답 상태를 확인하지 않는다는 점이 문제입니다. fetch는 404나 500 응답에서도 예외를 던지지 않으므로, 서버가 에러 HTML을 돌려주면 response.json()에서 Unexpected token '<' 같은 구문 에러가 납니다. if (!response.ok) throw new Response('Not Found', { status: response.status })처럼 Response를 던지면 7장의 Error Boundary가 isRouteErrorResponse로 상태 코드를 구분해 보여 줄 수 있습니다.
파라미터 Loader
// routes/post.tsx
import { useLoaderData, LoaderFunctionArgs } from 'react-router-dom';
export async function loader({ params }: LoaderFunctionArgs) {
const response = await fetch(`/api/posts/${params.postId}`);
const post = await response.json();
return { post };
}
export default function Post() {
const { post } = useLoaderData() as { post: Post };
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
라우트 경로를 path: 'posts/:postId'로 정의하면 :postId 부분이 params.postId로 loader에 전달됩니다. 값은 항상 문자열이고, 타입상 string | undefined라서 TypeScript에서는 없는 경우를 처리해야 합니다. loader는 request 인자도 받는데, new URL(request.url).searchParams.get('page')로 쿼리 문자열을 읽을 수 있고, fetch(url, { signal: request.signal })처럼 신호를 넘기면 사용자가 로딩 중에 다른 페이지로 이동했을 때 요청이 자동으로 취소됩니다. 이 신호를 넘기지 않으면 빠르게 여러 글을 눌러 이동할 때 이전 요청들이 계속 진행되어 네트워크를 낭비합니다.
/posts/1에서 /posts/2로 이동하면 같은 Post 컴포넌트가 재사용되고 loader만 다시 실행됩니다. 컴포넌트가 다시 마운트되지 않으므로, 컴포넌트 안에서 useState로 관리하던 값(예: 댓글 입력란)은 이전 글의 것이 남아 있을 수 있습니다. 글마다 상태를 초기화하려면 라우트 요소에 key를 주거나, 상태를 URL이나 loader 데이터에서 파생시키는 것이 좋습니다.
Action
// routes/new-post.tsx
import { Form, redirect, ActionFunctionArgs } from 'react-router-dom';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.get('title'),
content: formData.get('content'),
}),
});
const post = await response.json();
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
return (
<Form method="post">
<input name="title" required />
<textarea name="content" />
<button type="submit">Create Post</button>
</Form>
);
}
<Form method="post">는 일반 HTML 폼과 같은 모양이지만, 제출하면 페이지를 새로 불러오는 대신 이 라우트의 action을 호출합니다. action은 request.formData()로 입력값을 받아 서버에 저장하고, redirect()로 새 글 페이지로 이동시킵니다. 입력값을 useState로 관리하거나 onSubmit에서 preventDefault()를 호출할 필요가 없어서, 폼 코드가 HTML에 가까워집니다.
action이 끝나면 라우터가 현재 화면의 모든 loader를 자동으로 다시 실행합니다. 목록 페이지에서 글을 추가하거나 삭제한 뒤 목록을 새로 불러오는 코드를 쓰지 않아도 화면이 최신 데이터로 갱신되는 이유입니다. 반면 action이 가벼운 작업(좋아요 토글)이라면 모든 loader를 다시 부르는 것이 과할 수 있으므로, 라우트에 shouldRevalidate를 두어 재실행 범위를 줄일 수 있습니다.
실무에서 보완할 점은 검증과 에러입니다. formData.get('title')의 타입은 FormDataEntryValue | null이라 문자열이라는 보장이 없고, 서버가 검증 에러를 돌려줘도 이 코드는 post.id가 undefined인 주소로 리다이렉트합니다. 검증에 실패하면 redirect 대신 return { errors: { title: '제목을 입력하세요' } }처럼 값을 반환하고, 컴포넌트에서 useActionData()로 받아 필드 옆에 표시하는 것이 정석입니다. 제출 중 버튼을 잠그려면 useNavigation().state === 'submitting'을 확인합니다.
Protected Routes
// components/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from './AuthContext';
export default function ProtectedRoute() {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
// main.tsx
{
element: <ProtectedRoute />,
children: [
{
path: 'dashboard',
element: <Dashboard />,
},
{
path: 'profile',
element: <Profile />,
},
],
}
path 없이 element만 있는 라우트는 레이아웃 라우트라서 주소에 아무것도 더하지 않고 자식들을 감싸기만 합니다. ProtectedRoute가 로그인하지 않은 사용자를 /login으로 보내고, 로그인한 사용자에게는 <Outlet />으로 자식 페이지를 보여 줍니다. replace는 현재 기록을 교체해서, 로그인 페이지에서 뒤로 가기를 눌렀을 때 다시 보호된 페이지로 돌아가 곧바로 튕겨 나오는 반복을 막습니다. 로그인 후 원래 가려던 페이지로 돌려보내려면 <Navigate to="/login" state={{ from: location }} replace />처럼 현재 위치를 넘겨 둡니다.
Data Router와 함께 쓸 때 이 방식에는 알아 둘 함정이 있습니다. loader는 컴포넌트 렌더링 전에 실행되므로, 로그인하지 않은 사용자가 /dashboard로 들어오면 ProtectedRoute가 리다이렉트를 결정하기도 전에 Dashboard의 loader가 이미 보호된 API를 호출합니다. API가 401을 돌려주면 리다이렉트 대신 에러 화면이 뜨기도 합니다. 그래서 Data Router에서는 인증 확인도 loader에서 하는 것이 권장됩니다. 보호 영역의 부모 라우트에 loader: async () => { if (!(await getUser())) throw redirect('/login'); return null; }를 두면 자식 loader보다 먼저 판단하지는 않지만(부모와 자식 loader는 병렬 실행), redirect가 던져지는 순간 이동이 결정되므로 화면에 보호된 내용이 그려지지 않습니다. 자식 loader가 먼저 보호 데이터를 요청하는 것까지 막으려면 각 loader에서 인증을 확인하는 공통 함수를 호출합니다.
무엇보다 이 모든 검사는 클라이언트 측 편의 기능입니다. 사용자는 브라우저에서 자바스크립트를 조작할 수 있으므로, 데이터 보호는 반드시 API 서버가 요청마다 인증을 확인해서 해야 합니다. useAuth가 읽는 Context가 RouterProvider보다 바깥에서 제공되어야 한다는 점도 설정할 때 자주 놓칩니다.
Error Boundary
// routes/root.tsx
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Error</h1>
<p>Something went wrong</p>
</div>
);
}
이 컴포넌트는 라우트 설정에 연결해야 동작합니다. Data Router에서는 라우트 객체에 errorElement: <ErrorBoundary />를 지정하며(v6.9 이상에서는 ErrorBoundary: ErrorBoundary처럼 컴포넌트를 직접 넘길 수도 있음), 이처럼 라우트 모듈에서 ErrorBoundary를 export하는 것만으로 자동 연결되는 것은 Remix와 v7 프레임워크 모드의 규칙입니다. 연결하지 않으면 React Router의 기본 에러 화면(“Unexpected Application Error!”)이 뜨고, 콘솔에 “You can provide a way better UX than this when your app throws errors by providing your own ErrorBoundary” 안내가 나옵니다.
loader나 action, 렌더링 중에 던져진 에러는 가장 가까운 상위 라우트의 에러 요소가 받습니다. 루트에만 두면 어떤 에러든 앱 전체가 에러 화면으로 바뀌지만, 중첩 라우트마다 두면 해당 영역만 에러 화면으로 바뀌고 내비게이션 같은 부모 레이아웃은 유지됩니다. 존재하지 않는 경로로 이동하면 라우터가 404 Response를 만들어 던지므로 isRouteErrorResponse로 받아 “페이지를 찾을 수 없습니다”를 보여 줄 수 있습니다. 그 외의 에러는 Error 객체일 수도, 아무 값일 수도 있으므로 error instanceof Error ? error.message : ...처럼 확인하고 쓰며, 운영 환경에서는 여기서 Sentry 같은 에러 수집 도구로 보고합니다.
Navigation
Link
import { Link } from 'react-router-dom';
<Link to="/about">About</Link>
<Link to={`/posts/${post.id}`}>View Post</Link>
useNavigate
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const handleClick = () => {
navigate('/dashboard');
// navigate(-1); // 뒤로
// navigate(1); // 앞으로
};
사용자가 클릭해서 이동하는 곳에는 <Link>를, 저장 완료 후 이동처럼 코드 흐름의 결과로 이동할 때는 useNavigate()를 씁니다. 단순히 다른 페이지로 가는 버튼을 onClick={() => navigate('/about')}으로 만들면 새 탭에서 열기, 링크 주소 복사, 검색 엔진 크롤링이 모두 안 되므로 가능하면 <Link>를 쓰는 편이 좋습니다. 폼 제출 후 이동이라면 앞의 action에서 redirect()를 반환하는 방식이 더 자연스럽습니다.
navigate를 렌더링 중에 호출하면 You should call navigate() in a React.useEffect(), not when your component is first rendered. 경고가 나옵니다. 조건에 따라 렌더링 중 이동해야 한다면 6장처럼 <Navigate /> 컴포넌트를 반환합니다. navigate('/dashboard', { replace: true })로 기록을 교체하거나, state 옵션으로 다음 페이지에 값을 넘길 수 있는데, state는 새로고침하면 브라우저 기록에 남아 유지되지만 주소를 복사해 다른 곳에서 열면 사라지므로 꼭 필요한 값은 URL 매개변수로 두는 것이 안전합니다.
정리 및 체크리스트
핵심 요약
- React Router: SPA 라우팅
- Nested Routes: 레이아웃 공유
- Loader: 데이터 페칭
- Action: Form 처리
- Protected Routes: 인증 라우트
- Error Boundary: 에러 처리
구현 체크리스트
- React Router 설치
- 라우터 설정
- Nested Routes 구현
- Loader 구현
- Action 구현
- Protected Routes 구현
- Error Boundary 추가
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Next.js와 비교하면 어떤가요?
A. 이 글에서 다룬 방식은 브라우저에서만 동작하는 SPA라 정적 파일로 어디든 배포할 수 있지만, 첫 화면이 자바스크립트 로드 후에 그려지고 검색 엔진 대응이 약합니다. Next.js는 서버 렌더링과 서버 컴포넌트를 전제로 해서 SEO와 첫 로딩이 중요한 서비스에 맞습니다. React Router도 v7의 프레임워크 모드를 쓰면 서버 렌더링이 가능해 이 차이가 줄었습니다.
Q. v5에서 v6로 업그레이드가 어려운가요?
A. Switch가 Routes로, component prop이 element로, useHistory가 useNavigate로 바뀌고, 경로 매칭이 순서가 아닌 구체성 기준으로 바뀌는 등 변경이 많습니다. v6에서 v7은 미리 켜 둘 수 있는 future 플래그를 제공해서, 경고를 하나씩 해결하면 비교적 순조롭게 올라갈 수 있습니다.
Q. Remix와 관계가 있나요?
A. 같은 팀이 만들었고, Remix는 React Router의 loader·action 개념을 서버까지 확장한 프레임워크였습니다. React Router v7에서 Remix의 기능이 React Router의 “프레임워크 모드”로 합쳐져, 이제는 사실상 같은 프로젝트입니다.
Q. 라우트별로 코드를 나눠 불러올 수 있나요?
A. 라우트 객체의 lazy: () => import('./routes/posts') 옵션을 쓰면 해당 경로로 이동할 때 컴포넌트와 loader를 함께 불러옵니다. 처음 방문 시 번들 크기가 줄고, loader까지 지연 로드된다는 점이 React.lazy만 쓰는 것과 다릅니다.