List Virtualization 목록 가상화
react-window를 사용하여 가상리스트 구현하기
서론
프로젝트를 진행하며 검색 결과를 표시하는 리스트 컴포넌트를 만들어야 했습니다.
모든 목록의 데이터를 받아 화면에 렌더링하게 되면 스크롤을 내릴수록 DOM에 Element가 추가되어 무겁게 버벅이는 현상이 발생합니다.
리스트의 모든 목록을 화면에 로드하면 초기 렌더링 성능과 스크롤 성능에서 안좋은 영향을 미치기 때문에, 목록 가상화 기법을 사용하는 react-window
라이브러리를 도입하게 되었습니다.
list virtualized
수많은 리스트를 가상화하여 렌더링 하는 것은 리액트의 공식 문서에서도 소개된 고급 사용 사례입니다.
list virtualization
혹은 windowing
이라 불리는 기법은 목록을 가상화하여 성능을 최적화하고, 대규모 데이터셋을 효율적으로 렌더링하는 기법입니다.
리스트의 모든 요소를 DOM에 추가 하는 것이 아니라 화면에 보이는 요소만 DOM에 추가하거나 유지하는 개념입니다.
react-window
react-window는 react-virtualized
라이브러리를 개발한 Brian Vaughn가 react-virtualized
의 불필요한 기능과 구성요소를 제외하여 가볍고 빠른 가상화를 제공하기 위해 재설계한 라이브러리입니다.
react-window
와 같은 가상화 라이브러리에서는 화면에 요소가 보이는지를 판단하기 위해 스크롤 위치와 아이템의 크기 및 인덱스를 이용합니다.
부모 컨테이너의 현재 스크롤 위치(scroll Top
혹은 scroll Left
)를 확인하고, 각 아이템의 크기와 인덱스를 이용해 해당 아이템이 화면에 나타나는지를 계산합니다.
예를 들어, 부모 컨테이너의 높이가 600px, scrollTop이 200이고, 각 아이템의 높이가 60px이라고 가정해보겠습니다.
화면 상단에 보이는 첫번째 아이템의 인덱스는 Math.floor(200 / 6)
== 3번째
아이템이 됩니다.
화면에 보여야 하는 아이템의 범위를 계산하면, 시작 인덱스가 3이고,
종료 인덱스는 Math.ceil((200 + 600) / 60) - 1
== 12번째
아이템이 됩니다.
즉, 부모 컨테이너의 크기와 스크롤 상태, 아이템의 고정된 크기만 가지고 각 아이템의 인덱스를 기반으로 화면에 보여야 할 첫번째 아이템과 마지막 아이템을 계산할 수 있습니다.
해당 범위 내의 아이템만 React Component로 렌더링하고, 나머지는 DOM에서 제거하는 식으로 동작합니다.
아이템의 크기(높이)가 고정되어 FixedSizeList
를 사용하여 구현했지만, 가변 크기 항목을 렌더링하기 위해 VariableSizeList
를 사용할 경우, 각 항목의 크기를 미리 지정한 후 계산이 이루어집니다.
만약 크기를 미리 지정할 수 없다면, 초기 렌더링 이후 항목 크기를 측정하여 동적으로 보정합니다.
라이브러리 선정 이유
react-virtualized와의 비교
동일한 기능을 제공하는 react-virtualized
은 react-window
보다 번들 사이즈가 약 5배 정도 더 큽니다. react-window
가 tree shacking
이 가능하고, 필요한 기능만 포함하므로 번들 사이즈가 더 작습니다.
사용한 프로젝트에서는 아직 모바일을 고려하지 않고 있지만, 추후 모바일을 고려하게 되었을 때, 사용자의 부담을 줄이고 중·저사양 기기들에서도 효율적인 렌더링이 가능한 react-window
를 선택하게 되었습니다.
tanstack/react-virtual과의 비교
선택할 수 있는 다른 라이브러리로는 @tanstack/react-virtual
이 있습니다.
최근 npm의 다운로드 통계를 살펴보면 react-window
를 추월하여 많은 개발자가 사용하고 있음을 알 수 있습니다.
@tanstack/react-virtual
는 React
이외에도 Vanilla JS
, Svelte
, Solid
등 다양한 프레임워크에서도 사용할 수 있습니다.
또한, 세부적인 API를 제공하여 가상화 동작을 세밀하게 제어해 더 유연하고 다양한 설정을 할 수 있습니다.
예를 들어 항목의 크기 계산 로직을 사용자가 정의할 수 있어 가변 크기의 항목을 사용할 수 있고, 서버 기반 데이터 페칭과 통합하여 사용할 수 있습니다.
하지만 당연하게도 그렇기 때문에 보다 복잡한 설정을 필요로 합니다.
렌더링 할 리스트의 요소가 가변적이지 않고, 클라이언트 측에서 모든 데이터를 페치하기에 @tanstack/react-virtual
의 이점을 활용할 수 없었습니다.
때문에 빠르고 간편하게 프로젝트에 도입할 수 있는 react-window
를 선택하게 되었습니다.
react-window
와 함께 사용한 라이브러리는 아래와 같습니다.
react-virtualized-auto-sizer
: 사용 가능한 모든 공간에 맞게 width와 height가 설정되고, 해당 값을 자식에 전달합니다.react-window-infinite-loader
: 큰 데이터를 스크롤하여 표시할 때, 바로 로드할 수 있는 청크로 나누기 위해 사용했습니다. 사용자가 목록을 위아래로 스크롤 할 때, 데이터를 적시에 가져와 무한 스크롤을 만드는 데 사용되었습니다.
프로젝트에 적용하기
import React, { useEffect, useCallback } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { useSearchPickStore } from '@/stores/searchPickStore';
import SearchItemRenderer from './SearchItemRenderer';
export function SearchInfiniteScrollList() {
const {
searchResultList,
hasNext,
isLoading,
loadMoreSearchPicks
} = useSearchPickStore();
const loadMoreItems = useCallback(async () => {
await loadMoreSearchPicks();
}, [hasNext, isLoading, lastCursor]);
const isItemLoaded = (index: number) => {
return !hasNext || index < searchResultList.length;
};
return (
<AutoSizer>
{({ height, width }) => (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={
hasNext ? searchResultList.length + 1 : searchResultList.length
}
loadMoreItems={loadMoreItems}
threshold={5}
>
{({ onItemsRendered, ref }) => (
<List
height={height}
width={width}
itemCount={searchResultList.length}
itemSize={60}
onItemsRendered={onItemsRendered}
ref={ref}
itemData={searchResultList}
>
{({ index, style }) => (
<SearchItemRenderer
index={index}
item={searchResultList[index]}
style={style}
/>
)}
</List>
)}
</InfiniteLoader>
)}
</AutoSizer>
);
}
1. AutoSizer
부모 컨테이너의 크기를 자동으로 감지하여 하위 컴포넌트에 높이와 너비를 전달해줍니다.
({ height, width }) => ...
:height
: 부모 컨테이너의 높이입니다.width
: 부모 컨테이너의 너비입니다.- 이를 기반으로 자식 컴포넌트(
InfiniteLoader
와List
)가 크기를 동적으로 조정합니다.
2. InfiniteLoader
스크롤 이벤트를 감지하여 필요한 경우 더 많은 데이터를 로드합니다.
isItemLoaded
: 특정 인덱스의 데이터가 로드되었는지 확인하는 함수입니다. 반환값이true
이면 해당 항목은 이미 로드된 것으로 간주합니다.itemCount
: 전체 항목 수를 설정합니다. 데이터가 더 로드될 수 있는 경우(hasNext === true
) 하나의 빈 항목을 추가로 포함하도록 작성하였습니다.loadMoreItems
: 추가 데이터를 로드하는 함수입니다. 해당 함수는 Promise를 반환해야 합니다.threshold
: 사용자 스크롤 위치와 끝 사이의 버퍼 영역의 크기로, 데이터를 프리페치하는 영역의 임계점입니다. 기본값은 15로, 화면에 보일 아이템의 갯수에 따라 조절하시면 됩니다.
3. FixedSizeList
고정된 높이를 가진 항목의 가상화된 목록을 렌더링합니다.
height
,width
: 목록의 전체 크기로,AutoSizer
로부터 전달받아 부모 컨테이너 크기에 맞춥니다.itemCount
: 렌더링할 항목의 총 개수로,searchResultList.length
로 설정하였습니다.itemSize
: 각 항목의 고정된 높이(px)입니다.onItemsRendered
: 스크롤된 영역 내의 항목 인덱스를 감지하여InfiniteLoader
로 전달하는 콜백 함수입니다.ref
:InfiniteLoader
에서 관리하는 참조를 전달하여 스크롤 상태와 동기화합니다.itemData
: 리스트에 렌더링 할 아이템 배열입니다.
4. SearchItemRenderer
개별 항목을 렌더링하기 위해 만든 컴포넌트입니다. index와 item을 받아 아이템을 렌더링합니다.
동작 확인하기
관리자 도구를 열어 확인해보면, 동적으로 리스트의 아이템들이 DOM에서 제거되고, 추가되는 것을 확인할 수 있습니다.
아이템의 크기가 고정되어 있기 때문에, 부모 컨테이너의 위치에서 해당 아이템이 렌더링될 위치를 미리 계산할 수 있습니다. 이 과정에서 부모 컨테이너는 position: relative
로 설정되고, 각 아이템은 position: absolute
로 설정됩니다.
아이템의 index
와 아이템의 고정된 높이(60px)를 활용하여 각 아이템의 top
속성을 동적으로 계산합니다.
이렇게 하면 리스트의 모든 항목을 메모리에 유지하거나 DOM에 모두 렌더링하지 않아도, 화면에 표시되어야 할 아이템만 효율적으로 렌더링할 수 있습니다.
회고
좋은 기회에 windowing
이라는 기법을 알게되어 프로젝트에 적용해 볼 수 있었습니다.
고정적인 아이템의 높이가 아닌 가변적인 아이템을 렌더링 해야 하면 tanstack/react-virtual을 사용해보려 합니다. 유행에 편승하여 왜 개발자들이 많이 사용하는지 느껴보고 싶습니다..
좋은 프로젝트의 참여 기회, 좋은 기능의 개발 기회, 좋은 기법을 알려주신 바구니 팀원 분들 모두 감사드립니다. 좋좋좋