
Next.js의 Intercepting Routes와 Parallel Routes를 활용하여, 인스타그램처럼 게시글을 클릭하면 모달이 열리고, 새로고침하면 해당 게시글 페이지로 이동하는 기능을 구현합니다.
📌 인스타그램 게시글 클릭 시 모달처럼 표시됨
📌 새로고침하면 해당 게시글을 전체 페이지로 보여줌
📌 모달 바깥을 클릭하면 다시 피드 페이지로 돌아감
✅ 이 기능을 Next.js의 Intercepting Routes와 Parallel Routes를 이용해 구현할 수 있습니다!
공식문서
Intercepting Routes를 활용하면 특정 페이지에서만 특정한 UI(모달)를 인터셉트하여 보여줄 수 있음.
그러나! 이 기능은 "첫 접근"이 아닐 때만 작동함 → 즉, 새로고침하면 원래의 상세 페이지로 이동함.
app/
├── book/[id]/page.tsx (📄 원래의 게시글 상세 페이지)
├── (.)book/[id]/page.tsx (📄 인터셉트하여 모달로 보여줄 페이지)
✅ 폴더 네이밍 규칙
(.)book/[id] → 동일한 경로를 인터셉트하여 모달로 변환(..)book/[id] → 1단계 상위 경로를 인터셉트(...)book/[id] → app/ 폴더 바로 아래에서 인터셉트📌 (.)book/[id]/page.tsx (모달 형태로 게시글 표시)
import BookInfo from '@/app/book/[id]/page';
import Modal from '@/components/modal';
export default function Page(props: any) {
return (
<Modal>
<BookInfo {...props} />
</Modal>
);
}
📌 모달 컴포넌트 (modal.tsx)
'use client';
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
export default function Modal({ children }: { children: ReactNode }) {
return createPortal(
<dialog className="w-3/4 backdrop:bg-black/50">
{children}
</dialog>,
document.getElementById('modal-root') as HTMLElement
);
}
✅ createPortal을 사용하여 모달을 항상 #modal-root에 렌더링하여 구조를 유지함.
단순히
return <dialog>...</dialog>로 작성하지 않는 이유
이럴 경우, 어떠한 page 컴포넌트 하위에 작성되게 됨 (div나 section 아래에 작성된다)
원래 dialog는 화면 최상단 레이어에 배치되는 컴포넌트이기에 조금 어색한 구조가 됨.
따라서modal-root아이디를 갖는 요소 아래에 고정적으로 배치해버리는 코드로 작성함.
📌 layout.tsx에 #modal-root 추가
<body>
<div id="modal-root"></div>
</body>
✅ 이렇게 하면 #modal-root에 모달이 렌더링되도록 설정됨!
📌 모달이 자동으로 열리고, ESC 키나 바깥 클릭으로 닫히도록 개선
'use client';
import { ReactNode, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useRouter } from 'next/navigation';
export default function Modal({ children }: { children: ReactNode }) {
const dialogRef = useRef<HTMLDialogElement>(null);
const router = useRouter();
useEffect(() => {
if (!dialogRef.current?.open) {
dialogRef.current?.showModal();
dialogRef.current?.scrollTo({ top: 0 });
}
}, []);
return createPortal(
<dialog
ref={dialogRef}
onClose={() => router.back()}
onClick={(e) => {
if ((e.target as any).nodeName === 'DIALOG') router.back();
}}
className="w-3/4 backdrop:bg-black/50"
>
{children}
</dialog>,
document.getElementById('modal-root') as HTMLElement
);
}
✅ 추가 기능
useEffect, useRef를 사용하여 모달이 자동으로 열리도록 설정router.back()을 사용하여 모달을 닫으면 이전 페이지로 이동router.back() 실행 → 배경 클릭하면 모달이 닫힘✅ 왜 (e.target as any).nodeName === 'DIALOG'가 모달 바깥 클릭을 감지할까?
dialog 내부를 클릭하면 e.target은 내부 요소(p, button 등)가 됨.e.target이 DIALOG가 됨.이제 모달이 팝업되었을 때, 배경이 빈 화면 (.)book/[id].tsx이 아니라 피드(index 페이지)가 나오도록 설정해보겠습니다.
이를 위해 Parallel Routes를 사용합니다.
공식문서
Parallel Routes는 여러 개의 독립적인 UI를 병렬로 렌더링할 수 있는 기능입니다.
이를 활용하면 모달이 뜰 때 배경에 피드를 유지할 수 있습니다.
📌 활용 예시
📌 이점
✔ 여러 개의 독립적인 패널을 한 페이지에서 렌더링 가능
✔ 각 패널이 비동기적으로 데이터를 패칭 & 업데이트 가능
✔ 한 패널이 로딩 중이더라도 다른 패널은 정상적으로 렌더링됨
📌 기존에는 book/[id]와 (.)book/[id] 폴더가 각각 존재했지만, 이제 @modal 폴더를 추가합니다.
📂 폴더 구조
app/
├── @modal/
│ ├── default.tsx (모달이 없을 때 null 반환)
│ ├── (.)book/[id]/page.tsx (모달 인터셉트)
├── book/[id]/page.tsx (원래 게시글 상세 페이지)
├── layout.tsx (모달을 위한 설정)
📌 변경 내용
1. @modal/ 폴더를 생성하고 default.tsx를 추가 → 모달이 없을 경우 null을 반환하도록 설정
2. (.)book/[id] 폴더를 @modal/ 하위에 배치 → 이제 Parallel Route에서 modal로 동작
3. layout.tsx에서 Parallel Routes를 적용 → children(index) + modal(모달)이 동시에 렌더링됨
layout.tsxlayout.tsx는 @modal을 children과 마찬가지로 인수로 받을 수 있음.
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children} {/* 기본적으로 index 페이지가 렌더링됨 */}
{modal} {/* 모달이 있을 경우 추가로 렌더링됨 */}
<div id="modal-root"></div>
</body>
</html>
);
}
✅ 설명
{children} → index 페이지가 렌더링됨{modal} → 모달이 열릴 경우 해당 모달이 추가됨<div id="modal-root"></div> → createPortal을 사용하여 모달을 별도 관리default.tsx에서 null 반환export default function Default() {
return null;
}
✅ 역할: Parallel Route에서 modal이 없을 경우 null을 반환하여 기존 UI(index)만 표시되도록 함.
index 페이지에 방문한 경우| 렌더링 요소 | 설명 |
|---|---|
children | index.tsx (기본 피드) |
modal | default.tsx (null 반환) |
app 폴더에는 modal을 반환할 페이지가 없음.
default.tsx는 null을 반환함.
➡ 결과적으로 index 페이지만 화면에 표시됨
book/id 링크를 클릭한 경우 (모달 열기)| 렌더링 요소 | 설명 |
|---|---|
children | index.tsx (기본 피드 유지) |
modal | (.)book/[id]/page.tsx (모달 표시) |
/book/1으로 접근하면@modal/(.)book/[id]/page.tsx를 찾음.modal에 넣음.book/[id]/page.tsx가 원래 children이어야 하지만, 이미 이 경로가 modal로 쓰이고 있음.🚀 왜
book/[id]/page.tsx를children으로 넣을 수 없을까?
- 우리가 Interception(가로채기) 했기 때문!
@modal/(.)book/[id]/page.tsx가 명시적으로 모달 역할을 하기 때문에, Next.js는 이걸modal로만 처리하고children으로 중복해서 넣지 않음.book/[id]/page.tsx는 직접/book/1으로 이동해야만children이 됨.
(즉, 새로고침하면children으로 들어감)
➡ 결과적으로 index 피드 위에 모달이 떠 있는 형태 완성!
| 렌더링 요소 | 설명 |
|---|---|
children | book/[id]/page.tsx (게시글 상세 페이지) |
modal | default.tsx (null 반환) |
➡ 결과적으로 모달이 아닌 전체 페이지가 게시글 상세 화면으로 전환됨!
| 동작 상황 | children | modal |
|---|---|---|
| index로 접근 | index.tsx | default.tsx → null 반환 (모달 없음) |
| 게시글 클릭 | index.tsx (피드 유지) | (.)book/[id]/page.tsx (모달 표시) |
| 새로고침 | book/[id]/page.tsx (전체 화면) | default.tsx → null 반환 |
🚀 즉, Parallel Routes를 활용하면 모달이 뜰 때 배경 피드를 유지하면서도, 새로고침하면 전체 페이지가 열리는 효과를 구현할 수 있음! 🔥