BlogReactDynamic size list virtualization

Dynamic size list virtualization

가상화란?

가상화(virtualization) 또는 윈도잉(windowing)은 대량의 데이터를 효율적으로 렌더링하기 위한 기술입니다. 수천 개의 항목을 모두 DOM에 렌더링하는 대신, 뷰포트에 보이는 항목만 동적으로 생성하고 렌더링하는 방식입니다. 사용자가 스크롤할 때마다 뷰포트에 보이는 항목에 따라 필요한 구성 요소만 마운트하고, 보이지 않는 항목은 언마운트하여 성능과 메모리 사용을 최적화합니다. 이를 통해 대규모 데이터 셋을 효율적으로 관리할 수 있으며, 사용자 경험을 크게 개선할 수 있습니다.

가상화를 고려해야 할 때

가상화를 고려해야 할 몇 가지 경우는 다음과 같습니다:

  • 대량의 데이터가 렌더링될 때
  • 스크롤이 많은 컴포넌트일 때
  • 브라우저 메모리 사용 최적화가 필요할 때
  • 각각 리스트 아이템이 무겁게 렌더링될 때
  • 각각 리스트 아이템이 네트워크 호출해야 할 때

React-window vs React-virtuoso

react-windowreact-virtuoso
Size896 kB259 kB
Last Publish10 months ago2 days ago
Downloads2,162,955763,904
TypeScript⭕️
Dynamic Height⭕️
Height Calculation⭕️
Document Structuring⭕️
Additional Packages Required⭕️
Window Scroll⭕️

2024-09-12 기준

동적 높이를 갖는 리스트 아이템이 있을 경우, 높이를 계산하여 스크롤 복원이 필요한 경우에는 가상화 렌더링 라이브러리 중 react-virtuoso가 적합하다고 판단됩니다.

예시 코드

"use client";
 
import styled from "@emotion/styled";
import { Box } from "@mui/material";
import { PropsWithChildren, useEffect, useRef, useState } from "react";
 
const StyledBox = styled(Box)`
  .virtuoso-container {
    width: 100% !important;
    height: 704px !important;
 
    @media (max-height: 667px) {
      height: 352px !important;
    }
 
    @media (min-height: 668px) and (max-height: 736px) {
      height: 440px !important;
    }
 
    @media (min-height: 737px) and (max-height: 812px) {
      height: 528px !important;
    }
 
    @media (min-height: 813px) and (max-height: 896px) {
      height: 616px !important;
    }
 
    @media (min-height: 897px) and (max-height: 1024px) {
      height: 704px !important;
    }
 
    @media (min-height: 1025px) and (max-height: 1180px) {
      height: 792px !important;
    }
 
    @media (min-height: 1181px) and (max-height: 1365px) {
      height: 880px !important;
    }
 
    @media (min-height: 1366px) {
      height: 968px !important;
    }
  }
`;
 
interface Props extends PropsWithChildren {
  virtuosoHeight: number;
}
 
const VirtuosoWrapper: React.FC<Props> = ({ virtuosoHeight, children }) => {
  const ref = useRef<HTMLDivElement>(null);
  const [boxHeight, setBoxHeight] = useState<number>(0);
 
  useEffect(() => {
    setBoxHeight(ref.current?.clientHeight || 0);
  }, []);
 
  return (
    <StyledBox ref={ref}>
      {children}
      <Box height={virtuosoHeight - boxHeight} />
    </StyledBox>
  );
};
 
export default VirtuosoWrapper;

사용자 viewport 높이마다 다른 height 값이 지정되어 있어, 화면 크기에 따라 가상화 리스트 높이가 달라지도록 합니다. (전체 가상화 리스트 height - viewport마다 다른 height) 높이를 갖는 div를 두어 가상화 리스트 하단에 푸터나 다른 콘텐츠와 겹치지 않도록 합니다.

"use client";
 
import { safeSessionStorage } from "@toss/storage";
import _ from "lodash";
import { useEffect, useRef, useState } from "react";
import { ItemContent, StateSnapshot, Virtuoso, VirtuosoHandle } from "react-virtuoso";
import VirtuosoWrapper from "./VirtuosoWrapper";
 
interface Props {
  data?: any[]; // 원하는 타입을 지정하세요.
  isScrolled?: boolean;
  itemContent: ItemContent<any, unknown>; // 원하는 타입을 지정하세요.
}
 
const ScrollRestorationVirtuoso: React.FC<Props> = ({ data, isScrolled, itemContent }) => {
  const virtuosoRef = useRef<VirtuosoHandle>(null);
  const [virtuosoHeight, setVirtuosoHeight] = useState<number>(0);
  const [initialState] = useState<StateSnapshot>(() => {
    if (!isScrolled) return { scrollTop: -1000, ranges: [] }; // 뒤로가기가 아니라면 복원하지 않습니다. 쿼리스트링 isScrolled=true여야 합니다.
 
    const state = safeSessionStorage.get("virtuoso-state");
    if (!state) return { scrollTop: -1000, ranges: [] };
 
    return JSON.parse(state) as StateSnapshot; // 뒤로가기 했을 때 복원할 이전 값입니다.
  });
 
  useEffect(() => {
    const getVirtuosoState = _.debounce(() => {
      if (!virtuosoRef.current) return;
 
      virtuosoRef.current.getState((state) => {
        safeSessionStorage.set("virtuoso-state", JSON.stringify(state)); // 스크롤 이벤트마다 값을 저장합니다.
      });
    }, 250);
 
    window.addEventListener("scroll", getVirtuosoState);
    return () => window.removeEventListener("scroll", getVirtuosoState);
  }, [virtuosoRef]);
 
  return (
    <VirtuosoWrapper virtuosoHeight={virtuosoHeight}>
      <Virtuoso
        className="virtuoso-container"
        ref={virtuosoRef}
        useWindowScroll
        overscan={176}
        data={data ?? []}
        totalCount={data?.length}       // 전체 목록 개수가 보장되어야 합니다.
        restoreStateFrom={initialState} // 뒤로가기 했을 때 복원합니다.
        itemContent={itemContent}       // 내부 리스트 아이템 컴포넌트입니다.
        isScrolling={() => ...}         //  스크롤을 진행했다면 URL 쿼리스트링을 변경해줍니다. (isScrolled=true)
        totalListHeightChanged={(height) => setVirtuosoHeight(height)}
      />
    </VirtuosoWrapper>
  );
};
 
export default ScrollRestorationVirtuoso;
  • wrapper 컴포넌트로 감싸주고 Wrapper에서 지정한 className을 지정합니다.

  • virtuoso-container를 사용하며, virtuoso에서 제공하는 totalListHeightChanged 메소드를 사용하여 전체 가상화 리스트 height를 wrapper 컴포넌트의 props으로 전달합니다.

  • 가상화 리스트의 스크롤이 아닌 window scroll을 사용합니다.

  • 스크롤 이벤트가 발생할 때 debounce 처리하여 sessionStorage에 가상화 리스트의 상태를 저장합니다.

  • 가상화 리스트에 스크롤 이벤트가 생겼다면 isScrolling 메소드를 사용하여 감지하고 쿼리 스트링을 변경합니다. isScrolled=true

  • 사용자가 뒤로 가기해서 돌아왔다면 (isScrolled=true 쿼리 스트링이 있을 경우) sessionStorage에 저장된 상태 값을 사용하여 스크롤을 복원합니다.

  • overscan을 사용하여 미리 보여줄 리스트 아이템을 준비합니다.

예시 그림

예시 그림

참고 자료