zustand와 함께 모달 만들기

cho·2025년 2월 22일

💥 트러블 슈팅

목록 보기
7/11
post-thumbnail

팀 프로젝트를 하면서 한 번도 직접 모달을 만들어본 적이 없었는데, 이번에 개인 프로젝트를 진행하면서 처음으로 모달을 구현해봤다. 이 과정에서 Zustand를 전역 상태 관리 라이브러리로 선택했는데, 그 이유는 다음과 같다.

✅ 사용법이 간단하다.

  • Redux는 actions, reducers, dispatch 등을 설정해야 해서 보일러플레이트 코드가 많지만, Zustand는 단순히 store 하나만 만들면 끝이라 코드량이 적다.
  • useStore 훅을 사용하면 원하는 상태를 바로 가져올 수 있어서 React Context보다 사용이 간편하다.

✅ 불필요한 리렌더링을 방지할 수 있다.

  • Context API를 사용하면 Provider 내부의 모든 컴포넌트가 리렌더링될 수 있는 문제가 있지만, Zustand는 선택적 구독 (Selective Subscription) 을 지원해 필요한 상태만 구독할 수 있다.
  • 성능 최적화가 기본적으로 적용되어 있어서, 상태 변경이 발생해도 불필요한 리렌더링 없이 효율적으로 관리할 수 있다.

❓모달 상태를 Context API로 관리하지 않은 이유

모달을 Context API로 관리하면 모달이 열릴 때마다 불필요한 리렌더링이 발생할 가능성이 높다고 생각했다.
Zustand를 사용하면 구독 방식으로 상태를 관리할 수 있어, 리렌더링을 최소화하면서도 모달의 열림/닫힘 상태를 쉽게 제어할 수 있다고 판단했다.

그래서 처음에는 다음과 같이 간단하게 구현했다.

import React, { ReactElement } from "react";
import { create } from "zustand";

interface ModalState {
  isOpen: boolean;
  openModal:  () => void;
  closeModal: () => void;
}
export const useModalStore = create<ModalState>((set) => ({
  isOpen: false,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}));

🔍 문제점

하지만 사용하다 보니 모달이 전역에서 하나만 열렸다 닫혔다 하는 문제가 있었다.
내가 원했던 건 각각 필요한 곳에서, 서로 다른 모달을 독립적으로 띄우는 것이었는데, 처음 열린 모달 하나만 계속 열리는 상태가 되어버렸다.

🥸 개선 방법 고민

다시 다른 프로젝트들을 참고하며 어떻게 만들고 싶은지 고민해봤다.

내가 생각한 가장 좋은 방법은, 필요한 모달 컴포넌트를 openModal 함수의 인자로 전달하는 방식이었다.

const handleEdit = () => {
    openModal(CreateEditTask, {
      mode: "edit",
      initialData: {
        id,
        title,
        description,
        dueDate,
      },
      status,
      onTasksUpdate,
    });
  };

  const handleDelete = () => {
    openModal(DeleteModal, { id, onTasksUpdate });
  };

이렇게 하면 전체 레이아웃에서 모달을 렌더링해두고, 필요한 모달을 동적으로 띄울 수 있어서 사용하기 간편해진다.

블로그에 이렇게 만든 예시도 없고, 이전 프로젝트에서도 zustand와 hook이 너무 복잡하게 얽혀있어서 간단하면서도 저런 방식으로 구현되는 모달을 만들고 싶었다.

✨ 개선된 useModalStore

import React, { ReactElement } from "react";
import { create } from "zustand";

interface ModalState {
  isOpen: boolean;
  component: ReactElement | null;
  openModal: <T extends object>(
    Component: React.ComponentType<T>,
    props: T,
  ) => void;
  closeModal: () => void;
}

export const useModalStore = create<ModalState>((set) => ({
  isOpen: false,
  component: null,
  openModal: <T extends object>(
    Component: React.ComponentType<T>,
    props: T,
  ) => {
    const element = React.createElement(Component, props);
    set({ isOpen: true, component: element });
  },
  closeModal: () => set({ isOpen: false, component: null }),
}));

이렇게 바꿔보았다. 중간에 이렇게 만들 수 있기까지 수 많은 과정을 거쳤지만.. (시간이 좀 지나 기억안남 이슈🥹)

Next.js 환경에서 적용

나는 Next.js의 App Router를 사용하고 있어서, useModalStore를 직접 사용하면 하위 모든 컴포넌트가 클라이언트 컴포넌트가 될 가능성이 있었다. 이를 방지하기 위해 클라이언트 전용 Provider 컴포넌트를 만들어 레이아웃에 추가했다.

// Modal Provider
"use client";

import { useEffect, useState } from "react";

import { useModalStore } from "@/store/useModalStore";

export default function ModalProvider() {
  const { component } = useModalStore();  // 모달 상태에서 현재 렌더링할 컴포넌트를 가져옴
  const [mounted, setMounted] = useState(false); // 컴포넌트가 마운트 되었는지 여부를 관리하는 상태

  useEffect(() => {
    setMounted(true); // 컴포넌트가 마운트되면 mounted 상태를 true로 설정
  }, []);

  if (!mounted) {
    return null;  // 초기 렌더링에서는 아무것도 렌더링하지 않음
  }

  return component; // 상태에서 가져온 컴포넌트를 렌더링
}

그리고 RootLayout에 ModalProvider를 추가해 전체 레이아웃에서 모달을 관리할 수 있도록 했다.

import "./globals.css";

import type { Metadata } from "next";
import { Inter } from "next/font/google";

import ModalProvider from "@/components/common/modal/modal-provider";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Kanban TodoList",
  description: "할 일 목록을 만들어보세요",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <body className={inter.className}>
        {children}
        <ModalProvider />
      </body>
    </html>
  );
}

이렇게 구현하니 사용처에서는 openModal(Component, props) 방식으로 쉽게 모달을 띄울 수 있고, 모달 자체가 어떻게 동작하는지도 이해하기 쉬운 구조가 되었다.

Zustand로 모달을 만들고 있다면 이 방식을 한 번 시도해보는 것도 좋을 것 같다🤓

0개의 댓글