[Next.js] modal 만들기, 인스타그램처럼 modal 띄우기

chaen·2025년 2월 18일

REACT / NEXT.js

목록 보기
18/22
post-thumbnail

Next.js의 Intercepting Routes와 Parallel Routes를 활용하여, 인스타그램처럼 게시글을 클릭하면 모달이 열리고, 새로고침하면 해당 게시글 페이지로 이동하는 기능을 구현합니다.


🚀 1. 인스타그램의 모달 동작 방식

📌 인스타그램 게시글 클릭 시 모달처럼 표시됨
📌 새로고침하면 해당 게시글을 전체 페이지로 보여줌
📌 모달 바깥을 클릭하면 다시 피드 페이지로 돌아감

✅ 이 기능을 Next.js의 Intercepting RoutesParallel Routes를 이용해 구현할 수 있습니다!


🏗️ 2. Intercepting Routes란?

공식문서
Intercepting Routes를 활용하면 특정 페이지에서만 특정한 UI(모달)를 인터셉트하여 보여줄 수 있음.

그러나! 이 기능은 "첫 접근"이 아닐 때만 작동함 → 즉, 새로고침하면 원래의 상세 페이지로 이동함.

📌 폴더 구조 예제

app/
 ├── book/[id]/page.tsx (📄 원래의 게시글 상세 페이지)
 ├── (.)book/[id]/page.tsx (📄 인터셉트하여 모달로 보여줄 페이지)

폴더 네이밍 규칙

  • (.)book/[id] → 동일한 경로를 인터셉트하여 모달로 변환
  • (..)book/[id] → 1단계 상위 경로를 인터셉트
  • (...)book/[id]app/ 폴더 바로 아래에서 인터셉트

3. Intercepting Modal 기본 구현

📌 (.)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에 모달이 렌더링되도록 설정됨!


🚀 4. 모달 기능 업그레이드 (자동 열기 & 닫기)

📌 모달이 자동으로 열리고, 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.targetDIALOG가 됨.

🎁 5. Parallel Routes로 배경에 피드 표시하기

이제 모달이 팝업되었을 때, 배경이 빈 화면 (.)book/[id].tsx이 아니라 피드(index 페이지)가 나오도록 설정해보겠습니다.
이를 위해 Parallel Routes를 사용합니다.


🎯 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(모달)이 동시에 렌더링됨


Parallel Routes를 적용한 layout.tsx

layout.tsx@modalchildren과 마찬가지로 인수로 받을 수 있음.

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)만 표시되도록 함.


🎯 6. Parallel Routes 적용 후 동작 원리

1) 사용자가 index 페이지에 방문한 경우

렌더링 요소설명
childrenindex.tsx (기본 피드)
modaldefault.tsx (null 반환)

app 폴더에는 modal을 반환할 페이지가 없음.
default.tsx는 null을 반환함.
➡ 결과적으로 index 페이지만 화면에 표시됨


2) 사용자가 book/id 링크를 클릭한 경우 (모달 열기)

렌더링 요소설명
childrenindex.tsx (기본 피드 유지)
modal(.)book/[id]/page.tsx (모달 표시)

1. /book/1으로 접근하면

  • Next.js는 가장 먼저 @modal/(.)book/[id]/page.tsx를 찾음.
  • 이 파일이 존재하므로, 이걸 modal에 넣음.
  • 이제 children을 찾을 차례인데...

2. 그런데 children을 찾으려고 보니까...

  • book/[id]/page.tsx가 원래 children이어야 하지만, 이미 이 경로가 modal로 쓰이고 있음.
  • 즉, children으로 넣을 페이지가 없음.
  • 하지만 Next.js는 빈 화면을 렌더링하지 않으려고 하기 때문에, "기존 페이지를 유지"하는 전략을 선택함.

3. 따라서 기존 index.tsx(홈 피드)를 유지하면서 modal을 추가함.

  • 즉, index.tsx가 사라지는 대신 modal이 새로 추가된 것!

🚀 book/[id]/page.tsxchildren으로 넣을 수 없을까?

  • 우리가 Interception(가로채기) 했기 때문!
  • @modal/(.)book/[id]/page.tsx가 명시적으로 모달 역할을 하기 때문에, Next.js는 이걸 modal로만 처리하고 children으로 중복해서 넣지 않음.
  • book/[id]/page.tsx는 직접 /book/1으로 이동해야만 children이 됨.
    (즉, 새로고침하면 children으로 들어감)

➡ 결과적으로 index 피드 위에 모달이 떠 있는 형태 완성!


3) 사용자가 새로고침한 경우

렌더링 요소설명
childrenbook/[id]/page.tsx (게시글 상세 페이지)
modaldefault.tsx (null 반환)

➡ 결과적으로 모달이 아닌 전체 페이지가 게시글 상세 화면으로 전환됨!


🎯 7. 최종 정리

동작 상황childrenmodal
index로 접근index.tsxdefault.tsxnull 반환 (모달 없음)
게시글 클릭index.tsx (피드 유지)(.)book/[id]/page.tsx (모달 표시)
새로고침book/[id]/page.tsx (전체 화면)default.tsxnull 반환

🚀 즉, Parallel Routes를 활용하면 모달이 뜰 때 배경 피드를 유지하면서도, 새로고침하면 전체 페이지가 열리는 효과를 구현할 수 있음! 🔥


참고: 한 입 크기로 잘라먹는 Next.js(v15)

0개의 댓글