기존 Antd Modal의 문제점
회사 프로젝트 A에서는 Ant Design(antd)의 Modal 컴포넌트를 사용해 다양한 팝업을 관리했다.
하지만 다음과 같은 문제로 인해 기존 방식이 한계에 부딪혔다.
- 핸들러 전달 이슈
- handler 가 제대로 전달 되지 않음.
- QueryProvider 문제
- 모달 내부에서 react-query의 QueryClient를 사용한 API 호출이 제대로 동작하지 않았다.
이러한 문제를 해결하기 위해 Zustand를 사용한 useModal 훅을 구현하고, Antd Modal을 래핑한 커스텀 모달 컴포넌트를 도입했다.
왜 Zustand인가?
- Context보다 간단하면서도, 전역 상태 관리를 빠르게 구성할 수 있어서 선택했다.
모달처럼 앱 전역에서 필요로 하는 UI 상태는 전역으로 두는 게 명확하고 유지보수에 유리하다.
Zustand로 useModal 훅 구현
stores/useModal.ts
import { ReactNode } from "react";
import { create } from "zustand";
type Modal = {
element: ReactNode;
id?: string;
};
type ModalStore = {
modals: Modal[];
open: (modal: Modal) => void;
close: (id?: string | string[]) => void;
};구조
- modals: 현재 열려 있는 모달들의 배열.
- open: 모달을 열 때 호출. 동일한 ID의 모달은 중복되지 않도록 먼저 제거 후 추가한다.
- close: 특정 ID 또는 전체 모달을 닫을 수 있다.
const useModal = create<ModalStore>((set) => ({
modals: [],
open: ({ element, id }: Modal) => {
if (!id) {
console.error("모달 id가 존재하지 않습니다.");
return;
}
set((state) => ({
modals: [
...state.modals.filter((modal) => modal.id !== id),
{ element, id },
],
}));
},
close: (id?: string | string[]) => {
set((state) => {
if (Array.isArray(id)) {
return {
modals: state.modals.filter(
(modal) => modal.id && !id.includes(modal.id),
),
};
}
if (id) {
return {
modals: state.modals.filter((modal) => modal.id !== id),
};
}
return { modals: [] };
});
},
}));
export default useModal;- ID가 없으면 에러 출력.
- 동일 ID의 모달은 중복되지 않도록 필터링.
- 모달을 배열로 관리하는 이유는 동시에 여러 개의 모달을 띄울 수 있게 하기 위함이다.
- 다중 모달을 지원하지 않는다면 객체 형태로도 충분하지만, 확장성을 고려해 배열로 구성했다.
GlobalPopup
모달을 페이지 상단에 전역으로 렌더링할 수 있도록 GlobalPopup 컴포넌트를 만들었다.
common/GlobalPopup.tsx
import { useEffect } from "react";
import { useRouter } from "next/router";
import useModal from "@/stores/useModal";
const GlobalPopup = () => {
const { modals, close } = useModal();
const { pathname } = useRouter();
useEffect(() => {
close();
}, [pathname, close]);
return modals.map(({ element, id }) => <div key={id}>{element}</div>);
};
export default GlobalPopup;왜 필요했나?
- 모달을 렌더링할 위치를 통일시켜 관리 포인트를 하나로 만들고자 했다.
- 라우팅 시점에 남아있는 모달을 자동으로 정리함으로써 UX 이슈(뒤에 있는 모달이 안 닫힘 등)를 방지할 수 있다.
Antd Modal을 래핑한 커스텀 Modal
- antd의 Modal을 요구사항에 맞게 래핑하여 일관된 스타일과 기능을 적용할 수 있도록 커스터마이징했다.
common/Modal/index.tsx
import { CSSProperties, ReactNode } from "react";
import { Modal as ModalBase, ModalProps } from "antd";
import Header from "./Header";
type Props = {
children: ReactNode;
style?: CSSProperties;
onClose?: () => void;
} & ModalProps;
const Modal = (props: Props) => {
const { title, children, width, style, footer, onClose, ...rest } = props;
return (
<ModalBase
{...rest}
open
width={width}
onCancel={onClose}
footer={null}
title={<Header label={title} onClose={onClose} />}
closeIcon={null}
style={style}
>
{children}
</ModalBase>
);
};
export default Modal;- Header는 모달 제목과 닫기 버튼을 통일된 UI로 제공하기 위해 만든 컴포넌트다.
footer=null로 모달 하단 버튼을 제거하고, 필요 시 children 영역에서 직접 커스터마이징할 수 있도록 했다.
작성해보자!
1. 모달 Key 정의
모달 ID는 상수로 관리한다.
모달의 중복 및 추적을 쉽게 하기 위해 modalKey.ts에 명시적으로 작성한다.
constants/modalKey.ts
export const USER_DETAIL_MODAL_KEY = "user_detail_modal_key";2. 커스텀 Modal 사용
title/width 등 필요한 속성을 지정할 수 있다.
import useModal from "@/stores/useModal";
const handleClose = () => {
close(USER_DETAIL_MODAL_KEY);
};
return (
<Modal width={900} onClose={handleClose} title="유저 상세">
<Layout>{/* 모달 내용 */}</Layout>
</Modal>
);3. 모달 오픈
<button
onClick={() => {
open({
element: <UserDetailModal />,
id: USER_DETAIL_MODAL_KEY,
});
}}
>
상세보기
</button>마무리
이 구조를 도입하면서 기존 방식을 사용하면서 있었던 문제점들이 해결되었고, 전역 상태 기반으로 선언적으로 모달을 관리하면서 코드가 훨씬 깔끔하고 관리하기 쉬워졌다.