개발새발 로그

[2024-03-07] 팀 프로젝트 개인 회고 - Carousel 버튼 스크롤 연타(?) 방지, Modal의 Reflow와 Repaint 본문

카테고리 없음

[2024-03-07] 팀 프로젝트 개인 회고 - Carousel 버튼 스크롤 연타(?) 방지, Modal의 Reflow와 Repaint

이즈흐 2024. 3. 7. 03:27

1. Carousel 버튼 스크롤 기능이 이상해!

Caruousel 버튼을 누를 때 수행하는 로직은 아래와 같았다.

  const buttonScrollLeft = () => {
    if (containerRef.current) {
      containerRef.current.scrollLeft -= childSize;
  	}
  }

근데 이렇게 하면 스크롤이 딱딱하게 증가하거나 감소하게 되었다.

슬라이드 하는 것처럼 부드럽게 스크롤 되게 하고싶어서  scrollBy()에 behavior: "smooth" 라는 속성을 이용했다.

이를 이용하면 버튼을 누른 뒤 부드럽게 스크롤이 되었다.

 

하지만 이를 통해서 문제점이 생겼다.

 

지금 Carousel은 스크롤 값으로 슬라이드가 되고있다.

그러다 보니 모든 슬라이드가 정렬된 상태로 보일 수 있도록 

Carousel 내부의 아이템들의 사이즈, 즉 width값에  맞춰서 슬라이딩이 되도록 했다.

 

근데 이렇게  스크롤 값으로 관리하다보니 문제가 생긴 것이다.

Carousel의 슬라이드 버튼을 연속해서 누르면 스크롤이 smooth 되는 와중에 또 스크롤 함수가 실행되어서

사이즈(width)에 맞게 슬라이드 되던 Carousel이 그 중심 스크롤 값을 잃게 되어 

슬라이드가 끊기게 되는 현상이 생겼다.

연타해서 클릭하면 슬라이드 구간이 안맞게 되는 모습

그래서 해결 방법을 찾아보니 슬라이딩 중일 때의 상태를 저장해서 버튼을 클릭 후 슬라이드가 끝날 때 까지 기다리는 방법이 있었다.

 

isButtonScroll 이라는 State를 만들고 버튼을 클릭하면 true로 만든다.

이때 함수는 isButtonScroll이 false일 때만 작동하게한다.

그리고 isButtonScroll은 버튼 스크롤이 끝날 때 false로 초기화한다.

 

위 조건으로 만들어봤지만 해결되지않았다.

사실 당연한 결과인데 state는 비동기적으로 동작한다.

setState 함수를 호출해도 상태가 바로 업데이트되지 않고, React가 상태 업데이트를 일정한 시점에 일괄적으로 처리한다.

 

그래서 아무리 스크롤이 시작됨을 코드로 동작시킨다해도 모든 로직이 끝난 뒤에야 state가 갱신된다.

즉 무의미한 것이다.

 

그래서 이 부분을 어떻게 해결할까 하다가 setTimeout을 사용하기로 했다.

state의 값을 set함수로 바꾸고,

스크롤 동작 부분을 setTimeout으로 감싸서 실행하면

state가 업데이트 되고 나서 setTimeout내부에 있던 함수가 실행되므로 타이밍 이슈가 해결된다.

 

그럼 이게 왜 되는 것일까?

setTimeout은 비동기다.

비동기 작업은 이벤트 루프에 의해서 처리된다.

setTimeout은 주어진 지연시간이 지난 후에 콜백 함수가 실행된다.

근데 0초로 지정해도 즉시 실행되지는 않는다.

이벤트 루프를 통해 현재 진행중인 스크립트가 완료될 때 까지 실행되지 않고, 이후 실행이 된다.

그래서 setIsButtonScroll(true); 가 완료되고 나서 실행되므로 isButtonScroll 상태가 true로 설정된 후에 스크롤 작업이 수행된다.

이로 인해 연속 클릭에 의해 함수가 여러 번 실행되는 것을 막을 수 있는 것이다.

 

그럼 아래와 같이 작성할 수 있다.

 const buttonScrollRight = () => {
    if (!isButtonScroll) {
      setIsButtonScroll(true);
      console.log(isButtonScroll);
      setTimeout(() => {
        if (containerRef.current) {
          containerRef.current.scrollBy({
            left: childSize,
            behavior: "smooth"
          });
          setCurrentElement((prev) => prev + 1);
        }
      }, 0);
    }
  };

 

 

하지만 setTimeout을 사용하는 것은 권장되지 않는다고한다.

만약 setTimeout 내부의 함수를 실행하기 전에 컴포넌트가 언마운트 된다면 

setTimeout이 종료되지 않아 메모리 누수가 될 수 도 있다고 한다.

-> 이 부분을 자세히 알고자 예제를 가져와보았다.

import React, { useState, useEffect } from 'react';

function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timerId = setTimeout(() => {
      setCount(count + 1);
    }, 1000);

    return () => {
      clearTimeout(timerId);
    };
  }, [count]);

  return <div>{count}</div>;
}

 

 

이 예제에서 useEffect 내부에서 setTimeout을 사용하여 count 상태를 1초 후에 변경하고 있다.

그리고 컴포넌트가 언마운트될 때 clearTimeout을 호출하여 타이머를 취소한다.

 

그런데 만약 count 상태가 1초 이내에 여러 번 변경된다면, 이전에 만들어진 setTimeout 예약들이 취소되지 않고 누적될 수 있다.

누적된 setTimeout 들이 제대로 clearTimeout이 되지않고 컴포넌트가 언마운트 되면 메모리 누수가 발생한다.

 

왜 메모리 누수가 발생하냐면

setCount 함수가 이미 존재하지 않는 컴포넌트의 상태를 변경하려고 시도하게 되고,

이것이 메모리 누수를 일으키는 원인이라고 한다.

 

또한 setTimout을 사용하면 콜백 함수가 실행되는 시점의 컨텍스트가 setTimeout을 호출한 시점의 컨텍스트와 다를 수 있습니다.

쉽게 말해서 예측이 어려운 코드가 된다.

 

그래서  setTimeout은 되도록이면 지양해야한다고 한다.

 

 

위와 같은 이유로 useEffect를 사용해서 아래처럼 기능할 수 있을까 했다.

const [scrollDirection, setScrollDirection] = useState(null);

const buttonScrollLeft = () => {
  if (containerRef.current && !isButtonScroll) {
    setIsButtonScroll(true);
    setScrollDirection('left');
  }
};

const buttonScrollRight = () => {
  if (!isButtonScroll) {
    setIsButtonScroll(true);
    setScrollDirection('right');
  }
};

useEffect(() => {
  if (isButtonScroll && containerRef.current) {
    if (scrollDirection === 'right') {
      containerRef.current.scrollBy({
        left: childSize,
        behavior: "smooth"
      });
      setCurrentElement((prev) => prev + 1);
    } else if (scrollDirection === 'left') {
      containerRef.current.scrollBy({
        left: -childSize,
        behavior: "smooth"
      });
      setCurrentElement((prev) => prev - 1);
    }
    setIsButtonScroll(false);
  }
}, [isButtonScroll, scrollDirection]);

하지만 이 코드 역시 smooth의 스크롤 기능때문에 스크롤이 되는 도중에 클릭할 수 있었다. -> 사실 당연하다..!

그래도 위 코드처럼 만약 state가 변경된 직후 처리가 필요하다면 위와 같이 이용할 수 있다는 점을 알게 되었다.

 

그래서 메모리 누수부분이 걱정되어 아래처럼 clear 해주는 로직도 구현해보았다

  const [timerId, setTimerId] = useState(null);

  useEffect(() => {
    return () => {
      if (timerId) {
        clearTimeout(timerId);
      }
    };
  }, [timerId]);

  const buttonScrollLeft = () => {
    if (containerRef.current && !isButtonScroll) {
      setIsButtonScroll(true);
      const id = setTimeout(() => {
        if (containerRef.current) {
          containerRef.current.scrollBy({
            left: -childSize,
            behavior: "smooth"
          });
          setCurrentElement((prev) => prev - 1);
        }
      });
      setTimerId(id);
    }
  };

  const buttonScrollRight = () => {
    if (!isButtonScroll) {
      setIsButtonScroll(true);
      const id = setTimeout(() => {
        if (containerRef.current) {
          containerRef.current.scrollBy({
            left: childSize,
            behavior: "smooth"
          });
          setCurrentElement((prev) => prev + 1);
        }
      }, 0);
      setTimerId(id);
    }
  };

실제로는 적용하지 않았다. 

컴포넌트 내에서 처리가 오래 걸리는 상황이 없기 때문에

setTimeout을 0초로 설정한 것을 통해 발생하는 메모리 누수 문제는 예상되지 않았다.

 

그래서 현재는 setTimeout을 사용한 채로 진행하고 있지만 이 부분은 더 공부해서 추후에 리팩토링을 고려해봐야할 것 같았다.

 

 

 

2. 이전에 개발한 useModal의 문제점

 

이전 시간에 개발했던 useModal을 피드백 받았다.

멘토님께서 피드백 해주신 부분은 아래와 같다!

 

1. 현재 useModal에서 모달을 display : none 속성을 사용해서 보이지 않게하고, 열릴 때 애니메이션을 위해서 setTimeout을 사용했는데 이 부분은 잘못된 부분 같다.
2. 현재 Modal이라는 함수형 컴포넌트가 useModal 내부에 있는데 만약 useModal 내부의 상태가 바뀌면 Modal 컴포넌트는 리-렌더링이 발생한다.

 

 

첫 번째 문제 - display:none을 사용해서 Modal을 만들면 안된다.

1번은 아주 중요한 내용이었다.

이 부분은 reflow와 repaint에 관한 내용이었는데 

이를 알기 위해서는 브라우저 렌더링 과정을 봐야한다.

 

브라우저 렌더링 과정

첫번째 -  DOM Tree와 CSSOM Tree 결합

HTML을 파싱해서 DOM Tree 생성

그러다가 link를 만나면 CSS 파싱 -> CSSOM Tree

 

이후 attachment라는 과정을 거치며 Render Tree라는 것을 생성

-> 여기에 대해서 간략히 설명하면

 각 노드에는 attach라는 메소드가 있으며, 해당 노드가 추가되면 메소드를 실행시킨다.

이 attach는 노드의 스타일을 객체 형태로 리턴시키면서, 둘이 합쳐지는 과정이다.

 

여기서 알아야할 점은 DOM과 Render Tree가 항상 일치하지는 않는다고 한다.
Render Tree는 문서의 시각적 측면에서 올바른 순서대로 내용을 그리도록 하기 위한 목적을 갖고 있다.

 

예를 들어 렌더 트리는 display: none의 경우, 화면상에 나타나지 않으므로 불필요하다고 판단, 트리에서 제외시켜버린다.

head와 같은 것들도 마찬가지로 제외시킨다.

 

두번째 - 레이아웃(Reflow)

레이아웃에서는 렌더 트리의 목적에 맞게, 각 요소의 구체적인 위치와 크기를 연산해낸다.

 

최신 브라우저에서는 레이아웃 과정 이후 update Layer Tree를 생성해낸다고 한다. - 추후 정리

 

결과적으로 이 레이어 트리를 브라우저에 픽셀로 렌더링하는 페인팅 과정을 거치게 된다.

각 노드를 거치면서 paint() 메서드를 호출한다.

 

이후에는 최신 브라우저의 경우 합성(composite) 단계가 조건적으로 발생한다.

이 단계는 생성된 레이어들을 합성하여 단 한장의 비트맵으로 만든다.

 

각 레이어 별로 paint 되기 떄문에 불필요한 paint를 줄여 효율적으로 그릴 수 있다.

 

4. Render Tree -> Layout 산출
(최신 브라우저의 경우 Layer 단계 발생)
5. Render Tree -> Painting 처리
(최신 브라우저의 경우, 만약 Renderer Layer가 2개 이상이면 Composite 단계 발생)

 

세번쨰 - Reflow, Repaint

브라우저는 계속해서 같은 스타일로 있지 않는다.

개발자의 의도에 따라 어떤 노드에 무엇을 추가하고, 어떤 요소에는 스타일이 달라지기도 한다.

 

또한 브라우저 크기를 조정하는 경우에도 다시 계산된다.

 

이럴 때 발생하는 것이 Reflow와 Repaint다.

스타일이나 DOM 내부를 변경하는 DOM API가 사용된다면

DOM은 변경을 감지하고, 브라우저 렌더링과정을 반복하고, 리-렌더링을 진행한다.

 

먼저, 레이아웃의 경우 다음과 같이 Paint와 Composite과정 모두를 하게 된다,.

JS, CSS 파싱 → 렌더 트리 구축 → 레이아웃 → 리페인트 → 레이어 업데이트 → 합성

만약 리페인트만 한다면,

JS, CSS 파싱 → 렌더 트리 구축 → 리페인트 → 레이어 업데이트 → 합성

만약 둘 다 필요 없는 스타일의 변화라면, 다음과 같겠습니다. 성능을 따진다면 가장 이상적이다.

JS, CSS 파싱 → 렌더 트리 구축 → 레이어 업데이트 → 합성

 

따라서, 우리는 레이아웃과 리페인트가 일어나도록 유발하는 스타일 속성이 무엇인지를 인지해야 한다.

 

Reflow가 발생하는 메서드

 

https://velog.io/@young_pallete/Reflow-Repaint%EC%9D%84-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90

 

위 처럼 장황하게 설명했지만 간략하게 말하면 Reflow가 발생하는 메서드는 사용을 자제해야한다.

내가 기존에 useModal을 만들 때 display: none에서 flex로 변환하는 과정이 있었고, 

이 과정이 Reflow를 일으키고 있었다.

 

그래서 해결 방법은 transform를 사용하는 방법이었다.

translate로 뷰포트에서 보이지 않는 공간에 Modal을 배치하고,

Modal을 open할 때 transform 를 통해서 보여지게 하는 것이다.

 

그럼 기존에 발생하던 Reflow대신 Repaint만 일어나게 되고,

애니메이션을 위해 사용했던 setTimeout도 제외하고 사용할 수 있게 된다.

 

 

두 번째 문제 - useModal내에서 Modal을 선언한다? 위험한 로직

나는 useModal이라는 커스텀 훅을 만들어서 훅 사용자가 정말 간편하게 Modal을 사용하도록 만들고 싶었다.

 

그러기 위해서는 useModal이라는 훅 내에서 Modal 컴포넌트까지 만들어서 모달의 상태와 같이 반환해주는 로직으로 만들었다.

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;

이제와서 코드를 넣는 것이 조금 이상하지만 위 코드를 봐야 설명이 될 것 같아서 가져왔다.

 

첫 번째 직면한 문제 - SSR에서 인식하지못하는 document

여기서 첫번째 문제는 createPortal에 두 번째 인자로 준 값이 문제였다.

만약 주어진 elementId가 없다면 지정된 id에 연결하는 것이다.

 

나는 nextjs에서 이를 구현하고 있었다.

그래서 기존에 if (!isOpen) return <></>;로 SSR에서 document.getElementById로 요소를 찾지못하는 문제를 해결 했었는데 이로 인해 Modal이 open이 안되어있으면 createPortal이 애당초 실행이 안되어있게 된다.

그래서 모달을 킬때의 애니메이션은 실행되지만 끌 때 애니메이션은 return <></>로 인해 그냥 꺼지게 된다.

 

그래서 useState를 사용해서 createPortal에 지정하는 portalElement를 state에 저장하고 useEffect를 통해서 렌더링 후에 portalElement를 업데이트 해주었다.
그렇게 되면 렌더링 이후에는 모달이 꺼져도 화면에는 사라진 것처럼 보이지만 DOM Tree에서는 남아있게 된다.

그리고 SSR에서 문제였던 분기처리는 if (!portalElement) return null;로 처리해주었다. 그럼 서버사이드렌더링에서 createPortal할 대상을 못찾는 것도 해결할 수 있게 된다.

 

두 번째 직면한 문제 - keyFrame 애니메이션을 등록했을 때 문제

근데 문제는 최초 렌더링이 됐을 때 꺼지는 애니메이션이 작동을 하게 된다.

즉 새로고침을 누르면 isOpen 이 false일 때 적용했던 animation이 작동하게 되는 것이다.

그래서 차라리 animation을 사용하지 않고,

isOpen값으로 scale값이나 translate 값을 ture일때 false일 때를 분기처리해줘서 transition효과를 주려고했다.

 

세 번째 직면한 문제 - useModal의 한계(내부에 컴포넌트를 선언하면 안되는 이유)

근데 transition 효과가 적용이 안된채로 모달이 작동했다.

이 문제를 찾아보니 useModal내에서 상태가 변하면 useModal 내의 선언했던 함수나 변수가 초기화되니까
useModal 내의 함수형 컴포넌트 Modal이 초기화되고 렌더링이 되어 그런 것이었다.

그래서 Modal과 useModal을 분리해줘야했다.

 

네 번째 직면한 문제 - 외부 클릭시 모달 닫는 useClickAway의 오류

Modal과 useModal을 분리하고 나서 ModalContent 자체도 풀어서 jsx엘리멘트가 그대로 출력하게했다


그랬더니 이전에 개발할 때 생겼던 useClickAway의 ref가 등록이 안되는 문제가 생겼다.
왜냐하면   if (!portalElement) return <></>; 로 인해서 ref가 등록이 안된 채로 렌더링이 되기 때문에 모달이 열려서 ref가 바뀐다고 한들 useClickAway에서는 ref가 등록이 안돼서 useClickAway에서 만든 기능이 무용지물이 되었다.

그래서 외부를 클릭하면 모달을 어떻게 닫아줄까 생각하다가

모달 내부에는 e.stopPropgation()으로 이벤트버블링을 막아주고, overlay부분의 onClick 이벤트에 close() 함수를 넣어줬다.

그래서 내부를 클릭해도 상위에 등록된 이벤트를 실행하지 않게한다.

하지만 이 부분은 overlay자식요소에 Modal 요소가 있을 때의 문제였고,
나는 overlay를 내부요소와 형제요소로 두었기 때문에 안해도 됐다.

 

그래서 최종적으로 아래 두 코드가 만들어 졌다.


const animateIn = {
  grow: "scale-100 mx-auto",
  slide: "translate-x-0",
  raise: "translate-y-[calc(50vh-70%)]"
};
const animateOut = {
  grow: "scale-0 mx-auto",
  slide: "translate-x-[100vw]",
  raise: "translate-y-[100vh]"
};

const Modal = ({
  children,
  className,
  width = 100,
  height = 100,
  modalType = "default",
  elementId = "global-modal",
  animate = "grow",
  close,
  isOpen = false
}: ModalProps) => {
  const [portalElement, setPortalElement] = useState<Element | null>(null);

  useEffect(() => {
    setPortalElement(document.getElementById("global-modal"));
  }, []);

  if (!portalElement) return null;

  return createPortal(
    <>
      <div
        onClick={close}
        className={cn(
          "fixed top-0 left-0 bottom-0 right-0 w-screen h-[100vh] z-20 dark:bg-gray-100/50 bg-black/50 transition-opacity duration-500",
          isOpen ? "opacity-100 translate-x-0" : "opacity-0 translate-x-[100vw]"
        )}
      />
      <div
        className={cn(
          modalTypeVariants({ modalType }),
          isOpen ? ` ${animateIn[animate]}` : ` ${animateOut[animate]}`,
          className
        )}
        style={{
          width: `${modalType === "fullScreen" ? "100%" : `${width}px`}`,
          height: `${modalType === "fullScreen" ? "100%" : `${height}px`}`
        }}>
        {children}
      </div>
    </>,
    modalType === "fullScreen"
      ? portalElement!
      : document.getElementById(elementId)!
  );
};

export default Modal;

 

"use client";

import { useState } from "react";

interface UseModalResult {
  open: () => void;
  close: () => void;
  isOpen: boolean;
}

const useModalState = (): UseModalResult => {
  const [isOpen, setOpen] = useState<boolean>(false);

  const open = () => {
    setOpen(true);
  };

  const close = () => {
    setOpen(false);
  };

  return { open, close, isOpen };
};
export default useModalState;

 

Modal 컴포넌트와 useModalState라는 것을 만들어서 사용하게끔 했다.

사용자가 useModalState를 사용해야한다는 점이 아쉬웠지만 이를 통해서 Modal의 유지보수가 자유로워졌다.

Modal의 애니메이션을 추가하거나 위치를 추가할 때 그냥 animateIn과 out의 객체에다가 추가해주기만 하면 손쉽게 추가 요구사항을 구현했다.

 

나중에는 useModal 커스텀훅을 다시 시도해보고싶다.

useModal 자체는 훅 사용자가 정말 사용하기에 간편해보였다..

728x90
반응형
LIST