모달창 패턴 Redux와 함께

강동욱·2024년 4월 21일
0

모달창 패턴 적용 안할 시 문제점

모달창 관리하는 컴포넌트가 비대해지는 현상

import Modal from './Modal';const App = () => {
  const [isOpen, setOpen] = useState(false);const handleClick = () => {
    setOpen(true);
  };return (
    <div className="App">
      <button onClick={handleClick}>모달창 열기</button>
      <Modal isOpen={isOpen} />
    </div>
  );
};

기존 모달을 열려면 위와 같이 간단하게 코드를 작성할 수 있습니다. 하지만 어떤 문제점이 있을까요

const App = () => {
 const [isOpen1, setOpen1] = useState(false);
  const [isOpen2, setOpen2] = useState(false);
  const [isOpen3, setOpen3] = useState(false);
  const [isOpen4, setOpen4] = useState(false);const handleClick1 = () => {
    setOpen1(true);
  };const handleClick2 = () => {
    setOpen2(true);
  };const handleClick3 = () => {
    setOpen3(true);
  };const handleClick4 = () => {
    setOpen4(true);
  };return (
    <div className="App">
      <button onClick={handleClick1}>모달1 열기</button>
      <button onClick={handleClick2}>모달2 열기</button>
      <button onClick={handleClick3}>모달3 열기</button>
      <button onClick={handleClick4}>모달4 열기</button>
      <Modal1 isOpen={isOpen1} />
      <Modal2 isOpen={isOpen2} />
      <Modal3 isOpen={isOpen3} />
      <Modal4 isOpen={isOpen4} />
    </div>
  );
}; 

모달의 성격이 다르고 각각의 모달을 열어야할 때 다음과 같이 반복적인 코드를 경험할 수 있습니다. 한눈에 보시다시피 어떻게 하면 코드를 깨끗하게 할 수 있을텐데 그런 생각이 들지 않나요??

const Modal1 = ({ isOpen, onSubmit, onCancel }) => {
  const handleClickSubmit = () => {
    onSubmit();
  };const handleClickCancel = () => {
    onCancel();
  };return (
    <ReactModal isOpen={isOpen}>
      <div>모달 입니다.</div>
      <div>
        <button onClick={handleClickSubmit}>확인</button>
        <button onClick={handleClickCancel}>취소</button>
      </div>
    </ReactModal>
  );

모달 컴포넌트를 생각하면 보통은 확인 버튼을 누르면 어떠한 비즈니스 로직을 처리한 후 모달을 닫아줍니다. 취소 버튼을 누르면 모달을 그냥 닫아주고요 이럴 때 위와 같이 onSubmit과 onCancel 함수를 props로 받아와서 실행해 줍니다. 여기서 문제는 모달에게 onSubmit과 onCancel을 전달해주는 App컴포넌트에서 발생합니다.

const App = () => {
  const [isOpen1, setOpen1] = useState(false);
  const [isOpen2, setOpen2] = useState(false);
  const [isOpen3, setOpen3] = useState(false);
  const [isOpen4, setOpen4] = useState(false);const handleClick1 = () => setOpen1(true);
  const handleClick2 = () => setOpen2(true);
  const handleClick3 = () => setOpen3(true);
  const handleClick4 = () => setOpen4(true);const handleModal1Submit = () => {
    // 모달1 비지니스 로직
    setOpen1(false);
  };
  const handleModal2Submit = () => {
    // 모달2 비지니스 로직
    setOpen2(false);
  };
  const handleModal3Submit = () => {
    // 모달3 비지니스 로직
    setOpen3(false);
  };
  const handleModal4Submit = () => {
    // 모달4 비지니스 로직
    setOpen4(false);
  };const handleModal1Cancel = () => setOpen1(false);
  const handleModal2Cancel = () => setOpen2(false);
  const handleModal3Cancel = () => setOpen3(false);
  const handleModal4Cancel = () => setOpen4(false);return (
    <div className="App">
      <button onClick={handleClick1}>모달1 열기</button>
      <button onClick={handleClick2}>모달2 열기</button>
      <button onClick={handleClick3}>모달3 열기</button>
      <button onClick={handleClick4}>모달4 열기</button>
      <MyModal1
        isOpen={isOpen1}
        onSubmit={handleModal1Submit}
        onCancel={handleModal1Cancel}
      />
      <Modal2
        isOpen={isOpen2}
        onSubmit={handleModal2Submit}
        onCancel={handleModal2Cancel}
      />
      <Modal3
        isOpen={isOpen3}
        onSubmit={handleModal3Submit}
        onCancel={handleModal3Cancel}
      />
      <Modal4
        isOpen={isOpen4}
        onSubmit={handleModal4Submit}
        onCancel={handleModal4Cancel}
      />
    </div>
  );
};

위와 같이 각각의 모달에대한 onSubmit과 onCancel의 함수가 다르기 때문에 그에 상응하는 함수를 따로따로 정의를 해줘야 합니다.

모달창을 하위컴포넌트 열어야할 때 반복되는 prop 전달

import MyModal from './MyModal';const App = () => {
  const [isOpen, setOpen] = useState(false);const openModal = () => {
    setOpen(true);
  };return (
    <div className="App">
      <button onClick={openModal}>모달 열기</button>
      <ChildComponent openModal={openModal} />
      <ChildComponent2 openModal={openModal} />
      <ChildComponent3 openModal={openModal} />
      <ChildComponent4 openModal={openModal} />
      <MyModal isOpen={isOpen} />
    </div>
  );
};

부모 컴포넌트에서 모달의 상태를 관리해서 다음과 같이 반복적으로 openModal 함수를 전해줘야 합니다.

문제점 요약

  • 부모 컴포넌트에서 여러개의 모달을 관리할 시 확인 취소 버튼에 대한 로직, 모달창을 열어야하는 상태를 전부 부모컴포넌트에서 관리한다.
  • 자식 컴포넌트에서 모달창을 열어야할 시 반복적인 prop을 전달하게 된다.
  • 쓰지 않은 모달에 대해서도 일단 부모 컴포넌트에서 import를 해야하기 때문에 최적화면에서는 그렇게 좋지가 않다.

해결방안

모달에 관한 로직, 상태를 외부에서 관리를 하자

부모 컴포넌트에서 각각의 모달에 대한 확인, 취소 로직 그리고 모달을 열어여하는 상태를 관리하기 때문에 부모 컴포넌트가 비대해지는 문제가 발생한 것이라고 생각합니다. 이를 해결하기 위해서는 부모컴포넌트에서 관리를 하지말고 어떠한 외부에서 관리를 하는 것이 해결책이라고 할 수 있습니다. 예를 들면 context API를 이용하거나 아니면 그 외 여러가지 클라이언트 상태 관리 라이브러리를 이용하는 것 입니다.

쓰지 않은 모달이 있을 수 있기 때문에 동적으로 import 하자

lazy 함수를 이용해서 렌더링 할 모달 컴포넌트만 import 하면 성능면에서도 더 좋은 결과를 얻을 수 있지 않을까 생각해 봅니다.

실전으로 해결해보기

1. LazyModal 컴포넌트 만들기

우리는 컴포넌트를 만들때 각각의 이름을 가지고 있습니다. 물론 다른 이름의 폴더 안에 똑같은 이름의 파일이 존재할 수 있습니다. 하지만 이것은 그다지 좋은 방법이 아닐 수 있습니다. 먼저 처음으로 이러한 패턴을 적용해보기 전에 다음과 같은 전제 조건이 있습니다.

  • 각각의 모달 컴포넌트는 lazy loading 할 수 있는 하나의 컴포넌트에서 렌더링 되어야 합니다.
  • 모달 컴포넌트는 ./[Login/Order]Modal.tsx와 같은 네이밍 컨벤션을 지켜야 합니다.
  • 모달 컴포넌트는 ./component/Modal 파일 안에서 전부 관리 되어야 합니다.

이러한 규칙을 지키면 아래와 같은 컴포넌트를 생성할 수 있습니다.

// component/Modal/LazyModal.tsx

import { Suspense, lazy } from 'react';

type Props = {
  fileName: string;
};

export default function LazyModal({ fileName }: Props) {
  const Component = lazy(() => import(`@/components/Modal/${fileName}`));

  const handleModalClose = () => {
	// 모달창 닫기 로직...
  };
  
  return (
    fileName && (
      <Errorboundary>
      	<Suspense fallback={<Loding />}>
        	<Component onClose={handleModalClose} />
     	 </Suspense>
      <Errorboundary>
    )
  );
}

filName을 props로 받아 Component라는 변수 안에 동적으로 컴포넌트를 생성해 줍니다. 만약에 잘못된 파일 이름을 받았을 때 쉽게 디버깅 해주기 위해서 Errorboundary를 설정해 줍니다.

2. LazyModal 컴포넌트를 렌더링해 줄 Provider컴포넌트 생성하기

1번에서 생성한 LazyModal 컴포넌트를 렌더링 해줄 하나의 글로벌 컴포넌트가 필요로 합니다. 리덕스에서 사용하는 provider 패턴을 이용해서 글로벌 컴포넌트를 생성해줄 것 입니다.

// component/Modal/ModalProvider.tsx

import { ReactNode } from 'react';
import LazyModal from './LazyModal';

type Props = {
  children: ReactNode;
};

// 지금은 MODALS을 하드 코딩으로 나타내는데 나중에는 이러한 객체를 redux를 통해서 관리할 것입니다.

const MODALS: ModalMap = {
 'TestModal': {
   id: 'TestModal',
   open: true
 },
 'LoginModal': {
   id: 'LoginModal',
   open: false,
   meta: {
     user: 'fedor'
   }
 }
}

export default function ModalProvider({ children }: Props) {
	const modals = Object.keys(MODALS).filter((id) => MODALS[id].open)

  return (
    <>
      {MODALS.map((modalName) => (
        <LazyModal key={modalName} fileName={modalName} />
      ))}
      {children}
    </>
  );
}
// ./layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
      <html lang="en">
        <body>
          <ModalProvider>
            <main>
              {children}
            </main>
          </ModalProvider>
        </body>
      </html>
  );
}

저는 nextjs를 사용하고 있어 이렇게 최상위 레이아웃에다가 ModalProvider를 감싸줌으로써 LazyModal 컴포넌트를 전역에서 렌더링해 주고 있습니다.

3. 재사용 가능한 BaseModal 컴포넌트 만들기

거의 대부분의 모달창은 확인과 취소 버튼이 있습니다. 뭐 그게 없더라도 기본적인 틀 안에서 모달을 만들 것 이라고 생각합니다. 저의 상황 같은 경우는 일단 모든 모달에 확인 버튼, 취소 버튼, 모달창 제목이 공통적으로 들어가서 재사용하기 위해 다음과 컴포넌트를 만들게 되었습니다.

import { ReactNode } from 'react';

type Props = {
  title: string;
  onClose?: () => void;
  onCheck?: () => void;
  children: string | ReactNode;
};

export default function BaseModal({
  onClose,
  title,
  children,
  onCheck,
}: Props) {
  const handleOnCloseClick = () => {
    if (typeof onClose === 'function') {
      onClose();
    }
  };
  const handelOnCheckClick = () => {
    if (typeof onCheck === 'function') {
      onCheck();
    }
  };
  
  return (
    <div className="fixed top-2 left-1/2 z-50 translate-x-[-50%] p-5 rounded-xl flex flex-col gap-4 items-center bg-secondary text-white">
      // 모달 제목
      <div className="text-2xl">
        <h1 className="">{title}</h1>
      </div>
      // 모달 메인 내용
      <div>{children}</div>
      // 모달 버튼
      <div>
        <button
          type="button"
          className="bg-third p-2 rounded mx-2 hover:bg-primary"
          onClick={handelOnCheckClick}
        >
          확인
        </button>
        <button
          type="button"
          className="bg-third p-2 rounded mx-2 hover:bg-primary"
          onClick={handleOnCloseClick}
        >
          취소
        </button>
      </div>
    </div>
  );
}

4. 각각의 모달 컴포넌트 만들기

아까 2번에서 만든 ModalProvider 컴포넌트에서 Modal에 관한 객체를 하드코딩해서 만들었는데 그 객체의 키값을 바탕으로 1번에서 만든 LazyModal 컴포넌트에서 동적으로 렌더링하니까 해당 키값과 부합한 컴포넌트를 만들어줍니다. 우리는 TestModal, LoginModal이 키값으로 있었으므로 이런 키값과 부합한 모달 컴포넌트를 만들어 주면 됩니다. 아래 코드에서 간단히 TestModal 컴포넌트를 만들어 보겠습니다.

// 🚨 파일 위치도 무조건 지켜줘야함
// @/components/Modal/TestModal

export default function TestModal({ onClose }: Props) {

  const onSubmitHandler = async () => {
  	// 모달창을 확인 버튼 누를 시 해당하는 비즈니스 로직 작성...
    
    onClose();
  };
  return (
    <BaseModal
      title="여행 기록 작성"
      onCheck={onSubmitHandler}
      onClose={onClose}
    >
      <p>작성하신 여행기록을 포스팅 하겠습니까?</p>
    </BaseModal>
  );
}

5. 모달 컴포넌트를 전역적으로 관리해보기

2번에서는 모달들의 정보를 임의의 객체로 만들어서 하드코딩 했는데 이러한 정보들을 전역적으로 관리해보겠습니다.

import { ModalMapType, ModalType } from '@/types/ModalType';
import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit';
import type { RootState } from '../store';

type InitialType = {
  modals: ModalMapType;
};

const initialState: InitialType = {
  modals: {},
};

const modalSlice = createSlice({
  name: 'modal',
  initialState,
  reducers: {
    openModal: (state, action: PayloadAction<Pick<ModalType, 'id'>>) => {
      state.modals[action.payload.id] = {
        id: action.payload.id,
        open: true,
      };
    },
    closeModal: (state, action: PayloadAction<Pick<ModalType, 'id'>>) => {
      state.modals[action.payload.id].open = false;
    },
  },
});

export const { openModal, closeModal } = modalSlice.actions;
const getModals = (state: RootState): ModalMapType => state.modal.modals;

export const isModalOpen: (state: RootState, id: string) => boolean = (
  state,
  id,
) => state.modal.modals[id]?.open ?? false;

export const getOpenModals = createSelector(getModals, (modals) =>
  Object.keys(modals).filter((modalName) => modals[modalName].open),
);
export default modalSlice.reducer;

openModal 액션인 경우 modal 컴포넌트에 사용할 이름을 받아옵니다(1번 참조). 그리고 나서 컴포넌트에 사용할 이름을 키로 등록하고 값으로는 open, id를 가집니다. 이렇게 맵 형식으로 관리하면 좀 더 수월하게 모달을 찾을 수 있습니다.

closeModal 액션인 경우 아이디 값만 찾아서 open을 false로 바꿔주기만 하면 끝입니다. openModal에서 만약에 modal의 meta정보를 담으시려면 meta키만 따로 추가해주면 됩니다. 저의 프로젝트에서는 따로 meta를 사용하지 않아 일단 생략했습니다.

6. 모달 컴포넌트를 실행 할 커스텀 훅 만들기

import { useAppDispatch, useAppSelector } from '@/store/hook';
import { closeModal, isModalOpen, openModal } from '@/store/modal/modal.slice';

export const useModal = (modalFileName: string) => {
  const dispatch = useAppDispatch();

  const onOpen = () => {
    dispatch(openModal({ id: modalFileName }));
  };
  const onClose = () => {
    dispatch(closeModal({ id: modalFileName }));
  };

  const isOpen = useAppSelector((state) => isModalOpen(state, modalFileName));

  return { onOpen, onClose, isOpen };
};
// 만약 LoginModal을 실행 시키고 싶다면 생성할 파일명만 전해주면 됩니다.
const {onOpen, onClose, isOpen} = useModal('LogiModal')
// LazyModal.tsx
const Component = lazy(() => import(`@/components/Modal/${fileName}`));

useModal은 이제 어디에서든지 사용되어 질 수 있는데요 살짝 헷갈리시면 1번의 코드인 LazyModal에 동적으로 import할 형식을 생각해주면 됩니다. 만약 다른 팀원들과 협업을 할 경우 useModal에 유효하지 않은 파일이름을 넣었을 때 추적이 어렵다고 느낄실 수 있겠는데요 그럴때를 대비해서 1번에서 Errorboundary로 감싸주고 있기 때문에 이러한 단점을 보완해 줄 수 있을 것이라고 생각합니다.

7. ModalPropvider 전역 변수로 교체

'use client';

import { useAppSelector } from '@/store/hook';
import { getOpenModals } from '@/store/modal/modal.slice';
import { ReactNode } from 'react';
import LazyModal from './LazyModal';

type Props = {
  children: ReactNode;
};

export default function ModalProvider({ children }: Props) {
  const modals = useAppSelector(getOpenModals);

  return (
    <>
      {modals?.map((modalName) => (
        <LazyModal key={modalName} fileName={modalName} />
      ))}
      {children}
    </>
  );
}

2번에서 작성한 MODALS 객체가 이제는 전역에서 관리해주기 때문에 위와같이 작성을 할 수 있습니다. getOpenModals는 5번에서 확인할 수 있습니다. getOpenModals는 왜 createSelector로 감싸줬을까는 createSelector 작동원리에서 확인할 수 있습니다.

결론

이러한 패턴을 적용시켜보면서 처음에 보일러플레이트 코드가 조금 적응이 안되긴 했습니다. 그리고 저 혼자 하는 작은 프로젝트에서는 그렇게 쓸일이 없다고 생각이 들기도 했습니다. 하지만 나중에 현업에 가서는 수많은 모달 컴포넌를 렌덩링 해야하고 이러면 전역적으로 관리할 필요성이 있다고 생각하여 글을 정리해보았습니다. 특히 LazyModal컴포넌트를 사용하여 모달 컴포넌트를 동적으로 렌더링할 수 있는 부분에서는 신기한 접근인 것 같아서 상당히 흥미로웠습니다.

참고자료

https://hackernoon.com/the-perfect-react-modal-implementation-for-2023
https://nakta.dev/how-to-manage-modals-1

profile
차근차근 개발자

0개의 댓글