목록으로

Parallel Routes + Intercepting Routes

#Nextjs

사이드프로젝트


main
date
Jun 4, 2026
slug
parallel-routes-intercepting-routes
author
status
Public
tags
Nextjs
summary
사이드프로젝트
type
Post
thumbnail
category
💻 Frontend
updatedAt
Oct 2, 2026 10:18 PM

지도는 그대로, URL만 바뀌면서 드로어가 열리게 하고 싶었다

핀픽은 지도 위에서 장소를 찍어보는 사이드 프로젝트다. 지도에서 마커를 클릭하면 장소 상세 정보가 드로어(옆에서 슬라이드로 열리는 패널)로 뜨는 UX를 만들고 싶었는데, 조건이 세 가지 있었다.
  • 드로어가 열려도 뒤에 있는 지도는 리렌더되면 안 된다. 줌 레벨이나 지도 중심 위치가 초기화되면 사용자가 지도를 다시 조작해야 하니까.
  • 그런데 드로어가 열려 있는 상태를 URL로도 표현하고 싶었다. /places/2처럼 특정 장소 URL을 그대로 공유하거나 새로고침해도 같은 화면이 나와야 하니까.
  • 마커를 연속으로 클릭할 때 브라우저 뒤로가기가 마커 클릭 횟수만큼 쌓이면 안 된다. 뒤로가기 한 번이면 지도로 돌아와야 한다.
이 세 조건을 한 번에 만족시키려면 "지도는 유지한 채 URL만 바뀌면서 드로어가 열리는" 구조가 필요했다. Next.js App Router가 제공하는 Parallel Routes와 Intercepting Routes를 조합하면 이게 가능하다는 걸 확인하고, 실제로 붙여봤다.

최종 디렉토리 구조

app/ ├── layout.tsx ← 루트 레이아웃 (children + drawer 슬롯) ├── page.tsx ← 홈 (지도) / │ ├── @drawer/ ← Parallel Route 슬롯 │ ├── default.tsx ← 슬롯 기본값 (null) │ └── (.)places/ │ └── [id]/ │ └── page.tsx ← 인터셉트 시 드로어만 렌더 │ └── places/ └── [id]/ └── page.tsx ← 직접 접근 시 지도 + 드로어 렌더
핵심은 @drawer라는 슬롯 하나와, 그 슬롯 안에 있는 인터셉트 라우트 하나다. 이 둘을 어떻게 조합했는지 순서대로 정리해본다.

1. Parallel Routes로 "URL에 안 잡히는 영역"을 만든다

Next.js에서 @로 시작하는 폴더는 Parallel Route 슬롯이다. URL 경로에는 전혀 영향을 주지 않고, 부모 레이아웃에서 별도의 prop으로 주입된다. 처음 이 개념을 봤을 때 딱 필요한 조각이라고 생각했다. 드로어를 "URL과 무관하게 지도 위에 얹히는 레이어"로 다루고 싶었기 때문이다.
// app/layout.tsx export default function RootLayout({ children, // 일반 페이지 영역 drawer, // @drawer 슬롯 — URL에 드러나지 않음 }) { return ( <html> <body> <AppProvider>{children}</AppProvider> {drawer} {/* 지도 위에 overlay */} </body> </html> ); }
슬롯에 렌더할 게 없을 때 뭘 보여줄지도 정해야 했다. @drawer/default.tsx가 그 역할을 한다.
// app/@drawer/default.tsx export default function DrawerDefault() { return null; }
홈(/)에 있을 때는 drawer가 null이 되니까 드로어가 자연스럽게 사라진다. 여기까지는 "지도 위에 조건부로 뭔가를 얹는" 정도의 구조였고, 진짜 문제는 다음 단계였다. 마커를 클릭했을 때 이 슬롯에 뭘 채워 넣을지, 그리고 그걸 URL과 어떻게 연결할지였다.

2. Intercepting Routes로 "같은 URL인데 다르게 렌더"를 만든다

(.) 접두사가 붙은 폴더는 Intercepting Route다. 같은 레벨의 경로로의 이동을 가로채서, 원래 페이지 대신 다른 컴포넌트를 렌더한다. app/@drawer/(.)places/[id]는 app/places/[id]로의 앱 내 이동(soft navigation)을 가로채는 역할을 한다.
접두사별로 가로채는 위치가 다르다는 것도 이번에 정리하면서 확인했다.
접두사
가로채는 위치
(.)
같은 레벨
(..)
한 단계 위
(..)(..)
두 단계 위
(...)
루트(app/)부터
여기서 헷갈렸던 부분이 하나 있다. @drawer는 슬롯이라 URL 경로에 포함되지 않으니까, 파일 시스템상 @drawer 밑에 있어도 라우팅 기준으로는 app/places와 같은 레벨로 취급된다. 그래서 (.)places로 충분했다. 처음엔 @drawer 한 단계를 감안해서 (..)places가 맞는 게 아닌가 헷갈렸는데, 슬롯은 URL 세그먼트가 아니라는 걸 다시 확인하고 나서야 (.)가 맞다는 걸 이해했다.

3. 진입 경로가 두 가지라서, 시나리오를 나눠서 검증했다

같은 URL /places/2로 도달하는 방법이 두 가지 있었다. 지도에서 마커를 클릭하는 경우(앱 안에서 이동)와, 그 URL을 직접 주소창에 치거나 공유받은 링크로 들어오는 경우(새로고침 수준의 진입)다. 이 둘은 Next.js 내부적으로 완전히 다르게 처리되기 때문에, 각각 어떤 경로를 타는지 따로 확인이 필요했다.
시나리오 A: 마커 클릭 (앱 내 이동)
사용자가 지도에서 마커 클릭 ↓ router.push('/places/2') [map-marker.tsx] ↓ Next.js soft navigation 발생 ↓ @drawer/(.)places/[id] 가 인터셉트 ↓ layout.tsx 에서: children = 홈 페이지 (지도) — 이전 상태 그대로 보존 drawer = @drawer/(.)places/[id]/page.tsx → <PlaceDetailDrawer placeId="2" /> ↓ 결과: 지도가 살아있는 채로 드로어가 슬라이드 인 URL: /places/2
이 경로가 원래 원했던 동작이다. soft navigation일 때는 children 슬롯이 이전 페이지(홈)를 그대로 유지한다. 지도 컴포넌트가 언마운트되지 않으니까 줌 레벨이나 위치 같은 상태가 그대로 보존된다.
시나리오 B: /places/2 직접 입력 또는 공유 링크
브라우저에서 /places/2 직접 접근 (hard navigation) ↓ 인터셉트 없음 (soft navigation이 아니므로) ↓ app/places/[id]/page.tsx 가 렌더됨 ↓ layout.tsx 에서: children = <HomePage /> + <PlaceDetailDrawer placeId="2" /> ← 함께 렌더 drawer = @drawer/default.tsx → null ↓ 결과: 지도가 렌더되고, 그 위에 드로어가 겹쳐서 표시됨 URL: /places/2
hard navigation일 때는 인터셉트가 아예 동작하지 않는다. 대신 app/places/[id]/page.tsx 자체가 <HomePage />와 <PlaceDetailDrawer />를 함께 렌더하도록 만들어뒀기 때문에, 지도 상태만 처음부터 다시 시작될 뿐 시각적으로는 시나리오 A와 동일한 결과가 나온다. "지도가 항상 보존된다"가 아니라 "URL 하나가 항상 같은 화면으로 귀결된다"는 게 이 구조의 실제 보장 범위라는 걸 이 단계에서 명확히 했다.
두 시나리오를 표로 정리하면 이렇다.
구분
시나리오 A (마커 클릭)
시나리오 B (직접 접근)
navigation 종류
soft (client-side)
hard (full page load)
인터셉트 여부
발생
미발생
지도 렌더 위치
children 슬롯 (보존)
places/[id]/page.tsx 내부
드로어 렌더 위치
drawer 슬롯
places/[id]/page.tsx 내부
시각적 결과
지도 + 드로어
지도 + 드로어
URL
/places/2
/places/2

4. 마커를 연속으로 누르면 히스토리가 쌓이는 문제

여기까지 구현하고 나니 하나가 걸렸다. router.push()만 쓰면 마커를 연속으로 클릭할 때마다 브라우저 히스토리 스택에 항목이 계속 쌓인다.
홈 → (push) → /places/1 → (push) → /places/2 router.back() 1회 → /places/1 ← 원하지 않는 동작 router.back() 2회 → 홈
사용자 입장에서는 마커 A를 봤다가 마커 B를 클릭한 거지, "장소 상세 페이지에 새로 진입"한 게 아니다. 그런데 매번 push를 쓰면 뒤로가기를 눌렀을 때 이전에 봤던 마커로 한 번씩 되돌아가는, 원치 않는 단계가 생긴다.
해결 방법은 드로어가 이미 열려 있는지를 기준으로 push와 replace를 분기하는 것이었다. 현재 URL이 이미 /places/로 시작한다면 — 즉 드로어가 열린 상태에서 다른 마커를 클릭한 거라면 — router.replace()로 현재 스택 항목을 교체한다. 반대로 홈에서 처음 드로어를 여는 경우에는 push를 써서 "홈으로 돌아갈 수 있는" 히스토리 항목을 하나 남긴다.
홈 → (push) → /places/1 → (replace) → /places/2 router.back() 1회 → 홈 ✅
이렇게 하니 뒤로가기 한 번이면 항상 지도로 돌아오게 됐다. 히스토리 스택의 의미를 "URL 이동 횟수"가 아니라 "드로어가 열려 있느냐"라는 상태 기준으로 다시 잡은 셈이다.

정리

Parallel Routes로 드로어를 URL과 분리된 오버레이 슬롯으로 만들고, Intercepting Routes로 그 슬롯이 특정 URL 이동을 가로채게 만들고, 마지막으로 push/replace 분기로 히스토리 스택을 상태에 맞게 정리했다. 세 조각이 각각 하나씩의 문제(지도 상태 보존, URL 공유 가능성, 불필요한 히스토리 누적)를 담당하는 구조다.
이번에 제일 크게 배운 건, App Router의 슬롯과 인터셉트 라우트는 "URL 이동"과 "화면 갱신"을 분리해서 다룰 수 있게 해준다는 점이다. 같은 목적지 URL이라도 어떤 경로로 도달했는지에 따라 다르게 렌더할 수 있다는 걸 알고 나니, "지도는 유지하되 URL은 정확해야 한다"는, 얼핏 상충하는 것처럼 보이던 요구사항 두 개를 굳이 한쪽을 포기하지 않고 둘 다 만족시킬 수 있었다.

© 2024. Dahoon06. All rights reserved.