Virtual Scroll이란? (가상 스크롤)

이영재·2025년 1월 13일

Virtual Scroll 이란?

virtual scroll은 scroll이 있는 요소에서 사용자가 볼 수 있는 viewport에 있는 요소만 실질적으로 렌더링 하는 기법으로, 이외의 요소들은 스크롤 영역만 차지하고 내부 구현은 하지 않게 하여 렌더링 리소스를 줄일 수 있는 기법이다.

많은 양의 데이터 목록을 화면에 렌더링 하는 경우, 모든 항목을 그리면 성능상 문제를 초래한다.

즉, 10만 개의 데이터를 렌더링 하기 위해 DOM에 10만 개의 노드를 추가하면 Call Stack size error가 발생할 것이다.

예시1

예시2

위 사진은 약 400개 정도의 데이터를 렌더링한 사진이다.

개발자 도구를 통해 DOM 트리를 확인해보면 다음과 같이 수많은 데이터들이 전부 추가된 것을 볼 수 있다.

이렇게 모든 데이터를 DOM에 추가하는 것이 아닌 현재 viewport의 스크롤 위치를 계산하여 해당 위치에 맞는 데이터(DOM)만 그려주는 최적화 기법이 virtual scroll이다.

Virtual Scroll 구현하기

  • 전체 요소
    • 전체 데이터 높이만큼의 비어있는 박스 영역
    • 실제로 전체 데이터를 렌더링하는 것이 아닌, 전체 데이터가 렌더링 되어 있다고 가정했을 때 그 높이만큼 계산하여 할당해줘야 함
  • 실제 렌더링 된 요소들
    • 렌더링을 한 데이터 영역
    • 새롭게 렌더링 될 때마다 해당 영역의 위치를 계산하여 갱신시켜 줘야 함
  • viewport
    • 사용자가 실제로 보는 영역

구현 with React, tailwindcss

가상 스크롤을 적용하지 않은 경우

  • 코드
    import { useEffect, useState } from "react";
    
    export const App = () => {
      const [data, setData] = useState<JSX.Element[]>();
    
      useEffect(() => {
        const array = [];
        for (let i = 0; i < 1000; i++) {
          array.push(
            <div
              className="h-[100px] m-[10px] border border-black flex gap-3"
              key={i}
            >
              <p>{i}</p>
              <img
                src="https://gratisography.com/wp-content/uploads/2024/10/gratisography-cool-cat-800x525.jpg"
                className="w-[100px]"
              />
              <img
                src="https://letsenhance.io/static/a31ab775f44858f1d1b80ee51738f4f3/11499/EnhanceAfter.jpg"
                className="w-[100px]"
              />
              <img
                src="https://letsenhance.io/static/8f5e523ee6b2479e26ecc91b9c25261e/1015f/MainAfter.jpg"
                className="w-[100px]"
              />
            </div>
          );
        }
        setData(array);
      });
    
      return (
        <div className="relative">
          {/*TotalElements*/}
          <div>
            {/*RenderedElements*/}
            {data}
          </div>
        </div>
      );
    };
    

1000개의 요소가 전부 DOM에 추가된 것을 볼 수 있다.

lighthouse 점수는 다음과 같다.

이제 가상 스크롤을 적용해보자

1. 스크롤 위치 확인

가상 스크롤을 구현하기 위해서 가장 먼저 알아야 하는 것은 현재 스크롤이 어디에 위치하고 있는지 확인하는 것임

현재 스크롤 위치를 확인하기 위해서 scroll Event를 window 객체에 추가해보자.

스크롤의 위치는 window.scrollY를 통해 알 수 있다.

  • 코드
    const [scrollPosition, setScrollPosition] = useState<number>(0);
    	
    const onScroll = () => {
        setScrollPosition(window.scrollY);
    };
    
    useEffect(() => {
    	window.addEventListener("scroll", onScroll);
    	return () => {
    		window.removeEventListener("scroll", onScroll);
    	}
     },[]);
    
    //디버깅용 코드
    useEffect(() => {
    	console.log(scrollPosition);
    },[scrollPosition])

scroll 위치가 잘 설정되었음을 확인할 수 있다.

이때 스크롤을 할 때 동안 무수히 많은 scroll 이벤트가 발생함도 동시에 알 수 있다.

이를 방지하고자 쓰로틀링을 적용해보자.

쓰로틀링을 적용하는 방법은 많이 있지만 requestAnimationFrame을 통해 쓰로틀링을 적용해보자

  • 코드
    
        
    const [scrollPosition, setScrollPosition] = useState<number>(0);
    
    useEffect(() => {	
    	let isThrottle = false;
      const onScroll = () => {
          if (!isThrottle) {
            window.requestAnimationFrame(() => {
              setScrollPosition(window.scrollY);
              console.log(window.scrollY);
              isThrottle = false;
            });
            isThrottle = true;
          }
        };
      window.addEventListener("scroll", onScroll);
    	return () => {
    		window.removeEventListener("scroll", onScroll);
    	}
     },[]);
    
    //디버깅용 코드
    useEffect(() => {
    	console.log(scrollPosition);
    },[scrollPosition])

2. 현재 창의 크기 확인하기

스크롤 위치를 확인하였다면 현재 창의 크기를 알아야 한다.

현재 창의 크기를 알아야 현재 화면에 몇 개의 요소가 들어가는 확인할 수 있기 때문임

현재 창의 크기를 확인하는 방법은 window.innerWidth, window.innerHeight 로 확인할 수 있다.

이때 현재 창의 높이만 알면 되기 때문에 window.innerHeight만 활용하도록 하면 된다.

또한 브라우저의 크기는 사용자가 임의로 바꿀 수 있기 때문에 resize 이벤트를 통해 브라우저 창의 크기를 동적으로 설정할 수 있게 한다.

이때도 마찬가지로 브라우저의 창 크기를 조절하는 동안 resize 이벤트가 발생하여 window.innerHeight 값을 계속 계산하므로 디바운싱을 적용하여 코드를 작성했다.

  • 코드
    const [windowHeight, setWindowHeight] = useState<number>(0);
    
    useEffect(() => {
        let resizeTimeout: number | null = null;
        const onResize = () => {
          if (resizeTimeout) {
            clearTimeout(resizeTimeout);
          }
          resizeTimeout = setTimeout(() => {
            setWindowHeight(window.innerHeight);
            console.log(window.innerHeight);
          }, 300);
        };
    
        window.addEventListener("resize", onResize);
    
        return () => {
          window.removeEventListener("resize", onResize);
        };
      }, []);

💡
쓰로틀링 (throttle) : 이벤트가 발생하고서 일정 주기마다 이벤트가 발생되도록 하는 기법
디바운스 (debounce) : 똑같은 이벤트가 발생할 때 마지막 이벤트만 인식하도록 하는 기법

3. 렌더링할 전체 요소의 크기 구하기

이제 전체 요소의 크기를 구하자

만약, 렌더링할 요소의 개수 * 렌더링할 요소의 높이를 하면 전체 높이를 구할 수 있다.

예시에서 렌더링할 요소의 높이는 100px이다.

총 개수는 1000개이기 때문에

전체 요소의 크기는 1000 * 100 = 100,000px이다.

  • 코드
    <div
          className="relative"
          style={{ height: `${data ? data.length * 100 : 0}px` }}
        >
          {/*실제 렌더링 할 요소들*/}
        </div>

4. 렌더링할 요소들 정하기

이제 뷰포트에 실제로 렌더링할 요소를 정할 차례이다.

예를 들어, 뷰포트의 높이가 430px 이고 렌더링할 요소의 높이가 100px인 경우에는 뷰포트에 몇 개의 요소를 렌더링할 수 있을까?

최대 4개의 요소를 렌더링할 수 있을 것이다.

⇒ 430 / 100 = 4

이처럼 뷰포트에 렌더링할 요소의 개수는 다음과 같이

Math.floor(현재 뷰포트의 높이 / 요소의 높이) 를 통해 구할 수 있다.

그렇다면 i번째 요소부터 i + 렌더링할 요소의 개수 - 1번째 요소를 렌더링하면 되는데 이때 i는 어떻게 구현할 수 있을까?

i는 scrollY / 렌더링할 요소의 높이로 구할 수 있다.

아래 사진을 보면 쉽게 이해할 수 있을 것이다.

렌더링할 요소의 높이 : 40

scrollY : 10

i = 10 / 40 = 0

즉, 0번째 요소부터 i + 뷰포트 높이 / 40 - 1 번째 요소까지 렌더링을 하면 된다.

아래의 경우, 몇 번째 요소가 i번째 요소일까?

340 / 40 = 8

8번째 요소부터 8 + 뷰포트 높이 / 40 - 1번째 요소까지 렌더링을 하면 된다.

  • 코드
      const [data, setData] = useState<JSX.Element[]>();
    	const [renderedData, setRenderedData] = useState<JSX.Element[]>();
    
      const [scrollPosition, setScrollPosition] = useState<number>(0);
      const [windowHeight, setWindowHeight] = useState<number>(window.innerHeight);
    
      const elementHeight = 100;
      const start = Math.max(0, Math.floor(scrollPosition / 100) - nodePadding);
      
      useEffect(() => {
        if (!data) {
          return;
        }
        setRenderedData(
          data.slice(start, start + windowHeight / elementHeight)
        );
      }, [start, data, windowHeight]);
      

5. nodePadding 추가하기

렌더링할 요소가 10개라고 가정하자.

이때 요소를 정확히 10개만 렌더링하게 된다면, 스크롤을 할 때마다 새로운 요소를 렌더링 해야 한다.

이러면 사용자 입장에서 새로운 요소가 나타날 때 지연 되는 현상을 생길 수 있다.

이런 경우를 방지하기 위해 nodePadding 여분의 요소를 렌더링할 요소의 위아래에 미리 렌더링 해놓는 것이다.

이때 nodePadding을 추가하면 렌더링할 요소의 시작 인덱스를 계산하는 법이 조금 바뀐다.

렌더링할 요소 위에 존재하는 nodePadding의 개수만큼 빼줘야 한다.

nodePadding의 개수가 2개이고 시작 인덱스가 i = 3이면
렌더링될 요소는 1 2 3 4 5 … 이므로

3 - 2를 해줘야 한다.

따라서, 시작 index = Math.max(0, scrollY / 렌더링할 요소의 높이) - nodePadding 개수)

다음과 같은 방법으로 시작 index를 구할 수 있다.

또한 렌더링 해야 되는 요소의 개수도 위아래에 추가될 nodePadding의 개수만큼 더 해줘야한다.

그래서 i번째 요소부터 i + 뷰포트 높이 / 렌더링할 요소의 높이 + nodePadding의 개수 * 2 - 1번째 요소를 렌더링하면 된다.

const [data, setData] = useState<JSX.Element[]>();
  const [scrollPosition, setScrollPosition] = useState<number>(0);
  const [windowHeight, setWindowHeight] = useState<number>(window.innerHeight);

  const [renderedData, setRenderedData] = useState<JSX.Element[]>();

  const nodePadding = 2;
  const elementHeight = 100;
  const start = Math.max(0, Math.floor(scrollPosition / 100) - nodePadding);

  useEffect(() => {
    if (!data) {
      return;
    }
    setRenderedData(
      data.slice(start, start + windowHeight / elementHeight + nodePadding * 2)
    );
  }, [start, data, windowHeight]);

6. 렌더링 된 요소 이동 처리

이제 렌더링할 요소의 범위도 확인했으니 그냥 렌더링 할 일만 남은 것 같다.

하지만 새롭게 렌더링되는 요소는 컨테이너(전체 요소)의 맨 위에 렌더링 된다.

사용자 스크롤 위치에 따라 요소가 실제 데이터 배열의 올바른 위치에 나타나야 하는데 이는 큰 불편함을 초래할 것이다.

실제 렌더링 된 요소에 아무런 이동 처리를 하지 않는 경우 다음과 같이 스크롤의 위치에 맞게 요소들이 렌더링 되어야 하는데, 스크롤 위치와 렌더링 위치가 맞지 않아 문제가 발생함을 알 수 있다.

이런 문제를 해결하기 위해 렌더링 되는 요소를 스크롤 위치에 맞게 화면에 올바른 위치로 이동시켜야 한다.

이 때 활용할 수 있는 방식은 position: absolute; top : ~~px~~ , transform : translateY(px) 등이 있을 것이다.

이 중에서 transform : translateY 속성을 활용하여 이동 처리를 할 것이다.

💡
transform : translateY 속성을 활용하는 이유?
요소를 수직 방향으로 이동 시키는 이유도 있지만, GPU 가속을 활용하기 때문에 absolute를 활용한 위치 조정보다 성능이 좋기 때문이다.

이동해야할 거리는 다음과 같이 구할 수 있다.

offsetY = 시작 인덱스 * 렌더링할 요소의 높이

즉, 시작 요소가 5번째이고, 렌더링할 요소의 높이가 50px라고 하면

TotalElement 컨테이너 위에서 5 * 50 = 250 px 만큼 아래로 내려와야 스크롤 위치에 맞는 정확한 위치로 이동할 수 있는 것이다.

렌더링 된 요소의 이동처리까지 적용했을 때 동작 화면이다.

위아래 모두 스크롤이 잘 되는 것을 확인할 수 있다.

구현 결과

lighthouse 점수

가상스크롤 적용 전

가상 스크롤 적용 후

참고

https://ji-musclecode.tistory.com/69

https://pepperminttt.tistory.com/56

https://velog.io/@dev-redo/React-VirtualScroll%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%9E%90

https://velog.io/@d0or_hyeok/JS-Virtual-Scroll-%EB%A7%8C%EB%93%A4%EA%B8%B0

https://dev.to/adamklein/build-your-own-virtual-scroll-part-i-11ib

profile
웹 개발에 관심이 많습니다 : )

0개의 댓글