티스토리 뷰

✅ React 가상 스크롤은 렌더링 최적화 방법 중 하나입니다. 라이브러리를 사용하여 구현해본 경험은 있으나, 직접 구현해보고 더 자세히 이해하기 위한 과정을 글로 작성합니다!

 

* ps) 가상스크롤의 대표적인 라이브러리는 react-virtualized와 react-window 가 있습니다.

 


📌 Virtual Scroll ?

  • API를 호출하여 100, 1000개 이상의 데이터를 불러와 화면에 렌더링한다고 가정했을 때 스크롤이 뻑뻑하거나, 심할 경우 브라우저가 다운되는 현상이 발생합니다. 이는 DOM에 대한 작업이 브라우저에 큰 부하를 걸기 때문입니다.
  • 이러한 현상을 개선하기 위해 Virtual Scroll 개념을 도입하면 많은 데이터라 하더라도 무리 없이 정상적으로 스크롤할 수 있습니다.
Virtual Scroll의 원리

- 전체 데이터(DOM)를 렌더링 하는 것이 아닌 현재 사용자가 보고 있는 화면의 스크롤 위치를 계산하여 해당 위치에 맞는 데이터(DOM)만 그려주는 원리입니다.

 

🧐 어떻게 구현할까 ?

 

가상 스크롤 개념에 대한 이미지를 보면서 생각해보겠습니다.

 

  • Total elemets

    • 전체 데이터 높이만큼의 비어있는 박스 영역입니다.
    • 실제로 전체 데이터를 렌더링하는 것이 아닌, 전체 데이터가 렌더링 되었다고 가정했을 때 그 높이만큼 계산하여 할당해주어야 합니다.
  • Rendered elements

    • 렌더링을 한 데이터 영역입니다.
    • 새롭게 렌더링 될 때마다 해당 영역의 위치를 계산하여 갱신시켜 주어야 합니다.
  • viewport

    • 사용자가 실제로 보는 영역입니다.
구현 포인트 !

1. viewport가 최상위 컴포넌트, 그 하위로 Total elements, 마지막으로 Rendered elements 박스가 위치

· 사용자에게 보이는 영역인 viewport가 최상위 컴포넌트에 위치해야 합니다.
=> scroll 이벤트 처리가 필요합니다.

· 전체 데이터만큼의 높이를 가지는 Total elements 영역이 그 하위로 위치해야 합니다.
=> 보통 viewport 범위를 벗어나므로 viewport에서 overflow scroll 처리가 필요합니다.

· 실제로 렌더링되는 영역인 Rendered elements 영역이 마지막으로 위치합니다.
=> 새로운 데이터가 렌더링이 되면 스크롤의 위치 갱신이 필요합니다. 갱신하지 않으면 Total elements의 영역 최상단에 붙어있기 때문에 어느 순간 viewport에 아무것도 보이지 않게 됩니다.

2. viewport에서 스크롤의 위치에 따라 새로운 데이터를 렌더링 필요

· addEventListener("scroll", 함수) 이벤트 사용 필요
· 새로운 데이터를 렌더링 할 로직 필요

 

💻 이제 코드로 살펴보겠습니다.

(소스 코드는 더보기란 참고해주세요)

 

1. Styled-Components를 사용하여 레더링 될 영역들의 CSS를 정의해줍니다.

더보기
import styled from 'styled-components';

export const Item = styled.div`
    height: 80px;
    margin: 10px;
    border: 1px solid black;
`;

export const Viewport = styled.div`
    position: absolute;
    width: 80%;
    height: 50%;
    overflow: scroll;
`

export const TotalElemets = styled.div`
    // position: relative;
    height:${props => props.height}px;
`

export const RenderedElements = styled.div`
    // position: absolute;
    // width: 100%;
    transform: translateY(
        ${(props) => props.offsetY}px
    );
`

 

2. 가상 스크롤을 적용할 간단한 컴포넌트 하나를 만들어줍니다.

( 저의 경우 간단하게 이미지 3개가 들어가는 div를 1000개 렌더링 하는 컴포넌트를 만들었습니다. )

더보기
import React, {useEffect, useState, useRef} from "react";
import {Viewport, TotalElemets, RenderedElements, Item} from "../Style/css";

const MainContainer1 = () => {

    const [totalData, setTotalData] = useState([]);

    useEffect(() => {
        const Dt = [];
        for (let i = 0; i < 1000; i++) {
            Dt.push(<Item key={`item_${i}`}>
                {i}
                <img width="100px" src={"https://cdn.lamanus.kr/wp-content/uploads/2018/08/28225854/google-2048x1536.png"}/>
                <img width="100px" src={"http://image.dongascience.com/Photo/2017/12/15130427942754.png"}/>
                <img width="100px" src={"https://biz.chosun.com/resizer/mY58GeufwPAZxi09EAgkrIqCIx4=/616x0/smart/cloudfront-ap-northeast-1.images.arcpublishing.com/chosunbiz/YGC6GZ2VCZF3LA2PW3CYPTJZYA.png"}/>
            </Item>);
        }
        setTotalData(Dt);
    }, []);

    return (
        <Viewport>
            <TotalElemets>
                <RenderedElements>
                    {
                        totalData
                    }
                </RenderedElements>
            </TotalElemets>
        </Viewport>
    )

}

export default MainContainer1;
예제 화면

 

3. 가상 스크롤 적용 !

더보기
import React, {useEffect, useState, useRef} from "react";
import {Viewport, TotalElemets, RenderedElements, Item} from "../Style/css";

const MainContainer1 = () => {

    const ViewportRef = useRef(null); // 스크롤 위치를 탐색하기 위해 필요

    const [totalData, setTotalData] = useState([]); // 전체 데이터
    const [dataRenderArr, setDataRenderArr] = useState([]); // 실제로 렌더링 할 데이터

    const [totalHeight, setTotalHeight] = useState(0); // 전체 데이터의 높이
    const [scrollTop, setScrollTop] = useState(0); // 현재 스크롤의 top 위치

    const curHeight = ViewportRef.current ? ViewportRef.current.clientHeight : 0; // 현재 Viewport 의 높이 (Viewport의 높이를 %로 주었기 때문에 따로 계산)
    const itemHeight = 100; // 렌더링되는 영역의 높이 (Item 태그의 경우 height 80px, margin 10px 로 총 80+10+10 = 100px 임)
    const nodePadding = Math.floor(curHeight / itemHeight) + 1; // 현재 화면에 보이는 아이템의 수

    const strIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - nodePadding);
    const endIdx = strIdx + 2 * nodePadding + 1;
    /*
    strIdx, endIdx : 렌더링할 데이터 시작 idx(strIdx), 마지막 idx(endIdx)

    strIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - nodePadding)의 의미
    - Math.floor(scrollTop / itemHeight) : 현재 Viewport 최상단에 보이는 데이터의 인덱스
    - nodePadding 은 현재 화면에 보이는 아이템의 수
    => 스크롤을 내렸을 때 Viewport 에 보이는 최상단 데이터가 화면에 보이는 아이템의 수를 넘어갈 때부터 인덱스를 구하여 새로운 데이터를 렌더링 해야함 !!

    endIdx = strIdx + 2 * nodePadding + 1의 의미
    - strIdx + nodePadding : 현재 보이지 않는 스크롤로 내린 데이터
    - strIdx + 2 * nodePadding : Viewport 로 보이는 데이터
    - + 1 을 해준 이유는 Viewport 의 마지막 데이터가 가끔 누락되는 경우가 있어서!
     */

    const offsetY = strIdx * itemHeight; // 갱신할 Y축 위치

    useEffect(() => {
        // 기본 데이터 만들기
        const Dt = [];
        for (let i = 0; i < 1000; i++) {
            Dt.push(<Item key={`item_${i}`}>
                {i}
                <img width="100px"
                     src={"https://cdn.lamanus.kr/wp-content/uploads/2018/08/28225854/google-2048x1536.png"}/>
                <img width="100px" src={"http://image.dongascience.com/Photo/2017/12/15130427942754.png"}/>
                <img width="100px"
                     src={"https://biz.chosun.com/resizer/mY58GeufwPAZxi09EAgkrIqCIx4=/616x0/smart/cloudfront-ap-northeast-1.images.arcpublishing.com/chosunbiz/YGC6GZ2VCZF3LA2PW3CYPTJZYA.png"}/>
            </Item>);
        }
        setTotalData(Dt);
        //

        setTotalHeight(Dt.length * itemHeight); // 전체 높이

        ViewportRef.current?.addEventListener("scroll", handleScrollHeight); // scroll 이벤트 연결

    }, []);

    // 전체 데이터가 바뀌거나, strIdx 가 바뀌면 새로운 데이터를 렌더링 해줘야함
    useEffect(() => {
        setDataRenderArr(totalData.slice(strIdx, endIdx));
        console.log(strIdx, endIdx); // 콘솔로 인덱스 값을 체크하기위해
    }, [totalData, strIdx]);

    const handleScrollHeight = () => {
        if (ViewportRef.current) {
            setScrollTop(ViewportRef.current.scrollTop); // 현재 스크롤 위치를 갱신
        }
    };

    return (
        <Viewport ref={ViewportRef}>
            <TotalElemets height={totalHeight}>
                <RenderedElements offsetY={`${offsetY}`}>
                    {
                        dataRenderArr
                    }
                </RenderedElements>
            </TotalElemets>
        </Viewport>
    )

}

export default MainContainer1;
결과

 

4. 가상 스크롤 적용 전 후 비교

좌 : 적용 전, 우 : 적용 후

 

  • Chrome lighthouse로 분석해보면 performance 가 개선된 것을 확인할 수 있습니다.

 

🤓 더 생각해 볼 것들

  1. scroll 이벤트를 계속 호출하며 검사를 할 필요가 있을까 ? (메모리 낭비, 메인스레드 부담)
  2. 모바일 환경에서 scroll 이벤트가 동작하지 않는데, 어떻게 수정할까 ?
  3. 미세한 화면 떨림 현상이 있는데 어떻게 수정할까 ?

공부하면서 여러 블로그 글을 읽어보고, 나름대로 구현을 해보면서 들었던 의문점들입니다. part. 2 에서는 이러한 의문점에 대해 개선해보고 생각한 내용들에 대해서 작성해보도록 하겠습니다.

 

🤔  정리 !

  • 가상 스크롤은 많은 데이터를 렌더링 할 때 스크롤된 위치를 계산하여 그 위치에 해당하는 DOM만 그려주는 방식입니다.
  • 렌더링 개선에 큰 효과 !

 

 

🔗 참고한 글

 

Implementing virtual scroll using react - TechBoxWeb

You might need to deal with a huge amount of data and that data need to be rendered on DOM.So here virtual croll comes for rescue

www.techboxweb.com

 

||Project1|| #5 Virtual List 직접 구현하기 (feat. useThrottle)

VirtualList를 구현하면서 어려웠던 부분을 추려본다.

velog.io

댓글