React Router v7 Pathless Routes 완벽 마스터하기

레이아웃 지옥에서 탈출하는 우아한 방법

안녕하세요!

오늘은 React Router v7의 숨겨진 보석, Pathless Routes에 대해 깊이 있게 알아보겠습니다.

이 패턴을 마스터하면 복잡한 레이아웃 구조도 우아하게 해결할 수 있습니다.

목차

  1. 레이아웃 지옥이란?
  2. Pathless Routes의 탄생
  3. 실전 프로젝트 구축하기
  4. 고급 패턴과 팁
  5. 트러블슈팅

레이아웃 지옥이란?

먼저 실제 프로젝트에서 겪는 문제를 살펴보겠습니다.

시나리오: 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의 강력한 기능으로, 복잡한 레이아웃 요구사항을 우아하게 해결합니다.

핵심은 언제 레이아웃이 필요하고, 언제 필요없는지를 명확히 구분하는 것입니다.

이제 여러분도 레이아웃 지옥에서 탈출할 준비가 되었습니다!