중첩 라우팅과 레이아웃
1. 중첩 라우팅
중첩 라우팅은 URL 구조를 계층적으로 구성할 수 있게 해주는 React Router의 기능입니다. 복잡한 UI 구조를 URL 구조와 일치시켜 표현할 수 있으며, 부모 라우트 내에서 자식 라우트의 컴포넌트를 유연하게 렌더링할 수 있습니다.
1.1 기본 구조
import {
createBrowserRouter,
RouterProvider,
Link,
Outlet,
} from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/dashboard',
element: <Dashboard />,
children: [
{
path: 'main',
element: <MainContent />,
},
{
path: 'settings',
element: <Settings />,
},
{
path: 'profile',
element: <Profile />,
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
function Dashboard() {
return (
<div>
<h1>대시보드</h1>
<nav>
<Link to="/dashboard/main">홈</Link>
<Link to="/dashboard/settings">설정</Link>
<Link to="/dashboard/profile">프로필</Link>
</nav>
{/* 자식 라우트가 이 위치에 렌더링됩니다 */}
<Outlet />
</div>
);
}
function MainContent() {
return <p>메인 내용</p>;
}
function Settings() {
return <p>설정창</p>;
}
function Profile() {
return <p>유저프로필</p>;
}
export default App;
1.2 Outlet 컴포넌트
Outlet은 부모 라우트에서 자식 라우트의 컴포넌트가 렌더링될 위치를 지정하는 React Router의 컴포넌트입니다. 중첩 라우팅에서 레이아웃을 구성할 때 핵심적인 역할을 합니다.
function Dashboard() {
return (
<div>
<h1>대시보드</h1>
<nav>
<Link to="/dashboard/main">홈</Link>
<Link to="/dashboard/settings">설정</Link>
<Link to="/dashboard/profile">프로필</Link>
</nav>
{/* 자식 라우트가 이 위치에 렌더링됩니다 */}
<Outlet />
</div>
);
}
1.3 URL 구조와 결과 화면
아래는 위 코드로 접근 가능한 URL 구조입니다. 각 URL에서는 Dashboard 컴포넌트가 레이아웃 역할을 하고, 그 안에 각각 다른 자식 컴포넌트가 렌더링됩니다.
localhost:5173/dashboard/main -> Dashboard + MainContent 컴포넌트
localhost:5173/dashboard/settings -> Dashboard + Settings 컴포넌트
localhost:5173/dashboard/profile -> Dashboard + Profile 컴포넌트
/dashboard/main접속 시: Dashboard 컴포넌트의 공통 레이아웃이 유지되고, Outlet 위치에MainContent컴포넌트가 렌더링됩니다./dashboard/settings접속 시: Dashboard 컴포넌트의 공통 레이아웃이 유지되고, Outlet 위치에Settings컴포넌트가 렌더링됩니다./dashboard/profile접속 시: Dashboard 컴포넌트의 공통 레이아웃이 유지되고, Outlet 위치에Profile컴포넌트가 렌더링됩니다.
2. 공통 레이아웃 패턴
중첩 라우팅을 활용하면 헤더, 푸터, 사이드바 등 공통 레이아웃을 쉽게 구현할 수 있습니다.
import {
createBrowserRouter,
RouterProvider,
Link,
Outlet,
} from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{
index: true, // path: '/'와 동일
element: <Home />,
},
{
path: 'about',
element: <About />,
},
{
path: 'products',
element: <Products />,
},
{
path: 'contact',
element: <Contact />,
},
],
},
]);
function RootLayout() {
return (
<div className="app">
<header>
<nav>
<Link to="/">홈</Link>
<Link to="/about">소개</Link>
<Link to="/products">상품</Link>
<Link to="/contact">문의</Link>
</nav>
</header>
<main>
<Outlet />
</main>
<footer>
<p>© 2025 My Website</p>
</footer>
</div>
);
}
function Home() {
return <h1>홈 페이지</h1>;
}
function About() {
return <h1>소개 페이지</h1>;
}
function Products() {
return <h1>상품 페이지</h1>;
}
function Contact() {
return <h1>문의 페이지</h1>;
}
function App() {
return <RouterProvider router={router} />;
}
export default App;
index 라우트
index: true 속성을 사용하면 부모 경로와 정확히 일치할 때 렌더링되는 기본 자식 라우트를 정의할 수 있습니다. 위 예제에서 / 경로로 접속하면 Home 컴포넌트가 렌더링됩니다.
{
path: '/',
element: <RootLayout />,
children: [
{
index: true, // '/'에서 렌더링
element: <Home />,
},
// ...
],
}
3. 다중 레이아웃
서로 다른 레이아웃이 필요한 페이지들을 구분하여 구성할 수 있습니다.
import {
createBrowserRouter,
RouterProvider,
Link,
Outlet,
} from 'react-router-dom';
const router = createBrowserRouter([
// 메인 레이아웃 (헤더, 푸터 있음)
{
path: '/',
element: <MainLayout />,
children: [
{ index: true, element: <Home /> },
{ path: 'about', element: <About /> },
{ path: 'products', element: <Products /> },
],
},
// 관리자 레이아웃 (사이드바 있음)
{
path: '/admin',
element: <AdminLayout />,
children: [
{ index: true, element: <AdminDashboard /> },
{ path: 'users', element: <AdminUsers /> },
{ path: 'settings', element: <AdminSettings /> },
],
},
// 인증 레이아웃 (최소한의 UI)
{
path: '/auth',
element: <AuthLayout />,
children: [
{ path: 'login', element: <Login /> },
{ path: 'register', element: <Register /> },
],
},
]);
function MainLayout() {
return (
<div className="main-layout">
<header>
<nav>
<Link to="/">홈</Link>
<Link to="/about">소개</Link>
<Link to="/products">상품</Link>
</nav>
</header>
<main>
<Outlet />
</main>
<footer>© 2025 My Website</footer>
</div>
);
}
function AdminLayout() {
return (
<div className="admin-layout">
<aside>
<nav>
<Link to="/admin">대시보드</Link>
<Link to="/admin/users">사용자 관리</Link>
<Link to="/admin/settings">설정</Link>
</nav>
</aside>
<main>
<Outlet />
</main>
</div>
);
}
function AuthLayout() {
return (
<div className="auth-layout">
<Outlet />
</div>
);
}
// 각 페이지 컴포넌트들...
function Home() {
return <h1>홈</h1>;
}
function About() {
return <h1>소개</h1>;
}
function Products() {
return <h1>상품</h1>;
}
function AdminDashboard() {
return <h1>관리자 대시보드</h1>;
}
function AdminUsers() {
return <h1>사용자 관리</h1>;
}
function AdminSettings() {
return <h1>관리자 설정</h1>;
}
function Login() {
return <h1>로그인</h1>;
}
function Register() {
return <h1>회원가입</h1>;
}
function App() {
return <RouterProvider router={router} />;
}
export default App;
4. Outlet context
Outlet 컴포넌트는 자식 라우트로 context를 전달할 수도 있습니다. 이를 통해 부모 컴포넌트의 상태나 함수를 자식 라우트에서 쉽게 사용할 수 있습니다.
context는 props를 일일이 전달하지 않고도 컴포넌트 간에 데이터를 공유할 수 있는 방식입니다.
import {
createBrowserRouter,
RouterProvider,
Outlet,
useOutletContext,
} from 'react-router-dom';
import { useState } from 'react';
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: 'settings', element: <Settings /> },
],
},
]);
function Layout() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState({ name: '홍길동' });
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
<div className={`app ${theme}`}>
<header>
<button onClick={toggleTheme}>테마 변경</button>
<span>현재 테마: {theme}</span>
</header>
{/* context를 통해 자식 컴포넌트로 데이터 전달 */}
<Outlet context={{ theme, toggleTheme, user, setUser }} />
</div>
);
}
// 자식 컴포넌트에서 context 사용
function Home() {
const { theme, user } = useOutletContext();
return (
<div>
<h1>안녕하세요, {user.name}님!</h1>
<p>현재 테마: {theme}</p>
</div>
);
}
function Settings() {
const { theme, toggleTheme, user, setUser } = useOutletContext();
const handleNameChange = (e) => {
setUser({ ...user, name: e.target.value });
};
return (
<div>
<h1>설정</h1>
<div>
<label>이름: </label>
<input value={user.name} onChange={handleNameChange} />
</div>
<div>
<label>테마: </label>
<button onClick={toggleTheme}>
{theme === 'light' ? '다크 모드로' : '라이트 모드로'}
</button>
</div>
</div>
);
}
function App() {
return <RouterProvider router={router} />;
}
export default App;
Outlet의 이러한 특성을 활용하면 공통 레이아웃을 유지하면서도 내부 콘텐츠만 동적으로 변경되는 UI를 쉽게 구현할 수 있습니다. 특히 대시보드처럼 복잡한 UI 구조를 가진 애플리케이션에서 매우 유용합니다.
useOutletContext vs Context API
- useOutletContext: 라우트 계층 구조 내에서만 데이터를 공유할 때 사용합니다. 간단하고 라우터와 밀접하게 연동됩니다.
- Context API: 애플리케이션 전역에서 데이터를 공유해야 할 때 사용합니다. (예: 인증 상태, 전역 설정 등)
라우트 내에서만 필요한 데이터는 useOutletContext를, 전역적으로 필요한 데이터는 Context API를 사용하는 것이 좋습니다. Context API에 대한 내용은 추후 자세히 알아볼 예정입니다.
5. 깊은 중첩 라우팅
여러 단계로 중첩된 라우팅도 구현할 수 있습니다.
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{ index: true, element: <Home /> },
{
path: 'products',
element: <ProductsLayout />,
children: [
{ index: true, element: <ProductList /> },
{
path: ':productId',
element: <ProductDetailLayout />,
children: [
{ index: true, element: <ProductInfo /> },
{ path: 'reviews', element: <ProductReviews /> },
{ path: 'specs', element: <ProductSpecs /> },
],
},
],
},
],
},
]);
위 구조에서 /products/123/reviews 경로로 접속하면 다음과 같이 렌더링됩니다.
RootLayout
└── ProductsLayout
└── ProductDetailLayout
└── ProductReviews
각 레이아웃 컴포넌트는 자신의 Outlet에 자식 컴포넌트를 렌더링합니다.