본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 4장: 인터셉팅 라우트와 모달 패턴
2026년 1월 26일·웹 개발·

4장: 인터셉팅 라우트와 모달 패턴

Next.js의 인터셉팅 라우트로 모달, 사진 갤러리, 미리보기 패턴을 구현합니다. 병렬 라우트와의 조합, 딥 링킹, 공유 가능한 URL을 다룹니다.

12분592자6개 섹션
nextjsreacttypescriptfrontend
공유
nextjs-app-router4 / 13
12345678910111213
이전3장: 동적 라우팅과 병렬 라우트다음5장: 데이터 페칭과 캐싱 전략의 대전환

3장에서 병렬 라우트의 동작 원리를 다루었습니다. 이번 장에서는 병렬 라우트와 함께 사용하면 강력한 UX 패턴을 만들 수 있는 인터셉팅 라우트(Intercepting Routes)를 살펴봅니다.

인터셉팅 라우트란

인터셉팅 라우트는 현재 레이아웃 안에서 다른 라우트의 콘텐츠를 가로채어(intercept) 표시하는 기능입니다.

예를 들어, 사진 피드에서 사진을 클릭하면 피드 위에 모달로 사진을 보여주지만, 해당 사진 URL을 직접 방문하면 전체 페이지로 보여주는 패턴을 구현할 수 있습니다.

인터셉팅 문법

문법매칭 대상
(.)같은 레벨의 세그먼트
(..)한 레벨 위의 세그먼트
(..)(..)두 레벨 위의 세그먼트
(...)루트(app/)부터의 세그먼트
Warning

이 문법은 파일 시스템 경로가 아닌 라우트 세그먼트 기준입니다. 라우트 그룹((group))이나 병렬 라우트 슬롯(@slot)은 세그먼트로 계산되지 않습니다.

모달 패턴 구현

가장 대표적인 인터셉팅 라우트 사용 사례는 모달입니다.

파일 구조

app/
  layout.tsx
  @modal/
    (.)photo/[id]/
      page.tsx         ← 피드에서 클릭 시 모달로 표시
    default.tsx        ← 모달이 없을 때 (null 반환)
  photo/[id]/
    page.tsx           ← 직접 URL 접근 시 전체 페이지
  feed/
    page.tsx           ← 사진 피드

루트 레이아웃

app/layout.tsx
typescript
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

기본 모달 슬롯

app/@modal/default.tsx
typescript
export default function Default() {
  return null;
}

모달이 활성화되지 않았을 때는 아무것도 렌더링하지 않습니다.

인터셉팅 모달 페이지

app/@modal/(.)photo/[id]/page.tsx
typescript
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>
  );
}

전체 페이지 (직접 접근용)

app/photo/[id]/page.tsx
typescript
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>
  );
}

사진 피드

app/feed/page.tsx
typescript
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>
  );
}

모달 컴포넌트

components/Modal.tsx
typescript
'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         ← 직접 접근 시 전체 페이지
app/blog/layout.tsx
typescript
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>
  );
}
app/blog/@preview/(.)post/[slug]/page.tsx
typescript
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 직접 접근 시 전체 페이지
app/@auth/(.)login/page.tsx
typescript
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을 직접 방문하면 전용 로그인 페이지가 표시됩니다.

인터셉팅 라우트의 제약

1. 하드 탐색에서는 인터셉팅이 발생하지 않음

인터셉팅은 소프트 탐색(클라이언트 사이드)에서만 동작합니다. URL을 직접 입력하거나 새로고침하면 원래 라우트(photo/[id]/page.tsx)가 렌더링됩니다.

2. 라우트 세그먼트 기준

인터셉팅 매칭은 파일 시스템이 아닌 라우트 세그먼트 기준입니다. 라우트 그룹과 슬롯은 세그먼트로 계산되지 않으므로, 경로를 셀 때 주의해야 합니다.

app/
  (marketing)/        ← 라우트 그룹 (세그먼트 아님)
    @modal/           ← 슬롯 (세그먼트 아님)
      (.)items/[id]/  ← items는 같은 레벨의 세그먼트
  items/[id]/
    page.tsx

3. 모달 닫기와 히스토리

router.back()으로 모달을 닫는 것이 일반적이지만, 사용자가 모달 URL을 직접 공유받아 방문한 경우 router.back()이 예상과 다르게 동작할 수 있습니다. 이런 경우를 대비해 router.push('/')와 같은 절대 경로 탐색을 fallback으로 제공하는 것이 좋습니다.

핵심 요약

  • 인터셉팅 라우트는 소프트 탐색 시 다른 라우트의 콘텐츠를 현재 레이아웃 안에서 표시합니다.
  • (.), (..), (...) 문법으로 인터셉팅할 대상의 상대 위치를 지정하며, 라우트 세그먼트 기준으로 계산됩니다.
  • 병렬 라우트(@modal)와 조합하면 모달, 사이드 패널, 미리보기 등의 고급 UX 패턴을 구현할 수 있습니다.
  • 같은 URL에 대해 탐색 방식(소프트/하드)에 따라 다른 UI를 제공하여, 공유 가능한 URL과 맥락 유지를 동시에 달성합니다.
  • 하드 탐색(새로고침, 직접 URL 입력)에서는 인터셉팅이 발생하지 않고 원래 라우트가 렌더링됩니다.

다음 장에서는 Next.js의 데이터 페칭과 캐싱 전략이 14에서 16까지 어떻게 변화했는지 심층적으로 다룹니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#nextjs#react#typescript#frontend

관련 글

웹 개발

5장: 데이터 페칭과 캐싱 전략의 대전환

Next.js 14에서 16까지 캐싱 전략이 어떻게 변화했는지 살펴봅니다. 네 가지 캐싱 레이어, fetch() 기본값 변경, 재검증 전략을 다룹니다.

2026년 1월 28일·20분
웹 개발

3장: 동적 라우팅과 병렬 라우트

Next.js의 동적 세그먼트, catch-all 라우트, 병렬 라우트(@slot)의 동작 원리와 대시보드, 조건부 렌더링 등 실전 패턴을 다룹니다.

2026년 1월 24일·10분
웹 개발

6장: Cache Components와 'use cache' 디렉티브

Next.js 16의 Cache Components 시스템을 다룹니다. 'use cache' 디렉티브, cacheLife 프로필, cacheTag 무효화, 세 가지 캐시 변형을 살펴봅니다.

2026년 1월 30일·17분
이전 글3장: 동적 라우팅과 병렬 라우트
다음 글5장: 데이터 페칭과 캐싱 전략의 대전환

댓글

목차

약 12분 남음
  • 인터셉팅 라우트란
    • 인터셉팅 문법
  • 모달 패턴 구현
    • 파일 구조
    • 루트 레이아웃
    • 기본 모달 슬롯
    • 인터셉팅 모달 페이지
    • 전체 페이지 (직접 접근용)
    • 사진 피드
    • 모달 컴포넌트
    • 동작 흐름
  • 실전: 게시물 미리보기 패턴
  • 실전: 인증 모달
  • 인터셉팅 라우트의 제약
    • 1. 하드 탐색에서는 인터셉팅이 발생하지 않음
    • 2. 라우트 세그먼트 기준
    • 3. 모달 닫기와 히스토리
  • 핵심 요약