기존 문제를 해결하기 위한 새로운 리액트 모달 구현

2025-11-16

기존 프로젝트에서 사용하던 모달 컴포넌트는 자잘한 문제들이 자주 발생했다. 특히 렌더링 구조와 UI가 뒤섞여 유지보수가 어려웠고, 여러 모달이 겹쳤을 때 overlay가 복수로 나타나는 버그도 있었다.

이 문제를 해결하기 위해 전체적인 모달 구조를 다시 설계하면서, “리액트에서 모달은 어떤 방식으로 구현하는 것이 좋은가?”에 대해 깊게 들여다보게 되었다. 나도 처음 리액트를 사용할 때는 모달을 어떻게 쓰는 게 정석인지 궁금했기 때문에, 이번 글에서는 각 방식의 특징과 트레이드오프, 그리고 내가 어떤 선택을 했는지 중심으로 정리해 보았다.


리액트에서 모달을 구현하는 두 가지 방식

리액트에서 모달을 구현할 때 일반적으로 다음 두 가지 접근 방식을 고려할 수 있다.

  1. 컴포넌트 내부에서 모달 상태를 관리하는 방식
  2. 전역 상태로 모달을 관리하는 방식

1. 컴포넌트 내부에서 상태를 관리하는 방식

import { useState } from 'react';

function Page() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>모달 열기</button>

      {isOpen && (
        <Modal onClose={() => setIsOpen(false)}>
          <h2>내부 상태로 관리하는 모달</h2>
          <p>여기 안에서 필요한 내용을 렌더링합니다.</p>
          <button onClick={() => setIsOpen(false)}>닫기</button>
        </Modal>
      )}
    </div>
  );
}

const [isOpen, isSetOpen] = useState(false) 와 같이 내부 상태를 통해 모달을 관리하는 방식은 일반적인 UI 컴포넌트를 렌더링하는 것처럼 모달을 렌더링하기 때문에 별도의 기술이나 학습이 필요하지 않다. 또한 모달을 사용함에 있어 사이드 이펙트가 매우 적고, 렌더링이나 내부 로직을 자유롭게 구성할 수 있다.

하지만 모달을 렌더링하는 컴포넌트가 아닌 다른 컴포넌트에서 해당 모달을 조작하기 위해서는 상태를 조작하는 함수를 전달해야 하는 번거로움이 발생하고, 모달을 한번에 관리하기가 어렵다는 단점이 있다.

때문에 모달을 확인/취소 정도로 단순하게 사용한다면 해당 방식을 사용해도 크게 어려움이 없다. 반대로, 모달 내에서 form을 관리한다던가, 복잡한 로직이 포함된 모달이 필요하다면 모달을 관리하는 코드를 여기저기 넣어줘야 하는 불편함이 생기게 된다.


2. 전역 상태로 모달을 관리하는 방식

export default function Page() {
  const open = useModalStore((state) => state.open);

  return (
    <div>
      <button onClick={() => open(CustomModal)}>모달 열기</button>
    </div>
  );
}

전역 상태를 통해 모달을 관리하면 이런 문제를 해결할 수 있다. 모달이 필요한 곳에서는 전역 상태를 주입하거나 수정하고, React Portal과 같은 방식을 통해 수정된 전역 상태를 바탕으로 모달을 렌더링하기만 하면 된다. 전역 상태를 사용하기 때문에 컴포넌트 간 props를 전달하지 않아도 되고, 모달 렌더링 방식을 공통화 할 수 있다는 장점이 있다.

하지만 최적화나 공통화를 구현하는 방식에 대한 난이도가 있으며, 추후에 새로운 기능이 필요할 때 유연하게 수정하기 어렵다는 단점이 있다.

결론적으로, 전역 관리를 선택하면 관리 효율이 올라가지만 설계 난이도가 함께 올라간다는 점이 핵심이다.


이번 프로젝트에서 선택한 구조와 개선한 부분

기존 프로젝트는 Context API 기반의 전역 상태로 모달을 관리하고 있었지만 다음과 같은 문제가 있었다.

  • 모달 UI와 렌더링 로직이 섞여 있어 UI를 수정하기 어려움
  • 여러 모달이 동시에 열릴 때 overlay가 중첩되는 문제
  • 모달 렌더링 로직이 복잡하게 얽혀 있어 매번 불필요한 payload를 넘겨야 했음

이를 해결하기 위해 다음과 같은 방식으로 구조를 재정비했다.


1. Zustand 도입 및 react-modal 제거

Context API는 구조가 길어지며 가독성이 떨어지는 문제가 있었기 때문에, 이미 사용 중이던 Zustand로 통합하여 코드량과 복잡도를 줄였다. 또한 React Portal과 모달 관련 로직들을 직접 구현함으로써 react-modal을 제거하였다. react-modal 라이브러리는 쉽게 모달을 구현할 수 있도록 도와주지만, 현재 react-modal의 기능을 많이 사용하지 않고 있으며, overlay 및 컴포넌트 스타일을 별도로 전달해야 하고 모달을 생성할 때 마다 portal을 생성하는 등의 문제가 있었기 때문에 굳이 필요하지 않다고 생각했다.

2. ModalRenderer를 통한 컴포넌트 렌더링 로직과 UI 분리

ModalRenderer 라는 컴포넌트에서는 전역상태를 바탕으로 모달의 overlay까지만 담당하도록 하여 모달의 UI를 쉽게 수정할 수 있도록 분리하였다. 또한 모달을 렌더링할 때마다 각각의 Context를 생성하여 모달 컴포넌트 내부에서 쉽게 Context를 사용할 수 있도록 구현하였다.

3. BaseModal 컴포넌트 컴파운드 패턴 도입

기존 공통 모달 컴포넌트는 UI와 로직이 뒤엉켜 있어 확장성이 떨어졌다. 이를 해결하기 위해 공통 UI 요소를 분리한 BaseModal을 만들었다. BaseModal 컴포넌트는 컴포넌트 컴파운드 패턴 구조를 사용하여 자주 사용되지만 변경될 수 있는 Header와 Footer를 내부적으로 분리하여 쉽게 교체하거나 사용할 수 있도록 구현했다. 또한 렌더링 로직과 UI를 분리했기 때문에 필요시에는 BaseModal이 아닌 완전히 새로운 모달을 사용함으로써 더욱 유연한 모달을 만들 수 있게 되었다.

코드 예시

useModalStore.js

import { devtools } from 'zustand/middleware';
import { createWithEqualityFn } from 'zustand/traditional';
import { shallow } from 'zustand/vanilla/shallow';

const useModalStore = createWithEqualityFn(
  devtools(
    (set, get) => ({
      modals: [],

      open: (Component, componentProps = {}) => {
        const id = generateId(Component);
        set((state) => {
          const next = state.modals.slice();
          next.push({ id, Component, props: componentProps });
          return { modals: next };
        });
        return id; // 필요 시 호출부에서 닫기 위해 ID 반환
      },

      close: async (idOrComponent, onBeforeClose) => {
        const target = get().modals.find((m) =>
          typeof idOrComponent === 'string'
            ? m.id === idOrComponent
            : m.Component === idOrComponent
        );
        if (!target) return console.error('모달을 찾을 수 없습니다.');
        const prevOnBeforeClose = target.props.onBeforeClose;
        const validateBeforeClose = onBeforeClose || prevOnBeforeClose;
        const isPreventClose = validateBeforeClose
          ? (await validateBeforeClose()) === false
          : false;
        if (isPreventClose) return;

        set((state) => ({
          modals: state.modals.filter((m) =>
            typeof idOrComponent === 'string'
              ? m.id !== idOrComponent
              : m.Component !== idOrComponent
          ),
        }));
      },

      closeAllModal: () => set({ modals: [] }),
    }),
    shallow,
    { name: 'modal-store' }
  )
);

export default useModalStore;

/** timestamp 추가를 통해 중복된 Component로 인한 오류 방지 util **/
function generateId(Component) {
  const uniqueValue = `${Math.random().toString(36).substring(2, 9)}-${Date.now()}`;
  const byName = Component?.displayName || Component?.name || 'Component';
  return `${byName}-${uniqueValue}`;
}

ModalRenderer.jsx

import cx from 'clsx';
import { Suspense } from 'react';
import { ModalProvider } from 'ModalContext.js';

import useModalStore from 'modalStore.js';
import useBodyScrollLock from 'useBodyScrollLock.js';

export const ModalRenderer = () => {
  const modals = useModalStore((state) => state.modals);
  const close = useModalStore((state) => state.close);
  useBodyScrollLock(modals.length > 0);

  return modals?.map((modal, index) => {
    const { Component, props, id } = modal;
    const { dimStyle, onBeforeClose, ...rest } = props;
    const providerValue = { id, onModalClose: () => close(id, onBeforeClose) };

    return (
      <ModalProvider key={id} value={providerValue}>
        <Suspense fallback={null}>
          <div
            className={cx(styles.modal_overlay, {
              [dimStyle || styles.dim]: index === modals.length - 1,
            })}
          >
            <Component
              id={id}
              onModalClose={providerValue.onModalClose}
              {...rest}
            />
          </div>
        </Suspense>
      </ModalProvider>
    );
  });
};

BaseModal.jsx

import cx from 'clsx';
import React from 'react';
import { useModalContext } from 'ModalContext.js';
import styles from './BaseModal.module.scss';

/**
 *  * @example
 * <BaseModal size="large" onSubmit={handleSubmit}>
 *   <BaseModal.Header title="제목" />
 *   <div>내용</div>
 *   <BaseModal.Footer closeText="닫기" submitText="저장" />
 * </BaseModal>;
 */
const BaseModal = ({
  size,
  onSubmit,
  preventSubmitClose,
  className,
  children,
}) => {
  const { id, onModalClose } = useModalContext();
  const onHandleSubmit = async (e) => {
    e.preventDefault();
    const submitter = e.nativeEvent.submitter.id;
    if (submitter !== id) return;

    onSubmit && (await Promise.resolve(onSubmit()));
    if (preventSubmitClose) return;
    onModalClose();
  };

  return (
    <form
      id={id}
      className={cx(styles.main, styles[size], className)}
      onSubmit={onHandleSubmit}
    >
      {children}
    </form>
  );
};

const ModalHeader = ({
  className,
  title,
  hasDivider = false,
  closeBtn = true,
}) => {
  const { onModalClose } = useModalContext();

  return (
    <header
      className={cx(
        styles.header,
        { [styles.header_divider]: hasDivider },
        className
      )}
    >
      <h3 className={styles.title}>{title}</h3>
      {closeBtn && <CloseButton />}
    </header>
  );
};

const ModalFooter = ({
  className,
  hasDivider = true,
  hasCloseBtn = true,
  cancelText,
  submitText,
  onSubmit,
  disabled,
  cancelDisabled,
}) => {
  const { id, onModalClose } = useModalContext();

  return (
    <footer
      className={cx(
        styles.footer,
        { [styles.footer_divider]: hasDivider },
        className
      )}
      onSubmit={onSubmit}
    >
      {hasCloseBtn && (
        <button
          type={'button'}
          onClick={onModalClose}
          className={cx(styles.button, styles.close)}
          disabled={cancelDisabled}
        >
          {cancelText || '닫기'}
        </button>
      )}
      <button
        id={id}
        type="submit"
        className={cx(styles.button, styles.submit)}
        disabled={disabled}
      >
        {submitText || '저장'}
      </button>
    </footer>
  );
};

BaseModal.Header = ModalHeader;
BaseModal.Footer = ModalFooter;

export default BaseModal;

개발 과정에서 가장 신경썼던 부분은 기존 방식과 동일하게 사용할 수 있을 것과, 어떤 모달도 구현할 수 있는 자유도를 갖출 것이었다. 모달의 유연성을 가장 해치는 부분이 모달 렌더링과 UI로직이 얽혀있는 곳이었기 때문에, 이를 수정하면서 책임의 분리가 왜 중요한지를 조금 더 느낄 수 있었다.

추가로, ModalRenderer의 ContextAPI 부분은 모달 컴포넌트를 편리하게 작성할 수 있도록 추가하였지만, 사용중에 굳이 필요없다고 느껴지면 코드의 가독성과 흐름을 위해 제거할 예정이다.

위 코드는 현재 프로젝트에 맞춰 작성되었기 때문에 만약 이 포스팅을 참고할 예정이라면 각자의 프로젝트에 맞추어BaseModal, Props 전달방식 등을 수정하여 적절히 사용하길 바란다.

이런 포스팅은 어떤가요?