프로필 로고
2026-04-17

병렬 라우트와 인터셉팅 라우트

Next.js App Router의 병렬 라우트(슬롯, default.tsx, useSelectedLayoutSegment)와 인터셉팅 라우트((.)/(..)/(...) 기호)로 한 화면에 여러 라우트를 독립적으로 띄우고 모달 overlay 패턴을 구현하는 방법.

  • Next.js
  • 병렬 라우트
  • 인터셉팅 라우트

병렬 라우트와 인터셉팅 라우트는 한 화면 안에 여러 라우트를 독립적으로 띄우고, 특정 경로로의 이동을 다른 위치에서 대신 렌더링하기 위해 Next.js 13의 App Router에 도입된 기능이다.

먼저, 어떤 화면에 쓰이는가

  1. 첫 번째 상황은 대시보드처럼 한 페이지 안에 여러 독립적인 영역이 동시에 존재하는 화면이다.

  2. 예를 들어 매출 분석 패널과 팀 활동 패널을 나란히 두고, 한쪽이 느려져도 다른 쪽은 자신만의 로딩 스피너로 먼저 떠야 하며, 한쪽이 에러로 죽어도 다른 쪽은 멀쩡히 동작해야 한다.

  3. 두 번째 상황은 피드에서 사진을 클릭하면 피드를 그대로 둔 채 사진만 모달로 띄우고 싶은 경우다.

  4. 동시에 누군가 그 모달의 URL /photos/1을 공유받아 직접 열었을 때는, 모달이 아니라 풀페이지로 보여야 한다는 요구가 따라온다.

  5. 두 상황은 표면적으로 달라 보이지만 핵심 요구는 같다.

  6. 한 화면 안에 여러 라우트가 독립적인 단위로 공존하고, 각각이 자신만의 활성 상태와 로딩/에러를 가지며, URL과 연결되어 동작해야 한다는 점이다.

기존 도구로는 왜 부족한가

  1. 화면을 시각적으로 나누는 것은 일반 컴포넌트로도 충분하다.

  2. 영역별 로딩과 에러도 Suspense와 Error Boundary로 어느 정도 분리할 수 있다.

    export default function Page() {
      return (
        <>
          <Suspense fallback={<SkeletonA />}>
            <ComponentA />
          </Suspense>
          <Suspense fallback={<SkeletonB />}>
            <ComponentB />
          </Suspense>
        </>
      )
    }
  3. 그러나 Suspense가 분리해 주는 것은 어디까지나 한 페이지 안의 컴포넌트 단위 로딩 UI일 뿐이다.

  4. 각 영역에 독립적인 라우트, URL 기반 활성 상태, 라우트 단위 에러 경계, 영역별 layout 유지 같은 라우팅 차원의 분리는 제공하지 못한다.

  5. 두 번째 모달 시나리오는 더 어렵다.

  6. 일반 컴포넌트로 모달을 만들면 URL이 바뀌지 않아 공유와 새로고침이 불가능해지고, 그렇다고 별도 라우트로 분리하면 피드 화면이 통째로 사라져 버린다.

  7. 두 조건을 동시에 만족시키려면 라우팅 자체가 한 화면 안에서 여러 갈래로 동작해야 한다.

  8. 병렬 라우트와 인터셉팅 라우트는 정확히 이 요구에서 출발한 기능이다.

첫 번째 시나리오를 푸는 도구, 병렬 라우트와 슬롯

  1. 병렬 라우트는 동일한 레이아웃 안에서 여러 페이지를 동시에 또는 조건부로 렌더링하는 기능이다.

  2. 병렬 렌더링되는 각 영역을 슬롯이라고 부르며, 슬롯은 @폴더명 형식으로 정의한다.

    app/
    └── dashboard/
        ├── layout.tsx
        ├── page.tsx
        ├── @analytics/
        │   ├── page.tsx
        │   └── loading.tsx
        └── @team/
            ├── page.tsx
            └── error.tsx
    
  3. 슬롯은 자신이 속한 세그먼트의 layout.tsxprops로 자동 전달되며, 레이아웃이 이를 받아 화면에 배치한다.

    // 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>
      )
    }
  4. props의 키 이름은 슬롯 폴더 이름에서 @를 제거한 값과 정확히 일치해야 한다.

  5. 각 슬롯 내부는 일반 App Router subtree처럼 동작하므로 자신만의 loading.tsx, error.tsx, layout.tsx를 가질 수 있다.

  6. 이 구조 덕분에 처음 시나리오에서 요구했던 "한쪽이 느려도 다른 쪽은 먼저 뜨고, 한쪽이 죽어도 다른 쪽은 멀쩡히 동작한다"가 라우팅 차원에서 자연스럽게 성립한다.

  7. 결국 병렬 라우트의 본질은 단순한 병렬 데이터 페칭이 아니라, 여러 UI subtree를 독립적인 라우팅 단위로 동시에 유지하고 전환할 수 있게 만든다는 점에 있다.

그러면 슬롯은 URL에 어떻게 노출되는가

  1. 슬롯이 @폴더명이라는 점에서 자연스럽게 떠오르는 질문은 "그러면 @analytics 같은 폴더가 URL에 어떻게 노출되는가"이다.

  2. 답은 노출되지 않는다는 것이다.

  3. 슬롯은 화면을 분할하는 렌더링 단위일 뿐, URL 경로에는 영향을 주지 않는다.

  4. 예를 들어 app/@analytics/views/page.tsx 파일이 있을 때, 실제 URL은 /@analytics/views가 아니라 /views이다.

  5. @analytics는 URL 매칭에서 완전히 무시되며, 오직 렌더링 구조를 나누기 위한 폴더 표기일 뿐이다.

  6. "슬롯은 URL 계산에서 무시된다"는 이 성질은 뒤에서 인터셉팅 기호를 계산할 때 다시 결정적인 역할을 한다.

슬롯의 활성 상태란

  1. 슬롯이 URL을 만들지 않는다면, 한 슬롯이 지금 자신의 어느 페이지를 보여주고 있는지는 어디에 기록되는가.

  2. Next.js는 슬롯마다 "이 슬롯은 지금 자신의 어느 page.tsx를 렌더링 중인지"를 별도의 값으로 들고 있으며, 이를 슬롯의 활성 상태(active state)라고 부른다.

  3. 활성 상태는 URL이나 컴포넌트 props로 노출되지 않고, 클라이언트 측 라우터가 메모리상에서 관리하는 내부 정보다.

  4. 가장 중요한 특징은 이 활성 상태가 슬롯마다 독립적으로 관리된다는 점이다.

  5. 예를 들어 다음 구조를 보자.

    app/
    ├── layout.tsx
    ├── @team/
    │   ├── page.tsx
    │   └── settings/
    │       └── page.tsx
    └── @analytics/
        └── page.tsx
    
  6. 사용자가 처음 /에 진입하면 @team의 활성 상태는 자신의 루트 page.tsx, @analytics의 활성 상태도 자신의 루트 page.tsx로 잡힌다.

  7. 사용자가 <Link>/settings로 이동하면 @team의 활성 상태는 settings/page.tsx로 옮겨가지만, @analytics에는 /settings에 대응하는 페이지가 없으므로 활성 상태가 이전 그대로 루트 page.tsx에 머문다.

  8. 즉 같은 URL /settings에서도 @team은 settings 페이지를, @analytics는 루트 페이지를 보여주며, 두 슬롯이 서로 다른 "지금 어디에 있는지"를 따로 들고 있는 셈이다.

  9. 이 활성 상태는 URL에 드러나지 않기 때문에, 레이아웃에서 슬롯의 현재 상태를 알고 싶다면 뒤에서 다룰 useSelectedLayoutSegment 훅을 통해 별도로 읽어야 한다.

활성 상태는 페이지 이동 사이에 어떻게 유지되는가

  1. 활성 상태가 페이지 이동 중에 그대로 유지되는지 여부는 내비게이션 유형에 따라 갈린다.

  2. 소프트 내비게이션은 <Link> 컴포넌트나 router.push()를 통한 클라이언트 사이드 이동이며, 이 경우 라우터가 메모리상에서 그대로 동작하므로 기존 슬롯의 활성 상태가 변경 없이 유지된다.

  3. 반면 하드 내비게이션은 브라우저 새로고침이나 주소창 직접 입력처럼 페이지 자체가 처음부터 다시 로드되는 이동이며, 이때 메모리에 있던 이전 활성 상태는 모두 사라진다.

  4. 그래서 하드 내비게이션 직후의 Next.js는 현재 URL이라는 단 하나의 정보만 가지고, 각 슬롯의 활성 상태를 처음부터 다시 계산해야 한다.

  5. 구체적으로는 각 슬롯의 폴더를 따로 훑으면서 현재 URL 세그먼트와 일치하는 page.tsx를 찾는 식이며, 이렇게 찾아낸 경로가 그 슬롯의 활성 subtree가 된다.

  6. 문제는 슬롯이 서로 독립적인 라우트 단위이기 때문에, 슬롯마다 내부에 가진 폴더 구조가 서로 다를 수 있다는 점이다.

  7. 어떤 슬롯에는 현재 URL과 매칭되는 page.tsx가 있지만, 다른 슬롯에는 같은 경로에 해당하는 파일이 아예 존재하지 않을 수 있다.

  8. 이 경우 매칭에 실패한 슬롯은 URL만으로는 무엇을 렌더링해야 할지 결정할 수 없는 상태가 된다.

복원에 실패한 슬롯을 위한 default.tsx

  1. 앞 문제를 구체화하기 위해 다음 구조를 보자.

    app/
    ├── layout.tsx
    ├── page.tsx
    ├── @team/
    │   ├── page.tsx
    │   └── settings/
    │       └── page.tsx
    └── @analytics/
        └── page.tsx
    
  2. 사용자가 /settings로 소프트 내비게이션하면 @analytics는 이전 활성 상태를 유지해 자신의 page.tsx를 그대로 보여줄 수 있다.

  3. 그러나 /settings에서 새로고침하면 Next.js는 URL만으로 각 슬롯의 활성 subtree를 복원해야 하므로, @analytics처럼 매칭되는 subtree가 없는 슬롯은 렌더링 대상을 결정하지 못한다.

  4. 이때 슬롯에 default.tsx가 없으면 Next.js는 해당 슬롯에 대해 404를 렌더링한다.

  5. default.tsx는 하드 내비게이션 시 슬롯의 활성 상태를 URL만으로 복원할 수 없을 때 사용되는 폴백 파일이다.

    // app/@analytics/default.tsx
    export default function Default() {
      return null
    }
  6. 소프트 내비게이션에서는 이전 상태가 그대로 유지되므로 default.tsx가 거의 호출되지 않지만, 새로고침 같은 하드 내비게이션 복원 과정에서는 필수적인 역할을 한다.

  7. 따라서 병렬 라우트를 사용할 때는 각 슬롯에 default.tsx를 함께 두는 것이 기본 원칙이다.

레이아웃이 슬롯의 상태에 반응해야 할 때

  1. 슬롯이 어떻게 등장하고 어떤 페이지를 보여주는지는 정리되었지만, 한 가지 케이스가 남는다.

  2. 슬롯이 활성화되었을 때 그 주변 UI도 함께 반응해야 하는 경우다.

  3. 대표적인 예가 @auth 슬롯에 로그인 모달이 떠 있을 때 배경을 어둡게 처리하고 싶은 상황이다.

  4. 그러나 레이아웃은 슬롯을 단순히 props로 받기만 할 뿐, 그 슬롯 내부에서 어떤 경로가 활성화되어 있는지는 기본적으로 알 수 없다.

  5. 이 간극을 메우기 위해 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}
        </>
      )
    }
  6. 인자로 넘기는 'auth'는 슬롯 폴더 이름인 @auth에서 @를 뺀 값이다.

  7. 반환값은 해당 레이아웃 기준으로 한 단계 아래에 위치한 활성 세그먼트의 이름이며, 슬롯이 비활성 상태이면 null이다.

  8. 예를 들어 @auth/login/password처럼 중첩된 경로로 들어가도 반환값은 여전히 가장 위 세그먼트인 'login'이며, 더 깊은 경로 전체가 필요하다면 복수형인 useSelectedLayoutSegments를 사용해야 한다.

  9. 이 훅 덕분에 슬롯의 활성 여부가 그대로 주변 UI의 반응(배경 흐리기 등)으로 자연스럽게 연결될 수 있다.

두 번째 시나리오로 돌아가서, 인터셉팅 라우트

  1. 이제 처음에 미뤄 두었던 모달 시나리오로 돌아가자.

  2. 피드에서 사진을 클릭하면 모달, 같은 URL을 직접 열면 풀페이지라는 요구는 병렬 라우트만으로는 풀리지 않는다.

  3. 병렬 라우트는 같은 URL에 대해 슬롯별로 다른 컴포넌트를 보여줄 수는 있지만, 같은 URL을 어떻게 들어왔는지에 따라 다른 렌더링을 하는 기능은 가지고 있지 않기 때문이다.

  4. 인터셉팅 라우트가 이 부분을 보완한다.

  5. 인터셉팅 라우트는 특정 경로로의 소프트 내비게이션을 가로채, 같은 경로를 현재 레이아웃 트리 안의 다른 위치에서 대신 렌더링하는 기능이다.

  6. 즉 URL은 실제 목적지로 바뀌지만, 라우트 해석은 다른 subtree에서 일어나며 기존 레이아웃은 그대로 유지된다.

  7. 인터셉팅 라우트 자체는 단독으로도 사용 가능하지만, 기존 페이지 위에 모달을 overlay하는 UX를 만들려면 보통 병렬 라우트의 슬롯과 함께 사용한다.

  8. 슬롯이 모달이 들어갈 자리를 마련해 주고, 인터셉팅 라우트가 그 자리에 들어올 콘텐츠를 가로채 가져오는 역할 분담이다.

인터셉팅 기호 읽는 법

  1. 인터셉팅 라우트는 폴더명에 특수 접두사를 붙여 선언하며, 기호는 가로채는 위치와 대상 사이의 거리를 의미한다.

  2. (.)는 같은 레벨, (..)는 한 단계 위, (..)(..)는 두 단계 위, (...)app 루트를 기준으로 가로챈다.

  3. 이때 거리는 파일 시스템상의 폴더 깊이가 아니라 라우트 트리상의 세그먼트 깊이로 계산된다.

  4. 앞에서 봤듯이 슬롯 폴더(@folder)와 라우트 그룹((group))은 URL 세그먼트 계산에서 무시되므로, 이 거리 계산에서도 빠진다.

  5. 따라서 app/@modal/은 슬롯이 무시되어 사실상 app/과 같은 레벨로 취급되며, app/@modal/ 안에서 app/photos/를 가로채려면 같은 레벨인 (.)를 사용해 (.)photos로 표기해야 한다.

  6. 반대로 슬롯이 app/feed/@modal/처럼 한 단계 아래에 있다면 기준점이 /feed가 되므로, 한 단계 위의 photos를 가로채려면 (..)photos가 맞는 표기가 된다.

    app/
    ├── layout.tsx
    ├── photos/
    │   └── [id]/page.tsx
    └── feed/
        ├── layout.tsx
        ├── page.tsx
        └── @modal/
            └── (..)photos/
                └── [id]/page.tsx
    

모달 패턴 완성

  1. 이제 처음 시나리오를 그대로 풀어내는 폴더 구조는 다음과 같다.

    app/
    ├── layout.tsx
    ├── feed/
    │   └── page.tsx
    ├── photos/
    │   └── [id]/
    │       └── page.tsx
    └── @modal/
        ├── default.tsx
        └── (.)photos/
            └── [id]/
                └── page.tsx
    
  2. @modal은 슬롯이므로 라우트 트리에서 무시되어, (.)photosphotos가 같은 app/ 레벨에서 형제 관계가 된다.

  3. 루트 레이아웃은 @modal 슬롯을 받아 페이지 위에 함께 렌더링한다.

    // app/layout.tsx
    export default function Layout({
      children,
      modal,
    }: {
      children: React.ReactNode
      modal: React.ReactNode
    }) {
      return (
        <>
          {children}
          {modal}
        </>
      )
    }
  4. 피드에서 사진을 <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>
      )
    }
  5. 반대로 주소창에 /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>
      )
    }
  6. 덕분에 처음에 요구했던 "클릭은 모달, 직접 접근은 풀페이지"라는 두 조건이 동시에 성립한다.

  7. 모달이 열려 있지 않은 상태에서는 @modal 슬롯에 아무것도 표시되지 않아야 하므로, default.tsx에서 null을 반환해 두어야 한다.

    // app/@modal/default.tsx
    export default function ModalDefault() {
      return null
    }

마지막으로, router.back()의 함정

  1. 모달 안에서 router.back()을 호출하면 URL이 이전 상태로 되돌아가면서 모달이 자연스럽게 닫힌다.

  2. 그러나 router.back()은 브라우저 히스토리에 의존하기 때문에, 사용자가 피드를 거치지 않고 /photos/1을 주소창에 직접 입력해 들어온 경우에는 의도와 다르게 사이트 바깥으로 빠져나가거나 히스토리가 꼬일 수 있다.

  3. 이런 케이스를 위해 실무에서는 히스토리 상태를 확인하거나, 진입 경로를 알 수 없는 상황에서는 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>
    }
  4. 이렇게 두면 인터셉트로 진입했을 때는 자연스러운 뒤로가기로 동작하고, 직접 접근한 경우에도 안전한 경로로 빠져나가게 된다.