포스트

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

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

“라우팅이 이렇게 강력할 수 있다니!” - Parallel Routes와 Intercepting Routes로 복잡한 UI도 쉽게!

🎯 이 글에서 배울 내용

  • Parallel Routes (병렬 라우트)
  • Intercepting Routes (가로채기 라우트)
  • Route Handlers (API Routes)
  • Middleware 기본

예상 소요 시간: 45분


🔀 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
// 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}</main>
    </div>
  );
}

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>
  );
}

🎭 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   // 모달로 표시
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>

🛣️ 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 변경은 되지만 전체 페이지는 안 바뀜
  • 뒤로가기 지원

🎯 오늘 배운 내용 정리

  1. Parallel Routes
    • @folder 문법
    • 여러 페이지 동시 표시
    • 독립적인 로딩/에러 처리
  2. Intercepting Routes
    • (.)folder 문법
    • 모달 패턴
    • 부드러운 UX
  3. Route Handlers
    • RESTful API 생성
    • GET/POST/DELETE 등
    • 동적 라우트 지원

📚 시리즈 네비게이션


“고급 라우팅 패턴으로 프로답게!” 🎨

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