ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [실습] Zustand와 createPortal을 활용한 Modal
    카테고리 없음 2024. 7. 24. 10:59
    개요

    1. 배경 요인
    2. 이전 코드 및 문제점
    3. 개선된 코드, 느낀점 및 나의 노력

     

    📖 배경 요인

    프로젝트를 진행하면서 createPortal을 통해서 상위 CSS에 영향을 받지 않는 Modal을 구현하고자 createPortal과 Zustand를 통해서 alertModal을 구현하였다. 그리고 이를 예시 삼아서 Modal기능을 하는 다른 여러 컴포넌트들을 부가적으로 만들게 되었는데, 각자가 본인의 Modal과 Portal을 생성해서 구현하다보니 중복된 로직과 비슷한 구조를 가지는 부분이 많아져서 이를 동적으로 작동할 수 있는 하나의 모듈화된 컴포넌트로 생성하고자 하였다. 또한 SOLID원칙에서 OCP(Open-Close Principle)를 만족하여 확장에 유연해 효율적이고 견고하며 유지보수가 용이한 코드를 만들어 DX 개선을 목적으로 하였다.

     

     


     

     

    💻 이전 코드 및 문제점


    📦 이전 코드

    Zustand Store로 관리하고 있는 상태

    // 전시 상세 모달
    type GalleryInfoProps = Omit<ComponentProps<typeof GalleryInfo>, 'close'>;
    
    export interface GalleryInfoState {
      galleryInfoValue: GalleryInfoProps;
      open: (galleryId: number, hasEnded: boolean) => void;
      close: () => void;
    }
    
    const galleryInfoDefaultValue: GalleryInfoProps = {
      galleryId: null,
      open: false,
      hasEnded: false,
    };
    
    export const galleryInfoStore = create<GalleryInfoState>((set) => ({
      galleryInfoValue: galleryInfoDefaultValue,
      open: (galleryId: number, hasEnded: boolean) =>
        set((state) => ({ ...state, galleryInfoValue: { open: true, galleryId, hasEnded } })),
      close: () => {
        set((state) => ({ ...state, galleryInfoValue: galleryInfoDefaultValue }));
      },
    }));
    
    ...
    
    
    // 메인 페이지 카테고리 선택 모달
    const categoryModalDefalutValue = {
      open: false,
    };
    
    export const categoryModalStore = create<BaseState>((set) => ({
      value: categoryModalDefalutValue,
      open: () => set({ value: { open: true } }),
      close: () => set({ value: { open: false } }),
    }));
    • 간단하게 2개의 모달에 대한 Zustand Store를 나타내보았다. 실제로는 이러한 중복되는 코드가 6개 정도 더 있어 총 200줄이 넘는 로직이다.
    • 보시다시피 open, close 부분이 중복되고 value값에 대한 내용도 어느정도 중복이 되는 것을 알 수 있다.

    createPortal을 사용하고 있는 Component

    import { CategoryModal } from '@/pages/main/components';
    import { categoryModalStore } from '@/stores/modal';
    import { createPortal } from 'react-dom';
    import { AnimatePresence } from 'framer-motion';
    import useGetMediaQuerySize from '@/hooks/useGetMediaQuerySize';
    import { useEffect } from 'react';
    
    const CategoryPortal = () => {
      const {
        close,
        value: { open },
      } = categoryModalStore();
      const $portal_root = document.getElementById('category-portal');
      const size = useGetMediaQuerySize(1315);
      useEffect(() => {
        if (!(size === 'select')) close();
      }, [size]);
      return (
        <>
          {$portal_root
            ? createPortal(
                <div>
                  <AnimatePresence>{open && <CategoryModal close={close} />}</AnimatePresence>
                </div>,
                $portal_root,
              )
            : null}
        </>
      );
    };
    
    export default CategoryPortal;
    • Portal을 사용하는 컴포넌트가 모듈화되지 않아 각각 하나의 컴포넌트에 대해서밖에 사용이 안되고 있다.

     

    📦 문제점

    1. 파일의 방대한 코드량으로 인해 유지 보수 간에 해당 로직을 찾기 힘든 문제
    2. 확장에 자유롭지 못하며, 해당 스토어를 재사용을 할 수 없다.
    3. 많아지는 store량과 portal의 개수로 인해 불필요한 코드량이 많아짐
    4. 그냥 내가 해당 코드에 대해 불편함을 느낌!!!! (가장 중요)

     

     


     

     

    💻 개선된 코드, 느낀점 및 나의 노력


    📦 개선된 코드

    Zustand Store로 관리하고 있는 상태

    export type OmitClose<T> = Omit<T, 'close'>;
    
    interface ModalState<T> {
      open: boolean;
      modalProps: OmitClose<T>;
      openModal: (props: T) => void;
      closeModal: () => void;
    }
    
    const modalStore = <T>() =>
      create<ModalState<T>>((set) => ({
        open: false,
        modalProps: {} as T,
        openModal: (props) => set({ open: true, modalProps: props }),
        closeModal: () => set({ open: false, modalProps: {} as T }),
      }));
    
    type GalleryInfoModalProps = Omit<ComponentProps<typeof GalleryInfo>, 'close'>;
    type ChatModalProps = Omit<ComponentProps<typeof ChatModal>, 'close'>;
    type SignupCheckModalProps = Omit<ComponentProps<typeof CheckModal>, 'close'>;
    
    export const useBaseModalStore = modalStore<object>();
    export const useGalleryInfoStore = modalStore<GalleryInfoModalProps>();
    export const useChatStore = modalStore<ChatModalProps>();
    export const useSignupCheckStore = modalStore<SignupCheckModalProps>();
    • 코드량을 1/4로 줄이는 데 성공하였다.
    • 불필요한 store의 갯수를 8개 없앨 수 있었다.

    createPortal을 사용하고 있는 Component

    import { OmitClose } from '@/stores/modal';
    import { useEffect } from 'react';
    import { createPortal } from 'react-dom';
    
    interface ContentPortalProps<T> {
      open: boolean;
      component: React.ComponentType<T>;
      modalProps?: OmitClose<T>;
      closeModal: () => void;
    }
    
    const ContentPortal = <T,>({
      open,
      component: Component,
      modalProps,
      closeModal,
    }: ContentPortalProps<T>) => {
      const $portal_root = document.getElementById('content-portal');
      useEffect(() => {
        return () => closeModal();
      }, [closeModal]);
      return (
        <>
          {$portal_root
            ? createPortal(
                <div>{open && <Component close={closeModal} {...(modalProps as T)} />}</div>,
                $portal_root,
              )
            : null}
        </>
      );
    };
    
    export default ContentPortal;
    • 불필요한 portal의 개수를 줄이고, 공통적으로 portal에서 적용해야하는 부분에 대해 한꺼번에 처리가 가능해졌다.

    createPortal을 호출하는 부분

    ...
    import { ContentPortal } from '@/components';
    import { useBaseModalStore, useGalleryInfoStore } from '@/stores/modal';
    
    import * as S from './styles';
    
    const MainPage = () => {
      const { closeModal: categoryCloseModal, open: categoryOpen } = useBaseModalStore();
      const {
        closeModal: galleryInfoCloseModal,
        open: galleryInfoOpen,
        modalProps,
      } = useGalleryInfoStore();
      ...
      return (
        <S.Container>
          ...
          <ContentPortal
            component={GalleryInfo}
            closeModal={galleryInfoCloseModal}
            open={galleryInfoOpen}
            modalProps={modalProps}
          />
          <ContentPortal
            component={CategoryModal}
            closeModal={categoryCloseModal}
            open={categoryOpen}
          />
        </S.Container>
      );
    };
    
    export default MainPage;

     

    📦 느낀점 및 나의 노력

    먼저 ContentPortal을 만들기 위해 props로 전달 받은 컴포넌트를 동적으로 랜더링할 수 있는 구조를 만들어야했으며 이를 구현하기 위해 제네릭 함수를 통해서 Type을 정의하고 문제를 해결할 수 있었다. 
    트러블 슈팅(1)
    문제: 랜더링되어 나타나는 모달을 닫기 위해서 사용되는 close함수를 어떻게 전달할 지에 대한 고민
    해결: close함수는 모든 모달에 사용되는 함수이기에 OmitClose<T>라는 타입을 설정하여 전달되는 modalProps를 close가 없는 상태로 만들고 명시적으로 close를 별도로 전달하였다.
    트러블 슈팅(2)
    문제: Zustand에 props에 대한 타입을 설정하기 위해서는 별로도 props에 대한 정보를 받아서 타입을 설정해주어야하는데 어떻게 전달을 해주어야할지에 대한 고민
    해결: 가장 오랫동안 고민했으며 create함수에서 사용되는 set, get을 그대로 사용하면서 외부에서 상태에 대한 값을 전달해야했다. 그래서 커링 기법을 통해서 인자를 추가로 전달하는 방법을 생각하였으며, 호출 시마다 해당 컴포넌트에 대한 값을 전달했더니 동기화가 되지 않는 문제가 생겨 Store에서 export할 때 명시적으로 처리하였다..
    트러블 슈팅(3)
    문제: Store의 사용위치가 Portal Component 안에서 구현되었었는데 해당 Zustand를 적용하려니 동적으로 사용할 수 없는 구조로 판단되었다.
    해결: props로 Portal에 필요한 값을 전달하는 구조로 수정하였다.
    • 이 외에도 ContextAPI로 구현하는 방법에 대해서도 고민을 해보았지만 동적으로 Component를 전달하는 방식에 대해서 비슷하게 구현이 가능하지만 ContextAPI 특유의 단점인 자식요소에 대한 랜더링이 성능을 오히려 악화시킬 것이라고 생각하였다.
    • 중간 중간 타입에러도 많이 나고 상태값에 대한 것과 동기화가 되지 않는 문제를 느끼면서 부족함을 다시 한번 느꼈지만 문제에 대해서 풀어갈수록 성장하는 부분도 느낄 수 있어서 좋았다.
    • 역시 재사용성이 높은 코드에 대한 고민은 좀 재밌는 것 같다.

    댓글

Designed by Tistory.