상세 컨텐츠

본문 제목

React 전역 Modal 추상화 정복기 - (1)

전역 모달 추상화 정복기

by 뎁희 2024. 4. 13. 01:04

본문

선언적으로 모달을 사용하기 위한 리팩토링 여정

 

-

모달 상태 관리를 Jotai에서 ContextAPI로 리팩토링하는 과정에서 얻은 배움을 단계별로 소개한다.

총 6개 챕터로 구성되었으며, 챕터 5에서 최종 완성 버전을 확인할 수 있다.


들어가며

프로젝트를 진행하면서 모달이 필요했고, 라이브러리를 이용하면 간단하겠지만 지금 나에게는 만들어보는 과정이 더 의미 있을 것이라는 판단에 직접 구현해 보기로 했다. 그리고 단계별 리팩토링으로 시행착오가 많았던 과정을 상세히 담기 위해 시리즈로 구성해 글로써 작성해 본다.

 

시리즈 요약

  1. Jotai가 쏘아올린 작은 공
  2. 👋 Bye Jotai, 🙌 Hello ContextAPI
  3. Context와 Hook의 역할 분리
  4. [☕️ 쉬어가기] Local 모달의 의미를 찾아서(부제: 냄새나는 코드 대잔치)
  5. 모달에 갇힌 서버 응답값 구출
  6. 모달 콘텐츠 템플릿 레이아웃 분리

전역 모달은 왜 필요할까?

1개의 컴포넌트에서 특정 모달 1개만 필요할 때는 어렵지 않다. 로컬에서 `open`과 `close`를 `useState`로 관리해 숨기고, 표시하기만 하면 된다. 하지만 모달은 서비스에서 다양한 모습으로 활용되기 때문에 재사용이 가능하도록 구현해 두면 유용하게 사용할 수 있다. 추상화된 모달 컴포넌트를 만들면 필요한 곳에 맞는 타입의 모달을 빠르게 생성할 수 있다.


1. Jotai로 구현하기

처음 구현한 모달 구조를 도식화 한 이미지이다. 모달을 원하는 컴포넌트에서 호출 시 위 이미지 구조로 렌더링 된다. App에 `Modal`을 자식으로 두고, `Modal`은 `Overlay`로 감싼 `ModalContainer`를 렌더링 한다.


>_ useModal.tsx

const useModal = (type?: ModalType) => {
    const [{ isOpen, type: modalType }, setModal] = useAtom(modalAtom);

    const openModal = () =>
        setModal({
            isOpen: true,
            type,
        });
    const closeModal = useResetAtom(modalAtom);

    return {
        isOpen,
        modalType,
        openModal,
        closeModal,
    };
};

커스텀훅 useModal은 모달의 type을 받는다. 훅 내부에서는 atom으로 관리하는 state를 변경하는 함수를 내보낸다. 모달 호출 컴포넌트에서 `openModal`을 호출하면 지정된 타입 객체의 `isOpen`을 변경해 화면에 보이도록 한다.

 

>_ Modal.tsx

const modals: Record<string, JSX.Element> = {
    confirm: <ConfirmModal />,
    createChatRoom: <CreateChatRoomModal />,
};

const Modal = () => {
    const { isOpen, modalType, closeModal } = useModal();

    if (!isOpen) return;

    return (
        <div className={cx('overlay')} onClick={closeModal}>
            {modalType && modals[modalType]}
        </div>
    );
};

export default Modal;

호출된 모달 타입에 따라 조건부 렌더링 된다. `modals` 객체에는 다른 모달 타입과 컴포넌트가 생기면 추가할 수 있다. 이때 `Modal` 컴포넌트를 호출하는 곳은 최상위 Root 컴포넌트(ex. App)이다. `Modal` 컴포넌트가 리렌더링 될 때, 관리 중인 모달의 `isOpen` 속성이 `true`라면 해당하는 모달이 렌더링 된다.


Example

<Button onClick={() => openModal(confirm)}>alert</Button>

모달을 띄우고 싶은 컴포넌트에서 `openModal`을 이용해 띄우려는 `type`을 인자로 전달한다.


재사용 가능한 UI 컴포넌트 분리

만든 모달은 배경을 클릭하면 닫을 수 있는 오버레이와 모달의 배경 및 X버튼을 공통으로 사용하고 있다. `Overlay` 컴포넌트는 다른 곳에서도 사용할 수 있도록 분리한다. 이제 모달이 아니더라도 배경을 클릭하면 닫히는 기능이 필요한 곳에 `Overlay` 컴포넌트를 재사용할 수 있다.


⚠️ 문제

`Modal` 컴포넌트를 `Root`에 두고, 필요한 곳에서 원하는 type명을 이용해 렌더링 하면서 전역 모달 기능을 구현했지만, 다양한 모달이 생길 때마다 type과 관련된 컴포넌트를 매번 `modals`에 추가해주어야 하는 불편함이 있다.

 

이 문제를 해결하기 위해 다른 변화 없이 type속성만 제거할 경우에는 어떤 모달이 열렸는지 알아야 하기 때문에 호출 컴포넌트에서 직접 `Modal` 컴포넌트로 감싸야했다. 그럼 결국 전역으로 관리하는 의미가 없어진다.


2. 렌더링 할 컴포넌트 바로 전달하기

🎯 목표: `openModal`에 `type`이 아닌 렌더링 하고 싶은 모달 컴포넌트를 바로 넘길 수 있도록 개선한다.

 

>_ useModal.tsx

커스텀훅에서 달라지는 부분은 `type`이나 `props` 없이 `element`를 직접 받아 `atom`에 저장한다는 점이다.

const useModal = () => {
  const [{ isOpen, element }, setModal] = useAtom(modalAtom);
  const openModal = (element: JSX.Element) =>
      setModal({
          isOpen: true,
          element,
      });
  ...
  ...
};

 

Example

const Home = () => {
    const { openModal, closeModal } = useModal();

    const renderModal = () => {
        openModal(<ConfirmModal onClose={closeModal} />);
    };

    return (
        <div>
            <button onClick={renderModal}>Confirm</button>
        </div>
    );
};

`openModal` 함수를 이용해 원하는 컴포넌트를 호출하고 `closeModal`를 `prop`로 전달한다.


⚠️ 문제

구현된 모달은 서비스 내 띄우는 모달이 무조건 1개라는 전제가 있다. 모달과 atom이 1:1 관계로 연결되어 있다는 뜻이다. atom을 배열로 관리해서 여러 개의 모달을 담을 수 있도록 개선해 보자.


3. 다중 모달 구현하기

>_ useModal.tsx

여러 개의 모달을 띄우고, 원하는 모달을 닫기 위해서는 `id`가 필요하므로 모달을 호출할 때 id를 함께 받도록 변경한다.

const useModal = () => {
  const setModals = useSetAtom(modalAtom);

  const openModal = ({ id, element }: { id: string; element: JSX.Element }) =>
    setModals((modals) => [
      ...modals,
      {
        id,
        element,
      },
    ]);

  const closeModal = (id: string) => setModals((modals) => modals.filter((modal) => modal.id !== id));

  const remove = useResetAtom(modalAtom);

  return {
    openModal,
    closeModal,
    remove,
  };
};

 

>_ App.tsx

const ModalStack = () => {
  const modalStack = useAtomValue(modalAtom);
  return modalStack.map((modal, idx) => <Modal key={idx} element={modal} />);
};

function App() {
  return (
    <div id="app">
      <ModalStack />
    </div>
  );
}

이제 Root 컴포넌트에서는 저장된 전체 모달 목록을 가져와 렌더링 할 수 있다.


⚠️ 문제

  1. 모달이 닫히려면 id가 필요한 구조가 되면서 모달이 열릴 때 id를 함께 전달해야 했다.
  2. modal container가 unmount 되어도 모달 상태는 그대로 남아있다.
  3. 다시 modal이 mount 되었을 때, 이전에 렌더링 해놓은 모달이 그대로 다시 뜬다

나는 무엇을 놓쳤을까?


유저의 사용 패턴과 UX의 중요성

나의 모달은 사용자를 고려하지 않았다. 모달을 만들 때 "열 수 있고, 닫을 수 있다."와 "다양한 콘텐츠의 모달을 만들 수 있다."만 신경 쓰고 유저의 사용 패턴은 생각하지 않았다. 구현된 모달은 "언제나 유저가 직접 닫는다."라는 암묵적 전제를 갖고 있다.

 

  1. 유저가 모달이 열린 상태에서 뒤로가기를 누른다면?
  2. 1개의 컴포넌트에 여러 개의 레이아웃이 있고 특정 부분에서만 열리는 모달을 띄우게 된다면?

 

만일 위 2가지 경우처럼 `closeModal`이 아닌 다른 방법으로 모달이 강제로 닫히는 경우에는 모달 컴포넌트가 제대로 `unmount` 되지 않는 상황이 발생한다. 그 이유는 호출 컴포넌트가 언마운트 되더라도 Root에서 열리면서 전역적으로 저장 중인 상태인 `isOpen`의 값이 바뀌지 않았기 때문이다.

 

웹서비스를 이용하다보면 의도치 않게 모달을 강제로 닫히게 하는 경우가 많고, 페이지를 이동하면 닫힐 것이라고 예상하는 것도 일반적이다. 하지만 현재 구현은 페이지를 이동할 때, 닫힐 것이라 예상했던 모달이 계속 나타나면서 사용성을 저하시킨다.

문제 상황

 

해결: 라우팅 변화에 대응하기

이 문제는 생각보다 간단하게 해결할 수 있었다. `useModal`훅이 `unmount`될 때, `modals`를 초기화해주면 라우팅 변화에 대응해 모달을 강제로 닫을 수 있게 된다.


 

 

 


 

 

관련글 더보기