Reflow를 줄여 애니메이션 최적화 하기!

이은서·2023년 2월 14일
4

저는 현재 간단한 리액트 라이브러리를 만들고 있습니다. 만들면서 애니메이션 최적화 한 경험을 공유해 보려고 합니다.

라이브러리 구현 내용

계층 구조로 만들어 폴더를 열고 닫을 수 있는 라이브러리입니다. 이 라이브러리를 구현한 방식을 소개해 드리도록 하겠습니다. 폴더를 overflow를 hidden으로 만들고 transition을 height으로 줘서 클릭할 경우 height 값을 바꿔주어야 합니다. 하지만 단순히 클릭된 엘리먼트의 height만 바꿔주면 안됩니다.

부모에도 height가 지정되어 있기때문에 밑에 있던 값이 밀려서 영역 밖으로 나가서 보여지지 않습니다. 즉 폴더를 열고 닫을 때 부모의 height도 같이 바꿔줘야 합니다. 닫은 폴더의 자식영역의 크기는 scrollHeight을 통해 가져올 수 있었고 저는 재귀함수를 통해 해당 요소부터 root까지 재귀함수를 돌면서 그 길이만큼 height를 변경시켜 주었습니다.

  const onClick = () => {
    ...
    recursiveChangeHeight(
          childrenRef.current,
          childrenRef.current.scrollHeight
    );	
  }

  ...
  const recursiveChangeHeight = useCallback(
    (element: HTMLElement, changeHeight: number): any => {
      const treeType = element.getAttribute("tree-type");
      if (treeType === "root") return;
      if (treeType === "folder-children") {
        let elementHeight = parseInt(element.style.height.replace("px", ""));
        elementHeight += changeHeight;
        element.style.height = elementHeight + "px";
      }

      if (element.parentElement === null) return;

      return recursiveChangeHeight(element.parentElement, changeHeight);
    },
    []
  );

버튼이 눌릴 때 폴더의 자식요소이면 늘거나 줄어들 길이만큼 height을 변경해줍니다. 변경을 하고 부모 element를 인자로 넣어줘서 다시 recursiveChangeHeight함수를 호출하고 이것을 root까지 반복할 수 있게 합니다. 이러면 부모의 height도 원하는 길이만큼 줄고 정상적으로 화면에 보이면서 처음에 봤던 라이브러리가 구현됩니다.

하지만 이것은 문제가 있습니다. height를 바꾸어주기 때문에 reflow가 애니메이션이 돌아가는 프레임 내내 발생합니다.

Reflow

우선 Reflow가 무엇인지 부터 간단하게 집고 넘어가겠습니다. 웹 브라우저에서 우리가 보는 화면을 나타내기 위해선 많은 작업들을 진행해야 합니다.

이 그림에서 처럼 HTML과 CSS를 파싱하고 그것을 이용해 Redner tree를 만들고 화면에 나타나게 됩니다.
물론 이 과정 외에도 각 레이어들을 합치는 Composite 과정이 있지만 우선 생략하도록 하겠습니다.
Render Tree과정을 거치면서 화면에 그려지기 전에 각 요소들을 브라우저 화면의 어느 위치에 어느 크기로 출력 해주어야 할지 정해야합니다. 즉 각 요소들을 배치하는 단계라고 할 수 있겠죠. 그래서 특정한 액션에 의해서 요소의 크기나 위치가 변경되면 Reflow가 발생합니다.

하지만 이렇게 배치하는 작업은 공짜가 아닙니다. Reflow가 일어나면 CPU가 이를 처리해야 하기 때문에 이를 최소화 하는 것이 좋습니다.

실제 Reflow가 일어나는 것을 확인해보자

그러면 실제 방금 구현한 라이브러리가 정말 Reflow가 일어나는지 확인부터 해보겠습니다.
이것은 Chrome의 Performance탭에서 확인할 수 있습니다.


여기서 보면 Layout이 발생하는데 이것이 Reflow 입니다.

좀 더 축소해서 보면 애니메이션이 진행되는 동안 각 프레임마다 reflow가 일어나게 되죠.

이걸 굳이 수정해야할까?

요즘 PC성능도 좋아지고 별 문제 없어보이는데 굳이 이걸 최적화를 해야할까 싶을 수도 있지만 제가 만드는 것은 라이브러리입니다. 실제 서비스를 구현하다보면 무거운 작업들이 많이 들어갈 수도 있는데 그때 제가 만든 라이브러리에서 반복적으로 reflow가 일어나면 추가적인 성능 저해가 발생할 수도 있습니다. 할 수 있는 한 최대한 성능을 최적화 하여 다른 작업들에 더 신경 쓸 수 있게 해야하지 않을까 라는 생각이 들었고 이것을 최적화 하였습니다.

어떻게 최적화 할까?

애니메이션에 height를 쓰지 않으면 됩니다.

height를 쓰지 않고 clip-path로 보여지는 영역만 바꿔줄 수도 있지만 clip-path는 무거운 작업입니다. 실제로 clip-path로 구현해본 결과 recalculate style에서 더 많은 시간이 걸려 오히려 성능이 악화되었습니다. 사파리에서는 clip-path 애니메이션이 지원되지도 않습니다.

각 요소들을 영향받지 않게 absolute로 바꾸고 transform과 opacity만을 이용해서 최대한 비슷하게 구현해볼 수 있습니다. transform과 opacity만 이용하면 reflow는 물론 repaint도 일어나지 않고 gpu에서 연산처리 하게 됩니다.

그러면 이걸 이용해서 다음과 같이 수정을 해보도록 하겠습니다.

자식 요소들 원하는 위치로 이동

자식 요소들을 translateY를 통해서 위치 이동을 시켜주어야 합니다. 열 때는 자신의 앞의 sibling 요소들의 길이 만큼 더 이동시켜주어야 하고 닫을 때에는 자신의 영역만큼 더 위로 이동해야합니다.

  const startAnimationSetting = (): number => {
    if (!childrenRef.current) return 0;

    let childrenHeight = 0;
    for (const child of childrenRef.current.children) {
      if (isOpen) {
        (
          child as HTMLElement
        ).style.transform = `translateY(${-child.scrollHeight}px)`;
      } else {
        (
          child as HTMLElement
        ).style.transform = `translateY(${childrenHeight}px)`;
      }
      childrenHeight += recursiveGetChildrenHeight(child as HTMLElement);
    }

    return childrenHeight;
  };

자신의 sibling요소의 길이를 얻기위해 recursiveGetChildrenHeight을 호출하여 누적시켜줍니다.
그럼 recursiveGetChildrenHeight을 보도록 하겠습니다.

자식영역 길이 구하기

기존에는 scrollHeight을 통해 자식 height를 가져올 수 있었지만 이제는 absolute로 설정했기 때문에 가져올 수 없습니다. 자식에도 folder가 있을 수 있고 그 폴더가 열려있는지 닫혀있는지에 따라 길이가 바뀔 수 있기 때문인데요. 그 영역을 계산하는 것 먼저 구현을 해보겠습니다.

  const recursiveGetChildrenHeight = (element: HTMLElement): number => {
    const treeType = element.getAttribute("tree-type");

    if (
      treeType === "folder-children" &&
      element.getAttribute("tree-open") === "false"
    ) {
      return 0;
    }

    if (treeType === "folder" || treeType === "folder-children") {
      let childrenHeight = 0;

      for (const child of element.children) {
        childrenHeight += recursiveGetChildrenHeight(child as HTMLElement);
      }

      return childrenHeight;
    }

    return element.scrollHeight;
  };

요소가 폴더가 아닌 단순 element일 경우 scrollHeight을 더해주고 요소가 폴더일 경우 닫혀있다면 길이에 변화가 없으니 0을 반환하고 열려있다면 자식들의 height을 재귀적으로 받아와 값을 계산 해주었습니다.

요소 밀어내기

translateY는 밑에 요소들을 밀어낼 수 없습니다. 애초에 밀어냈다면 Reflow가 일어났다는 뜻이겠죠. 직접 요소들을 밀어내도록 해야합니다.

folder에서 같은 depth에 있는 형제들을 밀어내주면 됩니다.

  const moveSiblingElements = (changeHeight: number) => {
    let nextSibling = childrenRef.current?.parentElement?.nextElementSibling;

    while (nextSibling) {
      const nowSibling = nextSibling as HTMLElement;
      const regex = /[-]?\d+(.\d+)?px/gi;
      let nowTransform = nowSibling.style.transform.match(regex);
      if (nowTransform) {
        nowSibling.style.transform = `translateY(${
          parseFloat(nowTransform[0].slice(0, -2)) + changeHeight
        }px)`;
      } else nowSibling.style.transform = `translateY(${changeHeight}px)`;

      nextSibling = nextSibling.nextElementSibling;
    }
  };

이전에 연산했던 자식 영역의 변동사항 만큼 밀어내거나 당겨오면 됩니다. 마찬가지로 기존의 translateY를 정규식을 통해 값을 빼오고 변경 후 다시 적용시켜 주었습니다.

길이 적용 시키기

자 그러면 이제 원하는 자식 영역의 길이도 구해낼 수 있습니다. 그러면 이것을 적용해야 하는데요. 폴더를 클릭했을 때 애니메이션 작업이 시작되어야 하기 때문에 click event listener에서 설정을 해주어야 합니다.

  const handleFolderClick = (
    e: React.MouseEvent<HTMLDivElement, MouseEvent>
  ) => {
	...

    let changeHeight = startAnimationSetting();
    changeHeight = isOpen ? -changeHeight : changeHeight;

    moveSiblingElements(changeHeight);
    if (changeHeight < 0) {
      setTimeout(() => {
        recursiveChangeHeight(childrenRef.current, changeHeight);
      }, 500);
      childrenRef.current.style.opacity = "0";
    } else {
      recursiveChangeHeight(childrenRef.current, changeHeight);
      childrenRef.current.style.opacity = "0.99";
    }
  };

recursiveChangeHeight은 이전에 작성한 함수입니다. 닫을 때에는 바로 height를 바꿔주면 영역이 가려지기 때문에 애니메이션이 끝난 후인 500ms 후에 자신과 부모 요소들의 실제 Height을 바꿔주게 됩니다.

다시 정리를 해보겠습니다.
1. startAnimationSetting에서 자식요소들의 위치를 설정해줌
2. 변화될 길이를 startAnimationSetting을 통해 가져옴
2. 그 길이만큼 sibling 요소들을 이동시켜줌
3. 실제 height에 적용하기 위해 처음에 작성한 recursiveChangeHeight함수를 호출하고 닫을 때에는 영역이 가려지기 때문에 애니메이션 끝난 후에 적용

결과 확인

이제 다 수정하였으니 다시 Chrome의 Performance에서 확인을 해보도록 하겠습니다.

우선 layout shift가 사라졌습니다. 또한 gpu를 통해 계산을 하기 때문에 task 자체가 없습니다.
그럼 확대를 해서 보도록 하겠습니다.

layout이 사라져있습니다!

마무리

transform과 opcacity를 이용하여 애니메이션을 최적화 해보았습니다.
해당 라이브러리 전체 코드는 https://github.com/eunseo9808/react-hierarchy/tree/main 이 곳에서 확인할 수 있는데 아직 개발중에 있습니다.
읽어주셔서 감사합니다.

profile
취준 프론트엔드 개발자

0개의 댓글