서론
모달(Modal)은 사용자에게 중요한 정보를 표시하거나 작업을 완료하도록 유도하는 UI 요소로 널리 사용된다.
하지만 모달의 상태 관리는 생각보다 복잡할 수 있다. 보통 모달의 열림/닫힘 상태를 관리하기 위해 보통 boolean 값을 사용하는 방식을 많이 채택한다. 최근에는 URL을 기반으로 모달 상태를 관리하는 방법이 떠오르고 있다.
이번 글에서는 Next.js의 App Router에서 제공하는 Parallel Routes 기능을 활용하여 모달 상태 관리를 URL 기반으로 리팩토링한 경험을 공유하려 한다. 단순히 boolean 값으로 관리하던 모달 상태를 왜 URL 기반으로 바꾸었는지, 그리고 이러한 접근 방식이 어떤 이점을 가져다주는지 함께 살펴보자.
Parallel Routes란?
Next.js의 App Router에서 도입된 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
app/
├── layout.tsx
├── page.tsx
└── @modal
├── default.tsx
└── page.tsx
이런 구조에서 layout.tsx는 다음과 같이 작성할 수 있다
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
modal 슬롯은 @modal 폴더의 컨텐츠를 렌더링하며, 이를 통해 메인 컨텐츠(children)와 모달을 동시에 표시할 수 있다.
boolean 상태에서 URL 기반 상태 관리로의 전환
기존 방식: boolean 상태를 사용한 모달 관리
일반적으로 React에서 모달을 관리할 때는 다음과 같이 boolean 상태를 사용한다:
const [isModalOpen, setIsModalOpen] = useState(false);
// 모달 열기
const openModal = () => setIsModalOpen(true);
// 모달 닫기
const closeModal = () => setIsModalOpen(false);
return (
<>
<button onClick={openModal}>모달 열기</button>
{isModalOpen && <Modal onClose={closeModal} />}
</>
);
이 방식은 간단하고 직관적이지만, 몇 가지 제한사항이 있다:
- 브라우저 히스토리와의 불일치: 모달이 열린 상태에서 뒤로 가기 버튼을 누르면 이전 페이지로 이동하며, 모달 상태는 URL에 반영되지 않는다.
- 상태 공유의 어려움: 여러 컴포넌트 간에 모달 상태를 공유하려면 상태 관리 라이브러리나 Context API를 사용해야 한다.
- 직접 링크의 어려움: 특정 모달이 열린 상태의 페이지로 직접 링크를 제공하기 어렵다.
결론 : 복잡하고 모달 열린 상태로 공유가 가능하게 하고싶음 -> URL 기반으로 바꾸자!
URL 기반 모달 상태 관리 구현 방법
1. 폴더 구조 설정
먼저, Parallel Routes를 위한 폴더 구조를 다음과 같이 설정했다:
src/app/
├── @modal
│ ├── default.tsx // 모달이 없을 때 표시할 컴포넌트
│ └── page.tsx // 모달 컴포넌트
├── layout.tsx // 루트 레이아웃
└── page.tsx // 메인 페이지
2. 기본 컴포넌트 설정 (default.tsx)
모달이 표시되지 않을 때는 default.tsx가 렌더링된다:
// src/app/@modal/default.tsx
export default function Default() {
return null;
}
3. 모달 컴포넌트 구현 (page.tsx)
모달 컴포넌트는 URL 쿼리 파라미터를 확인하여 표시 여부를 결정한다:
// src/app/@modal/page.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
export default function Modal() {
const router = useRouter();
const searchParams = useSearchParams();
const isVisible = searchParams.get('duplicate') === 'true';
if (!isVisible) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50"
onClick={() => router.back()}
>
<div
className="w-[90%] max-w-md rounded-xl bg-white p-6 text-center font-pretendard font-medium text-black shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<p className="text-4xl">😅</p>
<p className="mt-4 text-lg md:text-2xl">이미 등록된 전화번호입니다</p>
<p className="text-lg md:text-2xl">다른 번호로 시도해주세요!</p>
<button
onClick={() => router.back()}
className="mt-6 w-[210px] rounded-full bg-[#6B5CFF] py-3 text-lg font-semibold text-white hover:bg-[#5A52EE] md:w-full"
>
확인했어요
</button>
</div>
</div>
);
}
4. 레이아웃 설정 (layout.tsx)
루트 레이아웃에서는 children과 modal 슬롯을 모두 렌더링한다:
// src/app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
console.log('modal slot exists:', !!modal);
return (
<html lang="ko">
<body>
{children}
{modal}
</body>
</html>
);
}
나는 첫 메인에서 모달을 사용했기 때문에 RootLayout에 modal을 추가해줬다.
5. 메인 페이지에서 모달 열기 (page.tsx)
이제 모달을 열기 위해서는 단순히 URL을 변경하면 된다:
// src/app/page.tsx (일부)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// ...
try {
const { error } = await supabase.from('users').insert([{ phone_number: phoneNumber }]);
if (error) {
if (error.code === '23505') {
// 기존: setIsModalOpen(true);
// 변경: URL로 상태 관리
router.push('/?duplicate=true');
return;
}
throw error;
}
// ...
} catch (error) {
// ...
}
};
이전에는 setIsModalOpen(true)를 통해 상태를 변경했지만, 이제는 router.push('/?duplicate=true')를 통해 URL을 변경한다.
모달이 잘 뜨는 것을 확인할 수 있다.
https://ppussung-tarot.yourssu.com/?duplicate=true
해당 링크로 들어가면 바로 모달 창으로 접근하는 것도 볼 수 있다!
자세한 코드는 여기에서 확인할 수 있다.
https://github.com/yourssu/ppusyung-tarot/commit/11f6f60544090d45aa067b30c3771f8a310773e6
단일 모달 컴포넌트에서 다중 상태 처리하기
복수의 모달 타입을 하나의 컴포넌트에서 처리하는 방법이다. 모달 안의 컨텐츠만 달라져야 했었는데 이런 단일 모달 컴포넌트의 다중 상태를 어떻게 처리하는지 실제 프로젝트에서 활용한 코드를 살펴보자:
// src/app/@modal/page.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
function ModalContent() {
const router = useRouter();
const searchParams = useSearchParams();
const isDuplicate = searchParams.get('duplicate') === 'true';
const isResourceExhausted = searchParams.get('resourceExhausted') === 'true';
if (!isDuplicate && !isResourceExhausted) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50"
onClick={() => router.back()}
>
<div
className="w-[90%] max-w-md rounded-xl bg-white p-6 text-center font-pretendard font-medium text-black shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{isDuplicate && (
<>
<p className="text-4xl">😅</p>
<p className="mt-4 text-lg md:text-2xl">이미 등록된 전화번호입니다</p>
<p className="text-lg md:text-2xl">다른 번호로 시도해주세요!</p>
</>
)}
{isResourceExhausted && (
<>
<p className="text-2xl">🙏</p>
<p className="mt-4 text-base md:text-xl">서비스 자원이 모두 소진되었습니다.</p>
<p className="text-base md:text-xl">뿌슝타로에 많은 관심 보내주셔서 감사합니다!</p>
</>
)}
<button
onClick={() => router.back()}
className="mt-6 w-[210px] rounded-full bg-[#6B5CFF] py-3 text-lg font-semibold text-white hover:bg-[#5A52EE] md:w-full"
>
확인했어요
</button>
</div>
</div>
);
}
export default function Modal() {
return (
<Suspense>
<ModalContent />
</Suspense>
);
}
이 접근 방식의 주요 특징은 다음과 같다:
- 조건부 렌더링: 여러 쿼리 파라미터를 확인하여 적절한 모달 콘텐츠를 조건부로 렌더링한다.
- Suspense 활용: Suspense로 모달 콘텐츠를 감싸 클라이언트 컴포넌트 초기화 과정에서의 지연을 처리한다.
- 공통 UI 재사용: 모달의 기본 구조(배경, 컨테이너, 닫기 버튼 등)를 재사용하면서 내용만 조건부로 변경한다.
그럼 이번에는 ?resourceExhausted=true로 모달이 잘 생기는 것을 확인할 수 있다.
이 방식으로 모달을 열 때는 다음과 같이 다양한 모달을 쉽게 활성화할 수 있다.
// 중복 전화번호 모달 열기
router.push('/?duplicate=true');
// 자원 소진 모달 열기
router.push('/?resourceExhausted=true');
Suspense가 필요한 이유: 빌드 시 useSearchParams 오류 해결
모달 컴포넌트를 개발하면서 useSearchParams를 사용하다가 에러가 발생했다.
개발 환경에서는 괜찮았는데 빌드할 때 다음과 같은 오류가 발생했다.
이는 Next.js에서 클라이언트 컴포넌트에서 useSearchParams를 사용할 때 발생하는 특정 문제 때문이다.
Error: useSearchParams() should be wrapped in a suspense boundary at the same level as the component that uses it.
이 오류는 Next.js 13 이상에서 App Router를 사용할 때, useSearchParams 훅이 서스펜스(Suspense) 경계 내에서 사용되어야 한다는 것을 알려준다.
- 서버 컴포넌트와 클라이언트 컴포넌트의 하이드레이션: Next.js는 서버에서 초기 렌더링을 수행한 후 클라이언트에서 하이드레이션을 수행한다. 이 과정에서 useSearchParams는 클라이언트에서만 사용 가능한 정보에 의존한다.
- 스트리밍 렌더링 지원: Next.js 13부터는 스트리밍 렌더링을 지원하는데, Suspense를 사용하면 이 기능을 최대한 활용할 수 있다. useSearchParams가 준비되기 전에도 나머지 UI를 렌더링할 수 있게 된다.
- 클라이언트-서버 불일치 방지: Suspense는 클라이언트와 서버 간의 렌더링 불일치를 방지하는 데 도움을 준다.
결론
export default function Modal() {
return (
<Suspense> // suspense 추가
<ModalContent />
</Suspense>
);
}
suspense를 추가해줘서 에러를 해결했다.
'use client'와 Suspense의 차이
의문이 생겼다. 'use client' 지시문을 추가했는데도 왜 Suspense가 필요한지에 대한 것이다.
실제로 이 두 가지는 다른 목적을 가지고 있다.
- 'use client' 지시문:
- 이는 해당 컴포넌트와 그 하위 컴포넌트들이 클라이언트 측에서 실행됨을 Next.js에 알려준다.
- 클라이언트 컴포넌트라고 표시해도!! Next.js는 여전히 서버에서 이 컴포넌트의 초기 HTML을 생성한 다음, 클라이언트에서 하이드레이션한다.
- 이걸 써도 Next.js는 여전히 서버에서 초기 HTML을 생성하려고 시도한다.
- 'use client'는 단순히 "이 컴포넌트는 클라이언트에서 상호작용이 필요하다"라고 알려주는 것이다.
- Suspense boundary:
- Suspense는 비동기 작업이 진행되는 동안 로딩 상태를 처리할 수 있게 해주는 React 기능이다.
- Next.js App Router에서 useSearchParams()는 서버에서는 사용할 수 없고 클라이언트 하이드레이션이 완료된 후에만 올바른 값을 반환한다.
- Suspense가 없으면, Next.js는 전체 페이지를 클라이언트 측에서만 렌더링하도록 "강제로 전환"한다. => 이는 클라이언트 측 자바스크립트가 로드될 때까지 페이지가 비어 있을 수 있기 때문에 오류가 났다고 하는 것!
- Suspense를 사용하면 "이 특정 부분만 클라이언트에서 채워질 때까지 기다리고, 나머지 페이지는 정상적으로 서버에서 렌더링하라"고 알려준다.
useSearchParams()와 Suspense의 관계
useSearchParams()는 특별한 훅이다. 이 훅은 브라우저의 URL에서만 정보를 가져올 수 있기 때문에 서버에서는 사용할 수 없다.
-> useEffect와 다름!
Suspense 없이 useSearchParams()를 사용한다면?
- 이 컴포넌트는 서버에서 렌더링할 수 없는 클라이언트 데이터(URL 파라미터)에 의존하는데?
- 그렇다면 어떻게 해야 하지? 일단 서버 렌더링은 포기하고 전체를 클라이언트로 미루자.
Suspense를 사용한다면?
- 이 특정 부분은 클라이언트 데이터가 필요해서 서버에서 바로 렌더링할 수 없어.
- 하지만 이 부분만 나중에 클라이언트에서 채워줄게. 나머지 페이지는 계속 서버에서 렌더링해도 돼
정리하자면
'use client' = "이 코드는 클라이언트 기능이 필요해" (클라이언트 컴포넌트로 표시)
Suspense = "이 부분은 서버에서 즉시 렌더링할 수 없으니, 나중에 클라이언트에서 채워줘" (렌더링 전략 제어)
use client와 suspense를 둘 다 쓰는 이유도 이것 때문이다.
- 'use client'로 이벤트 핸들러, hooks 등을 사용 가능하게 하고
- Suspense로 useSearchParams()가 클라이언트에서만 실행되도록 하면서도 페이지의 나머지 부분은 서버 렌더링의 이점을 누릴 수 있게 한다.
https://nextjs.org/docs/app/api-reference/functions/use-search-params
Functions: useSearchParams | Next.js
API Reference for the useSearchParams hook.
nextjs.org
Next.js 문서에는 useSearchParams, usePathname, useRouter와 같은 특정 네비게이션 관련 훅들이 Suspense와 함께 사용되어야 한다고 명시되어 있다.
반면, useState, useEffect 등 일반적인 React 훅들은 단순히 'use client' 지시문만 있으면 사용 가능하다.
- 일반 React 훅(useState, useEffect 등) = 'use client'만 필요
- 특정 Next.js 네비게이션 훅(useSearchParams 등) = 'use client' + Suspense가 필요
이렇게 생각하면 될 것 같다!
'React' 카테고리의 다른 글
Next.js 프로젝트를 Vercel로 배포 자동화 + Route 53으로 도메인 변경 (0) | 2025.03.08 |
---|---|
[React] ky로 로그인 유지 구현하기 (with. JWT 인증) (0) | 2025.02.18 |
[React/리액트] ky를 활용한 토큰 인증 시스템 구현하기 - Google OAuth와 Token Refresh (0) | 2025.02.13 |
[React/리액트] Dialog의 위치 계산과 깜빡임 현상 해결하기 - RAF와 visibility 조합으로 (feat. 렌더링 사이클과 가시성) (0) | 2025.02.09 |
[React/리액트] SVG 아이콘 스타일링: fill vs stroke 그리고 디자인 시스템 (4) | 2025.02.01 |