Next.js 15 - Modal 구현 (with. Parallel Routes & Intercepting Routes)
웹 개발에서 모달창 구현을 직접 하거나 편리한 라이브러리는 대부분 상태관리와 함께 이루어진다.(본인 역시 react-modal을 그동안 사용해왔다.)
const [isOpen, setIsOpen] = useState<boolean>(false);
const toggleModal = () => setIsOpen(!isOpen);
const openModal = () => setIsOpen(true);
const closeModal = () => setIsOpen(false);
아마 대부분이 이런 기본 형태의 state를 활용했을 것이다.
이를 개발하다보면 필연적으로 마주치는 상황들이 몇가지 있는데 대표적으로 아래와 같이 있다.
- 모달창이 열린 상황에서 새로고침을 하면 state로 관리하기 때문에 모달창이 닫힘
- 뒤로가기 버튼을 누르면 이전 페이지로 돌아감 (모달창 컨트롤이 어려움)
- 모달이 열린 상태의 url을 공유가 불가능함
이를 해결하기 위한 방안으로 Next.js 15에서 제공하는 방식이 Parallel Routes와 Intercepting Routes 이다.
1. Parallel Routes (병렬 경로)
- 개념
https://nextjs.org/docs/app/building-your-application/routing/parallel-routes
Routing: Parallel Routes | Next.js
Simultaneously render one or more pages in the same view that can be navigated independently. A pattern for highly dynamic applications.
nextjs.org
병렬 경로를 처리함으로써 하나의 레이아웃에 여러 페이지나 컴포넌트를 동시에 보여줄 수 있는 기능이다.
생성 방법은 slot이라는 @접두사를 붙인 폴더 안에 page.tsx를 생성하는 방식이다.
이렇게 병렬 경로를 사용하면 로딩 시간이 단축되고 각 컴포넌트의 로딩 상태를 독립적으로 표시할 수 있어서 더 나은 사용자 경험을 제공한다.
- 코드
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<>
{children}
{team}
{analytics}
</>
)
}
- Navigation 방식
- Soft Navigation : 클라이언트 측 탐색 중에 slot 내의 하위 페이지를 변경하는 동시에 다른 slot의 활성 하위 페이지를 유지한다.
- 페이지 이동 시 변화한 route 아래 요소만 리렌더링
- Next.js 클라이언트 라우트 기능을 활용
- next/link 나 next/router를 활용한 경우
- Hard Navigation : 전통적인 방식으로 페이지 전체가 리렌더링된다.
- default.tsx
초기 로드 또는 전체 페이지 다시 로드 중에 일치하지 않는 slot에 대한 대체 수단으로 렌더링할 파일을 정의하는 개념이다.
지정한 slot에 페이지가 존재하지 않는다면 404가 대신에 해당 default.tsx가 렌더링된다.
위와 같이 있는 경우에 @analyics 폴더 안에 page.tsx가 존재하지 않기 때문에, 이런 경우 default.js 파일이 렌더링되는 것이다.
2. Intercepting Routes (경로 가로채기)
해당 작업은 개발 서버 중에 작업을 하면 바로 적용되지 않기 때문에 재시작이 필요하다.
- 개념
말 그대로 현재 레이아웃에서 다른 routes를 가로채서 보여주는 방식으로, 현재 context를 유지한 채로 새로운 routes를 렌더링하는 방식이다.
- 규칙
경로 가로채기의 규칙은 폴더가 아닌 세그먼트를 기준으로 한다.
이유는 지정한 경로 그룹은 url에 매핑되지 않기 때문에, /app/test/(.)x 는 /test/x와 일치하게 된다.
- (.) : 동일한 레벨의 세그먼트와 일치
- (..) : 한 단계 위의 세그먼트와 일치
- (..)(..) : 두 단계 위의 세그먼트와 일치
- (...) : root(app) 디렉토리 세그먼트와 일치
폴더 경로 | 일치하는 url |
/app/a/b/(.)x | /a/b/x |
/app/a/b/(..)x | /a/x |
/app/a/b/(...)x | /x |
3. Modal 만들기
이제 위의 개념들을 활용해서 Modal을 제작하면 된다.
- 구조
우선 아래 구조와 같이 폴더와 파일들을 만든다.
- 코드
app/page.tsx
app/page.tsx
"use client";
import Link from "next/link";
export default function Home() {
return (
<div className="w-full h-full flex flex-col justify-center items-center gap-4 bg-white">
<Link
className="p-4 border border-gray-400 rounded-lg"
href="/pop"
scroll={false}
>
모달띄우기
</Link>
</div>
);
}
app/layout.tsx
// app/layout.tsx
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const gmarket = localFont({
src: [
{
path: "../../public/assets/fonts/GmarketSansLight.otf",
weight: "300",
style: "light",
},
{
path: "../../public/assets/fonts/GmarketSansMedium.otf",
weight: "400",
style: "normal",
},
{
path: "../../public/assets/fonts/GmarketSansBold.otf",
weight: "700",
style: "bold",
},
],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
modal, <----- 추가
}: Readonly<{
children: React.ReactNode;
modal: React.ReactNode; <----- 추가
}>) {
return (
<html lang="en">
<body className={gmarket.className}>
{children}
{modal} <----- 추가
</body>
</html>
);
}
default.tsx
// default.tsx
export default function Default() {
return null;
}
@modal/(.)pop/page.tsx
// @modal/(.)pop/page.tsx
"use client";
import Modal from "@/components/modal";
import PopView from "@/components/testView";
const PopModal = () => {
return (
<Modal>
<PopView />
</Modal>
);
};
export default PopModal;
pop/page.tsx
// pop/page.tsx
"use client";
import PopView from "@/components/testView";
const PopPage = () => {
return <PopView />;
};
export default PopPage;
components/modal.tsx
(modal 디자인과 기능을 위한 코드)
아래는 간단한 설명을 위한 코드로, modal의 background와 실제 modal의 디자인은 입맛에 맞게 지정하면 된다.
// components/modal.tsx
import { useRouter } from "next/navigation";
import React, { ReactNode } from "react";
const Modal = ({
children,
}: Readonly<{
children: ReactNode;
}>) => {
const router = useRouter();
useEffect(() => {
if (isOpen) {
document.body.classList.add("overflow-y-hidden");
} else {
document.body.classList.remove("overflow-y-hidden");
}
}, [isOpen]);
return (
<React.Fragment>
<div className="fixed inset-0 w-screen h-screen bg-black opacity-60 z-10"></div>
<div className="fixed inset-0 w-full h-full flex items-center justify-center z-50">
<div className="w-1/2 h-2/3 max-h-[756px] py-4 bg-white rounded-lg">
<div className="flex items-center justify-end px-4">
<button
onClick={() => {
router.back();
}}
>
<p>닫기</p>
</button>
</div>
{children}
</div>
</div>
</React.Fragment>
);
};
export default Modal;
모달창 닫기의 기능을 구현하려면 router.back이나 next/link를 활용하는 것을 공식문서에는 추천하고 있다.
그 외 router.push나 replace는 soft navigation에서 문제를 일으키는 경우가 있다.
useEffect의 내용은 해당 모달창이 팝업된 상태에서 background의 scroll을 막기 위한 코드이다.
components/textView.tsx
(modal view를 담당하는 코드)
// components/testView.tsx
const PopView = () => {
return (
<div className="w-full h-full flex justify-center items-center">
<p className="text-3xl text-black">모달창입니다.</p>
</div>
);
};
export default PopView;
4. 실행 및 이점
- 브라우저의 navigation이 자연스러움 (뒤로가기, 앞으로가기)
- url을 통한 상태관리가 가능해짐
- 새로고침을 해도 모달이 유지 (모달페이지가 전체페이지로 전환)
5. 아쉬운 점? 부족한 점?
이렇게 좋은 점만 있을거라고 생각해 적용하던 Next.js 15에서의 모달 개발을 하다보니 조금은 당황스러운 상황을 마주치게 되었다.
예를 들어, 모달창 닫기 기능은 결국 router.back을 이용해서 가능한데 일반적인 내용만을 보여주는 modal이라면 상관이 없다. 하지만, form과 같이 data를 처리하는 방식의 모달은 뒤로가기 방식으로 닫히는 것이 본인은 이상하게 느껴졌다. 그렇다고 router.push를 사용해도 url만 바뀌고 모달창은 그대로 남아있기 때문에 적합하지 않았다.
이를 해결하기 위해 방법을 찾다보니 많은 사람들이 나와 같은 불편함과 어색함을 느끼고 url에 query를 붙이거나 url을 state와 같이 연동하는 방식으로 개선을 해서 사용을 하고 있었다. 혹은 이렇게 코드가 추가되다보니 더 불편하다고 생각해 다시금 라이브러리 사용으로 돌아가는 해외 개발자들도 많았다.
본인은 우선 Context API와 같은 전역 상태를 통해 이전 경로를 저장 + modal url에 query를 추가하는 방식으로 해결했다...만 이게 최선의 방식인지는 조금 의문이 남아있는 상태이다.
아래는 수정한 코드로 글을 마무리한다.
context/asPathContext.tsx
// context/asPathContext.tsx
"use client";
import { usePathname } from "next/navigation";
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
interface AsPathContext {
previousAsPath: string | null;
currentAsPath: string | null;
}
interface AsPathProvider {
children: ReactNode;
}
const AsPathContext = createContext<AsPathContext>({
previousAsPath: null,
currentAsPath: null,
});
export const AsPathProvider = ({ children }: AsPathProvider) => {
const pathName = usePathname();
const [asPathData, setAsPathData] = useState<AsPathContext>({
previousAsPath: null,
currentAsPath: null,
});
useEffect(() => {
setAsPathData({
previousAsPath: asPathData.currentAsPath,
currentAsPath: pathName,
});
}, [pathName]);
return (
<AsPathContext.Provider value={asPathData}>
{children}
</AsPathContext.Provider>
);
};
export const useAsPath = () => useContext(AsPathContext);
이전 url과 현재 url을 pathName이 변경될 때마다 context에 저장하는 방식이다.
app/layout.tsx
// app/layout.tsx
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { AsPathProvider } from "@/context/asPathContext";
const gmarket = localFont({
src: [
{
path: "../../public/assets/fonts/GmarketSansLight.otf",
weight: "300",
style: "light",
},
{
path: "../../public/assets/fonts/GmarketSansMedium.otf",
weight: "400",
style: "normal",
},
{
path: "../../public/assets/fonts/GmarketSansBold.otf",
weight: "700",
style: "bold",
},
],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
modal,
}: Readonly<{
children: React.ReactNode;
modal: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={gmarket.className}>
<AsPathProvider>
{children}
{modal}
</AsPathProvider>
</body>
</html>
);
}
app/page.tsx
// app/page.tsx
"use client";
import Link from "next/link";
export default function Home() {
return (
<div className="w-full h-full flex flex-col justify-center items-center gap-4 bg-white">
<Link
className="p-4 border border-gray-400 rounded-lg"
href="/pop?modal=true"
scroll={false}
>
모달띄우기
</Link>
</div>
);
}
components/modal.tsx
// components/modal.tsx
import { useAsPath } from "@/context/asPathContext";
import { useRouter, useSearchParams } from "next/navigation";
import React, { ReactNode } from "react";
const Modal = ({
children,
}: Readonly<{
children: ReactNode;
}>) => {
const router = useRouter();
const searchParams = useSearchParams();
const isOpen = searchParams.get("modal");
const { previousAsPath } = useAsPath();
return (
<React.Fragment>
{isOpen ? (
<React.Fragment>
<div className="fixed inset-0 w-screen h-screen bg-black opacity-60 z-10"></div>
<div className="fixed inset-0 w-full h-full flex items-center justify-center z-50">
<div className="w-1/2 h-2/3 max-h-[756px] py-4 bg-white rounded-lg">
<div className="flex items-center justify-end px-4">
<button
onClick={() => {
router.push(previousAsPath!);
}}
>
<p>닫기</p>
</button>
</div>
{children}
</div>
</div>
</React.Fragment>
) : null}
</React.Fragment>
);
};
export default Modal;