개발새발 로그

[2024-02-23] 팀 프로젝트 회고 - useModal을 만들면서, 불필요한 렌더링 본문

TIL

[2024-02-23] 팀 프로젝트 회고 - useModal을 만들면서, 불필요한 렌더링

이즈흐 2024. 2. 23. 13:11

useModal을 만들면서...

1. 무엇을 만들려고 했지?

나는 아래와 같은 모달을 만들려고했다.

사실 이게 모달인지 아닌지 구분이 가질 않았는데 

당근마켓을 보니까 아래처럼 백그라운드에 오버레이가 생기고, 모달처럼 grow 애니메이션이 나온다.

그리고 다른 페이지에서 아래 중고나라의 메뉴창처럼 왼쪽에서 오른쪽으로 슬라이드해서오는 것이 있는데 
이 부분도 url 주소창이 바뀌지 않는 걸로 봐서 모달로 판단되었다.

 

그래서 나도 내가 필요한 컴포넌트를 구현할겸 useModal을 구현하기로 한다.

참고로 현재 NextJS에서 구현 중이다!

2. 모달은 그냥 css로 만들면 안될까?

나는 처음에 아래와 같이 css로만 모달을 구현하고 Modal을 컴포넌트로 만들어서 구현했다.

"use client";

import useClickAway from "@/app/hooks/useClickAway";
import { CSSProperties, ReactNode } from "react";

interface ModalProps {
  children: ReactNode;
  width?: number;
  height?: number;
  visible?: boolean;
  onClose?: () => void;
  style?: CSSProperties;
}

const Modal = ({
  children,
  width = 500,
  height,
  visible = false,
  onClose,
  ...props
}: ModalProps) => {
  const ref = useClickAway<HTMLDivElement>(() => {
    onClose && onClose();
  });

  return (
    <div
      className="fixed top-0 left-0 w-screen h-screen bg-[rgba(0,0,0,0.5)] z-50"
      style={{ display: visible ? "block" : "none" }}
    >
      <div
        ref={ref}
        className="absolute -translate-x-1/2 -translate-y-1/2 p-[8px] bg-white shadow-lg box-border"
        {...props}
        style={{ width: `${width}px`, height: `${height}px` }}
      >
        {children}
      </div>
    </div>
  );
};

export default Modal;

 

이를 통해서 정말 간단하게 구현이 가능했다.

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

...

<Modal visible={isOpen} onClose={() => setIsOpen(false)}>
        <ul className="text-black" onClick={handleClickStore}>
          <li id="currentRegion">우리 동네</li>
          <li id="Nationwide">전국</li>
        </ul>
</Modal>

근데 문제가 있었다.

1. 만약 아래에 요소가 있다면 모달이 그 요소 뒤로 가는 문제
2. Modal에서 Modal이 열릴 때의 문제

위와 같은 문제들이 있었고, 

조언을 받아서 createPortal을 이용하기로 했다.

 

3. createPortal

createPortal을 사용하면 일부 자식을 DOM의 다른 부분으로 렌더링할 수 있습니다.

createPortal을 이용해 요소를 최상위 요소  body의 맨 아래로 보낸다면 브라우저의 맨 앞에서 렌더링 될 것 이고,

modal에서 modal이 열릴 때도 문제없이 작동한다.

const Modal = ({
  children,
  width = 100,
  height,
  visible = false,
  onClose,
  ...props
}: ModalProps) => {
  const ref = useClickAway<HTMLDivElement>(() => {
    onClose && onClose();
  });
  if (!visible) return null;
  return createPortal(
    <>
      <div
        className={cn(
          "fixed top-0 left-0 w-screen h-screen bg-[rgba(0,0,0,0.5)]",
          visible ? "block'" : "hidden"
        )}></div>
      <div
        ref={ref}
        className={cn(
          "absolute top-5 p-[8px] bg-white shadow-lg box-border rounded-lg",
          visible ? "block" : "hidden"
        )}
        {...props}
        style={{
          width: `${width}px`,
          height: `${height}px`
        }}>
        {children}
      </div>
    </>,
    document.body
  );
};

export default Modal;

그래서 처음에 위와 같이 구현했다.

NextJS에서 document는 서버사이드 렌더링에서 인식하지 못하기 때문에 아래와 같은 분기처리를 해주었다.

  if (!visible) return null;

추가하지 않는다면 아래와 같은 오류가 나온다

 

그래서 위 코드를 실행하면 아래와 같이 문제가 생긴다.

먼저 body에 portal해주었기 때문에 body에 붙게되는 문제

useClickAway가 작동을 안하는 문제가 있었다.

 

일단 body에 붙게되는 문제는 잘 생각해야했다.

나는 기본적인 모달처럼 중앙에 뜨는 모달도 가능해야하고,

모달이 전체화면처럼 뜨는 모달도 가능해야하고,

모달이 어느 요소의 위치에 종속되는 것도 가능해야했다.

 

그래서 이를 해결하기 위해서 props에서 modalType이라는 값을 받고,

modalType은 3가지의 모달을 표현하게 해서 (dropbox,default,fullscreen)
이후 분기처리에 사용하도록 했다.

 

그리고 useClickAway가 작동하지않는 이유를 찾아보았다.

console.log로 ref가 잘 등록이 되었는지 확인하던 도중
렌더링 이후에도 계속 ref는 null을 출력하게 되었다.

 

이게 왜 그런 것인지 생각해보니 아까 위에서 작성했던 코드때문이었다.

  if (!visible) return null;

이 코드로 인해 모달이 열리기 전까지는 CreatePortal이 실행이 안되어있으므로

ref로 등록한 div는 실제로 없는 상황이다.

그래서 모든 렌더링이 끝나도 ref는 null이 되어서

모달을 열고나서 바깥 배경을 클릭해도 ref가 인식이 안되니까 useClickAway가 실행이 안된 것이었다.

 

그래서 이를 해결하기위해 생각하던 도중

createPortal을 하기전에 미리 div에 ref를 등록하면 되지않을까? 라는 생각을 하게 되었다.

모달이 열리거나 닫히거나를 통해 returen null을 하기전에 미리 ref를 등록한 함수형 컴포넌트를 만들고, 이를 모달이 열렸을 때 호출하는 방법이었다.

 

 

그래서 위 두가지의 문제를 해결하기위해 모든 코드를 뒤집어 엎어버렸다!

 

4. 재사용이 가능한 모달을 위해서..!

먼저 구현하기 전에 계획한 점을 말하려고 한다.

1. 모달은 3가지 타입으로 가능해야한다.
2. Modal 내에서 모달의 상태를 관리하는 useState를 사용한다.
3. 애니메이션이 2가지 가능해야한다.(slide, grow)
4. useClickAway가 정상적으로 작동해야한다.

일단 간단하게 4가지로 추려보았다.

 

1번을 위해서는 Tailwind의 Class-Variants-authority를 사용하고, createPortal을 사용할 때 body일 때와 특정 요소의 종속될 때를 분기처리해야했다.

 - Tailwind의 cva를 사용해야하는 이유는 3가지 모달 타입이 전혀 다른 css 타입을 갖고있기 때문이었다.

 - 다른 방안으로는 합성 컴포넌트가 있었는데 러닝커브가 있어 일단 구현 가능성을 확인하기위해 배제했다.

2번을 위해서는 Modal 컴포넌트가 아닌 useModal을 만들어야 했다.

 - useModal을 통해서 isOpen 상태값과 상태값을 바꿀 수 있는 open과 close, Modal 컴포넌트를 넘겨줘야했다.

3번을 위해서는 현재 모달이 mount되었을 때의 상태값이 필요했다.

 - 이유가 뭐냐면 지금 모달은 css로 isOpen ? "flex" : "hidden" 이렇게 되어있는 상태다.

 - 애니메이션을 그냥 적용한다하면 요소가 실제로 나타났다가 사라지는 것이므로 애니메이션이 실행이 안된다.

 - 그래서 mount되면 애니메이션을 실행할 시간을 주고, 이후 isOpen ? "flex" : "hidden" 을 적용하는 방법을 택했다.

4번을 위해서는 위에서 말했던 것처럼 미리 ref를 등록한 함수형 컴포넌트를 만드는 것이다.

 - 그러면   if (!visible) return null; 에도 영향을 받지않고 ref는 정상적으로 원하는 div요소와 연결하게 된다.

 

아래 링크를 많이 참고했다.

https://github.com/microcmsio/react-hooks-use-modal

 

GitHub - microcmsio/react-hooks-use-modal: This is a customizable modal with react-portal.

This is a customizable modal with react-portal. Contribute to microcmsio/react-hooks-use-modal development by creating an account on GitHub.

github.com

 

1번, 2번, 3번, 4번을 완성한 코드는 아래와 같다.

"use client";

import { ReactNode, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/utils/cn";
import useClickAway from "../useClickAway";
import { modalTypeVariants } from "./ModalType.variants";
import { VariantProps } from "class-variance-authority";

interface useModalParams extends VariantProps<typeof modalTypeVariants> {
  initialValue?: boolean;
  width?: number;
  height?: number;
  elementId?: string;
  modalType?: "default" | "dropBox" | "fullScreen";
  animate?: "grow" | "slide";
  className?: string;
}

export interface UseModalResult {
  Modal: ({ children }: { children: ReactNode }) => JSX.Element;
  open: () => void;
  close: () => void;
  isOpen: boolean;
}

const animateIn = {
  grow: "animate-growIn",
  slide: "animate-slideIn"
};
const animateOut = {
  grow: "animate-growOut",
  slide: "animate-slideOut"
};

const useModal = ({
  initialValue = false,
  width = 100,
  height = 100,
  modalType = "default",
  elementId = "global-modal",
  animate = "grow",
  className
}: useModalParams): UseModalResult => {
  const [isOpen, setOpen] = useState<boolean>(initialValue);
  const [isUnmount, setIsUnmount] = useState(false);

  const open = useCallback(() => {
    setIsUnmount(false);
    setTimeout(() => {
      setOpen(true);
    }, 300);
    setOpen(true);
  }, [setOpen]);

  const close = useCallback(() => {
    setIsUnmount(true);
    setTimeout(() => {
      setOpen(false);
    }, 300);
  }, [setOpen]);

  const Modal = ({ children }: { children: ReactNode }) => {
    const ModalContent = ({ children }: { children: ReactNode }) => {
      const ref = useClickAway<HTMLDivElement>(() => {
        close();
      });
      return (
        <div
          ref={ref}
          className={cn(
            modalTypeVariants({ modalType }),
            isOpen ? "flex" : "hidden",
            isUnmount ? animateOut[animate] : animateIn[animate],
            elementId ? "z-50" : "",
            className
          )}
          style={{
            width: `${modalType === "fullScreen" ? "100%" : `${width}px`}`,
            height: `${modalType === "fullScreen" ? "100%" : `${height}px`}`
          }}>
          {children}
        </div>
      );
    };

    if (!isOpen) return <></>;

    return createPortal(
      <>
        <div
          className={cn(
            "fixed top-0 left-0 bottom-0 right-0 w-screen h-[100vh] bg-[rgba(0,0,0,0.5)]",
            isOpen ? "block" : "hidden",
            elementId ? "z-50" : ""
          )}
        />
        <ModalContent>{children}</ModalContent>
      </>,
      modalType === "fullScreen"
        ? document.getElementById("global-modal")!
        : document.getElementById(elementId)!
    );
  };

  return { Modal, open, close, isOpen };
};
export default useModal;

 

특히 modalType의 3가지 형태가 분기처리에서 중요했다.

elementId라는 props가 있고, 이걸 통해서 createPortal을 정하고, fullScreen 타입을 해버리면 이상하게 나오는 버그가 있었다.

이를 막기 위해서도 분기처리를 잘 해줘야했다.

또한 아래와 같이 Tailwind의 CVA를 사용해서 더욱 가독성있고, 편리하게 3가지 modalType을 구분했다.

import { cva } from "class-variance-authority";

export const modalTypeVariants = cva("p-[8px] bg-white shadow-lg box-border", {
  variants: {
    modalType: {
      default: "fixed top-0 bottom-0 left-0 right-0 m-auto rounded-lg",
      dropBox: "absolute rounded-lg",
      fullScreen: "absolute top-0 bottom-0 left-0 right-0 "
    }
  },
  defaultVariants: {
    modalType: "default"
  }
});

 

2. 왜 3번렌더링 하는거야?

이 부분은 나중에 조언을 구하려고한다...ㅠㅠ

 

 

3. Timer 컴포넌트에서 불필요한 렌더링 발견..!

기존에 구현했던 Timer 컴포넌트를 보자

"use client";

import useTimer from "@/app/hooks/useTimer";
import { cn } from "@/utils/cn";
import Clock from "./Clock";
import Image from "next/image";
import deadlineImage from "/public/assets/images/deadline.webp";

interface TimerProps {
  deadline: Date;
  createdAt: Date;
  className?: string;
  noneIcon?: boolean;
}

const Timer = ({
  createdAt,
  deadline,
  noneIcon = true,
  className,
}: TimerProps) => {
  const timeRemaining = useTimer(deadline);
  const formatNumber = (num: number) => (num < 10 ? `0${num}` : `${num}`);

  const { days, hours, minutes, seconds } = timeRemaining;

  const isUnderOneHour = days <= 0 && hours <= 0 && minutes <= 60;
  const isTimerFinished =
    days <= 0 && hours <= 0 && minutes <= 0 && seconds <= 0;

  const totalTimeRange = deadline.getTime() - createdAt.getTime();
  const currentTimeElapsed = new Date().getTime() - createdAt.getTime();
  const percentageElapsed = (currentTimeElapsed / totalTimeRange) * 100;
  const currentAngle = Math.floor((percentageElapsed / 100) * 360);

  return (
    <div
      className={cn(
        `${isUnderOneHour ? "text-[#FF0000]" : "text-[#6874FF] dark:text-[#96E4FF]"}`,
        className
      )}
    >
      <div className="flex relative">
        {noneIcon && (
          <Clock
            rotation={currentAngle <= 360 ? currentAngle : 0}
            className={cn(
              `${isUnderOneHour && !isTimerFinished && "animate-watch"}`
            )}
          />
        )}
        <div className={cn("w-[90px] pt-[1px] pr-[2px] dark:border-white")}>
          <span aria-label="타이머">
            {formatNumber(days)}:{formatNumber(hours)}:{formatNumber(minutes)}:
            {formatNumber(seconds)}
          </span>
          {isTimerFinished && (
            <div className="absolute -bottom-2 left-8 w-[70px]">
              <Image
                src={deadlineImage}
                alt="deadline"
                className="w-full h-auto dark:saturate-200"
                priority
              />
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default Timer;

나는 이 코드에서 무언가 계속 걱정이 있었다,

useTimer라는 커스텀 훅으로 안에서는 setInterval로 타이머가 돌고, state가 변경되는데 불필요한 렌더링이 있지않을까? 라는 생각이 있었다.

 const intervalId = setInterval(() => {
      setTimeRemaining(calculateTimeRemaining(deadline));
    }, 1000);

그래서 확인할 결과 역시나 위에서 존재하던 함수나 변수, JSX Element들도 타이머와 함께 리-렌더링되고 있었다.

이를 어떻게 해결해야할까? 생각했다.

useMemo나 useCallback을 쓰면 추측하건대 렌더링이 되어도 불필요한 초기화는 막을 수 있을 것이다.

하지만 변수가 많고 이를 모두 useMemo나 useCallback을 사용하기에는 비효율적이라 생각했다.

 

그래서 이전에 배운 해결방법인 렌더링 관계 트리를 이용해 렌더링 최적화를 시도했다.

https://ydoag2003.tistory.com/416

 

React - 렌더링 최적화와 렌더링 관계 트리

렌더링이 될 때 불필요한 렌더링을 줄이기 위해서useCallback과 useMemo를 사용하곤 했다. 그러다 과연 나는 이 최적화 도구를 적절하게 사용하고 있는 것일까에 대해 의문이 들었다. 본 내용은 useCall

ydoag2003.tistory.com

 

정말 간단하게 아래와 같이 작성가능했다.

기존의 Timer 컴포넌트를 

index.tsx

Time.tsx

TimerContainer.tsx로 나누었다.

 

index.tsx

import TimerContainer from "./TimerContainer";
import Time from "./Time";

interface TimerProps {
  deadline: Date;
  createdAt: Date;
  className?: string;
  isIcon?: boolean;
}

const Timer = ({
  createdAt,
  deadline,
  isIcon = true,
  className
}: TimerProps) => {
  return (
    <TimerContainer
      deadline={deadline}
      createdAt={createdAt}
      className={className}
      isIcon={isIcon}>
      <Time deadline={deadline} />
    </TimerContainer>
  );
};

export default Timer;

 

Time.tsx

"use client";

import useTimer from "@/app/hooks/useTimer";

interface TimeProps {
  deadline: Date;
}

const Time = ({ deadline }: TimeProps) => {
  const timeRemaining = useTimer(deadline);
  const formatNumber = (num: number) => (num < 10 ? `0${num}` : `${num}`);

  const { days, hours, minutes, seconds } = timeRemaining;

  return (
    <span aria-label="타이머">
      {formatNumber(days)}:{formatNumber(hours)}:{formatNumber(minutes)}:
      {formatNumber(seconds)}
    </span>
  );
};

export default Time;

 

TimerContainer.tsx

import { cn } from "@/utils/cn";
import Clock from "./Clock";
import { ReactNode } from "react";
import Image from "next/image";
import deadlineImage from "/public/assets/images/deadline.webp";
import { calculateTimeRemaining } from "@/utils/time";

interface TimerContainerProps {
  deadline: Date;
  createdAt: Date;
  className?: string;
  isIcon?: boolean;
  children: ReactNode;
}

const TimerContainer = ({
  deadline,
  createdAt,
  className,
  children,
  isIcon = true
}: TimerContainerProps) => {
  const { days, hours, minutes, seconds } = calculateTimeRemaining(deadline);

  const isUnderOneHour = days <= 0 && hours <= 0 && minutes <= 60;
  const isTimerFinished =
    days <= 0 && hours <= 0 && minutes <= 0 && seconds <= 0;

  const totalTimeRange = deadline.getTime() - createdAt.getTime();
  const currentTimeElapsed = new Date().getTime() - createdAt.getTime();
  const percentageElapsed = (currentTimeElapsed / totalTimeRange) * 100;
  const currentAngle = Math.floor((percentageElapsed / 100) * 360);

  return (
    <div
      className={cn(
        `${isUnderOneHour ? "text-[#FF0000]" : "text-[#6874FF] dark:text-[#96E4FF]"}`,
        className
      )}>
      <div className="flex relative">
        {isIcon && (
          <Clock
            rotation={currentAngle <= 360 ? currentAngle : 0}
            className={cn(
              `${isUnderOneHour && !isTimerFinished && "animate-watch"}`
            )}
          />
        )}
        <div className={cn("w-[90px] pt-[1px] pr-[2px] dark:border-white")}>
          {children}
          {isTimerFinished && (
            <div className="absolute -bottom-2 left-8 w-[70px]">
              <Image
                src={deadlineImage}
                alt="deadline"
                className="w-full h-auto dark:saturate-200"
                priority
              />
            </div>
          )}
        </div>
      </div>
    </div>
  );
};
export default TimerContainer;

 

계속해서 리-렌더링을 발생시키는 setInterval 부분을 Time 컴포넌트로 만들어서 Children으로 컴포넌트를 호출하게 했다.

이를 통해서 번들링될때 렌더링을 발생시키는 React.createElement를 하지 않게 됨으로

TimeContainer컴포넌트로 나눈 부분은 리-렌더링을 하지 않게 된다!

 

 

기존에 배운 렌더링 관계트리가 정말 유용하게 사용되었다.

되도록이면 useCallback과 useMemo를 사용하지않고, 최적화가 가능할 때까지 생각하는 습관을 가지게 된 것 같았다.

728x90
반응형
LIST