팀원들이 사용할 공통 모달 모듈을 만들어보자

이준희·2023년 7월 21일
1


현재 재직하고 있는 회사에서 여러개의 프로젝트를 한 개의 레포지토리로 관리하는 모노레포 구조를 적용하고 있다. 모달 공통 모듈을 만들어서 모든 프로젝트에서 간편한게 사용할 수 있도록하는 모달 작업을 맡아서 개발하게 되었다.

전형적인 모달 관리 패턴 🤔

import { useState } from "react";
import Modal from "./components/Modal";

function App() {
  const [isShowing, setIsShowing] = useState(false);
  const openModal = () => {
    setIsShowing(true);
  };
  return (
    <div>
      <button onClick={openModal}>Open modal</button>
      {isShowing && <Modal />}
    </div>
  );
}

export default App;

위와 같은 모달 패턴은 modal의 open 여부를 판단하는 boolean state를 따로 선언해야 하고 만약 모달의 개수가 여러개일 경우 각각의 boolean state를 선언해야하는 번거로움이 있다. 또한 JSX return 부분에서 조건부 렌더링을 시키는 코드가 추가되어야 하기 때문에 코드가 복잡해지는 단점이 있다.
그래서 최대한 이런 패턴을 지양하고 싶었다.

Promise 모달 패턴 🔥

어떻게하면 모달 관리를 직관적인 코드로 작성하고 팀원들이 쉽게 사용할 수 있도록 할까 고민하던 찰나에 react-promise-modal 이라는 라이브러리를 발견했다. 해당 라이브러리는 Promise 객체를 이용하여 모달 상태를 비동기 패턴으로 관리할 수 있는 모달 hooks를 제공해준다.
하지만 이 라이브러리는 마지막 npm 업데이트가 2021년일 정도로 지속적인 업데이트가 되고있지 않은 상황인데다가 가장 중요한건 react-bootstrap 이라는 서드파티 라이브러리와의 결합을 해야지만 사용할 수 있었다.
react-bootstrap의 용량이 1.4MB 인것도 문제지만 이미 tailwindCSS와 framer-motion의 조합으로 UI 개발을 진행하고 있는 상황에서 모달 하나만을 위해 react-bootstrap을 적용한다는건 굉장히 비합리적이였다. 따라서 react-promise-modal을 적용하지는 않되 해당 라이브러리를 참고하여 모달을 위한 custom hook을 따로 만들기로 했다.

export type PromiseResolvePayload<Action extends string> =
  Action extends "CONFIRM"
    ? { action: Action; value: any }
    : { action: Action };

export type CloseModalProps = {
  closeModal: (
    param?: PromiseResolvePayload<"CANCEL"> | PromiseResolvePayload<"CONFIRM">
  ) => void;
};

type ModalOptions<Props extends CloseModalProps> = {
  modalComponent: React.FunctionComponent<Props>;
  modalProps?: Omit<Props, "closeModal">;
  position: "top" | "center" | "bottom";
  animation?: "bottom-up";
  overlayColor?: "black" | "white";
  isFullScreen?: boolean;
};
export default function useModal() {
  const { modals } = useSnapshot(globalModalState);

  const showModal = useCallback(
    <Props extends CloseModalProps>(options: ModalOptions<Props>) => {
      return new Promise<PromiseResolvePayload<"CONFIRM" | "CANCEL">>(
        (resolve) => {
          globalState.modals = [
            ...modals,
            {
              ...options,
              position: modalPosition[options.position],
              animation: options.animation
                ? modalAnimation[options.animation][options.position]
                : {},
              resolve,
              id: modalId++,
            },
          ];
        }
      );
    },
    [modals]
  );

  const closeModal = useCallback(
    (data: PromiseResolvePayload<"CANCEL">) => {
      const newModals = [...modals];
      const lastModal = newModals.pop();
      lastModal?.resolve(data);
      globalState.modals = newModals;
    },
    [modals]
  );

  return { showModal, closeModal };
}

위와 같이 useModal 커스텀훅 안에 showModal, closeModal 메서드 2개를 선언하였다.
showModal의 파라미터로는 required 필드(모달 컴포넌트, position)가 있고, 그 외 optional한 4가지 파라미터를 추가로 넘기게된다.
showModal을 호출할 시 Promise 객체를 return 시키며 이때 Promise callback 함수에서 globalState의 modals 배열에 push 한다.

ClientConetext.tsx 코드 일부

 return (
    <QueryClientProvider client={queryClient}>
      <Toaster position="bottom-center" duration={3000} />
      {children}
      <ReactQueryDevtools />
      {modals.map((modal) => {
        const Modal = modal.modalComponent;

        return (
          <ModalContainer
            key={modal.id}
            closeModal={closeModal}
            animation={modal.animation}
            position={modal.position}
            overlayColor={modal.overlayColor}
            isFullScreen={modal.isFullScreen}
          >
            <Modal closeModal={closeModal} {...modal.modalProps} />
          </ModalContainer>
        );
      })}
    </QueryClientProvider>
  );

파일 계층에서 최상위인 ClientContext에서 modals의 배열을 렌더링 시킨다.
ModalContainer 컴포넌트는 Overlay와 모달의 position 및 animation(framer-motion)을 조정하는 역할을 한다.

closeModal 메서드는

closeModal({ action: 'confirm' | 'cancel'})

위 형태로 호출되며 closeModal 파라미터 값은 showModal 메서드에서 return 된 Promise 객체의 resolve 파라미터로 전달되게 된다.

모달 컴포넌트 파일 생성

import { CloseModalProps } from "./hooks/useModal";
type TestModalProps = {
  title: string;
  subTitle: string;
} & CloseModalProps;

const TestModal = ({ title, subTitle, closeModal }: TestModalProps) => {
  const cancel = (): void => {
    closeModal({ action: "CANCEL" });
  };
  const confirm = (): void => {
    closeModal({ action: "CONFIRM", value: { name: "leejoonhee", age: 28 } });
  };

  return (
    <div
      className="relative overflow-y-scroll flex flex-col px-6 py-10 bg-white w-full"
    >
      <h1 className="typo-head1">{title}</h1>
      <h2 className="typo-subHead1">{subTitle}</h5>
      <div className="flex gap-x-2 mt-4">
        <CommonButton
          theme="light"
          onClick={() => {
            cancel();
          }}
        >
          CLOSE
        </CommonButton>
        <CommonButton
          theme="primary"
          onClick={() => {
            confirm();
          }}
        >
          CONFIRM
        </CommonButton>
      </div>
    </div>
  );
};

export default TestModal;

showModal 호출

const { showModal } = useModal();

  const onHandleModalButton = async () => {
    const testModalRes = await showModal({
      modalComponent: TestModal,
      modalProps: { title: "Test", subTitle: "Test" },
      position: "bottom",
      animation: "bottom-up",
    });

    if (testModalRes.action === "CONFIRM") {
      console.log("confirm", testModalRes.value);
      await axios.get('/test/confirm', { params: testModalRes.value })
    } else {
      console.log("cancel");
    }
  };

예시와 같이 async/await 문법을 사용하여 비동기적으로 showModal 메서드를 호출하게 된다. 모달 response로부터 "CONFIRM" | "CANCEL" 액션을 return 받은 후 액션값에 따라 로직을 분기처리 할 수 있다. 또한 더이상 모달 상태에 대한 코드를 작성하지 않아도 되기 때문에 매우 직관적인 코드 작성이 가능하다!

이와같이 Modal 공통 모듈을 만들었으며 오프라인 코드리뷰 시간에 팀원들에게도 매우 긍정적인 피드백을 받았다. 현재 진행형으로 팀원들과 같이 모듈을 사용하면서 좀 더 보완할 수 있는 방법을 지속적으로 찾고있다.

🌟 작업을 하며 마주쳤던 이슈

작업을 대강 마무리하고 실기기로 웹에서 테스트해봤을 때 PC와 Android 환경에서는 문제가 없이 잘 동작했다. 하지만 아이폰에서는 모달이 나타날 때 애니메이션이 매우 버벅이는 이슈가 있었다.
처음에는 Safari 이슈인가? 해서 chrome, whale 앱 등에서도 테스트해봤지만 버벅이는건 똑같았다.. 애니메이션을 표현하는 framer-motion 라이브러리를 다운그레이드 해보기도 하고 dev환경이라 그런가 build해서 실환경에서 테스트해보기도 하는 등 몇시간을 구른 결과 결국 근본적인 이유인 성능 최적화를 중심으로 구글링을 시작했다.

원인

성능최적화 키워드로 찾아봤을 때 원인을 금방 찾을 수 있었다.
기존에 사용하던 framer-motion을 이용한 모달 애니메이션 값을

bottom: {
      initial: { bottom: -getWindowHeight() },
      animate: { bottom: "0px" },
    }

이런 형식으로 주고 있었다. (getWindowHeight 함수는 window.innerHeight 값을 return 하는 함수)

이게 문제가 되는 이유는 다름아닌 css position 속성인 bottom 값이다. 그 이유는 top, right, bottom, left 속성 사용 시 reflow를 발생시키기 때문이다. 애니메이션을 위해 -window.innerHeight 부터 0px까지 bottom 값이 동적으로 변하였고 reflow가 계속적으로 발생하였으니 성능이 악화되는건 너무나도 당연한 결과였다.

해결

그럼 렌더링 최적화와 동시에 어떻게 모달 애니메이션을 표현하지? 를 고민하며 찾아보던 중 매우 간단하게 해결법을 찾을 수 있었다. 그건 바로 css position 속성 말고 transform 속성으로 대체하는 것.
transform은 Repaint, Reflow가 일어나지 않기 때문에 높은 성능의 애니메이션을 구현할 수 있다.

결국 기존 framer-motion 애니메이션 코드를

bottom: {
      style: { willChange: "transform" },
      initial: { transform: `translateY(${-getWindowHeight()})` },
      animate: { transform: "translateY(0%)" },
    }

이렇게 수정하니 IOS 환경 뿐만아니라 레거시 한 모바일(아이폰8)에서도 애니메이션이 매우 매끄럽게 잘 동작했다!
또한 요소의 변화를 미리 브라우저에게 알려주어 브라우저가 미리 최적화를 하게 할 수 있는 속성인 will-change 속성을 추가하여 렌더링 성능 향상을 최대한 끌어올렸다.

이 이슈를 접하면서 내가 간과하고 있었던 브라우저 렌더링 과정의 중요성을 다시 한번 느끼게 해주었고 조만간 브라우저 렌더링 과정에 대한 글을 정리하여 업로드 할 예정이다.

참고자료
react-promise-modal
브라우저 렌더링 성능 최적화

1개의 댓글

comment-user-thumbnail
2023년 7월 21일

모노레포 구조에 대한 설명과 모달 작업에 대한 내용이 잘 풀어져 있어서 이해하기 쉬웠습니다. 특히, Promise 모달 패턴과 Custom Hook 사용에 대한 부분이 도움이 많이 되었습니다. 아이폰에서의 성능 이슈를 해결한 부분에 대한 설명도 매우 인상적이었습니다. 브라우저 렌더링 성능 최적화에 대한 글도 기대하겠습니다. 좋은 글 감사합니다!

답글 달기