Next.js의 인터셉팅 라우트로 모달, 사진 갤러리, 미리보기 패턴을 구현합니다. 병렬 라우트와의 조합, 딥 링킹, 공유 가능한 URL을 다룹니다.
3장에서 병렬 라우트의 동작 원리를 다루었습니다. 이번 장에서는 병렬 라우트와 함께 사용하면 강력한 UX 패턴을 만들 수 있는 인터셉팅 라우트(Intercepting Routes)를 살펴봅니다.
인터셉팅 라우트는 현재 레이아웃 안에서 다른 라우트의 콘텐츠를 가로채어(intercept) 표시하는 기능입니다.
예를 들어, 사진 피드에서 사진을 클릭하면 피드 위에 모달로 사진을 보여주지만, 해당 사진 URL을 직접 방문하면 전체 페이지로 보여주는 패턴을 구현할 수 있습니다.
| 문법 | 매칭 대상 |
|---|---|
(.) | 같은 레벨의 세그먼트 |
(..) | 한 레벨 위의 세그먼트 |
(..)(..) | 두 레벨 위의 세그먼트 |
(...) | 루트(app/)부터의 세그먼트 |
이 문법은 파일 시스템 경로가 아닌 라우트 세그먼트 기준입니다. 라우트 그룹((group))이나 병렬 라우트 슬롯(@slot)은 세그먼트로 계산되지 않습니다.
가장 대표적인 인터셉팅 라우트 사용 사례는 모달입니다.
app/
layout.tsx
@modal/
(.)photo/[id]/
page.tsx ← 피드에서 클릭 시 모달로 표시
default.tsx ← 모달이 없을 때 (null 반환)
photo/[id]/
page.tsx ← 직접 URL 접근 시 전체 페이지
feed/
page.tsx ← 사진 피드
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
{children}
{modal}
</body>
</html>
);
}export default function Default() {
return null;
}모달이 활성화되지 않았을 때는 아무것도 렌더링하지 않습니다.
import { getPhoto } from '@/lib/data';
import { Modal } from '@/components/Modal';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PhotoModal({ params }: PageProps) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<img
src={photo.url}
alt={photo.alt}
className="max-h-[80vh] w-auto"
/>
<div className="mt-4">
<h2 className="text-lg font-semibold">{photo.title}</h2>
<p className="text-gray-600">{photo.description}</p>
</div>
</Modal>
);
}import { getPhoto } from '@/lib/data';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PhotoPage({ params }: PageProps) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="mx-auto max-w-4xl py-8">
<img
src={photo.url}
alt={photo.alt}
className="w-full rounded-lg"
/>
<h1 className="mt-6 text-3xl font-bold">{photo.title}</h1>
<p className="mt-4 text-gray-600">{photo.description}</p>
</div>
);
}import Link from 'next/link';
import { getPhotos } from '@/lib/data';
export default async function FeedPage() {
const photos = await getPhotos();
return (
<div className="grid grid-cols-3 gap-2">
{photos.map(photo => (
<Link key={photo.id} href={`/photo/${photo.id}`}>
<img
src={photo.thumbnailUrl}
alt={photo.alt}
className="aspect-square object-cover"
/>
</Link>
))}
</div>
);
}'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const overlayRef = useRef<HTMLDivElement>(null);
const onDismiss = useCallback(() => {
router.back();
}, [router]);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
},
[onDismiss]
);
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onKeyDown]);
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center
bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === overlayRef.current) onDismiss();
}}
>
<div className="relative max-w-3xl rounded-lg bg-white p-6
shadow-xl dark:bg-gray-800">
<button
onClick={onDismiss}
className="absolute right-4 top-4 text-gray-400
hover:text-gray-600"
aria-label="닫기"
>
X
</button>
{children}
</div>
</div>
);
}핵심: 같은 URL(/photo/123)에 대해 탐색 방식에 따라 다른 UI가 표시됩니다.
블로그 목록에서 게시물을 클릭하면 사이드 패널에 미리보기를 표시하는 패턴입니다.
app/
blog/
layout.tsx
page.tsx
@preview/
(.)post/[slug]/
page.tsx ← 목록에서 클릭 시 미리보기 패널
default.tsx
post/[slug]/
page.tsx ← 직접 접근 시 전체 페이지
export default function BlogLayout({
children,
preview,
}: {
children: React.ReactNode;
preview: React.ReactNode;
}) {
return (
<div className="flex">
<div className="flex-1">{children}</div>
<aside className="w-96 border-l">{preview}</aside>
</div>
);
}import { getPost } from '@/lib/data';
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function PostPreview({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
return (
<div className="p-6">
<h2 className="text-xl font-bold">{post.title}</h2>
<p className="mt-2 text-sm text-gray-500">{post.date}</p>
<div className="mt-4 line-clamp-10 text-sm">{post.content}</div>
<a
href={`/blog/post/${slug}`}
className="mt-4 inline-block text-blue-600 hover:underline"
>
전체 글 읽기
</a>
</div>
);
}목록에서 게시물을 클릭하면 URL이 /blog/post/hello-world로 변경되면서 오른쪽 사이드 패널에 미리보기가 표시됩니다. URL을 직접 방문하면 전체 페이지 레이아웃으로 표시됩니다.
로그인이 필요한 페이지에서 모달로 로그인 폼을 표시하는 패턴입니다.
app/
layout.tsx
@auth/
(.)login/
page.tsx ← 인앱에서 "로그인" 클릭 시 모달
default.tsx
login/
page.tsx ← /login 직접 접근 시 전체 페이지
import { Modal } from '@/components/Modal';
import { LoginForm } from '@/components/LoginForm';
export default function LoginModal() {
return (
<Modal>
<h2 className="mb-4 text-xl font-bold">로그인</h2>
<LoginForm />
</Modal>
);
}사용자가 보호된 콘텐츠에서 "로그인" 링크를 클릭하면 모달이 표시되고, 현재 페이지의 컨텍스트가 유지됩니다. /login을 직접 방문하면 전용 로그인 페이지가 표시됩니다.
인터셉팅은 소프트 탐색(클라이언트 사이드)에서만 동작합니다. URL을 직접 입력하거나 새로고침하면 원래 라우트(photo/[id]/page.tsx)가 렌더링됩니다.
인터셉팅 매칭은 파일 시스템이 아닌 라우트 세그먼트 기준입니다. 라우트 그룹과 슬롯은 세그먼트로 계산되지 않으므로, 경로를 셀 때 주의해야 합니다.
app/
(marketing)/ ← 라우트 그룹 (세그먼트 아님)
@modal/ ← 슬롯 (세그먼트 아님)
(.)items/[id]/ ← items는 같은 레벨의 세그먼트
items/[id]/
page.tsx
router.back()으로 모달을 닫는 것이 일반적이지만, 사용자가 모달 URL을 직접 공유받아 방문한 경우 router.back()이 예상과 다르게 동작할 수 있습니다. 이런 경우를 대비해 router.push('/')와 같은 절대 경로 탐색을 fallback으로 제공하는 것이 좋습니다.
(.), (..), (...) 문법으로 인터셉팅할 대상의 상대 위치를 지정하며, 라우트 세그먼트 기준으로 계산됩니다.@modal)와 조합하면 모달, 사이드 패널, 미리보기 등의 고급 UX 패턴을 구현할 수 있습니다.다음 장에서는 Next.js의 데이터 페칭과 캐싱 전략이 14에서 16까지 어떻게 변화했는지 심층적으로 다룹니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Next.js 14에서 16까지 캐싱 전략이 어떻게 변화했는지 살펴봅니다. 네 가지 캐싱 레이어, fetch() 기본값 변경, 재검증 전략을 다룹니다.
Next.js의 동적 세그먼트, catch-all 라우트, 병렬 라우트(@slot)의 동작 원리와 대시보드, 조건부 렌더링 등 실전 패턴을 다룹니다.
Next.js 16의 Cache Components 시스템을 다룹니다. 'use cache' 디렉티브, cacheLife 프로필, cacheTag 무효화, 세 가지 캐시 변형을 살펴봅니다.