React Router v7 Pathless Routes 완벽 마스터하기
레이아웃 지옥에서 탈출하는 우아한 방법
안녕하세요!
오늘은 React Router v7의 숨겨진 보석, Pathless Routes에 대해 깊이 있게 알아보겠습니다.
이 패턴을 마스터하면 복잡한 레이아웃 구조도 우아하게 해결할 수 있습니다.
목차
레이아웃 지옥이란?
먼저 실제 프로젝트에서 겪는 문제를 살펴보겠습니다.
시나리오: SaaS 애플리케이션 구축
// 요구사항:
// 1. 랜딩 페이지 - 마케팅 레이아웃
// 2. 대시보드 - 앱 레이아웃
// 3. 로그인/회원가입 - 레이아웃 없음
// 4. 관리자 페이지 - 관리자 레이아웃
// 5. 에러 페이지 - 레이아웃 없음
전통적인 접근의 문제점
// ❌ 문제 1: 불필요한 레이아웃 중첩
routes/
├── root.tsx
├── routes/
│ ├── _index.tsx // 랜딩 - root 레이아웃 적용됨
│ ├── login.tsx // 로그인 - root 레이아웃 적용됨 😱
│ └── dashboard/
│ └── _layout.tsx // 대시보드 - 이중 레이아웃 😱
// ❌ 문제 2: 조건부 레이아웃의 복잡성
// root.tsx
export default function Root() {
const location = useLocation();
const isAuthPage = location.pathname.includes('/login');
const isAdminPage = location.pathname.includes('/admin');
// 조건부 렌더링 지옥...
if (isAuthPage) return <Outlet />;
if (isAdminPage) return <AdminLayout><Outlet /></AdminLayout>;
return (
<AppLayout>
<Outlet />
</AppLayout>
);
}
Pathless Routes의 탄생
Pathless Routes는 이런 문제를 우아하게 해결합니다!
핵심 개념
// 🎯 Pathless Route = "경로 없는 라우트"
// _.로 시작하면 부모 레이아웃을 무시하고 독립적인 URL 생성
_app.dashboard.tsx → /dashboard (app 레이아웃 ✅)
_.dashboard.tsx → /dashboard (레이아웃 없음 ✨)
마법의 언더스코어 도트 (_.
)
// routes 구조
routes/
├── _app.tsx # 앱 레이아웃 정의
├── _app.home.tsx # /home (레이아웃 적용)
├── _.login.tsx # /login (레이아웃 무시!) ✨
└── _.admin.dashboard.tsx # /admin/dashboard (독립적!)
실전 프로젝트 구축하기
실제 SaaS 애플리케이션을 단계별로 구축해보겠습니다.
Step 1: 프로젝트 초기 설정
# React Router v7 프로젝트 생성
npx create-react-router@latest my-saas-app
cd my-saas-app
Step 1-1: Flat Routes 설정하기
React Router v7에서 Flat Routes를 활성화하고 커스터마이징하는 방법입니다.
// react-router.config.ts
import { type Config } from "@react-router/dev/config";
export default {
// Flat Routes 활성화 (v7에서는 기본값)
routes(defineRoutes) {
return defineRoutes((route) => {
// 수동 라우트 정의도 가능
// route("/custom", "routes/custom-route.tsx");
});
},
// 또는 파일 기반 라우팅 사용 (권장)
// 기본적으로 app/routes 폴더의 flat routes 규칙을 따름
appDirectory: "app",
// Flat Routes 관련 옵션
ignoredRouteFiles: ["**/.*"], // 숨김 파일 무시
// 라우트 파일 네이밍 규칙 커스터마이징
routeConfig: {
// v7 스타일 flat routes (기본값)
routeFileNaming: "v7",
// 또는 레거시 스타일
// routeFileNaming: "v6"
}
} satisfies Config;
Step 2: 레이아웃 구조 설계
// app/routes/_landing.tsx
// 🎨 랜딩 페이지 레이아웃
export default function LandingLayout() {
return (
<div className="min-h-screen">
{/* 마케팅 헤더 */}
<header className="bg-gradient-to-r from-blue-600 to-purple-600">
<nav className="container mx-auto px-6 py-4">
<div className="flex justify-between items-center">
<Logo />
<div className="space-x-6">
<Link to="/features">Features</Link>
<Link to="/pricing">Pricing</Link>
<Link to="/login" className="btn-primary">
Login
</Link>
</div>
</div>
</nav>
</header>
<Outlet />
{/* 마케팅 푸터 */}
<footer className="bg-gray-900 text-white py-12">
<div className="container mx-auto px-6">
<p>© 2024 MySaaS. All rights reserved.</p>
</div>
</footer>
</div>
);
}
// app/routes/_app.tsx
// 📱 메인 앱 레이아웃
export default function AppLayout() {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" />;
}
return (
<div className="flex h-screen">
{/* 사이드바 */}
<aside className="w-64 bg-gray-800 text-white">
<div className="p-6">
<UserProfile user={user} />
</div>
<nav className="mt-6">
<NavLink to="/dashboard">Dashboard</NavLink>
<NavLink to="/projects">Projects</NavLink>
<NavLink to="/settings">Settings</NavLink>
</nav>
</aside>
{/* 메인 콘텐츠 */}
<main className="flex-1 bg-gray-50">
<Outlet />
</main>
</div>
);
}
Step 3: Pathless Routes로 인증 페이지 구현
// app/routes/_.auth.login.tsx
// 🔐 레이아웃 없는 로그인 페이지
import { Form, useActionData } from 'react-router';
import { authenticate } from '~/lib/auth.server';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get('email');
const password = formData.get('password');
const result = await authenticate(email, password);
if (!result.success) {
return json({ error: result.error });
}
return redirect('/dashboard');
}
export default function LoginPage() {
const actionData = useActionData<typeof action>();
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50">
<div className="max-w-md w-full">
{/* 독립적인 로고 */}
<div className="text-center mb-8">
<Logo size="large" />
<h1 className="mt-4 text-3xl font-bold">Welcome Back</h1>
</div>
<div className="bg-white rounded-2xl shadow-xl p-8">
<Form method="post" className="space-y-6">
{actionData?.error && (
<Alert variant="error">{actionData.error}</Alert>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full px-4 py-2 border rounded-lg"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 w-full px-4 py-2 border rounded-lg"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700"
>
Sign In
</button>
</Form>
<div className="mt-6 text-center text-sm">
Don't have an account?{' '}
<Link to="/auth/signup" className="text-blue-600 hover:underline">
Sign up
</Link>
</div>
</div>
{/* 독립적인 푸터 */}
<div className="mt-8 text-center text-sm text-gray-500">
<Link to="/auth/forgot-password">Forgot password?</Link>
</div>
</div>
</div>
);
}
Step 4: 복잡한 Pathless 구조 구현
// app/routes/_.admin.tsx
// 🛡️ 관리자 레이아웃 (Pathless이지만 레이아웃 역할)
export default function AdminLayout() {
const { user } = useAuth();
if (!user?.isAdmin) {
return <Navigate to="/dashboard" />;
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<header className="bg-red-900 px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">🛡️ Admin Panel</h1>
<Link to="/dashboard" className="text-sm hover:underline">
Back to App →
</Link>
</div>
</header>
<div className="flex">
<nav className="w-64 bg-gray-800 min-h-screen p-4">
<NavLink to="/admin">Overview</NavLink>
<NavLink to="/admin/users">Users</NavLink>
<NavLink to="/admin/analytics">Analytics</NavLink>
<NavLink to="/admin/settings">Settings</NavLink>
</nav>
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
</div>
);
}
// app/routes/_.admin._index.tsx
// 관리자 대시보드 (/admin)
export default function AdminDashboard() {
return (
<div>
<h2 className="text-2xl font-bold mb-6">Admin Dashboard</h2>
<div className="grid grid-cols-3 gap-6">
<StatCard title="Total Users" value="1,234" />
<StatCard title="Revenue" value="$45,678" />
<StatCard title="Active Sessions" value="89" />
</div>
</div>
);
}
Step 5: 데모 페이지 시스템 구축
// app/routes/_.demo.tsx
// 🎪 데모 섹션 레이아웃
export default function DemoLayout() {
return (
<div className="min-h-screen bg-purple-50">
<header className="bg-white shadow-sm">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-purple-600">
🎪 Component Demos
</h1>
<Link to="/" className="text-sm hover:underline">
Back to Home
</Link>
</div>
</div>
</header>
<div className="container mx-auto px-6 py-8">
<div className="flex gap-8">
{/* 데모 네비게이션 */}
<aside className="w-64">
<nav className="bg-white rounded-lg shadow p-4 space-y-2">
<DemoLink to="/demo">Overview</DemoLink>
<DemoLink to="/demo/forms">Forms</DemoLink>
<DemoLink to="/demo/modals">Modals</DemoLink>
<DemoLink to="/demo/charts">Charts</DemoLink>
</nav>
</aside>
{/* 데모 콘텐츠 */}
<main className="flex-1">
<div className="bg-white rounded-lg shadow p-6">
<Outlet />
</div>
</main>
</div>
</div>
</div>
);
}
고급 패턴과 팁
패턴 1: 조건부 Pathless Routes
// app/routes/_.($lang).docs.tsx
// 다국어 문서 (레이아웃 없음)
export default function Docs() {
const { lang = 'en' } = useParams();
const docs = getDocsByLang(lang);
return (
<div className="documentation-viewer">
<DocsSidebar docs={docs} />
<DocsContent docs={docs} />
</div>
);
}
// URL 예시:
// /docs (영어)
// /ko/docs (한국어)
// /ja/docs (일본어)
패턴 2: Pathless Route 내부 중첩
// app/routes/_.preview.tsx
export default function PreviewLayout() {
return (
<div className="preview-container">
<PreviewToolbar />
<Outlet />
</div>
);
}
// app/routes/_.preview.$projectId.tsx
export default function ProjectPreview() {
// /preview/:projectId
return <Preview />;
}
// app/routes/_.preview.$projectId.fullscreen.tsx
export default function FullscreenPreview() {
// /preview/:projectId/fullscreen
return <FullscreenView />;
}
패턴 3: 동적 레이아웃 스위칭
// app/routes/_.workspace.$id.tsx
export async function loader({ params, request }: LoaderFunctionArgs) {
const workspace = await getWorkspace(params.id);
const url = new URL(request.url);
const mode = url.searchParams.get('mode');
return json({ workspace, mode });
}
export default function Workspace() {
const { workspace, mode } = useLoaderData<typeof loader>();
// 모드에 따라 다른 레이아웃
if (mode === 'presentation') {
return <PresentationView workspace={workspace} />;
}
if (mode === 'edit') {
return <EditorView workspace={workspace} />;
}
return <DefaultView workspace={workspace} />;
}
트러블슈팅
문제 1: Pathless Route가 작동하지 않음
// ❌ 잘못된 예
routes/
└── _demo/
└── index.tsx // 작동 안함!
// ✅ 올바른 예
routes/
└── _.demo._index.tsx // 또는
└── _.demo/
└── route.tsx
문제 2: 중첩 레이아웃 충돌
// ❌ 문제 상황
routes/
├── _app.tsx
├── _app.settings.tsx
└── _.settings.tsx // 같은 URL 충돌!
// ✅ 해결 방법
routes/
├── _app.tsx
├── _app.settings.tsx // /settings (앱 레이아웃)
└── _.admin.settings.tsx // /admin/settings (독립)
문제 3: 데이터 로딩 이슈
// app/routes/_.isolated.tsx
export async function loader({ request }: LoaderFunctionArgs) {
// Pathless route도 일반 loader 사용 가능
const user = await authenticateUser(request);
if (!user) {
throw redirect('/login');
}
return json({ user });
}
export default function IsolatedPage() {
const { user } = useLoaderData<typeof loader>();
// 정상 작동!
}
핵심 정리
Pathless Routes를 사용해야 할 때
✅ 사용하기 좋은 경우:
- 인증 페이지 (로그인, 회원가입)
- 관리자 패널
- 프리뷰/전체화면 모드
- 독립적인 데모 페이지
- 에러 페이지
- 외부 임베드 페이지
❌ 피해야 할 경우:
- 일관된 네비게이션이 필요한 페이지
- SEO가 중요한 콘텐츠 페이지
- 공통 푸터/헤더가 필요한 페이지
베스트 프랙티스 체크리스트
// ✅ 명확한 네이밍 컨벤션
_.auth.* // 인증 관련
_.admin.* // 관리자
_.demo.* // 데모
_.preview.* // 프리뷰
// ✅ 공통 컴포넌트 재사용
// components/layouts/standalone.tsx
export function StandaloneWrapper({ children }) {
return (
<div className="standalone-layout">
<MetaTags />
<Analytics />
{children}
</div>
);
}
// ✅ 타입 안전성
type PathlessRoute = `_.${string}`;
마무리
Pathless Routes는 React Router v7의 강력한 기능으로, 복잡한 레이아웃 요구사항을 우아하게 해결합니다.
핵심은 언제 레이아웃이 필요하고, 언제 필요없는지를 명확히 구분하는 것입니다.
이제 여러분도 레이아웃 지옥에서 탈출할 준비가 되었습니다!