Published on

상태 관리 라이브러리를 이용해 모달 로직 개선하기 - (with. Zustand)

이미지 출처

프런트엔드 개발자라면, 모달 UI를 구현하고 사용해 본 경험이 것이다. 페이지를 전환할 필요가 없고, 몇몇 상황에서 모달이 유용하게 쓰일뿐더러 useState를 사용하면 모달을 손쉽게 열고 닫을 수 있을 만큼 구현 방법도 간단한 편이다.

이전 프로젝트와 마찬가지로 이번 프로젝트에서도 모달을 사용하게 되었는데, 이번에 모달을 구현하는 과정에서 코드가 지저분하고 복잡해진다는 생각이 많이 들었다.

이번 기회에 구조 계선을 하기로 마음먹게 되었고, 때마침 잠깐 여유가 생겼기 때문에 상태 관리 라이브러리를 이용해 모달 구현 과정을 계선하기로 결정하게 되었다.



⚙️ 프로젝트 환경


  • React + Yarn Berry + Vite + TypeScript
  • Zustand
  • Tailwind CSS


🧐 기존 모달 구현 과정


현재 진항하고 있는 프로젝트에서 이용 후기 페이지에 구현된 리뷰 상세 보기 (돋보기 버튼)을 클릭했을 때 모달이 열리는 UI를 예시로 들어보자.

  1. 버튼을 클릭했을 때 DetailReviewModal 모달 컴포넌트를 열기 위해서는 현재 모달의 on/off 상태가 필요하다.
  2. 그리고 showModal state 변수의 상태가 true일 경우 모달 컴포넌트를 렌더링 한다.


ReviewTableRow.tsx

const ReviewTableRow = () => {
  const [showModal, setShowModal] = useState<boolean>(false)

  ... 기타 코드

  return (
    <tr>
      ... 기타 코드
      {showModal && (<DetailReviewModal/>)}
    </tr>
  )
}

export default ReviewTableRow

  • 기존에는 위와 같은 방식으로 모달 컴포넌트를 사용했는데, return 문 내부에 모달을 표시하는 조건 코드가 포함돼 결과적으로 코드를 더 복잡하게 만들었다.

  • 또한 다른 컴포넌트에서 또 다른 모달을 사용할 경우 매번 showModal과 같은 불리언 변수를 만들어줘야 하는 코드의 중복 작성이 발생했다.

  • 상태 관리 라이브러리를 통해 이를 방지할 수 있지만, 여러 개의 모달을 사용하는 상황이 발생할 경우 많은 수의 파일이 발생하는 문제가 있었다.


💡 따라서 이 문제에 대한 해답을 고민하던 중 모달을 열고 닫는 상태 관리뿐만 아니라 모달 컴포넌트 자체도 전역으로 관리하는 방향으로 해답을 찾게 되었다.



✍️ 사전작업


useModalStore.ts


import create from "zustand"

export interface ModalComponentProps<P = Record<string, unknown>> {
  Component: React.FC<P>
  props?: P
}

interface ModalState {
  modals: ModalComponentProps[]
  open: <P extends Record<string, unknown>>(
    Component: React.FC<P>,
    props?: P,
  ) => void
  close: () => void
}

export const useModalStore = create<ModalState>((set) => ({
  modals: [],
  open: <P extends Record<string, unknown>>(
    Component: React.FC<P>,
    props?: P,
  ) =>
    set((state) => ({
      modals: [...state.modals, { Component, props } as ModalComponentProps],
    })),
  close: () =>
    set((state) => ({
      modals: state.modals.slice(0, -1),
    })),
}))

modals부터 살펴보면, <모달 컴포넌트/> 와 같은 형태가 아닌 배열의 형태로 React.FC<P>( 함수형 컴포넌트 타입 )를 받도록 되어있다. modals를 배열로 지정한 이유는 중첩 모달이 발생할 경우를 고려했기 때문인데,

(모달 로직 개선 전) 모달에서 또 다른 중첩 모달을 열기 위해 boolean 형태의 state 변수를 또 사용해야 할뿐더러 복잡한 코드가 작성되었다. 따라서, modals 배열 형태의 변수에 컴포넌트를 쌓으므로써 모달과 중첩 모달을 관리했다.



📎 모달 렌더링 컴포넌트 배치하기


ModalContainer.tsx의 역할은 useModalStore로부터 현재 열려있는 모달을 가져오고, 가져온 모달을 각각의 Component로 렌더링하는 것이다.

💡 main.tsx, index.tsx, App.tsx, router.tsx와 같은 최상위 파일에서 관리하거나 모달이 사용되는 상위 컴포넌트에 반드시 위치시켜야 한다.


ModalContainer.tsx

import { v4 as uuidv4 } from 'uuid';

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

function ModalContainer() {
  const modals = useModalStore((state) => state.modals)

  return (
    <>
      {modals.map((modal) => {
        const { Component, props } = modal
        return (
          <div key={uuidv4()} className="modal">
            <Component {...props} />
          </div>
        )
      })}
    </>
  )
}

export default ModalContainer

ModalContainer.tsx 배치 예시

const 부모 컴포넌트 = () => {
  return (
    <main>
      <모달이 발생하는 컴포넌트 />
      <ModalContainer />
    </main>
  )
}

👍 hook으로 관리하기


useModalStore를 컴포넌트에서 바로 사용할 경우 modals, open, close 메서드와 변수를 각각 3번 호출해야 하는 상황이 발생한다.

const modals = useModalStore((state) => state.modals)
const open = useModalStore((state) => state.open)
const close = useModalStore((state) => state.close)

사실 위와 같이 작성해도 정상적으로 동작하기 때문에 큰 문제는 없다. 하지만 2가지 이점을 근거로 useModals hook 파일을 따로 만들어 관련 로직을 관리해 주었다.


useModals.ts

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

export const useModals = () => {
  const modals = useModalStore((state) => state.modals)
  const open = useModalStore((state) => state.open)
  const close = useModalStore((state) => state.close)

  return {
    modals,
    open,
    close,
  }
}


1. 가독성

/** hook 적용 전 */
const modals = useModalStore((state) => state.modals)
const open = useModalStore((state) => state.open)
const close = useModalStore((state) => state.close)

/** hook 적용 후 */
const { close, open } = useModals()
  • useModals 훅을 호출함으로써 객체 분해 할당을 활용해 1줄로 간단하게 호출이 가능하다.

2. 일관된 인터페이스 & 유지보수 용이성 향상

// ComponentA.jsx
import { useModalStore } from "@/shared/store/useModalStore";

const ComponentA = () => {
  const open = useModalStore((state) => state.open);
  const close = useModalStore((state) => state.close);

  // ...
};

// ComponentB.jsx
import { useModalStore } from "@/shared/store/useModalStore";

const ComponentB = () => {
  const open = useModalStore((state) => state.open);
  const close = useModalStore((state) => state.close);

  // ...
};
  • useModalStore를 여러 컴포넌트에서 사용할 경우 useModalStore에서 변경사항이 발생했을 때 모든 컴포넌트를 일일이 수정하는 상황이 발생하게 된다.
    useModals 훅을 적용함으로써 동일한 인터페이스로 모달 관련 기능을 제공하고 일관된 인터페이스, 유지 보수 용이성 향상으로 이어진다.

🔍 open, close, modals 사용 원리

모달 열기 버튼과 같은 모달을 여는 버튼에 open 메서드를 적용시키면 modals 배열에 모달 컴포넌트가 쌓이게 된다.


다음으로 현재 열려있는 모달 위에 또 모달을 열어야 할 경우 open 메서드를 사용해서 modals에 순서대로 쌓이게 된다. 모달을 닫을 경우(close) slice 함수를 사용해 스택구조로 LIFO로 관리된다.

이로써 모달이 중첩될 경우 불필요한 코드를 작성할 필요 없이 modals 배열 변수에 쌓아서 관리하는 방식으로 진행되고,

모달을 사용하는 모든 컴포넌트에서 동일하게 open, close만을 사용해 모달 & 중첩 모달 관리가 가능해진다.



개선 코드

const ReviewTableRow = () => {
  const { open } = useModals()

  const handleOpenModal = () => {
    /** 두 번째 인자로 props를 전달할 수 있다. */
    open(DetailReviewModal, { userId })
  }

  return (
    <tr>
      ... 기타 코드

      <button
        type="button"
        onClick={handleOpenModal}
      >
        모달 열기
      </button>
    </tr>
  )
}

export default ReviewTableRow


모달 개선 로직 적용 결과

상태 관리 라이브러리(Zustand)를 활용해 해당 컴포넌트에서만 적용되는 것이 아닌, 여러 컴포넌트에서 open, close 메서드만 활용하면 간편하게 모달을 호출하고 관리할 수 있게 된다.

또한 모달이 중첩되더라도 기존 모달 사용 방식과 동일하게 open, close를 사용해 주면 되기 때문에 몇 개의 중첩된 모달이 발생해도 큰 문제가 되지 않는다.



최근 진행하고 있는 프로젝트에서 또다시 모달을 사용하게 되었는데, 모달과 관련된 로직으로 인해 불필요한 코드가 반복되고 복잡함을 야기한다는 생각이 많이 들었다.

때마침 이번 프로젝트에서는 상태 관리 라이브러리를 최대한 활용하기로 마음먹었던 상태였고 기존에 느꼈던 모달 로직에 대한 불편함을 마음속에 담아두고 있었기 때문에 관련 내용에 대한 고민을 많이 할 수 있었다.

이번 포스팅을 작성하며 문득 개선된 로직으로 모달을 쉽게 관리할 수는 있지만, 현재처럼 순서대로 중첩 모달을 관리하는 것이 아닌 순서에 상관없이 모달을 관리해야 하는 요구사항이 발생할 수도 있을 것 같다는 생각이 들었다. 해당 요구사항이 필요할 확률은 극히 드물지만, 더 범용적으로 사용할 수 있는 방법을 고안할 필요가 있다는 것을 느꼈다.

또한 이번에는 소개하지 않았지만, 저번 포스팅인 React Portals을 활용해 모달의 관심사를 분리하는 작업을 이번 작업과 연계해서 진행하는 작업을 진행하며 모달과 관련된 마음에 응어리가 어느 정도 사라진 것 같은 느낌이 든다.

앞으로도 모달과 관련된 더 나은 방안이 있다면 개선하고, 더 양질의 코드를 작성하기 위해 공부하고 정리하자.