포스트

[이제와서 시작하는 Next.js 마스터하기 #7] 동적 라우팅과 고급 패턴

[이제와서 시작하는 Next.js 마스터하기 #7] 동적 라우팅과 고급 패턴

“URL은 단순하지만 화면은 복잡할 수 있습니다.” - App Router의 고급 패턴으로 실제 서비스 구조를 설계해봅니다.

🎯 이 글에서 배울 내용

  • Parallel Routes (병렬 라우트)
  • Intercepting Routes (가로채기 라우트)
  • Route Groups (라우트 그룹)
  • Dynamic Routes와 generateStaticParams
  • Route Handlers (API Routes)

예상 소요 시간: 45분


🗂️ Route Groups - URL을 바꾸지 않는 폴더 정리

App Router에서 폴더는 기본적으로 URL이 됩니다. 하지만 괄호로 감싼 폴더는 URL에 포함되지 않습니다.

1
2
3
4
5
6
7
8
9
app/
├── (marketing)/
│   ├── layout.tsx
│   └── page.tsx          // /
├── (dashboard)/
│   ├── layout.tsx
│   └── dashboard/
│       └── page.tsx      // /dashboard
└── layout.tsx

(marketing)(dashboard)는 코드 정리를 위한 그룹일 뿐, 실제 URL에는 나타나지 않습니다. 그래서 팀, 기능, 레이아웃 기준으로 폴더를 나눌 수 있습니다.

언제 쓰면 좋을까?

상황 예시
공개 페이지와 앱 페이지의 레이아웃이 다름 (marketing), (app)
관리자/사용자 영역을 분리하고 싶음 (admin), (account)
팀별 소유권을 폴더에 드러내고 싶음 (growth), (commerce)
같은 URL 깊이에서 일부 페이지만 다른 layout 필요 (shop)/cart, checkout

주의할 점도 있습니다.

  • 서로 다른 그룹이 같은 URL을 만들면 충돌합니다. (marketing)/about/page.tsx(shop)/about/page.tsx는 둘 다 /about입니다.
  • 여러 root layout을 쓰면 그룹 간 이동에서 전체 페이지 로드가 일어날 수 있습니다.
  • top-level layout.tsx 없이 여러 root layout만 둘 경우 / 페이지가 어느 그룹에 속하는지 분명해야 합니다.

Route Groups는 기능이 화려한 도구라기보다, URL 설계와 코드 소유권을 분리하는 장치라고 이해하면 쉽습니다.


🔀 Parallel Routes - 동시에 여러 페이지 표시

1. 기본 개념

1
2
3
4
5
6
7
app/
├── layout.js
├── page.js
├── @sidebar/
│   └── page.js
└── @main/
    └── page.js
1
2
3
4
5
6
7
8
9
10
11
12
// app/layout.js
export default function Layout({ children, sidebar, main }) {
  return (
    <div className="grid grid-cols-12">
      <aside className="col-span-3">{sidebar}</aside>
      <main className="col-span-9">
        {main}
        {children}
      </main>
    </div>
  );
}

@sidebar, @main 같은 폴더는 URL segment가 아니라 slot입니다. 부모 layout은 slot 이름과 같은 props를 받아 화면에 배치합니다.

2. 실전 예제: 대시보드

1
2
3
4
5
6
7
8
app/dashboard/
├── layout.js
├── @analytics/
│   └── page.js      // 분석 패널
├── @revenue/
│   └── page.js      // 매출 패널
└── @users/
    └── page.js      // 사용자 패널
1
2
3
4
5
6
7
8
9
10
// app/dashboard/layout.js
export default function DashboardLayout({ analytics, revenue, users }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      <div>{analytics}</div>
      <div>{revenue}</div>
      <div>{users}</div>
    </div>
  );
}

각 slot은 독립적인 loading.tsx, error.tsx를 가질 수 있습니다.

1
2
3
4
5
6
7
8
app/dashboard/
├── layout.tsx
├── @analytics/
│   ├── loading.tsx
│   └── page.tsx
└── @users/
    ├── error.tsx
    └── page.tsx

분석 패널이 느려도 사용자 패널을 먼저 보여줄 수 있고, 특정 slot에서 에러가 나도 전체 대시보드가 한 번에 무너지지 않게 설계할 수 있습니다.

3. default.tsx가 필요한 순간

Parallel Routes를 쓰면 새로고침이나 직접 URL 접근에서 특정 slot의 현재 상태를 복원할 수 없는 경우가 있습니다. 이때 default.tsx가 fallback 역할을 합니다.

1
2
3
4
// app/dashboard/@analytics/default.tsx
export default function DefaultAnalytics() {
  return <p>분석 패널을 선택해주세요.</p>;
}

default.tsx를 두지 않으면 복원할 수 없는 slot에서 404가 날 수 있습니다. 대시보드, inbox, split-view처럼 slot이 많은 화면에서는 처음부터 fallback UI를 정해두세요.


🎭 Intercepting Routes - 모달 패턴

1. 사진 갤러리 예제

1
2
3
4
5
6
7
8
9
app/
├── photos/
│   ├── page.js           // /photos
│   └── [id]/
│       └── page.js       // /photos/1
└── @modal/
    └── (..)photos/
        └── [id]/
            └── page.js   // 모달로 표시

Intercepting Routes의 핵심은 같은 URL도 진입 방식에 따라 다르게 보여줄 수 있다는 점입니다.

진입 방식 결과
/photos에서 사진 클릭 기존 갤러리 위에 모달로 표시
/photos/1 URL 직접 입력 사진 상세 페이지 전체 표시
모달에서 새로고침 공유 가능한 상세 페이지로 동작
뒤로가기 모달을 닫고 갤러리로 복귀
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/photos/page.js
export default function PhotosPage() {
  const photos = [1, 2, 3, 4];

  return (
    <div className="grid grid-cols-4 gap-4">
      {photos.map(id => (
        <Link key={id} href={`/photos/${id}`}>
          <img src={`/photo-${id}.jpg`} alt={`Photo ${id}`} />
        </Link>
      ))}
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
// app/@modal/(..)photos/[id]/page.js
import Modal from '@/components/Modal';

export default async function PhotoModal({ params }) {
  const { id } = await params;

  return (
    <Modal>
      <img src={`/photo-${id}.jpg`} alt={`Photo ${id}`} />
    </Modal>
  );
}

2. Modal 컴포넌트 구현

위 예제에서 사용한 Modal 컴포넌트를 직접 만들어보겠습니다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// components/Modal.js
'use client';

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

export default function Modal({ children }) {
  const router = useRouter();
  const dialogRef = useRef(null);

  useEffect(() => {
    // 모달을 자동으로 열기
    if (dialogRef.current) {
      dialogRef.current.showModal();
    }
  }, []);

  function onDismiss() {
    router.back(); // 이전 페이지로 돌아가기
  }

  function onClickOutside(e) {
    // 모달 외부 클릭 시 닫기
    if (e.target === dialogRef.current) {
      onDismiss();
    }
  }

  return (
    <dialog
      ref={dialogRef}
      onClose={onDismiss}
      onClick={onClickOutside}
      className="backdrop:bg-black backdrop:opacity-50 p-0 rounded-lg shadow-2xl"
    >
      <div className="relative">
        {/* 닫기 버튼 */}
        <button
          onClick={onDismiss}
          className="absolute top-4 right-4 text-white bg-black/50 rounded-full w-8 h-8 flex items-center justify-center hover:bg-black/70"
        ></button>

        {/* 모달 내용 */}
        <div className="p-6">
          {children}
        </div>
      </div>
    </dialog>
  );
}

코드 설명 (초보자용):

  1. <dialog> 태그: HTML5의 네이티브 모달 요소
    • showModal(): 모달을 화면 중앙에 표시
    • backdrop: 모달 뒤의 어두운 배경
  2. useRouter(): Next.js 라우터 사용
    • router.back(): 이전 페이지로 돌아가기
    • 모달을 닫으면 갤러리로 돌아감
  3. 외부 클릭 감지:
    • onClick={onClickOutside}: 모달 외부 클릭 시 닫기
    • e.target === dialogRef.current: 배경 클릭인지 확인

💡 Tip: 이 Modal 컴포넌트는 다른 곳에서도 재사용 가능합니다!

1
2
3
4
5
// 다른 곳에서 사용
<Modal>
  <h2>알림</h2>
  <p>저장되었습니다!</p>
</Modal>

Intercepting Routes는 Parallel Routes와 함께 쓸 때 가장 자연스럽습니다. @modal slot에 모달을 띄우고, 직접 접근할 때는 일반 상세 페이지를 렌더링하는 구조가 대표적입니다.

폴더 이름의 의미도 짚고 넘어갑시다.

표기 의미
(.)segment 같은 segment 레벨에서 가로채기
(..)segment 한 segment 위에서 가로채기
(..)(..)segment 두 segment 위에서 가로채기
(...)segment app 루트 기준으로 가로채기

이 표기는 파일 시스템의 폴더 깊이가 아니라 route segment 기준입니다. @modal은 slot이고 URL segment가 아니므로, 실제 폴더 깊이와 계산이 다르게 느껴질 수 있습니다.


🧭 Dynamic Routes와 generateStaticParams

동적 라우트는 대괄호 폴더로 만듭니다.

1
2
3
4
app/blog/[slug]/page.tsx      // /blog/hello-nextjs
app/shop/[category]/[id]/page.tsx
app/docs/[...segments]/page.tsx
app/search/[[...query]]/page.tsx

자주 쓰는 패턴은 세 가지입니다.

패턴 예시 의미
[slug] /blog/react 필수 한 segment
[...slug] /docs/a/b/c 필수 catch-all
[[...slug]] /docs, /docs/a/b optional catch-all

정적 생성할 값이 정해져 있다면 generateStaticParams를 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export async function generateStaticParams() {
  const posts = await getPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPostPage({ params }) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return <article>{post.title}</article>;
}

Next.js 16의 App Router 예시에서는 paramsawait하는 형태가 자연스럽습니다. 동적 페이지에서 없는 데이터를 만났다면 빈 화면을 반환하지 말고 notFound()로 명시하세요.

generateStaticParams를 쓸 때는 다음을 결정해야 합니다.

  • 모든 slug를 빌드 시점에 만들 것인가?
  • 일부 인기 페이지만 만들고 나머지는 요청 시 처리할 것인가?
  • 새 글이 추가될 때 재배포가 필요한가, 아니면 revalidation 전략이 있는가?
  • 존재하지 않는 동적 segment에 대해 404를 낼 것인가?

콘텐츠 수가 적은 블로그라면 전체 slug를 반환해도 충분합니다. 상품 수가 많은 쇼핑몰이라면 인기 상품만 미리 만들고 나머지는 캐싱/재검증 전략과 함께 설계하는 편이 현실적입니다.


🛣️ Route Handlers - API 엔드포인트

1. GET 요청

1
2
3
4
5
6
7
8
9
10
11
12
// app/api/posts/route.js
export async function GET(request) {
  // 실제로는 데이터베이스에서 가져옵니다
  // 예: const posts = await prisma.post.findMany();

  // 여기서는 외부 API로 시뮬레이션
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await response.json();

  // 최근 10개만 반환
  return Response.json(posts.slice(0, 10));
}

사용 예시:

1
2
3
4
// 클라이언트에서 호출
const response = await fetch('/api/posts');
const posts = await response.json();
console.log(posts); // 게시물 목록 출력

2. POST 요청

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// app/api/posts/route.js
export async function POST(request) {
  const data = await request.json();

  // 유효성 검사
  if (!data.title || !data.content) {
    return Response.json(
      { error: '제목과 내용은 필수입니다' },
      { status: 400 }
    );
  }

  // 실제로는 데이터베이스에 저장
  // 예: const post = await prisma.post.create({ data: { title: data.title, content: data.content } });

  // 시뮬레이션: 새 게시물 객체 생성
  const newPost = {
    id: Date.now(),
    title: data.title,
    content: data.content,
    createdAt: new Date().toISOString()
  };

  console.log('📝 새 게시물 생성:', newPost);

  return Response.json(newPost, { status: 201 });
}

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
// 클라이언트에서 호출
const response = await fetch('/api/posts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: '새 게시물',
    content: '내용입니다'
  })
});

const newPost = await response.json();
console.log('생성됨:', newPost);

3. 동적 라우트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// app/api/posts/[id]/route.js
export async function GET(request, { params }) {
  const { id } = await params;

  // 실제로는 데이터베이스에서 조회
  // 예: const post = await prisma.post.findUnique({ where: { id: parseInt(id) } });

  // 외부 API로 시뮬레이션
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);

  if (!response.ok) {
    return Response.json(
      { error: 'Post not found' },
      { status: 404 }
    );
  }

  const post = await response.json();
  return Response.json(post);
}

export async function DELETE(request, { params }) {
  const { id } = await params;

  // 실제로는 데이터베이스에서 삭제
  // 예: await prisma.post.delete({ where: { id: parseInt(id) } });

  console.log(`🗑️ 게시물 ${id} 삭제`);

  return Response.json({ success: true, message: `Post ${id} deleted` });
}

사용 예시:

1
2
3
4
5
6
7
8
9
10
11
// GET: 특정 게시물 조회
const response = await fetch('/api/posts/1');
const post = await response.json();
console.log(post);

// DELETE: 게시물 삭제
const deleteResponse = await fetch('/api/posts/1', {
  method: 'DELETE'
});
const result = await deleteResponse.json();
console.log(result); // { success: true, message: 'Post 1 deleted' }

🔍 자주 묻는 질문 (FAQ)

Q1: Parallel Routes는 언제 사용하나요?

사용 케이스:

  • 대시보드 (여러 패널 동시 표시)
  • 소셜 미디어 (피드 + 사이드바)
  • 이커머스 (상품 목록 + 필터)

장점:

  • 각 섹션 독립적으로 로딩
  • 에러 격리
  • 병렬 데이터 페칭
Q2: Intercepting Routes는 언제 쓰나요?

사용 케이스:

  • 갤러리 모달
  • 로그인/회원가입 모달
  • 빠른 미리보기

장점:

  • 부드러운 UX
  • URL 변경은 되지만 전체 페이지는 안 바뀜
  • 뒤로가기 지원
Q3: Route Groups와 URL 폴더는 어떻게 구분하나요?

괄호가 없는 폴더는 URL이 됩니다. 괄호가 있는 폴더는 URL에 들어가지 않는 정리용 그룹입니다.

1
2
app/blog/page.tsx              // /blog
app/(marketing)/about/page.tsx // /about

그룹 이름은 사용자에게 보이지 않지만, 같은 URL을 만드는 그룹이 두 개 있으면 충돌합니다. URL 설계는 한 번만 하고, 그룹은 레이아웃과 팀 소유권을 나누는 용도로 쓰세요.

Q4: Parallel Routes와 일반 컴포넌트 조합은 무엇이 다른가요?

일반 컴포넌트 조합은 한 페이지 안에서 직접 import해서 배치합니다. Parallel Routes는 slot마다 독립적인 route tree, loading UI, error boundary를 가질 수 있습니다.

작은 카드 몇 개라면 컴포넌트 조합이 충분합니다. 대시보드, inbox, analytics처럼 각 영역의 로딩과 URL 상태를 따로 관리해야 한다면 Parallel Routes를 검토하세요.


🎯 오늘 배운 내용 정리

  1. Route Groups
    • 괄호 폴더는 URL에 포함되지 않음
    • 레이아웃과 팀 소유권 분리에 유용
    • 같은 URL을 만드는 그룹 충돌 주의
  2. Parallel Routes
    • @folder 문법
    • 여러 페이지 동시 표시
    • default.tsx로 slot fallback 제공
  3. Intercepting Routes
    • (.)folder 문법
    • 모달 패턴
    • 소프트 네비게이션과 직접 접근의 화면 차이
  4. Dynamic Routes
    • [slug], [...slug], [[...slug]]
    • generateStaticParamsnotFound()
    • 정적 생성 범위와 재검증 전략 결정
  5. Route Handlers
    • RESTful API 생성
    • GET/POST/DELETE 등
    • 동적 라우트 지원

📚 시리즈 네비게이션


“고급 라우팅은 화려한 문법보다, URL과 화면 상태를 분리해서 생각하는 힘입니다.” 🎨

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.