본문으로 건너뛰기

안동민 개발노트

본문 시작

라우트 그룹 활용

URL을 바꾸지 않는 라우트 그룹으로 화면군을 조직하고 독립 레이아웃·병렬 슬롯·인터셉팅 모달을 구성합니다.

App Router의 강력함은 단순히 URL 경로를 파일 시스템으로 매핑하는 것을 넘어, 개발자가 더 유연하고 체계적으로 라우트 구조를 관리할 수 있도록 지원하는 데 있습니다.

그 중심에 있는 기능 중 하나가 바로 라우트 그룹(Route Groups)입니다.

라우트 그룹은 특정 폴더들을 URL 경로에 영향을 주지 않으면서 논리적으로 묶거나, 특정 그룹에만 고유한 레이아웃을 적용하고 싶을 때 사용됩니다.

이는 복잡한 애플리케이션에서 라우트 구조를 깔끔하게 유지하고, 다양한 레이아웃 요구사항을 효율적으로 처리할 수 있게 해줍니다.

먼저 괄호 폴더가 실제 URL에는 나타나지 않으면서도 코드 구조와 레이아웃 책임을 어떻게 나누는지 확인해 봅니다.

괄호 폴더는 URL을 바꾸지 않고 코드와 레이아웃 책임만 나눈다

라우트 그룹을 볼 때는 폴더명, 실제 URL, 적용 레이아웃, 충돌 가능성을 한 줄로 맞춰 읽으면 된다.

파일 위치실제 URL그룹의 역할주의할 점
app/(marketing)/page.tsx/마케팅 홈을 논리적으로 묶음URL에는 marketing이 나오지 않음
app/(marketing)/about/page.tsx/about소개 페이지가 마케팅 레이아웃을 공유기존 /about 경로 유지
app/(dashboard)/dashboard/page.tsx/dashboard대시보드 쪽 파일을 별도 그룹으로 관리그룹명보다 실제 세그먼트가 URL을 만듦
app/(marketing)/layout.tsxURL 없음그룹 내부 페이지만 감싸는 레이아웃RootLayout 아래에 중첩됨
app/(auth)/login/page.tsx + app/(admin)/login/page.tsx/login 충돌두 그룹이 같은 최종 URL을 만들 수 있음최종 URL은 반드시 하나만 존재해야 함

라우트 그룹이란 무엇인가요?

라우트 그룹은 폴더 이름을 괄호(())로 감싸서 정의합니다.

예를 들어 (marketing), (auth)와 같이 생성할 수 있습니다.

라우트 그룹의 주요 특징
  • URL 경로에 영향 없음: 라우트 그룹 폴더는 실제 URL 경로에는 나타나지 않습니다. 즉, app/(marketing)/about/page.tsx/about 경로에 매핑됩니다.
  • 논리적 그룹화: 개발자가 관련된 라우트들을 한데 모아 관리하기 위한 목적으로 사용됩니다. 예를 들어, (marketing), (dashboard), (auth) 등 기능별로 그룹화할 수 있습니다.
  • 독립적인 레이아웃 적용: 가장 중요한 활용 사례 중 하나는 특정 라우트 그룹에만 고유한 레이아웃을 적용하는 것입니다. 예를 들어, 웹사이트의 마케팅 페이지들은 특정 레이아웃을 사용하고, 대시보드 페이지들은 또 다른 레이아웃을 사용할 때 유용합니다.
  • layout.tsx와 함께 사용: 라우트 그룹 안에 layout.tsx 파일을 정의하면, 해당 그룹 내의 모든 페이지와 중첩 라우트에 그 레이아웃이 적용됩니다.

라우트 그룹 활용 실습

이전 절에서 대시보드 레이아웃을 만들어 보았습니다.

이제 마케팅 관련 페이지들(예: 홈, 소개, 문의)에는 또 다른, 독립적인 레이아웃을 적용하고 싶다고 가정해 봅시다.

목표 구조
  • 마케팅 섹션: 홈 (/), 소개 (/about), 문의 (/contact)
    • 이 페이지들은 RootLayout을 공유하지만, 만약 (marketing) 그룹 내부에 별도의 레이아웃을 추가한다면 그 레이아웃이 중첩될 수 있습니다.
  • 대시보드 섹션: 대시보드 홈 (/dashboard), 개요 (/dashboard/overview) 등
    • 이 페이지들은 (dashboard) 그룹 내의 DashboardLayout을 공유하며, 이 또한 RootLayout 내부에 중첩됩니다.
실습 단계

src/app/(marketing) 라우트 그룹 생성: src/app 디렉터리 안에 (marketing)이라는 이름의 새 폴더를 생성합니다.

그리고 기존에 src/app/page.tsxsrc/app/about/page.tsx가 있다면 이 파일들을 (marketing) 폴더 안으로 이동시킵니다.

또한, src/app/contact 페이지를 새로 만들어 (marketing) 폴더 안에 추가해 보세요.

layout.tsx
page.tsx
page.tsx
page.tsx
layout.tsx
...
  • src/app/(marketing)/contact/page.tsx 내용
    src/app/(marketing)/contact/page.tsx
    export default function ContactPage() {
      return (
        <div>
          <h3>문의하기</h3>
          <p>문의 사항은 프로젝트에서 안내한 공식 채널을 통해 접수할 수 있습니다.</p>
        </div>
      );
    }

src/app/(dashboard) 라우트 그룹 생성: src/app/(dashboard) 폴더를 만들고 기존 src/app/dashboard 폴더 전체를 그 안으로 이동합니다.

최종 경로는 src/app/(dashboard)/dashboard입니다.

괄호가 붙은 (dashboard)는 URL에서 빠지지만, 안쪽의 일반 dashboard 폴더가 /dashboard 세그먼트를 유지합니다.

layout.tsx
page.tsx
...
layout.tsx

루트 레이아웃의 <nav> 링크 업데이트: src/app/layout.tsx 파일의 내비게이션 링크들을 업데이트하여 새로 만든 contact 페이지로 가는 링크도 추가합니다.

src/app/layout.tsx (일부)
// ...
        <header style={{ backgroundColor: '#f0f0f0', padding: '10px', borderBottom: '1px solid #ddd' }}>
          <nav>
            <Link href="/">홈</Link> | {' '}
            <Link href="/about">소개</Link> | {' '}
            <Link href="/contact">문의</Link> | {' '} {/* 새 링크 추가 */}
            <Link href="/dashboard">대시보드</Link>
          </nav>
          <h1>나 혼자 Next.js</h1>
        </header>
// ...

실습 확인: 개발 서버(npm run dev)를 실행한 후, 다음 URL들을 방문하며 URL 경로가 어떻게 유지되는지 확인해 보세요.

  • http://localhost:3000/ (여전히 홈 페이지)
  • http://localhost:3000/about (여전히 소개 페이지)
  • http://localhost:3000/contact (새로 만든 문의 페이지)
  • http://localhost:3000/dashboard (여전히 대시보드 홈)

라우트 그룹 이름 (marketing), (dashboard)는 URL에 나타나지 않고, 일반 폴더인 contact, dashboard만 URL 세그먼트가 됩니다.

이것이 라우트 그룹의 가장 기본적인 역할입니다.


라우트 그룹별 독립적인 레이아웃 적용

이제 라우트 그룹의 진정한 강점인, 그룹별 독립적인 레이아웃 적용을 해보겠습니다.

만약 마케팅 페이지들에는 특정 헤더나 푸터가 대시보드 페이지와 다르게 필요하다면, (marketing) 그룹 내부에 layout.tsx를 정의할 수 있습니다.

src/app/(marketing)/layout.tsx 생성: src/app/(marketing) 폴더 안에 layout.tsx 파일을 생성하고 다음 내용을 작성합니다.

src/app/(marketing)/layout.tsx
export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div style={{ border: '2px dashed #0070f3', padding: '20px', margin: '20px 0', borderRadius: '10px' }}>
      <h2 style={{ color: '#0070f3' }}>마케팅 섹션 (그룹 레이아웃)</h2>
      {children}
    </div>
  );
}
설명
  • MarketingLayout(marketing) 그룹 내의 모든 페이지(홈, 소개, 문의)에 적용됩니다.
  • 이 레이아웃은 RootLayoutchildren으로 렌더링되고, 이 레이아웃의 children으로 다시 해당 페이지의 page.tsx 콘텐츠가 렌더링되는 중첩 구조를 가집니다.

실습 확인: 개발 서버를 다시 확인하고 다음 URL들을 방문해 보세요.

  • http://localhost:3000/
  • http://localhost:3000/about
  • http://localhost:3000/contact

이 세 페이지에서는 이제 최상위 헤더/푸터(RootLayout) 안에, 파란색 점선 테두리를 가진 마케팅 섹션 영역(MarketingLayout)이 보이고 그 안에 페이지 콘텐츠가 나타날 것입니다.

반면, src/app/(dashboard)/dashboard/layout.tsxhttp://localhost:3000/dashboard에 계속 적용됩니다.

이처럼 라우트 그룹을 사용하면 URL 경로를 깔끔하게 유지하면서도, 필요에 따라 다양한 레이아웃을 유연하게 적용할 수 있습니다.

이는 특히 대규모 애플리케이션이나 여러 섹션이 독립적인 디자인을 가질 때 매우 유용합니다.


주의사항

동일한 URL 세그먼트를 가진 여러 라우트 그룹을 만들 수도 있습니다.

예를 들어, (auth)/login/page.tsx(admin)/login/page.tsx는 모두 /login 경로로 매핑됩니다.

이 경우 Next.js는 어떤 page.tsx를 렌더링해야 할지 모호해지므로 에러가 발생합니다.

따라서 각 라우트 그룹의 최종 URL 경로는 고유해야 합니다.

라우트 그룹은 레이아웃이나 폴더 구조를 위한 논리적 그룹화에 사용하고, 실제 URL 충돌이 발생하지 않도록 주의해야 합니다.

라우트 그룹은 Next.js App Router의 구조화 및 유연성을 높이는 데 핵심적인 역할을 합니다.

이를 잘 활용하면 복잡한 애플리케이션도 효율적으로 관리하고 확장할 수 있습니다.


병렬 라우트 (Parallel Routes): @slot으로 UI를 독립 스트림으로 분리하기

아래 다이어그램은 @analytics, @team 같은 슬롯 폴더가 URL 세그먼트가 아니라 레이아웃 props가 된다는 점을 정리합니다.

Parallel Route는 한 URL 아래 독립 UI 스트림을 조립한다

@slot 폴더는 URL segment가 아니라 layout이 동시에 받을 named prop이며, 각 영역은 자기 loading과 navigation 상태를 가진다.

  1. children
    기본 스트림

    app/(dashboard)/dashboard/page.tsx가 주 화면을 렌더

  2. @analytics
    분석 스트림

    analytics/page.tsx가 독립적으로 준비

  3. @team
    팀 스트림

    team/page.tsx가 별도 상태를 유지

  4. fallback
    default.tsx

    직접 진입 때 복원할 수 없는 slot을 대체

대시보드에서 분석 패널팀 활동 패널을 동시에 보여주고 싶다면, 하나의 children 영역에 모든 것을 밀어 넣기보다 병렬 라우트를 쓰는 편이 구조적으로 훨씬 명확합니다.

핵심 아이디어
  • @analytics, @team처럼 @ 접두사의 슬롯 폴더를 만들면, 레이아웃이 각 슬롯을 독립적으로 렌더링할 수 있습니다.
  • 슬롯마다 loading.tsx, error.tsx, default.tsx를 따로 둘 수 있어 장애 격리가 쉬워집니다.
폴더 구조 예시
layout.tsx
page.tsx
page.tsx
loading.tsx
error.tsx
default.tsx
page.tsx
loading.tsx
error.tsx
default.tsx
src/app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <main>
      {children}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
        <section>{analytics}</section>
        <section>{team}</section>
      </div>
    </main>
  );
}
src/app/dashboard/page.tsx
export default function DashboardPage() {
  return <h1>대시보드 개요</h1>;
}

children도 이름 없는 기본 슬롯이므로 /dashboard를 구성할 page.tsx가 필요합니다.

레이아웃은 이 기본 슬롯과 @analytics, @team 슬롯을 함께 받아 원하는 위치에 배치합니다.

src/app/dashboard/@analytics/loading.tsx
export default function LoadingAnalytics() {
  return <p>분석 데이터를 불러오는 중...</p>;
}
src/app/dashboard/@analytics/error.tsx
'use client';

export default function AnalyticsError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <p>분석 패널 오류: {error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  );
}
src/app/dashboard/@analytics/default.tsx
export default function AnalyticsDefault() {
  return <p>분석 패널 기본 화면</p>;
}

병렬 라우트 Pitfall 체크리스트

  • 슬롯 폴더명은 반드시 @slotName 형태여야 합니다.
  • 직접 접근/새로고침 시를 대비해 슬롯별 default.tsx를 준비합니다.
  • 슬롯별 loading.tsx, error.tsx를 분리해 장애 전파 범위를 줄입니다.
  • 슬롯 간 의존 데이터를 최소화해 한 패널 실패가 전체 레이아웃 실패로 번지는 상황을 피합니다.

인터셉팅 라우트 (Intercepting Routes): 모달로 상세 화면 띄우기

아래 다이어그램은 같은 사진 상세 URL이 피드에서는 모달로, 직접 접근에서는 전체 페이지로 보이는 차이를 보여줍니다.

인터셉팅 라우트는 같은 상세 URL을 모달 또는 전체 페이지로 다르게 보여준다

피드에서 클릭하면 현재 화면 위에 모달을 얹고, 직접 접근하면 공유 가능한 전체 페이지로 렌더링한다.

접근 방식사용 파일보이는 결과사용자가 얻는 것
피드에서 /photo/42 클릭feed/@modal/(..)photo/[id]/page.tsx피드 위에 사진 모달 표시목록 맥락을 잃지 않고 상세 확인
브라우저에 /photo/42 직접 입력photo/[id]/page.tsx사진 상세 전체 페이지 표시공유 가능한 고유 URL 유지
닫기 버튼 또는 배경 클릭router.back() 또는 /feed 링크모달만 닫고 피드로 복귀뒤로가기 동작이 자연스러움
모달이 없는 상태feed/@modal/default.tsx빈 슬롯 렌더새로고침 시 깨진 슬롯 방지
경로 깊이 조정(.), (..), (...)어느 상위 세그먼트를 가로챌지 결정잘못 쓰면 전혀 다른 라우트를 가로챔

피드 목록(/feed)에서 카드 클릭 시 전체 페이지 이동 대신 모달 상세를 띄우고, 새로고침/직접 접근에서는 전체 페이지(/photo/[id])를 보여주고 싶을 때 Intercepting Routes가 유용합니다.

폴더 구조 예시 (모달 상세 + 전체 페이지 병행)
page.tsx
layout.tsx
default.tsx
page.tsx
page.tsx
src/app/feed/layout.tsx
export default function FeedLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <>
      {children}
      {modal}
    </>
  );
}
src/app/feed/@modal/(..)photo/[id]/page.tsx
import PhotoModalClient from './PhotoModalClient';

export default async function PhotoModalPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  return <PhotoModalClient id={id} />;
}
src/app/feed/@modal/(..)photo/[id]/PhotoModalClient.tsx
'use client';

import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';

export default function PhotoModalClient({ id }: { id: string }) {
  const router = useRouter();
  const closeButtonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    const previouslyFocused = document.activeElement instanceof HTMLElement
      ? document.activeElement
      : null;
    const previousOverflow = document.body.style.overflow;

    closeButtonRef.current?.focus();
    document.body.style.overflow = 'hidden';

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') router.back();
      if (event.key === 'Tab') {
        event.preventDefault();
        closeButtonRef.current?.focus();
      }
    };

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.body.style.overflow = previousOverflow;
      previouslyFocused?.focus();
    };
  }, [router]);

  return (
    <div
      className="modal-backdrop"
      role="presentation"
      onMouseDown={(event) => {
        if (event.target === event.currentTarget) router.back();
      }}
    >
      <div
        className="modal-card"
        role="dialog"
        aria-modal="true"
        aria-labelledby="photo-modal-title"
      >
        <h2 id="photo-modal-title">사진 상세 (모달) #{id}</h2>
        <button ref={closeButtonRef} type="button" onClick={() => router.back()}>
          닫기
        </button>
      </div>
    </div>
  );
}
src/app/photo/[id]/page.tsx
export default async function PhotoDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  return <h1>사진 상세 전체 페이지 #{id}</h1>;
}
src/app/feed/@modal/default.tsx
export default function FeedModalDefault() {
  return null; // 모달이 없을 때 빈 슬롯
}

인터셉팅 라우트 Pitfall 체크리스트

  • (.), (..), (...) 경로 깊이를 잘못 쓰면 의도치 않은 세그먼트를 가로챕니다.
  • 모달 슬롯에는 default.tsx를 두어 모달이 없는 상태를 명시합니다.
  • 접근성(포커스 트랩, ESC 닫기, 배경 스크롤 잠금)을 함께 설계합니다.
  • 공유 가능한 URL이 필요하면 전체 페이지 라우트(/photo/[id])를 반드시 유지합니다.

라우트 그룹, 병렬 라우트, 인터셉팅 라우트는 모두 폴더 구조로 UI 책임을 나누지만, 해결하는 문제는 서로 다릅니다.

아래 다이어그램에서 각 기능의 선택 기준과 필수 파일을 한 번에 비교해 보세요.

고급 라우팅 기능은 해결하는 문제가 서로 다르다

라우트 그룹, 병렬 라우트, 인터셉팅 라우트는 모두 폴더 규칙이지만 URL, 레이아웃, 사용자 흐름 중 어디를 조정하는지가 다르다.

기능해결하는 문제URL에 보이는가필수 파일/규칙
Route Groups관련 라우트를 논리적으로 묶고 그룹별 레이아웃 적용아니오, 괄호 폴더는 숨겨짐(group)/layout.tsx, 최종 URL 충돌 확인
Parallel Routes한 화면 안의 여러 패널을 독립 슬롯으로 렌더아니오, @slot은 URL 세그먼트가 아님@analytics/page.tsx, layout props, default.tsx
Intercepting Routes목록 흐름에서는 모달, 직접 접근은 전체 페이지로 렌더예, 상세 URL은 유지됨(..)photo/[id], @modal, 원본 photo/[id]
Dynamic RoutesURL 조각을 데이터 조회 키로 사용예, [id]가 실제 값으로 바뀜[id]/page.tsx, params await
Nested Layouts하위 경로가 공통 UI를 공유예, 실제 폴더 세그먼트가 URL 형성layout.tsx와 children 위치

여러 고급 라우팅 기능을 함께 설계할 때는 URL에 드러나는 구조와 UI 슬롯의 책임을 분리해서 확인해야 합니다.

URL 구조와 UI 조립 책임은 서로 다른 축이다

route group·parallel route·intercepting route를 주소와 화면이라는 두 질문으로 구분한다.

  1. URL 계층
    segment folder

    주소 조각과 params를 결정

  2. URL 제외
    (group)

    주소를 바꾸지 않고 route를 조직

  3. UI 병렬
    @slot

    같은 URL에서 여러 화면 영역을 동시에 조립

  4. 탐색 문맥
    (.) intercept

    현재 layout 위에 다른 route를 modal처럼 표시

라우트 그룹 활용하기 적용 전에는 서버/클라이언트 경계, 캐싱 조건, 배포 영향을 함께 확인해야 합니다.

고급 라우팅은 경계와 fallback을 통과한 뒤 적용한다

복잡한 폴더 문법보다 직접 진입·새로고침·부분 실패에서 화면이 복원되는지 먼저 검증한다.

  1. G1
    Canonical URL

    공유·새로고침할 대표 주소가 하나인가

  2. G2
    Server boundary

    데이터·권한은 server에 남아 있는가

  3. G3
    Client boundary

    modal·event에 필요한 최소 subtree인가

  4. G4
    Fallback

    loading·error·default·not-found가 준비됐는가

  5. G5
    Navigation

    링크·뒤로가기·직접 진입 결과가 일관적인가

마지막으로 라우트 그룹, 병렬 라우트, 인터셉팅 라우트를 URL 변화와 UI 조립 책임으로 나누어 봅니다.

URL을 바꾸는 문제인지, 화면을 조립하는 문제인지가 선택 기준이다

고급 라우팅 기능을 고를 때는 먼저 사용자가 보는 URL 변화와 개발자가 나누고 싶은 UI 책임을 분리한다.

상황고를 기능판단 근거피해야 할 오해
URL은 그대로 두고 코드 폴더만 묶고 싶다Route Groups괄호 폴더는 URL에 나오지 않음그룹명이 경로가 된다고 생각하지 않기
마케팅과 대시보드가 서로 다른 레이아웃을 쓴다Route Groups + group layout그룹 내부에 layout.tsx를 둘 수 있음RootLayout을 불필요하게 거대하게 만들지 않기
대시보드 한 화면에 분석/팀 패널이 동시에 필요하다Parallel Routeslayout이 여러 슬롯 props를 받음@slot을 URL 세그먼트로 착각하지 않기
목록에서 클릭하면 모달, 직접 접근하면 전체 페이지가 필요하다Intercepting Routes상세 URL은 공유하면서 표시 방식만 달라짐원본 상세 페이지를 없애지 않기
URL 값으로 데이터를 조회한다Dynamic Routes[id], [...slug]가 데이터 키가 됨단순 상세 페이지에 고급 라우팅을 과하게 쓰지 않기