가상 스크롤로 DOM 부하 핸들링하기

minzip·6일 전
6

문제 해결 과정

목록 보기
5/5

실시간 스트리밍 서비스인 라이부의 최종 발표 당시에 한 멘토분께서 DOM에 쌓이는 컴포넌트들을 핸들링하는 부분이 있는지 질문해주셨고, 개선책으로 생각하고 있었던 가상스크롤을 언급하며 리팩토링 계획을 말씀드릴 수 있었다.

이번 글에서는 위 리팩토링 중에 학습한 가상 스크롤의 구현 방식과, 기술 도입을 고민했던 과정에 대해 정리해보고자 한다 🚀


가상 스크롤 (Virtual Scroll)

가상 스크롤은 사용자가 보이는 영역만 렌더링하고, 스크롤을 내릴 때 필요한 요소를 동적으로 추가하는 기법이다. 이를 통해 불필요한 DOM 요소 생성을 막아 성능을 최적화할 수 있다.

위 그림에서 보이는 것처럼, Viewport는 실제 사용자가 보고 있는 영역이다. 이 영역에 속하는 요소들만 렌더링하는 것이 바로 가상 스크롤이다. 그 외의 영역은 렌더링되지 않지만, 요소의 높이를 계산해 공간을 채워두어 존재하는 것처럼 보이게 된다.

이제 직접 구현을 통해 가상 스크롤을 이해해보자!

직접 구현하기

0. 가상 스크롤 적용 전


const ItemList = () => {
  const [items, setItems] = useState<JSX.Element[]>([]);

  useEffect(() => {
    const fetchData = () => {
      const newItems = new Array(1000).fill(null).map((_, index) => <Item key={index} item={`Item ${index + 1}`} />);
      setItems(newItems);
    };

    fetchData();
  }, []);

  return (
    <Container>
      <div>
        {items}
      </div>
    </Container>
  );
};

위 코드의 실행 결과로 1000개의 모든 DOM 엘리먼트가 한꺼번에 렌더링된다.

LightHouse를 사용하여 성능을 측정한 결과, TBT(Total Blocking Time) 점수가 매우 떨어지는 것을 확인할 수 있다.

TBT가 높다는 것은 페이지가 로딩 중일 때 사용자의 입력에 즉각적으로 반응하지 못하는 시간이 길어짐을 뜻하며, 이는 사용자 경험 저하로 이어질 수 있기에 개선이 필수적이다.

1. View Port 구하기

viewportHeight는 사용자가 화면에서 볼 수 있는 영역의 높이이다. 이 값을 기반으로 몇 개의 아이템을 한 번에 보여줄 수 있는지 결정할 수 있다.

useRef와 useEffect 훅을 사용하여 화면 크기를 추적하고, 리사이즈가 발생할 때마다 화면 높이를 업데이트해주었다.

const VirtualScrollList = () => {
  const [viewportHeight, setViewportHeight] = useState(0);

  const containerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const updateViewportHeight = () => {
      if (containerRef.current) {
        setViewportHeight(containerRef.current.offsetHeight);
      }
    };

    updateViewportHeight();
    window.addEventListener("resize", updateViewportHeight);

    return () => {
      window.removeEventListener("resize", updateViewportHeight);
    };
  }, []);

  return (
    <Container ref={containerRef}>
      <div>
        {items}
      </div>
    </Container>
  );
};

디바운싱 적용

브라우저의 크기를 조정하는 과정에서 연속적인 resize 이벤트가 일어나기 때문에 이를 개선하고자 디바운싱을 적용할 수 있다.

디바운싱(debouncing) : 연속으로 호출되는 함수들 중에 마지막에 호출되는 함수(또는 제일 처음 함수)만 실행되도록 하여 성능을 최적화하는 기법

const debounce = (func: (...args: any[]) => void, delay: number) => {
  let timeout: number;
  return (...args: any[]) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => func(...args), delay);
  };
};

const VirtualScrollList = () => {
  useEffect(() => {
    const updateViewportHeight = () => {...};

    const debouncedUpdateViewportHeight = debounce(updateViewportHeight, 200);

    debouncedUpdateViewportHeight();
    window.addEventListener("resize", debouncedUpdateViewportHeight);

    return () => {
      window.removeEventListener("resize", debouncedUpdateViewportHeight);
    };
  }, []);
};

위와 같이 200ms 동안 추가적인 resize 이벤트가 발생하지 않으면 updateViewportHeight가 호출되도록 디바운싱을 적용할 수 있다.

2. scrollTop 구하기

현재 유저가 보고 있는 스크롤 위치를 추적하려면 onScroll 이벤트를 활용하여 현재 스크롤 위치인 scrollTop을 구해야 한다. scrollTop은 요소의 상단이 얼마나 스크롤되었는지를 나타내는 값으로, 사용자가 스크롤한 양을 알 수 있다.

const VirtualScrollList = () => {
  const [scrollTop, setScrollTop] = useState(0);

  const onScroll = () => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop);
    }
  };


  useEffect(() => {
    const container = containerRef.current;
    if (container) {
      container.addEventListener("scroll", onScroll);
      return () => {
        container.removeEventListener("scroll", onScroll);
      };
    }
  }, []);
};

스크롤을 내릴수록 scrollTop의 값이 증가함을 확인할 수 있다.

3. 렌더링 엘리먼트 계산

이제 1, 2에서 구한 view port의 높이와 scollTop의 값으로 렌더링될 전체 요소의 크기를 구할 수 있다.

만약 view port의 높이가 500px이고, 각 엘리먼트의 높이가 100px이라면 뷰포트에 렌더링 가능한 요소의 개수는 최대 5개일 것이다. 만약 나누어 떨어지지 않아 빈 공간이 생기는 경우를 고려해 나의 경우 Math.ceil(올림)으로 계산하였다.

view port에 렌더링될 엘리먼트 개수 = Math.ceil(현재 view port의 높이 / 엘리먼트의 높이)

다음으로 몇번째 엘리먼트부터 렌더링되는 것을 알 수 있을까?
이는 엘리먼트의 높이가 고정이라면 쉽게 구할 수 있다.

시작 노드의 인덱스 = Math.floor(scrollTop / 엘리먼트의 높이)

그림과 같이 만약 현재 scrollTop이 100이라면 100/50 = 2로 item[2]인 3번 엘리먼트가 시작 노드가 되는 것이다.

위의 과정을 코드로 옮기면 아래와 같다.

const ITEM_HEIGHT = 122;
const TOTAL_ITEMS = 1000;

const VirtualScrollList = () => {
  const [visibleChildren, setVisibleChildren] = useState<JSX.Element[]>([]);

  const updateVisibleItems = () => {
    if (!containerRef.current || items.length === 0) return;
    const startNode = Math.floor(scrollTop / ITEM_HEIGHT);

    // 총 아이템 개수보다 초과되어 렌더링되지 않도록 한다.
    const visibleNodesCount = Math.min(Math.ceil(viewportHeight / ITEM_HEIGHT), TOTAL_ITEMS - startNode);

    const visibleChildren = items.slice(startNode, startNode + visibleNodesCount);
    setVisibleChildren(visibleChildren);
  };
};
  return (
    <Container ref={containerRef}>
      <div
        style={{
          width: "100%",
          height: `${items ? ITEM_HEIGHT * TOTAL_ITEMS : 0}px`,
        }}
      >
        {visibleChildren}
      </div>
    </Container>
  );

또한 총 아이템들의 길이만큼 스크롤할 엘리먼트가 있어야 하기 때문에 div태그에 height를 총 아이템의 길이로 설정해주었다.

4. DOM 요소 이동

위의 코드를 실행해보면 아직 문제가 있는 것을 확인할 수 있다.

스크롤에 따라 기대하는 엘리먼트들이 렌더링되긴 했으나 리스트의 상단에 엘리먼트들이 렌더링되는 것을 확인할 수 있다. 즉 우리의 스크롤에 맞춰 리스트 컨테이너 역시 수직으로 함께 이동하며 새로 렌더링된 엘리먼트들을 보여줘야 할 것이다.
해당 기능 구현에는 transform: tranlateY 속성을 활용하였다.

translateY는 레이아웃을 변경하지 않고 GPU를 활용한 하드웨어 가속을 사용하므로, 렌더링 성능에 부담을 주지 않는다. 이로 인해 대량의 요소를 이동시킬 때 top, left 속성보다 성능이 좋다.

수직으로 이동해야할 거리는 아래와 같다.

offsetY = 시작 노드의 인덱스 * 엘리먼트의 높이

쉽게 생각해 위에 보이지 않는 엘리먼트들이 위에 있는 것처럼 엘리먼트 리스트를 옮겨준다고 생각하면 된다.

const VirtualScrollList = () => {
  const [visibleChildren, setVisibleChildren] = useState<JSX.Element[]>([]);
  const [offsetY, setOffsetY] = useState<number>(0);

  const containerRef = useRef<HTMLDivElement | null>(null);

  const updateVisibleItems = () => {
    if (!containerRef.current || items.length === 0) return;
    const startNode = Math.floor(scrollTop / ITEM_HEIGHT);

    const visibleNodesCount = Math.min(Math.ceil(viewportHeight / ITEM_HEIGHT), TOTAL_ITEMS - startNode);

    const visibleChildren = items.slice(startNode, startNode + visibleNodesCount);
    setVisibleChildren(visibleChildren);
    
    // offset 추가
    const offsetY = startNode * ITEM_HEIGHT;
    setOffsetY(offsetY);
  };

  useEffect(() => {
    updateVisibleItems();
  }, [items, scrollTop, viewportHeight]);

  return (
    <Container ref={containerRef}>
      <div
        style={{
          width: "100%",
          height: `${items ? ITEM_HEIGHT * TOTAL_ITEMS : 0}px`,
          transform: `translateY(${offsetY}px)`,  // transform 추가
        }}
      >
        {visibleChildren}
      </div>
    </Container>
  );
};

구현된 결과는 아래와 같다.

스크롤과 함께 translateY의 값이 변화하며 엘리먼트가 원하는대로 따라오는 것을 확인할 수 있다.

5. 패딩 엘리먼트 추가

위 구현에서 문제점은 새로운 엘리먼트로 넘어갈 때 미리 렌더링 되어 있지 않아 빈 공간이 보인다는 점이다. 이는 불편한 사용자 경험으로 이어질 수 있다.

따라서 보통 자연스러운 가상스크롤을 위해 view port의 상하에 미리 패딩 엘리먼트(node padding)를 렌더링해둔다.

패딩 엘리먼트를 만약 상하 각각 2개 추가하고, 시작 인덱스가 3번이라면 실제로 렌더링되는 요소는 1번일 것이다.
따라서 startNode = Math.floor(scrollTop / ITEM_HEIGHT) - NODE_PADDING으로 수정될 것이며, 이 때 음수 값이 나올 수 있기 때문에 Math.max(0, startNode)를 추가해주었다.

렌더링할 엘리먼트의 개수 역시 상하로 추가되기 때문에 NODE_PADDING을 두번 더해주면 된다.
visibleNodesCount = Math.ceil(viewportHeight / ITEM_HEIGHT) + 2 * NODE_PADDING

const NODE_PADDING = 2;

const VirtualScrollList = () => {
  
  const updateVisibleItems = () => {
    if (!containerRef.current || items.length === 0) return;

    let startNode = Math.floor(scrollTop / ITEM_HEIGHT) - NODE_PADDING;
    startNode = Math.max(0, startNode);

    let visibleNodesCount = Math.ceil(viewportHeight / ITEM_HEIGHT) + 2 * NODE_PADDING;
    visibleNodesCount = Math.min(visibleNodesCount, TOTAL_ITEMS - startNode);

    const visibleChildren = items.slice(startNode, startNode + visibleNodesCount);
    setVisibleChildren(visibleChildren);

    const offsetY = startNode * ITEM_HEIGHT;
    setOffsetY(offsetY);
  };
};

스크롤을 빠르게 내려도 패딩 엘리먼트가 미리 렌더링 되어있기 때문에 자연스러운 가상스크롤이 가능해졌다!

쓰로틀링 적용

현재의 구현은 scroll 이벤트가 일어날때마다 렌더링할 엘리먼트를 계산하고 DOM에 반영하는데, 이는 과도한 리렌더링을 일으킨다.

따라서 쓰로틀링을 통해 일정 간격으로만 실행되도록 개선하였다.

쓰로틀링(Throttling): 특정 작업이나 이벤트가 너무 자주 발생하는 경우, 일정 시간 간격으로 호출 빈도를 제한하여 성능을 최적화하는 기법

const VirtualScrollList = () => {
  const isThrottle = useRef<boolean>(false);
  const lastScrollTop = useRef(0);
  
  const onScroll = () => {
    if (!containerRef.current) return;

    lastScrollTop.current = containerRef.current.scrollTop; // 마지막 스크롤 위치 저장

    if (isThrottle.current) return; // 쓰로틀링 중이면 무시

    isThrottle.current = true;
    setTimeout(() => {
      isThrottle.current = false;
      setScrollTop(lastScrollTop.current); // 최종 스크롤 위치 반영
      updateVisibleItems();
    }, 100); // 100ms 간격 제한
  };
};

적용 후 확연히 스크롤링 시 리스트 컨테이너의 리렌더링 횟수가 감소한 것을 확인할 수 있었다.


개선 결과 ✨

문제가 되었던 TBT가 430ms에서 0ms로 매우 크게 개선되었다!


높이가 가변적인 경우에는 어떻게 구현할 수 있을까?

위 구현 과정을 통해서 높이를 알고있는 엘리먼트들에 대한 가상스크롤을 구현해볼 수 잇었다.

하지만 우리 서비스의 경우에는 높이가 가변적인 채팅 엘리먼트에 대해서 대응할 수 있는 가상스크롤이 필요했다 🤯

관련한 레퍼런스가 많지 않아 고통받았지만... 가장 우리 서비스와 핏한 포스팅에서 접근 방식을 찾을 수 있었다.

Virtual scrolling of content with variable height with Angular

해당 포스팅의 서비스 역시 메시지 길이에 따라 엘리먼트의 높이가 가변적이라는 특성이 있었다.
이 경우 컴포넌트가 렌더링되기 전에는 그 엘리먼트가 어떤 높이를 가질지 알 수 없기 때문에, 컴포넌트의 높이를 예측하는 방법을 직접 구현하는 방식으로 진행한 것이 매우 인상적이었다.

// 출처 : https://dev.to/georgii/virtual-scrolling-of-content-with-variable-height-with-angular-3a52

const Padding = 24 * 2;
const NameHeight = 21;
const DateHeight = 14;
const MessageMarginTop = 14;
const MessageRowHeight = 24;
const MessageRowCharCount = 35;
const TagsMarginTop = 16;
const TagsRowHeight = 36;
const TagsPerRow = 3;

export const heroMessageHeightPredictor = (m: HeroMessage) => {
  const textHeight =
    Math.ceil(m.text.length / MessageRowCharCount) * MessageRowHeight;

  const tagsHeight = m.tags.length
    ? TagsMarginTop + Math.ceil(m.tags.length / TagsPerRow) * TagsRowHeight
    : 0;

  return (
    Padding +
    NameHeight +
    DateHeight +
    MessageMarginTop +
    textHeight +
    tagsHeight
  );
};

위 코드를 통해 문자열의 길이, 태그 개수 등을 기준으로 DOM에 그려질 엘리먼트의 높이를 예측하는 방법을 볼 수 있다. 이 방법으로 1차적으로 데이터에 대한 높이를 저장하고, 그 값을 바탕으로 가상 스크롤을 구현한다.

하지만 여전히 실제 DOM에 그려진 높이와 예측한 값 사이에는 차이가 발생할 수밖에 없다. 그래서 해당 포스팅에서는 1차 렌더링은 예측된 높이를 사용하고, 그 후에는 실제 DOM에 그려진 엘리먼트의 높이를 측정해 상태를 교체하는 방식으로 오차를 최소화했다.


프로젝트에 도입할 수 있을까? 🤔

직접 구현을 통해 가상스크롤의 동작방식과 개선된 성능을 확인해 볼 수 있었다. 또한 가변적인 높이에 대응해 구현한 레퍼런스 역시 분석해보았다.
하지만 결론적으로는 프로젝트에 바로 도입하기에는 어렵다는 결론이 나왔다.

발표 이후 참고하였던 스트리밍 사이트들(유튜브, 숲)을 살펴보았을 때, 실시간 채팅의 경우 일정 개수를 넘기면 아예 제거되는 것을 확인할 수 있었다. 치지직의 경우에는 조금 더 사용자 경험을 고려해 유저가 오래된 채팅을 보지 않는 경우에 채팅을 제거하는 것을 확인할 수 있었다.
이는 실시간 채팅이 과거 데이터를 보여주는 메신저 채팅과 다르게, 입장을 했을 때부터의 채팅을 보여주기 때문에 굳이 DOM에 계속해서 남겨야 하는 필요성이 떨어진다는 점을 이유로 생각해볼 수 있다.

또한 도입 테스트를 위해 1차적인 구현을 완료했지만, 그 과정에서 채팅 유형에 따라 다양한 UI의 높이를 예측해 모두 대응하는 가상 스크롤을 구현하는 것이 예상보다 개발 비용이 크게 들 것 같다는 점이 가장 큰 문제로 부각되었다.

따라서 아쉽지만 실시간 채팅에서의 가상 스크롤 도입은 잠정 보류로 남게 되었다 🥲

마치며 💭

개인적으로 꼭 도입해보고 싶었던 기술이었기에 아쉬움이 남았지만, 학습과 문제 해결 과정에서 배운 점이 많았던 것 같다. 향후에 수많은 데이터를 렌더링해야 하는 상황이 온다면, 이번 경험을 바탕으로 더 효율적이고 최적화된 방식으로 가상 스크롤을 적용할 수 있을 것이라 생각한다🔥

profile
내일은 더 성장하기

0개의 댓글

관련 채용 정보