PC 인스타그램에는 하나의 신기한 기능이 존재합니다.
인스타 프로필에서 게시물을 클릭하면 레이아웃이 팝업처럼 뜨지만,
새로고침하면 하나의 사이트로 레이아웃이 바뀝니다.
이 글에서는...
- Next.js의 Parallel Routes에 대해 소개합니다.
- Next.js의 Intercepting Routes에 대해 소개합니다.
- 두 라우터를 사용하여 만든 인스타그램 게시물 라우팅을 구현합니다.
*아 TMI로 예제 사진 만든다고 그냥 생각나는 인스타 프로필 들어가서 찍었습니다
Parallel routes는 nextjs app router에서 최근에 출시된 기능입니다.
사진과 같이 app 라우터의 하위에 @ 키워드를 붙인 slot을 생성하면, 동일 레벨에 위치하는 하나의 레이아웃에서 동시에 표현할 수 있습니다.
사진에서 보시는 것처럼, 동일한 페이지 내에서 사용되는 컴포넌트를 slot을 생성해서 사용하면 이를 병렬로 렌더링할 수 있습니다.
// layout.tsx
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<>
{children}
{team}
{analytics}
</>
)
}
Parallel Routes를 사용하면 사용자 역할과 같은 조건에 따라 라우트를 조건부로 렌더링할 수 있습니다.
// layout.tsx
import { checkUserRole } from '@/lib/auth'
export default function Layout({
user,
admin,
}: {
user: React.ReactNode
admin: React.ReactNode
}) {
const role = checkUserRole()
return <>{role === 'admin' ? admin : user}</>
}
우리가 만드려는 궁극적인 목표인데, Parallel Routes를 Intercepting Routes와 함께 사용하여 딥 링크를 지원하는 모달을 생성할 수 있습니다.
이건 추후에 Intercepting Routes를 소개드린 후 자세히 알아보겠습니다.
Parallel Routes에는 Active State가 존재합니다.
우리가 navigation을 어떻게 하느냐에 따라 두 가지의 active state로 나뉩니다.
클라이언트 측을 탐색하며 부분 렌더링을 수행합니다. 새로고침이나 외부에서 접근하는 방식이 아니라, Next.js가 라우팅을 탐색할 수 있는 선(페이지 -> 페이지)에서 slot을 읽습니다.
soft navigation의 경우, 위에서 언급했던 slot을 먼저 읽어 렌더링합니다.
전체 페이지를 로드하는 시점에서는 hard navigation이 수행됩니다. 이 상태에서는 Next.js는 slot을 읽을 수 없습니다.
hard navigation의 경우에는 default.tsx 파일을 렌더링하거나, 이 파일이 없는 경우 404를 렌더링합니다. Intercepting Routes와 함께 사용할 때에는 intercept가 취소되어 intercept하는 대상인 기존의 파일을 렌더링합니다.
Intercepting routes는 현재 레이아웃 내에서 애플리케이션의 다른 부분의 라우트를 로드할 수 있는 기능입니다. 어떤 상태나 라우터의 변경 없이 콘텐츠를 표시하려는 경우에 유용합니다.
인스타그램 피드에서 사진을 클릭할 때, 사진을 피드 위에 모달로 오버레이하여 표시할 수 있도록 합니다. 이 경우에 Next.js는 /photo/123
라우트를 가로채고 URL을 마스킹하여 /feed
위에 오버레이합니다.
Intercepting Routes는 다음과 같이 설정할 수 있습니다. 인터셉트하려는 라우터의 depth에 따라 세그먼트명을 표기합니다.
(.)
: 동일 레벨의 세그먼트를 매칭합니다.
(..)
: 한 레벨 위의 세그먼트를 매칭합니다.
(..)(..)
: 두 레벨 위의 세그먼트를 매칭합니다.
(...)
: 루트 앱 디렉터리의 세그먼트를 매칭합니다.
*일반적인 파일 시스템과 비슷해보일 수 있어도 완전히 다르게 작동하는 컨벤션이기 때문에, 혼동할 수 있는 점에 주의하며 사용해야 합니다.
Intercepting Routes를 사용하려면 무조건 Parallel Routes와 같이 사용해야 합니다. 그렇지 않으면 원활하게 작동하지 않습니다. (사실 Intercepting Routes만 작동해야 하는 요구 사항을 찾아보기 또한 어렵습니다)
사실 설명 만으로는 이를 완벽히 이해하기 어려운 면이 있습니다.
이제 인스타그램의 피드와 똑같은 방식으로 이를 구현해보겠습니다.
*제가 창작한 예제가 아니라 Nextjs의 공식 예제를 세부적으로 나누어 담았습니다.
깃허브 링크 보기
├── app
│ ├── @modal # Parallel Routes
│ │ ├── (.)photos # Intercepting Routes
│ │ │ ├──[id]
│ │ │ │ ├── modal.tsx
│ │ │ │ └── page.tsx
│ │ └── default.tsx
│ └── photos
│ │ └── [id]
│ │ │ └── page.tsx
...
먼저 인스타그램 게시물을 리스트로 보여주는 프로필 페이지를 간단히 구조만 구현해보겠습니다.
// app/page.tsx
import Link from 'next/link';
export default function Page() {
let photos = Array.from({ length: 6 }, (_, i) => i + 1);
return (
<section>
{photos.map((id) => (
<Link key={id} href={`/photos/${id}`} passHref>
{id}
</Link>
))}
</section>
);
}
그 다음은 모달로 뜨는 밑의 페이지를 구현해보겠습니다.
제일 먼저, 동일한 루트의 레이아웃에서 modal을 children과 같은 하나의 props로 받아 연결해주겠습니다.
// app/layout.tsx
import './global.css';
export const metadata = {
title: 'NextGram',
description:
'A sample Next.js app showing dynamic routing with modals as a route.',
};
export default function RootLayout(props: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{props.children}
{props.modal}
<div id="modal-root" />
</body>
</html>
);
}
그리고 hard navigation을 진행했을 때 보여줄 페이지를 간단하게 만들어주겠습니다.
// app/photos/[id]/page.tsx
export const dynamicParams = false;
export function generateStaticParams() {
let slugs = ['1', '2', '3', '4', '5', '6'];
return slugs.map((slug) => ({ id: slug }));
}
export default function PhotoPage({
params: { id },
}: {
params: { id: string };
}) {
return <div className="card">{id}</div>;
}
이제 Parallel Routes와 Intercepting Routes를 함께 사용해보겠습니다.
Parallel Routes를 사용하여 피드를 클릭했을 때 게시물이 모달처럼 뜨게 하는 기능을 구현할 것입니다. 그 후, Intercepting Routes를 사용하여 soft navigation이 진행되었을 때에만 구현해둔 모달을 뜨게 하고, hard navigation인 경우에는 위에서 만든 세부 페이지를 렌더링하게 구현해보겠습니다.
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from './modal';
export default function PhotoModal({
params: { id: photoId },
}: {
params: { id: string };
}) {
return <Modal>{photoId}</Modal>;
}
// app/@modal/(.)photos/[id]/modal.tsx
'use client';
import { type ElementRef, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { createPortal } from 'react-dom';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<ElementRef<'dialog'>>(null);
useEffect(() => {
if (!dialogRef.current?.open) {
dialogRef.current?.showModal();
}
}, []);
function onDismiss() {
router.back();
}
return createPortal(
<div className="modal-backdrop">
<dialog ref={dialogRef} className="modal" onClose={onDismiss}>
{children}
<button onClick={onDismiss} className="close-button" />
</dialog>
</div>,
document.getElementById('modal-root')!
);
}
react-dom에서 제공하는 createPortal을 사용하여 구현합니다.
이렇게 Parallel Routes 하위에 Intercepting Routes를 구현해두면 다음과 같은 단계를 거칩니다.
1. 사용자가 photos/A 라우터로 접근함
2. Soft Navigation의 경우, Next.js가 라우터를 Intercept해
구현해둔 Intercepting Routes로 이동함
3. Intercepting Routes의 상위가 Parallel Routes로 구성되어
있기 때문에 해당 로직이 트리거되어 모달이 보여짐
이렇게 되면 Instagram과 동일한 UX를 사용자에게 제공할 수 있습니다.
Parallel Routes와 Intercepting Routes는 비교적으로 다소 생소한 부분이 있었는데, 이번에 자세히 알아보게 됨으로써 다음에 사용해야 할 요구사항이 있으면 꼭 한번 사용해보아야겠다고 생각했습니다. 글에서 설명드렸던 요구 사항과 비슷한 상황에 놓여 있으시다면, 이 방법을 사용해보시는 것을 추천드립니다.