병렬 라우트(Parallel Routes)는 하나의 레이아웃 안에 여러 페이지를 @slot 폴더로 동시에 렌더링하는 App Router 기능이고, 인터셉트 라우트(Intercepting Routes)는 (.), (..), (...) 접두사로 다른 라우트를 현재 컨텍스트 안에서 가로채는 기능이다. 이 둘을 조합하면 Instagram 스타일의 모달, 다중 위젯 대시보드, 독립 로딩 상태를 갖는 탭 UI를 새로고침 시에도 깨지지 않게 구현할 수 있다. Next.js 15 기준의 실전 코드로 하나씩 정리한다.
@slot 폴더는 부모 layout.tsx에 동일한 이름의 prop으로 주입되며, 각 슬롯은 자체 loading.tsx와 error.tsx를 가질 수 있다.
병렬 라우트 사용 시 모든 슬롯에 default.js가 있어야 새로고침(하드 내비게이션)에서 404가 나지 않는다.
인터셉트 라우트 접두사 (.)는 같은 레벨, (..)는 한 단계 위, (...)는 app 루트를 기준으로 라우트를 가로챈다.
모달 패턴은 @modal 슬롯 + (.)photo/[id] 인터셉트 조합이 표준이다. soft navigation에서는 모달로, hard navigation에서는 전체 페이지로 표시된다.
useSelectedLayoutSegment(slotName)으로 슬롯의 현재 경로를 읽어 탭 UI의 활성 상태를 표시한다.
병렬 라우트란 무엇인가
App Router 이전, pages-router 시절에는 한 URL당 한 페이지 컴포넌트가 원칙이었다. 대시보드처럼 하나의 화면에 여러 독립 페이지를 조립해야 하는 경우, 데이터 fetching을 getServerSideProps 하나에 몰아넣고 컴포넌트 트리에서 나눠 쓰는 방식이 일반적이었다. 병렬 라우트는 이 제약을 없앤다. app/ 디렉터리 아래 @analytics, @revenue 같은 이름의 폴더를 만들면 각 슬롯은 독립된 page.tsx, loading.tsx, error.tsx를 가지며 동시에 렌더링된다. 병렬 slot이 실패해도 다른 slot은 계속 렌더링되고, 로딩 상태도 slot별로 격리된다.
솔직히 말하면, 나는 App Router 초기에 이 기능을 무시하고 있었다. 대부분의 화면은 서버 컴포넌트에서 Promise.all로 fetching하면 충분했기 때문이다. 하지만 모달을 URL로 표현하고 새로고침에서도 깨지지 않게 만들어야 하는 순간, 병렬 + 인터셉트 라우트를 쓰지 않을 수가 없다. 이 두 개념은 세트로 익히는 게 훨씬 이해가 빠르다. 공식 문서는 Next.js Parallel Routes 문서에서 확인할 수 있고, 이 글은 거기서 빠진 실전 함정을 채우는 데 초점을 맞춘다.
@slot 폴더 규칙과 layout 연결
규칙은 단순하다. 부모 세그먼트 아래 @name 폴더를 만들면 그 slot이 부모의 layout.tsx에 name이라는 prop으로 주입된다. slot 폴더는 URL에는 영향을 주지 않는다. 즉 app/dashboard/@analytics/page.tsx는 /dashboard URL에서 렌더링될 뿐, /dashboard/analytics가 되지 않는다.
app/
├── dashboard/
│ ├── layout.tsx // 위의 레이아웃
│ ├── page.tsx // children slot
│ ├── @analytics/
│ │ ├── page.tsx
│ │ ├── loading.tsx // analytics만 로딩 중인 상태
│ │ └── error.tsx // analytics만 실패 시 대체 UI
│ ├── @revenue/
│ │ └── page.tsx
│ └── @team/
│ └── page.tsx
각 slot은 자체 로딩/에러 UI를 가진다. 이는 서버 컴포넌트에서 Suspense로 감싸는 것과 비슷하지만, URL 세그먼트 단위로 격리된다는 점에서 다르다. 사용자가 /dashboard/analytics/details로 이동하면 @analytics slot만 그 하위 세그먼트로 전환되고, @revenue와 @team은 그대로 유지된다. 이것이 다중 위젯 대시보드에서 각 위젯이 독립적으로 딥링크될 수 있게 하는 핵심이다.
default.js 파일은 왜 필요한가
병렬 라우트를 처음 쓰면 반드시 만나는 에러가 있다. @modal slot으로 모달을 만들고 URL을 열었다가 새로고침하면 404가 뜨는 현상이다. 이유는 이렇다.
Next.js는 클라이언트 내비게이션 중에는 이전에 렌더링된 slot의 상태를 유지한다. 하지만 페이지를 새로고침하거나 URL로 직접 접속(하드 내비게이션)하면 라우터는 처음부터 URL을 매칭한다. 이때 URL이 특정 slot의 어떤 라우트에도 매칭되지 않으면, Next.js는 그 slot을 어떻게 렌더링해야 할지 모른다. 이 경우의 기본값을 제공하는 파일이 default.js다.
// app/@modal/default.tsx
// 모달이 열려 있지 않은 상태의 기본 렌더링 (아무것도 그리지 않음)
export default function Default() {
return null
}
인터셉트 라우트의 (.), (..), (...) 문법
인터셉트 라우트는 다른 라우트를 현재 레이아웃 컨텍스트 안에서 가로채서 렌더링하게 한다. 접두사가 세 가지 있고, 문법이 파일 시스템의 상대경로 문법을 참고했지만 완전히 같지는 않다는 점이 함정이다.
접두사
의미
예시
(.)
같은 세그먼트
feed/(.)photo/[id] — feed/photo/[id]를 가로챔
(..)
한 세그먼트 위
feed/(..)photo/[id] — photo/[id](app 바로 아래)를 가로챔
(..)(..)
두 세그먼트 위
드물게 사용
(...)
app 루트 기준
(...)photo/[id] — 어디서든 루트의 photo/[id]를 가로챔
여기서 가장 헷갈리는 부분은 (..)가 파일 시스템 경로가 아니라 라우트 세그먼트를 기준으로 한다는 점이다. @slot 폴더나 (group) 폴더는 라우트 세그먼트로 카운트되지 않는다. 예를 들어 app/@modal/(..)photo/[id]/page.tsx는 @modal이 세그먼트가 아니기 때문에 app/photo/[id]를 가로챈다. 이 규칙을 놓치면 인터셉트가 조용히 실패하고 원래 페이지가 전체 화면으로 뜬다.
인스타그램 스타일 모달 구현하기
이제 실전이다. 목표는 다음과 같다.
피드 페이지 /feed에서 사진 썸네일을 클릭하면 모달로 사진이 열린다.
모달 상태에서 URL은 /photo/[id]가 된다.
URL /photo/[id]를 새 탭에 붙여넣고 열면 전체 페이지로 사진이 열린다.
모달에서 뒤로가기 → 모달만 닫힘. 새로고침 → 전체 페이지로 전환.
필요한 파일 구조는 다음과 같다.
app/
├── layout.tsx // 루트 레이아웃 (modal slot을 받음)
├── @modal/
│ ├── default.tsx // 모달이 없을 때 null
│ └── (.)photo/
│ └── [id]/
│ └── page.tsx // 모달 안에서 렌더링될 컴포넌트
├── photo/
│ └── [id]/
│ └── page.tsx // 전체 페이지 버전
└── feed/
└── page.tsx // Link href="/photo/123"
이 구조에서 <Link href="/photo/123">로 이동하면 라우터는 먼저 @modal/(.)photo/[id]를 찾아 인터셉트한다. 새로고침 시에는 인터셉트 대신 app/photo/[id]/page.tsx가 전체 페이지로 렌더링된다. 라우팅과 관련해 더 나아가서, 인증이나 지역 라우팅이 필요하다면 Next.js 미들웨어 완벽 가이드에서 URL 리라이팅과 세션 검증 패턴을 함께 참고하는 것을 권한다.
다중 위젯 대시보드 만들기
병렬 라우트의 두 번째 킬러 유스케이스가 대시보드다. 각 위젯이 독립적으로 로딩되고, 독립적으로 실패 복구되며, 독립적으로 딥링크될 수 있다. 예를 들어 매출 위젯이 느린 서드파티 API 때문에 3초 걸려도, 팀 위젯은 100ms 안에 표시된다.
// app/dashboard/@revenue/page.tsx
import { getRevenue } from '@/lib/analytics'
export default async function RevenueWidget() {
// 이 fetching이 느려도 다른 slot은 영향받지 않는다
const data = await getRevenue()
return (
<article>
<h3>매출</h3>
<p>{data.mtd.toLocaleString('ko-KR')}원</p>
</article>
)
}
// app/dashboard/@revenue/loading.tsx
export default function Loading() {
return <div className="skeleton h-32" />
}
// app/dashboard/@revenue/error.tsx
'use client'
export default function Error({ reset }: { reset: () => void }) {
return (
<div>
<p>매출 데이터를 불러올 수 없습니다.</p>
<button onClick={reset}>다시 시도</button>
</div>
)
}
이 패턴의 장점은 활성 탭이 URL의 진짜 세그먼트라는 점이다. 사용자가 /settings/billing을 북마크하거나 공유해도 그대로 열린다. 클라이언트 상태로 관리하는 탭 UI와 달리 뒤로가기가 자연스럽게 동작한다.
자주 만나는 함정과 디버깅
실전에서 병렬/인터셉트 라우트를 도입할 때 반복해서 밟는 지뢰들이 있다.
1. default.js 누락으로 인한 새로고침 404
가장 흔하다. 특히 @modal slot을 만들었다면 @modal/default.tsx가 있는지 먼저 확인한다. 개발 모드에서는 종종 통과하다가 프로덕션 빌드에서 문제가 드러나기도 한다. 나도 배포 직후 QA가 "링크 붙여넣으면 404가 나요"라고 알려주고 나서야 이 규칙을 뼈에 새겼다.
2. 인터셉트가 조용히 실패
(..) 접두사는 URL 세그먼트 기준이지 파일 경로 기준이 아니다. (group) 폴더와 @slot 폴더는 세그먼트로 카운트되지 않는다. 인터셉트가 안 걸리면 이 부분부터 의심한다.
3. 모달 안에서 서버 컴포넌트 캐싱
인터셉트된 모달과 전체 페이지가 같은 데이터를 불러오는 경우, React의 cache() 또는 Next.js의 use cache 지시자로 fetching 결과를 공유하면 중복 요청을 피할 수 있다. 특히 getPhoto(id) 같은 함수는 cache()로 감싸는 것이 사실상 표준이다.
4. 클라이언트 컴포넌트가 slot을 감쌀 때
클라이언트 컴포넌트는 children을 통해 서버 컴포넌트를 렌더링할 수는 있지만, @slot prop을 직접 클라이언트 컴포넌트에 통과시키는 구조는 예상대로 동작하지 않을 때가 있다. slot을 다루는 레이아웃은 가능한 한 서버 컴포넌트로 유지하는 편이 안전하다.
5. 모달 상태 서버 액션과 함께 사용
모달 안에서 폼을 처리한다면 서버 액션을 그대로 쓸 수 있다. 액션이 성공한 뒤 redirect()를 호출하면 인터셉트 대신 전체 페이지로 이동한다. 이 동작이 의도한 것인지 매번 확인해야 한다. 더 깊게 들어가려면 Next.js 15 서버 액션 완벽 가이드의 검증·리다이렉트 패턴을 참고하는 것이 좋다.
자주 묻는 질문
병렬 라우트와 인터셉트 라우트의 차이는 무엇인가요?
병렬 라우트(@slot)는 하나의 layout에 여러 페이지를 동시 렌더링하는 구조적 기능이고, 인터셉트 라우트((.), (..), (...))는 다른 URL의 페이지를 현재 컨텍스트로 가로채는 기능이다. 모달 패턴에서는 둘을 함께 쓴다: @modal slot이 자리를 만들고, (.)photo/[id]가 그 자리에 다른 라우트를 그린다.
모달을 새로고침하면 404가 뜨는데 왜 그런가요?
모든 병렬 slot(특히 @modal)에 default.tsx가 있어야 한다. 새로고침(하드 내비게이션) 시 라우터는 slot의 activeSegment를 알 수 없으므로 default.tsx를 렌더링한다. 이 파일이 없으면 404가 발생한다. 모달이 없을 때는 return null로 충분하다.
(..) 접두사가 예상과 다르게 동작합니다. 왜 그런가요?
(..)는 파일 시스템 경로가 아니라 라우트 세그먼트를 기준으로 한다. @slot과 (group) 폴더는 세그먼트로 카운트되지 않는다. 예: app/@modal/(..)photo/[id]는 @modal이 세그먼트가 아니므로 app/photo/[id]를 가로챈다.
한 페이지에 병렬 slot을 몇 개까지 둘 수 있나요?
기술적 하드 리미트는 없다. 다만 각 slot마다 layout에 prop을 추가해야 하고, 병렬 fetching이 늘어나는 만큼 서버 부하와 초기 페이로드가 커진다. 실전에서는 위젯 3~6개 정도까지가 관리하기 좋다.
인터셉트 라우트는 서버 사이드 렌더링에서도 동작하나요?
인터셉트는 클라이언트 사이드 내비게이션에서만 활성화된다. 서버에서 렌더링되는 첫 요청이나 새로고침은 원본 라우트가 그대로 렌더링된다. 이 이중성(soft = 모달, hard = 전체 페이지)이 모달 패턴의 핵심 장점이다.