Next.js Route Groups
Next.js App Router에서 폴더 구조가 곧 URL 구조가 된다. app/auth/sign-in/page.tsx는 /auth/sign-in으로 매핑되고, app/dashboard/page.tsx는 /dashboard로 매핑된다. 이 규칙은 직관적이지만, 실제 프로젝트에서는 URL은 같은 레벨인데 레이아웃은 다르게 적용해야 하는 상황이 자주 발생한다.
예를 들어 로그인/회원가입 페이지는 헤더와 푸터만 있는 깔끔한 레이아웃이 필요하고, 대시보드 페이지는 사이드바가 있는 레이아웃이 필요하고, 워크스페이스 생성 같은 폼 페이지는 중앙 정렬된 단일 카드 레이아웃이 필요하다. 이 세 가지를 하나의 layout.tsx로 처리하려면 pathname을 확인해서 조건부로 레이아웃을 바꾸는 지저분한 코드가 된다.
Route Groups는 이 문제를 해결하기 위해 존재한다. 폴더 이름을 괄호로 감싸면 — (auth), (main), (forms) — 해당 폴더 이름은 URL 경로에 포함되지 않으면서, 그 안에 독립적인 layout.tsx를 가질 수 있다.
기본 개념
일반 폴더와 Route Group의 차이를 비교하면 이렇다.
# 일반 폴더 → URL에 반영됨
app/auth/sign-in/page.tsx → /auth/sign-in
app/dashboard/page.tsx → /dashboard
# Route Group → URL에 반영 안 됨
app/(auth)/sign-in/page.tsx → /sign-in
app/(main)/dashboard/page.tsx → /dashboard
괄호 안의 이름은 순전히 개발자를 위한 논리적 분류다. Next.js 라우터는 괄호 폴더를 URL 세그먼트로 인식하지 않는다. 이름 자체는 아무거나 가능하지만, 보통 해당 그룹의 역할을 나타내는 이름을 사용한다.
레이아웃 분리가 핵심
Route Groups의 가장 중요한 용도는 같은 URL 깊이에서 서로 다른 레이아웃을 적용하는 것이다. 각 Route Group 폴더 안에 layout.tsx를 두면, 해당 그룹에 속한 모든 페이지가 그 레이아웃을 공유한다.
app/
├── layout.tsx ← Root Layout (모든 페이지 공통)
├── (auth)/
│ ├── layout.tsx ← Auth Layout (헤더 + 푸터, 중앙 정렬)
│ ├── sign-in/
│ │ └── page.tsx → /sign-in
│ ├── sign-up/
│ │ └── page.tsx → /sign-up
│ └── forgot-password/
│ └── page.tsx → /forgot-password
├── (main)/
│ ├── layout.tsx ← Main Layout (사이드바)
│ └── workspace/
│ └── [workspaceId]/
│ └── page.tsx → /workspace/abc123
└── (forms)/
├── layout.tsx ← Forms Layout (중앙 카드)
└── workspace/
├── create/
│ └── page.tsx → /workspace/create
└── select-plan/
└── page.tsx → /workspace/select-plan
이 구조에서 레이아웃 계층은 이렇게 작동한다:
/sign-in접속 → Root Layout → Auth Layout → SignIn Page/workspace/abc123접속 → Root Layout → Main Layout → Workspace Page/workspace/create접속 → Root Layout → Forms Layout → Create Page
각 레이아웃은 완전히 독립적이다. Auth Layout에서 인증 상태를 확인해서 리다이렉트하는 로직을 넣어도, Main Layout이나 Forms Layout에는 영향이 없다.
실제 레이아웃 구현 패턴
Auth Layout — 인증 플로우 전용
인증 관련 페이지는 보통 중앙에 폼 하나를 보여주는 단순한 레이아웃이다. 여기에 인증 상태 체크 로직을 넣어서, 이미 로그인한 사용자는 대시보드로 리다이렉트할 수 있다.
'use client';
import { ReactNode, useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
export default function AuthLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { isAuthenticated } = useAuthStore();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
if (isAuthenticated) {
// 인증된 상태에서도 접근 허용할 경로 체크
const allowPrefixes = ['/forgot-password', '/reset-password'];
const isAllowed = allowPrefixes.some(prefix => pathname.startsWith(prefix));
if (!isAllowed) {
redirectToWorkspace();
return;
}
}
setIsChecking(false);
}, [isAuthenticated, pathname]);
if (isChecking) return null;
return (
<div className="min-h-screen bg-gray-50">
<AuthHeader />
<div className="flex min-h-screen items-center justify-center">
<div className="w-[440px]">{children}</div>
</div>
<AuthFooter />
</div>
);
}
이 레이아웃은 (auth) 그룹에만 적용되므로, 다른 그룹의 페이지에서는 이 인증 체크 로직이 전혀 실행되지 않는다.
Main Layout — 사이드바가 있는 메인 앱
메인 앱 영역은 보통 사이드바 네비게이션을 포함한다.
'use client';
import { ReactNode } from 'react';
import { SidebarProvider } from '@/components/ui/sidebar';
export default function MainLayout({ children }: { children: ReactNode }) {
return (
<SidebarProvider defaultOpen>
{children}
</SidebarProvider>
);
}
사이드바 Provider를 이 레이아웃에서 감싸면, (main) 그룹의 모든 페이지에서 사이드바 상태(열림/닫힘)를 공유할 수 있다. Auth나 Forms 페이지에서는 사이드바가 불필요하므로, 이 Provider가 로드되지 않아 번들 크기도 절약된다.
Forms Layout — 단계별 폼 전용
워크스페이스 생성이나 플랜 선택 같은 단계별 폼은 사이드바 없이 중앙 정렬된 카드 형태가 적합하다.
'use client';
import { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
export default function FormsLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const isWide = pathname === '/workspace/select-plan';
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className={`space-y-6 ${isWide ? 'w-[738px]' : 'w-[440px]'}`}>
{children}
</div>
</div>
);
}
pathname에 따라 카드 너비를 조절하는 것처럼, 같은 그룹 안에서도 페이지별로 약간의 변형을 줄 수 있다.
Route Group 주의사항
같은 URL 경로를 가진 페이지는 하나만
Route Group은 URL에 영향을 주지 않으므로, 서로 다른 그룹에서 같은 URL 경로의 page.tsx를 만들면 충돌이 발생한다.
# ❌ 빌드 에러 — 둘 다 /about을 가리킴
app/(marketing)/about/page.tsx
app/(shop)/about/page.tsx
같은 URL 세그먼트를 공유하는 경우(예: /workspace 아래에 다른 하위 경로), 하위 경로가 겹치지 않도록 주의해야 한다.
Root Layout 중복
각 Route Group에 자체 layout.tsx를 가질 수 있지만, 최상위 app/layout.tsx(Root Layout)는 반드시 하나만 존재해야 한다. Route Group이 Root Layout을 대체하지는 않는다 — Root Layout 아래에 중간 레이어로 삽입되는 것이다.
만약 Root Layout 자체를 그룹별로 분리하고 싶다면, app/layout.tsx를 삭제하고 각 Route Group에 <html>과 <body>를 포함하는 layout을 만들 수 있다. 하지만 이 경우 그룹 간 이동 시 full page reload가 발생하므로 일반적으로 권장하지 않는다.
# Root Layout 분리 (비권장, 특수 케이스용)
app/
├── (marketing)/
│ ├── layout.tsx ← <html><body> 포함
│ └── page.tsx
└── (app)/
├── layout.tsx ← <html><body> 포함
└── dashboard/
└── page.tsx
loading.tsx, error.tsx도 그룹별 분리 가능
Route Group 안에 loading.tsx나 error.tsx를 넣으면 해당 그룹에만 적용된다. 인증 페이지와 메인 앱의 로딩 UI를 다르게 표시하고 싶을 때 유용하다.
app/
├── (auth)/
│ ├── layout.tsx
│ ├── loading.tsx ← Auth 전용 로딩 스피너
│ └── ...
└── (main)/
├── layout.tsx
├── loading.tsx ← Main 전용 스켈레톤 UI
└── ...
언제 Route Groups를 쓸까
Route Groups가 필요한 전형적인 시나리오:
- 레이아웃 분리: 인증 / 메인 앱 / 온보딩 플로우처럼 완전히 다른 UI 구조가 필요할 때
- Provider 격리: 특정 페이지 그룹에만 필요한 Context Provider를 해당 그룹의 layout에서만 감싸고 싶을 때
- 논리적 분류: URL은 변경하지 않으면서 관련 라우트를 폴더로 묶어 코드 가독성을 높이고 싶을 때
반대로, 단순히 URL 경로를 중첩하고 싶은 거라면 일반 폴더를 사용하면 된다. Route Groups는 "URL에는 영향 없이 레이아웃이나 구조만 분리하고 싶을 때" 쓰는 도구다.
관련 문서
- App Router Layout - Next.js App Router의 레이아웃 시스템
- App Router Navigating - App Router에서의 네비게이션