본문으로 건너뛰기

Parallel Routes, Intercepting Routes

코드는 Next.js 16.3 기준입니다.


사진 모달 하나에 두 기능이 필요합니다​

인스타그램 피드에서 사진을 누르면 모달이 뜹니다. 피드는 뒤에 그대로 남아 있고 주소창은 /p/123으로 바뀝니다. 그 주소를 친구에게 보내면 친구는 사진 한 장이 꽉 찬 페이지를 봅니다.

인스타그램 화면 두 개를 나란히 놓은 목업. 왼쪽은 피드 격자가 어두워지고 그 위에 사진과 댓글이 담긴 모달이 얹힌 화면이고, 오른쪽은 같은 주소를 링크로 열어 뒤에 피드 없이 사진만 꽉 찬 화면이다. 주소창은 양쪽 다 슬래시 p 슬래시 123이다

이 동작 하나에 두 기능이 함께 들어갑니다. 맡는 역할이 다릅니다.

Parallel Routes는 피드 위에 모달이 들어갈 빈 자리를 만들고, Intercepting Routes는 그 자리로 사진 페이지를 끌어온다. 둘을 합치면 피드 위에 사진 모달이 뜨고 URL은 슬래시 p 슬래시 123이 된다

Parallel RoutesIntercepting Routes
하는 일한 layout에 여러 page를 동시에 그립니다다른 경로의 page를 지금 화면에서 엽니다
표시폴더 이름 앞에 @폴더 이름 앞에 점 괄호
모달에서 맡는 것모달이 들어갈 자리그 자리에 넣을 내용
URL 영향없습니다없습니다

둘 다 URL을 건드리지 않습니다. 괄호 폴더와 같은 성격입니다. 그쪽은 Routing에 정리해 두었습니다.


Parallel Routes​

@폴더가 slot을 만듭니다​

인스타그램 데스크톱 홈에는 세 덩어리가 한 화면에 같이 있습니다. 가운데 피드, 위쪽 스토리 줄, 오른쪽 추천 계정입니다. 세 덩어리를 한 layout 안에서 동시에 그리는 것이 Parallel Routes입니다.

인스타그램 데스크톱 홈 목업. 위쪽 스토리 원형 줄에 @stories, 가운데 게시물 카드에 children, 오른쪽 추천 계정 목록에 @suggestions라고 표시되어 있다. 세 영역이 한 화면에 같이 있다

폴더 이름 앞에 @를 붙이면 slot이 됩니다.

app 폴더 아래 page.tsx와 @stories, @suggestions 폴더가 있는 트리를 왼쪽에 두고, 오른쪽에 스토리와 피드와 추천이 한 화면에 동시에 배치된 모습을 화살표로 이어 그린 그림. slot 이름은 경로에 나타나지 않는다

slot은 부모 layout의 props로 들어옵니다. 폴더 이름에서 @를 뗀 것이 prop 이름입니다.

// app/layout.tsx
export default function RootLayout({
children,
stories,
suggestions,
}: {
children: React.ReactNode;
stories: React.ReactNode;
suggestions: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
{stories}
{/* children이 app/page.tsx, 즉 피드다 */}
<main>{children}</main>
{suggestions}
</body>
</html>
);
}

children도 slot입니다. 폴더로 만들지 않아도 이미 있는 것이라 이름을 따로 지을 일이 없습니다.

The children prop is an implicit slot that does not need to be mapped to a folder. This means app/page.js is equivalent to app/@children/page.js.

slot 이름은 URL에 나타나지 않습니다​

app/@stories/highlights/page.tsx를 만들면 열리는 주소는 /highlights입니다. @stories가 경로에서 빠집니다.

However, slots are not route segments and do not affect the URL structure.

주의

같은 레벨의 slot들은 합쳐져 그 경로의 페이지 하나가 됩니다. 그래서 프리렌더링되는 slot과 요청 시점에 렌더링되는 slot을 같은 레벨에 섞어 둘 수 없습니다.

Because of this, you cannot have separate prerendered and dynamically rendered slots at the same route segment level. If one slot is dynamic, all slots at that level must be dynamic.

추천 계정 slot에서 쿠키를 읽으면 스토리와 피드까지 동적 렌더링으로 넘어갑니다. 두 방식의 차이는 렌더링과 Hydration에 있습니다.

같은 URL이어도 이동 방식에 따라 slot이 달라집니다​

여기가 가장 헷갈립니다. Next.js는 slot마다 지금 어느 하위 페이지가 열려 있는지 기억해 둡니다. 그 기억이 살아 있는지가 갈림길입니다.

Soft Navigation과 Hard Navigation을 두 레인으로 비교한 그림. Link 클릭으로 이동하면 URL에 없는 @stories slot이 보던 그대로 남고, 새로고침하면 활성 상태를 알 수 없어 @stories의 default.tsx가 렌더링된다

Soft NavigationHard Navigation
언제<Link>로 이동할 때새로고침, 주소 직접 입력, 첫 방문
slot 활성 상태기억해 둔 것을 씁니다알 수 없습니다
URL과 안 맞는 slot보던 화면이 그대로 남습니다default.tsx를 그립니다

<Link>로 이동할 때는 부분 렌더링만 일어납니다. URL이 가리키는 slot만 바뀌고 나머지는 건드리지 않습니다. 하이라이트를 보다가 홈으로 돌아와도 스토리 영역은 하이라이트를 계속 보여줍니다. URL과 맞지 않아도 그렇습니다.

새로고침은 사정이 다릅니다.

After a full-page load (browser refresh), Next.js cannot determine the active state for the slots that don't match the current URL.

브라우저가 보낸 것은 주소 하나입니다. 이것만 보고 스토리 영역에 무엇이 열려 있었는지 되살릴 방법이 없습니다.

주소창이 양쪽 다 슬래시 하나인 인스타그램 홈 화면 두 개를 나란히 놓은 목업. Link로 돌아온 왼쪽은 스토리 영역에 보던 하이라이트가 남아 있고, 새로고침한 오른쪽은 default.tsx가 그린 기본 스토리 원형 줄로 바뀌었다. 피드는 양쪽이 같다

URL과 맞지 않는 slot은 default.tsx를 그립니다​

URL로 맞출 수 없는 slot에 무엇을 그릴지 미리 정해 두는 파일입니다.

// app/@stories/default.tsx
export default function Default() {
return <StoryBar />;
}

children에도 필요합니다. /highlights로 직접 들어온 경우를 보면 이유가 보입니다. 이때 @stories는 하이라이트로 맞지만 children은 맞을 것이 없습니다. app/highlights/page.tsx가 없기 때문입니다.

// app/default.tsx
export default function Default() {
return <Feed />;
}

이 파일을 빼먹으면 그 라우트가 404로 떨어집니다.

If you don't create a default.js for the children slot, it will return a 404 page for the route.

보충 - 이름 있는 slot은 404가 아니라 에러입니다

공식문서 두 페이지가 다르게 말합니다. Parallel Routes 페이지는 이렇게 씁니다.

On refresh, Next.js will render a default.js for @analytics. If default.js doesn't exist, a 404 is rendered instead.

나중에 갱신된 default.js 페이지는 이름 있는 slot에서 에러가 난다고 합니다.

If default.js doesn't exist, an error is returned for named slots (@team, @analytics, etc) and requires you to define a default.js in order to continue.

옛 동작을 원하면 notFound()를 부르는 default.tsx를 직접 두라고 안내합니다.

// app/@stories/default.tsx
import { notFound } from 'next/navigation';

export default function Default() {
notFound();
}

이름 있는 slot은 에러, children은 404입니다. 어느 쪽이든 default.tsx를 두면 끝납니다.


Intercepting Routes​

점 개수가 몇 단계 위인지 정합니다​

폴더 이름 앞에 점 괄호를 붙이면 그 이름의 경로를 가로챕니다. 점 개수로 어디서 찾을지 지정합니다.

점 괄호 매처 네 가지를 나열한 그림. 점 하나는 같은 레벨, 점 둘은 한 단계 위, 점 둘을 두 번 쓰면 두 단계 위, 점 셋은 app 루트를 가리킨다

매처찾는 곳
(.)같은 레벨의 세그먼트
(..)한 단계 위 세그먼트
(..)(..)두 단계 위 세그먼트
(...)app 루트의 세그먼트

두 단계 위는 (..)(..)입니다. 점을 세 개로 늘리면 중간을 건너뛰고 app 루트를 가리킵니다.

@slot 폴더는 세그먼트에서 빠집니다​

이 대목에서 계산이 어긋납니다. 점 개수를 폴더 개수로 세면 틀립니다.

주의

The (..) convention is based on route segments, not the file-system. For example, it does not consider @slot folders in Parallel Routes.

@modal 같은 slot 폴더는 세그먼트에 들어가지 않으니 계단 수에서 빠집니다.

피드를 /feed에 두고 사진 모달을 만드는 경우를 봅니다. 가로채는 파일은 app/feed/@modal/(..)p/[id]/page.tsx이고 원래 페이지는 app/p/[id]/page.tsx입니다.

파일 트리로 세면 @modal에서 app까지 두 칸이고, 세그먼트로 세면 @modal이 빠져 feed 한 칸인 것을 나란히 비교한 그림. 매처는 세그먼트 쪽을 따른다

파일 트리를 올라가면 @modal에서 feed로, 다시 app으로 두 칸입니다. 세그먼트로 세면 @modal이 빠져서 feed 한 칸입니다. 매처가 (..) 하나로 끝나는 이유입니다.

This means that the photo route is only one segment level higher, despite being two file-system levels higher.


두 기능을 합쳐 사진 모달 만들기​

앞 절은 피드가 /feed에 있는 경우였습니다. 인스타그램 피드는 /에 있습니다. 그래서 p가 같은 레벨이 되고 매처는 (.)입니다.

만들려는 동작이 네 가지입니다. 공식문서가 모달의 흔한 숙제로 꼽는 것들입니다.

  • 모달 내용을 URL로 공유합니다.
  • 새로고침해도 모달이 닫히지 않고 맥락이 유지됩니다.
  • 뒤로 가기를 누르면 이전 라우트로 가지 않고 모달이 닫힙니다.
  • 앞으로 가기를 누르면 모달이 다시 열립니다.

피드에서 사진을 클릭하면 모달이 피드 위에 얹히고 URL만 바뀌는 흐름과, 같은 주소를 링크로 직접 열면 사진 전체 페이지가 나오는 흐름을 두 레인으로 비교한 그림

폴더 구조​

app/
├─ layout.tsx
├─ page.tsx # 피드
├─ @modal/
│ ├─ default.tsx # null을 반환해 닫힌 상태를 만든다
│ └─ (.)p/
│ └─ [id]/
│ └─ page.tsx # 모달 안의 사진
└─ p/
└─ [id]/
└─ page.tsx # 링크로 직접 들어왔을 때의 전체 페이지

p 세그먼트가 app 바로 아래에 있고 @modal은 세그먼트에서 빠집니다. @modal/(.)p가 app 레벨에서 p를 찾으니 점 하나로 맞습니다.

p/[id]/page.tsx - 공유받은 링크가 여는 페이지​

링크를 공유받은 사람이 볼 화면입니다. 가로채기와 무관하게 먼저 있어야 합니다.

// app/p/[id]/page.tsx
import { Photo } from '@/app/ui/photo';

export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;

return <Photo id={id} />;
}

@modal/default.tsx - 모달이 닫힌 상태​

모달이 열려 있지 않은 상태입니다. 피드만 보고 있을 때 @modal 자리에 아무것도 없어야 합니다.

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

@modal/(.)p/[id]/page.tsx - 가로챈 모달​

같은 사진을 모달 껍데기에 담습니다.

// app/@modal/(.)p/[id]/page.tsx
import { Modal } from '@/app/ui/modal';
import { Photo } from '@/app/ui/photo';

export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;

return (
<Modal>
<Photo id={id} />
</Modal>
);
}

<Modal>과 내용을 갈라 둔 이유가 있습니다. 껍데기만 클라이언트 컴포넌트로 두면 안쪽 <Photo>는 서버 컴포넌트로 남습니다.

layout.tsx - modal slot 그리기​

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

피드의 사진을 <Link>로 감싸면 클릭했을 때 모달이 뜹니다. 같은 주소를 새 탭에서 열면 전체 페이지가 나옵니다.

모달 닫기​

router.back()을 부르면 히스토리를 한 칸 되돌려 모달이 닫힙니다.

// app/ui/modal.tsx
'use client';

import { useRouter } from 'next/navigation';

export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();

return (
<div className="overlay" onClick={() => router.back()}>
<div className="sheet">{children}</div>
</div>
);
}
Link로 닫을 때는 catchAll이 필요합니다

router.back() 대신 <Link>로 닫으면 모달이 그대로 남습니다. 클라이언트 사이드 이동에서는 slot에 맞는 라우트가 없어도 보던 화면이 계속 보이기 때문입니다.

Since client-side navigations to a route that no longer match the slot will remain visible, we need to match the slot to a route that returns null to close the modal.

그래서 @modal에 null을 반환하는 라우트를 맞춰 둡니다. /로만 닫으면 app/@modal/page.tsx로 됩니다. 어디로 이동해도 닫히게 하려면 catch-all을 씁니다.

// app/@modal/[...catchAll]/page.tsx
export default function CatchAll() {
return null;
}

slot마다 로딩과 에러를 따로 둡니다​

slot은 각자 스트리밍됩니다.

Parallel Routes can be streamed independently, allowing you to define independent error and loading states for each route.

추천 계정 조회가 느려도 피드를 붙잡아 두지 않습니다. slot 폴더 안에 loading.tsx와 error.tsx를 두면 그 slot의 경계가 됩니다.

스토리 slot은 스켈레톤, 피드는 도착, 추천 slot은 에러 UI를 각각 보여주는 세 칸 그림. 한 slot이 늦거나 실패해도 나머지는 그대로 보인다

app/
├─ @stories/
│ ├─ loading.tsx # 스토리만 스켈레톤
│ └─ page.tsx
└─ @suggestions/
├─ error.tsx # 추천만 에러 UI
└─ page.tsx

loading.tsx와 <Suspense>의 범위 차이는 서버 컴포넌트 데이터 조회와 Server Function에 정리해 두었습니다.


조건부로 slot을 골라도 양쪽 다 서버에서 실행됩니다​

layout에서 slot을 골라 그리는 것도 됩니다. 프로필 화면에서 내 프로필이면 편집 버튼을, 남의 프로필이면 팔로우 버튼을 보여주는 경우입니다.

// app/[username]/layout.tsx
import { getViewer } from '@/lib/auth';

export default async function ProfileLayout({
params,
owner,
visitor,
}: {
params: Promise<{ username: string }>;
owner: React.ReactNode;
visitor: React.ReactNode;
}) {
const { username } = await params;
const viewer = await getViewer();

return viewer?.username === username ? owner : visitor;
}
주의

layout이 고르지 않은 slot도 서버에서 실행됩니다.

Both slots render on the server, regardless of which one the layout returns. The conditional decides what the user sees, not what runs.

@owner/page.tsx는 남의 프로필을 보는 사람에게도 실행됩니다. 그 결과가 브라우저로 가는 응답에 실립니다. layout의 삼항 연산자는 화면에 무엇을 보여줄지만 정합니다.

권한 확인을 layout에 두면 안 됩니다. 각 slot의 page 안에서 확인하거나 데이터를 읽는 계층에서 확인해야 합니다. 화면에서 버튼을 숨기는 것으로 Server Function의 권한을 대신하지 못하는 것과 같은 이야기입니다.


정리​

Parallel Routes

  • @폴더가 slot이 되고 부모 layout의 props로 들어옵니다. children도 slot입니다.
  • slot 이름은 URL에 나타나지 않습니다. 같은 레벨의 slot은 프리렌더링과 동적 렌더링을 섞지 못합니다.
  • <Link> 이동에서는 URL과 안 맞는 slot도 보던 화면이 남습니다. 새로고침에서는 default.tsx가 그려집니다.
  • default.tsx를 빼먹으면 이름 있는 slot은 에러, children은 404입니다.
  • slot마다 loading.tsx와 error.tsx를 따로 둡니다.

Intercepting Routes

  • (.)는 같은 레벨, (..)는 한 단계 위, (..)(..)는 두 단계 위, (...)는 app 루트입니다.
  • 세는 대상은 세그먼트입니다. @modal 같은 slot 폴더는 계단 수에서 빠집니다.

둘을 합칠 때

  • @modal/default.tsx는 null을 반환해 닫힌 상태를 맡습니다. 모달 화면은 @modal/(.)p/[id]/page.tsx가 만듭니다.
  • 원래 경로의 page.tsx는 그대로 둡니다. 링크를 공유받은 사람이 보는 화면입니다.
  • router.back()으로 닫습니다. <Link>로 닫으려면 [...catchAll]로 null을 맞춰 둡니다.
  • 조건부로 slot을 골라도 안 고른 쪽이 서버에서 실행됩니다. 권한은 slot 안에서 확인합니다.

참고 자료​