병렬 라우트와 인터셉팅 라우트는 한 화면 안에 여러 라우트를 독립적으로 띄우고, 특정 경로로의 이동을 다른 위치에서 대신 렌더링하기 위해 Next.js 13의 App Router에 도입된 기능이다.
먼저, 어떤 화면에 쓰이는가
-
첫 번째 상황은 대시보드처럼 한 페이지 안에 여러 독립적인 영역이 동시에 존재하는 화면이다.
-
예를 들어 매출 분석 패널과 팀 활동 패널을 나란히 두고, 한쪽이 느려져도 다른 쪽은 자신만의 로딩 스피너로 먼저 떠야 하며, 한쪽이 에러로 죽어도 다른 쪽은 멀쩡히 동작해야 한다.
-
두 번째 상황은 피드에서 사진을 클릭하면 피드를 그대로 둔 채 사진만 모달로 띄우고 싶은 경우다.
-
동시에 누군가 그 모달의 URL
/photos/1을 공유받아 직접 열었을 때는, 모달이 아니라 풀페이지로 보여야 한다는 요구가 따라온다. -
두 상황은 표면적으로 달라 보이지만 핵심 요구는 같다.
-
한 화면 안에 여러 라우트가 독립적인 단위로 공존하고, 각각이 자신만의 활성 상태와 로딩/에러를 가지며, URL과 연결되어 동작해야 한다는 점이다.
기존 도구로는 왜 부족한가
-
화면을 시각적으로 나누는 것은 일반 컴포넌트로도 충분하다.
-
영역별 로딩과 에러도
Suspense와 Error Boundary로 어느 정도 분리할 수 있다.export default function Page() { return ( <> <Suspense fallback={<SkeletonA />}> <ComponentA /> </Suspense> <Suspense fallback={<SkeletonB />}> <ComponentB /> </Suspense> </> ) } -
그러나
Suspense가 분리해 주는 것은 어디까지나 한 페이지 안의 컴포넌트 단위 로딩 UI일 뿐이다. -
각 영역에 독립적인 라우트, URL 기반 활성 상태, 라우트 단위 에러 경계, 영역별 layout 유지 같은 라우팅 차원의 분리는 제공하지 못한다.
-
두 번째 모달 시나리오는 더 어렵다.
-
일반 컴포넌트로 모달을 만들면 URL이 바뀌지 않아 공유와 새로고침이 불가능해지고, 그렇다고 별도 라우트로 분리하면 피드 화면이 통째로 사라져 버린다.
-
두 조건을 동시에 만족시키려면 라우팅 자체가 한 화면 안에서 여러 갈래로 동작해야 한다.
-
병렬 라우트와 인터셉팅 라우트는 정확히 이 요구에서 출발한 기능이다.
첫 번째 시나리오를 푸는 도구, 병렬 라우트와 슬롯
-
병렬 라우트는 동일한 레이아웃 안에서 여러 페이지를 동시에 또는 조건부로 렌더링하는 기능이다.
-
병렬 렌더링되는 각 영역을 슬롯이라고 부르며, 슬롯은
@폴더명형식으로 정의한다.app/ └── dashboard/ ├── layout.tsx ├── page.tsx ├── @analytics/ │ ├── page.tsx │ └── loading.tsx └── @team/ ├── page.tsx └── error.tsx -
슬롯은 자신이 속한 세그먼트의
layout.tsx에props로 자동 전달되며, 레이아웃이 이를 받아 화면에 배치한다.// app/dashboard/layout.tsx export default function DashboardLayout({ children, analytics, // props team, // props }: { children: React.ReactNode analytics: React.ReactNode team: React.ReactNode }) { return ( <div> <div>{children}</div> <div>{analytics}</div> <div>{team}</div> </div> ) } -
props의 키 이름은 슬롯 폴더 이름에서@를 제거한 값과 정확히 일치해야 한다. -
각 슬롯 내부는 일반 App Router subtree처럼 동작하므로 자신만의
loading.tsx,error.tsx,layout.tsx를 가질 수 있다. -
이 구조 덕분에 처음 시나리오에서 요구했던 "한쪽이 느려도 다른 쪽은 먼저 뜨고, 한쪽이 죽어도 다른 쪽은 멀쩡히 동작한다"가 라우팅 차원에서 자연스럽게 성립한다.
-
결국 병렬 라우트의 본질은 단순한 병렬 데이터 페칭이 아니라, 여러 UI subtree를 독립적인 라우팅 단위로 동시에 유지하고 전환할 수 있게 만든다는 점에 있다.
그러면 슬롯은 URL에 어떻게 노출되는가
-
슬롯이
@폴더명이라는 점에서 자연스럽게 떠오르는 질문은 "그러면@analytics같은 폴더가 URL에 어떻게 노출되는가"이다. -
답은 노출되지 않는다는 것이다.
-
슬롯은 화면을 분할하는 렌더링 단위일 뿐, URL 경로에는 영향을 주지 않는다.
-
예를 들어
app/@analytics/views/page.tsx파일이 있을 때, 실제 URL은/@analytics/views가 아니라/views이다. -
@analytics는 URL 매칭에서 완전히 무시되며, 오직 렌더링 구조를 나누기 위한 폴더 표기일 뿐이다. -
"슬롯은 URL 계산에서 무시된다"는 이 성질은 뒤에서 인터셉팅 기호를 계산할 때 다시 결정적인 역할을 한다.
슬롯의 활성 상태란
-
슬롯이 URL을 만들지 않는다면, 한 슬롯이 지금 자신의 어느 페이지를 보여주고 있는지는 어디에 기록되는가.
-
Next.js는 슬롯마다 "이 슬롯은 지금 자신의 어느
page.tsx를 렌더링 중인지"를 별도의 값으로 들고 있으며, 이를 슬롯의 활성 상태(active state)라고 부른다. -
활성 상태는 URL이나 컴포넌트
props로 노출되지 않고, 클라이언트 측 라우터가 메모리상에서 관리하는 내부 정보다. -
가장 중요한 특징은 이 활성 상태가 슬롯마다 독립적으로 관리된다는 점이다.
-
예를 들어 다음 구조를 보자.
app/ ├── layout.tsx ├── @team/ │ ├── page.tsx │ └── settings/ │ └── page.tsx └── @analytics/ └── page.tsx -
사용자가 처음
/에 진입하면@team의 활성 상태는 자신의 루트page.tsx,@analytics의 활성 상태도 자신의 루트page.tsx로 잡힌다. -
사용자가
<Link>로/settings로 이동하면@team의 활성 상태는settings/page.tsx로 옮겨가지만,@analytics에는/settings에 대응하는 페이지가 없으므로 활성 상태가 이전 그대로 루트page.tsx에 머문다. -
즉 같은 URL
/settings에서도@team은 settings 페이지를,@analytics는 루트 페이지를 보여주며, 두 슬롯이 서로 다른 "지금 어디에 있는지"를 따로 들고 있는 셈이다. -
이 활성 상태는 URL에 드러나지 않기 때문에, 레이아웃에서 슬롯의 현재 상태를 알고 싶다면 뒤에서 다룰
useSelectedLayoutSegment훅을 통해 별도로 읽어야 한다.
활성 상태는 페이지 이동 사이에 어떻게 유지되는가
-
활성 상태가 페이지 이동 중에 그대로 유지되는지 여부는 내비게이션 유형에 따라 갈린다.
-
소프트 내비게이션은
<Link>컴포넌트나router.push()를 통한 클라이언트 사이드 이동이며, 이 경우 라우터가 메모리상에서 그대로 동작하므로 기존 슬롯의 활성 상태가 변경 없이 유지된다. -
반면 하드 내비게이션은 브라우저 새로고침이나 주소창 직접 입력처럼 페이지 자체가 처음부터 다시 로드되는 이동이며, 이때 메모리에 있던 이전 활성 상태는 모두 사라진다.
-
그래서 하드 내비게이션 직후의 Next.js는 현재 URL이라는 단 하나의 정보만 가지고, 각 슬롯의 활성 상태를 처음부터 다시 계산해야 한다.
-
구체적으로는 각 슬롯의 폴더를 따로 훑으면서 현재 URL 세그먼트와 일치하는
page.tsx를 찾는 식이며, 이렇게 찾아낸 경로가 그 슬롯의 활성 subtree가 된다. -
문제는 슬롯이 서로 독립적인 라우트 단위이기 때문에, 슬롯마다 내부에 가진 폴더 구조가 서로 다를 수 있다는 점이다.
-
어떤 슬롯에는 현재 URL과 매칭되는
page.tsx가 있지만, 다른 슬롯에는 같은 경로에 해당하는 파일이 아예 존재하지 않을 수 있다. -
이 경우 매칭에 실패한 슬롯은 URL만으로는 무엇을 렌더링해야 할지 결정할 수 없는 상태가 된다.
복원에 실패한 슬롯을 위한 default.tsx
-
앞 문제를 구체화하기 위해 다음 구조를 보자.
app/ ├── layout.tsx ├── page.tsx ├── @team/ │ ├── page.tsx │ └── settings/ │ └── page.tsx └── @analytics/ └── page.tsx -
사용자가
/settings로 소프트 내비게이션하면@analytics는 이전 활성 상태를 유지해 자신의page.tsx를 그대로 보여줄 수 있다. -
그러나
/settings에서 새로고침하면 Next.js는 URL만으로 각 슬롯의 활성 subtree를 복원해야 하므로,@analytics처럼 매칭되는 subtree가 없는 슬롯은 렌더링 대상을 결정하지 못한다. -
이때 슬롯에
default.tsx가 없으면 Next.js는 해당 슬롯에 대해 404를 렌더링한다. -
default.tsx는 하드 내비게이션 시 슬롯의 활성 상태를 URL만으로 복원할 수 없을 때 사용되는 폴백 파일이다.// app/@analytics/default.tsx export default function Default() { return null } -
소프트 내비게이션에서는 이전 상태가 그대로 유지되므로
default.tsx가 거의 호출되지 않지만, 새로고침 같은 하드 내비게이션 복원 과정에서는 필수적인 역할을 한다. -
따라서 병렬 라우트를 사용할 때는 각 슬롯에
default.tsx를 함께 두는 것이 기본 원칙이다.
레이아웃이 슬롯의 상태에 반응해야 할 때
-
슬롯이 어떻게 등장하고 어떤 페이지를 보여주는지는 정리되었지만, 한 가지 케이스가 남는다.
-
슬롯이 활성화되었을 때 그 주변 UI도 함께 반응해야 하는 경우다.
-
대표적인 예가
@auth슬롯에 로그인 모달이 떠 있을 때 배경을 어둡게 처리하고 싶은 상황이다. -
그러나 레이아웃은 슬롯을 단순히
props로 받기만 할 뿐, 그 슬롯 내부에서 어떤 경로가 활성화되어 있는지는 기본적으로 알 수 없다. -
이 간극을 메우기 위해
useSelectedLayoutSegment훅에parallelRoutesKey를 넘기면, 특정 슬롯의 현재 활성 세그먼트 이름을 문자열로 읽어올 수 있다.app/ ├── layout.tsx ├── page.tsx └── @auth/ ├── default.tsx └── login/ └── page.tsx'use client' import { useSelectedLayoutSegment } from 'next/navigation' export default function Layout({ auth }: { auth: React.ReactNode }) { const segment = useSelectedLayoutSegment('auth') const isModalOpen = segment !== null return ( <> <main style={{ opacity: isModalOpen ? 0.4 : 1 }}>{/* 배경 */}</main> {auth} </> ) } -
인자로 넘기는
'auth'는 슬롯 폴더 이름인@auth에서@를 뺀 값이다. -
반환값은 해당 레이아웃 기준으로 한 단계 아래에 위치한 활성 세그먼트의 이름이며, 슬롯이 비활성 상태이면
null이다. -
예를 들어
@auth/login/password처럼 중첩된 경로로 들어가도 반환값은 여전히 가장 위 세그먼트인'login'이며, 더 깊은 경로 전체가 필요하다면 복수형인useSelectedLayoutSegments를 사용해야 한다. -
이 훅 덕분에 슬롯의 활성 여부가 그대로 주변 UI의 반응(배경 흐리기 등)으로 자연스럽게 연결될 수 있다.
두 번째 시나리오로 돌아가서, 인터셉팅 라우트
-
이제 처음에 미뤄 두었던 모달 시나리오로 돌아가자.
-
피드에서 사진을 클릭하면 모달, 같은 URL을 직접 열면 풀페이지라는 요구는 병렬 라우트만으로는 풀리지 않는다.
-
병렬 라우트는 같은 URL에 대해 슬롯별로 다른 컴포넌트를 보여줄 수는 있지만, 같은 URL을 어떻게 들어왔는지에 따라 다른 렌더링을 하는 기능은 가지고 있지 않기 때문이다.
-
인터셉팅 라우트가 이 부분을 보완한다.
-
인터셉팅 라우트는 특정 경로로의 소프트 내비게이션을 가로채, 같은 경로를 현재 레이아웃 트리 안의 다른 위치에서 대신 렌더링하는 기능이다.
-
즉 URL은 실제 목적지로 바뀌지만, 라우트 해석은 다른 subtree에서 일어나며 기존 레이아웃은 그대로 유지된다.
-
인터셉팅 라우트 자체는 단독으로도 사용 가능하지만, 기존 페이지 위에 모달을 overlay하는 UX를 만들려면 보통 병렬 라우트의 슬롯과 함께 사용한다.
-
슬롯이 모달이 들어갈 자리를 마련해 주고, 인터셉팅 라우트가 그 자리에 들어올 콘텐츠를 가로채 가져오는 역할 분담이다.
인터셉팅 기호 읽는 법
-
인터셉팅 라우트는 폴더명에 특수 접두사를 붙여 선언하며, 기호는 가로채는 위치와 대상 사이의 거리를 의미한다.
-
(.)는 같은 레벨,(..)는 한 단계 위,(..)(..)는 두 단계 위,(...)는app루트를 기준으로 가로챈다. -
이때 거리는 파일 시스템상의 폴더 깊이가 아니라 라우트 트리상의 세그먼트 깊이로 계산된다.
-
앞에서 봤듯이 슬롯 폴더(
@folder)와 라우트 그룹((group))은 URL 세그먼트 계산에서 무시되므로, 이 거리 계산에서도 빠진다. -
따라서
app/@modal/은 슬롯이 무시되어 사실상app/과 같은 레벨로 취급되며,app/@modal/안에서app/photos/를 가로채려면 같은 레벨인(.)를 사용해(.)photos로 표기해야 한다. -
반대로 슬롯이
app/feed/@modal/처럼 한 단계 아래에 있다면 기준점이/feed가 되므로, 한 단계 위의photos를 가로채려면(..)photos가 맞는 표기가 된다.app/ ├── layout.tsx ├── photos/ │ └── [id]/page.tsx └── feed/ ├── layout.tsx ├── page.tsx └── @modal/ └── (..)photos/ └── [id]/page.tsx
모달 패턴 완성
-
이제 처음 시나리오를 그대로 풀어내는 폴더 구조는 다음과 같다.
app/ ├── layout.tsx ├── feed/ │ └── page.tsx ├── photos/ │ └── [id]/ │ └── page.tsx └── @modal/ ├── default.tsx └── (.)photos/ └── [id]/ └── page.tsx -
@modal은 슬롯이므로 라우트 트리에서 무시되어,(.)photos와photos가 같은app/레벨에서 형제 관계가 된다. -
루트 레이아웃은
@modal슬롯을 받아 페이지 위에 함께 렌더링한다.// app/layout.tsx export default function Layout({ children, modal, }: { children: React.ReactNode modal: React.ReactNode }) { return ( <> {children} {modal} </> ) } -
피드에서 사진을
<Link>로 클릭하면 소프트 내비게이션이 일어나, 이동이 인터셉트되어@modal/(.)photos/[id]/page.tsx가 모달로 렌더링된다.// app/feed/page.tsx import Link from 'next/link' export default function FeedPage() { return ( <ul> <li> <Link href="/photos/1">사진 1</Link> </li> </ul> ) }// app/@modal/(.)photos/[id]/page.tsx 'use client' import { useRouter } from 'next/navigation' export default function PhotoModal({ params }: { params: { id: string } }) { const router = useRouter() return ( <div onClick={() => router.back()}> <div onClick={(e) => e.stopPropagation()}> <h2>사진 #{params.id}</h2> </div> </div> ) } -
반대로 주소창에
/photos/1을 직접 입력하거나 새로고침하면 하드 내비게이션이 되어 인터셉팅이 적용되지 않고,app/photos/[id]/page.tsx가 전체 페이지로 렌더링된다.// app/photos/[id]/page.tsx export default function PhotoPage({ params }: { params: { id: string } }) { return ( <div> <h1>사진 #{params.id} 상세 페이지</h1> </div> ) } -
덕분에 처음에 요구했던 "클릭은 모달, 직접 접근은 풀페이지"라는 두 조건이 동시에 성립한다.
-
모달이 열려 있지 않은 상태에서는
@modal슬롯에 아무것도 표시되지 않아야 하므로,default.tsx에서null을 반환해 두어야 한다.// app/@modal/default.tsx export default function ModalDefault() { return null }
마지막으로, router.back()의 함정
-
모달 안에서
router.back()을 호출하면 URL이 이전 상태로 되돌아가면서 모달이 자연스럽게 닫힌다. -
그러나
router.back()은 브라우저 히스토리에 의존하기 때문에, 사용자가 피드를 거치지 않고/photos/1을 주소창에 직접 입력해 들어온 경우에는 의도와 다르게 사이트 바깥으로 빠져나가거나 히스토리가 꼬일 수 있다. -
이런 케이스를 위해 실무에서는 히스토리 상태를 확인하거나, 진입 경로를 알 수 없는 상황에서는
router.push('/feed')처럼 명시적인 폴백 이동을 함께 구현하는 것이 안전하다.'use client' import { useRouter } from 'next/navigation' export default function PhotoModal() { const router = useRouter() const close = () => { if (window.history.length > 1) { router.back() } else { router.push('/feed') } } return <div onClick={close}>{/* 모달 콘텐츠 */}</div> } -
이렇게 두면 인터셉트로 진입했을 때는 자연스러운 뒤로가기로 동작하고, 직접 접근한 경우에도 안전한 경로로 빠져나가게 된다.