Zustand로 선언적 모달 관리 hook 구현하기

2024-10-30

기존 Antd Modal의 문제점

회사 프로젝트 A에서는 Ant Design(antd)의 Modal 컴포넌트를 사용해 다양한 팝업을 관리했다.
하지만 다음과 같은 문제로 인해 기존 방식이 한계에 부딪혔다.

  1. 핸들러 전달 이슈
  • handler 가 제대로 전달 되지 않음.
  1. 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>

마무리

이 구조를 도입하면서 기존 방식을 사용하면서 있었던 문제점들이 해결되었고, 전역 상태 기반으로 선언적으로 모달을 관리하면서 코드가 훨씬 깔끔하고 관리하기 쉬워졌다.