List Virtualization
이 글에선 목록 가상화(윈도잉으로도 알려진)를 다룰 것이다. 이는 동적 목록을 렌더링할 때 전체 목록을 렌더링하지 않고 화면에 보이는 콘텐츠의 행들만 렌더링하는 것이다. 렌더링된 행들은 전체 목록 중 사용자의 스크롤에 따라 움직이는 화면(윈도우)에 보이는 것들만 추린 작은 부분 집합이다. 이는 렌더링 성능 향상으로 이어질 수 있다.
만약 React를 사용하여 큰 규모의 목록을 효율적으로 보여줘야 한다면, react-virtualized가 익숙할 수 있다. Brian Vaughn이 만든 윈도잉 라이브러리로, 목록에서(스크롤 되는 뷰포트 내에서) 현재 보이는 항목들만 렌더링한다. 이는 몇 천개 행의 데이터를 한 번에 렌더링하기 위한 수고를 감수하지 않아도 됨을 뜻한다. react-window를 이용한 목록 가상화 구현 방법을 소개하고 있는 영상도 있으니 참고하길 바란다.
목록 가상화는 어떤 방식으로 동작하는가?
목록의 항목들을 “가상화”하기 위해선 윈도우를 고정시키고 목록 주변에서 윈도우를 움직여야 한다. react-virtualized의 윈도잉이 동작하기 위해선 아래 요소들이 필요하다:
- (window) relative 포지션 속성을 가지는 작은 컨테이너 돔 엘리먼트(e.g
<ul>
) - 스크롤을 위한 큰 돔 엘리먼트
- 컨테이너 내부에 위치하고 absolute 포지션 속성을 가지며 top, left, width, height 속성을 설정한 자식 요소들
목록 가상화는 목록의 1000여개의 요소들을 일시에 렌더링(초기 렌더링 지연이나 스크롤 성능에 영향을 줄 수 있는)하지 않고 사용자에게 보이는 아이템만 렌더링하는 데에 중점을 두고 있다.
이는 중·저사양의 기기들에서 목록을 빠르게 렌더링하는 데 도움을 준다. 이전 아이템들을 제거하고 새로운 아이템들로 교체하면서, 사용자의 스크롤에 따라 더 많은 아이템들을 불러오고 보여줄 수 있다.
react-virtualized의 더 작은 대안
react-window는 react-virtualized의 저자가 용량이 더 작고, 빠르고, tree-shakable하게 만든 라이브러리다.
tree-shakable한 라이브러리의 크기는 어떤 API를 사용하기로 선택했느냐에 따라 달라진다. react-virtualized의 경우, ~20-30KB(gzipped)까지 용량을 줄일 수 있었다:
두 라이브러리의 API는 대부분 비슷하지만, react-window가 조금 더 심플하다. react-window는 다음과 같은 컴포넌트들을 포함하고 있다:
List
목록은 윈도윙된 요소들의 목록(행), 즉 사용자에게 현재 보여지는 요소들(e.g FixedSizeList, VariableSizeList)만 렌더링한다. 그리드를 이용해 행들을 렌더하며 props를 내부 그리드에 전달한다.
React로 데이터 목록 렌더링하기
React를 이용해 간단한 데이터 목록(itemsArray
)을 렌더링하는 예제이다:
import React from "react";
import ReactDOM from "react-dom";
const itemsArray = [
{ name: "Drake" },
{ name: "Halsey" },
{ name: "Camillo Cabello" },
{ name: "Travis Scott" },
{ name: "Bazzi" },
{ name: "Flume" },
{ name: "Nicki Minaj" },
{ name: "Kodak Black" },
{ name: "Tyga" },
{ name: "Buno Mars" },
{ name: "Lil Wayne" }, ...
]; // our data
const Row = ({ index, style }) => (
<div className={index % 2 ? "ListItemOdd" : "ListItemEven"} style={style}>
{itemsArray[index].name}
</div>
);
const Example = () => (
<div
style=
class="List"
>
{itemsArray.map((item, index) => Row({ index }))}
</div>
);
ReactDOM.render(<Example />, document.getElementById("root"));
react-window로 목록 렌더링하기
이번엔 동일한 예제를 react-window의 FixedSizeList
를 이용해 구현한 것이다. 몇 개의 props(width
, height
, itemCount
, itemSize
)를 받고, 행을 렌더링하는 함수를 자식으로 받는다:
import React from "react";
import ReactDOM from "react-dom";
import { FixedSizeList as List } from "react-window";
const itemsArray = [...]; // our data
const Row = ({ index, style }) => (
<div className={index % 2 ? "ListItemOdd" : "ListItemEven"} style={style}>
{itemsArray[index].name}
</div>
);
const Example = () => (
<List
className="List"
height={150}
itemCount={itemsArray.length}
itemSize={35}
width={300}
>
{Row}
</List>
);
ReactDOM.render(<Example />, document.getElementById("root"));
CodeSandbox에서 FixedSizeList
를 테스트해볼 수 있다.
Grid
그리드는 세로축과 가로축(e.g FixedSizeGrid, VariableSizeGrid)을 따라 테이블 성격의 데이터를 가상화해서 렌더링한다. 현재 수평/수직 스크롤 위치에 따라 채워져야 하는 그리드 셀들만 렌더링한다.
그리드 레이아웃을 이용해 위에서 사용한 것과 동일한 목록을 렌더하고 싶다면, 다차원 배열이라고 가정할 때, FixedSizeGrid
를 이용해 다음과 같이 구현할 수 있다:
import React from 'react';
import ReactDOM from 'react-dom';
import { FixedSizeGrid as Grid } from 'react-window';
const itemsArray = [
[{},{},{},...],
[{},{},{},...],
[{},{},{},...],
[{},{},{},...],
];
const Cell = ({ columnIndex, rowIndex, style }) => (
<div
className={
columnIndex % 2
? rowIndex % 2 === 0
? 'GridItemOdd'
: 'GridItemEven'
: rowIndex % 2
? 'GridItemOdd'
: 'GridItemEven'
}
style={style}
>
{itemsArray[rowIndex][columnIndex].name}
</div>
);
const Example = () => (
<Grid
className="Grid"
columnCount={5}
columnWidth={100}
height={150}
rowCount={5}
rowHeight={35}
width={300}
>
{Cell}
</Grid>
);
ReactDOM.render(<Example />, document.getElementById('root'));
CodeSandbox에서 FixedSizeGrid
를 테스트해볼 수 있다.
더 많은 react-window 심화 예제들
Scott Taylor는 react-window
와 FixedSizeGrid
를 이용해 오픈소스 Pitchfork music reviews scraper (src) 를 구현했다. 이를 실제로 적용한 앱을 아래 영상에서 확인할 수 있다:
Pitchfork scraper는 react-window-infinite-loader (demo)를 이용해 큰 규모의 데이터를 화면 안으로 스크롤될 때만 로딩되는 작은 덩어리들로 분할한다.
실제 앱에선 아래와 같이 react-window-infinite-loader를 이용해 코드를 작성한다:
import React, { Component } from 'react';
import { FixedSizeGrid as Grid } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
...
render() {
return (
<InfiniteLoader
isItemLoaded={this.isItemLoaded}
loadMoreItems={this.loadMoreItems}
itemCount={this.state.count + 1}
>
{({ onItemsRendered, ref }) => (
<Grid
onItemsRendered={this.onItemsRendered(onItemsRendered)}
columnCount={COLUMN_SIZE}
columnWidth={180}
height={800}
rowCount={Math.max(this.state.count / COLUMN_SIZE)}
rowHeight={220}
width={1024}
ref={ref}
>
{this.renderCell}
</Grid>
)}
</InfiniteLoader>
);
}
}
react-virtualized
에서 전환할 계획이 있다면 이 커밋을 참고하자.
FixedSizeList
를 이용해 Pitchfork scraper를 구현하는 것도 가능하다 (demo, demo on Pixel):
실제 구현 코드는 아래와 같다:
return (
<InfiniteLoader
isItemLoaded={this.isItemLoaded}
loadMoreItems={this.loadMoreItems}
itemCount={this.state.count}
>
{({ onItemsRendered, ref }) => (
<section>
<FixedSizeList
itemCount={this.state.count}
itemSize={ROW_HEIGHT}
onItemsRendered={onItemsRendered}
height={this.state.height}
width={this.state.width}
ref={ref}
>
{this.renderCell}
</FixedSizeList>
</section>
)}
</InfiniteLoader>
)
만약 더 복잡한 그리드 가상화에 대한 해결책이 필요한 경우엔 어떻게 할까? The Movie Database 데모앱은 react-virtualized와 Infinite Loader를 사용해서 이 문제를 해결한다.
react-window와 react-window-infinite-loader로의 전환은 오래 걸리진 않지만, 몇몇 컴포넌트는 아직 지원하지 않고 있다. 그럼에도, 기능 구현은 거의 완료된 상태이다.
빠진 컴포넌트는 WindowScroller와 AutoSizer이다… 이는 아래에서 살펴볼 것이다.
...
return (
<section>
<AutoSizer disableHeight>
{({width}) => {
const {movies, hasMore} = this.props;
const rowCount = getRowsAmount(width, movies.length, hasMore);
...
return (
<InfiniteLoader
ref={this.infiniteLoaderRef}
...
{({onRowsRendered, registerChild}) => (
<WindowScroller>
{({height, scrollTop}) => (
react-window에서 어떤 부분들이 빠졌는가?
react-window는 아직 react-virtualized의 모든 API를 지원하고 있지 않기 때문에, 사용을 고려하고 있다면 비교 문서를 확인해보자. 어떤 부분들이 빠졌는가?
- WindowScroller - window 스크롤 위치에 맞춰 목록을 스크롤할 수 있도록 도와주는
react-virtualized
의 컴포넌트이다. 아직 react-window에 추가될 계획은 없기 때문에 직접 구현해야 한다. - AutoSizer - 현재 영역에서 가능한 최대 길이와 높이를 가지는 HOC이며, 자동적으로 단일 자식에게 그 길이와 높이를 부여한다. Brian이 이를 독립적인 패키지로 구현했다. 관련 최신 이슈는 여기서 확인할 수 있다.
- CellMeasurer - 사용자에게 보이지 않도록 임시로 렌더링하여 셀 내 콘텐츠를 측정하는 HOC이다. 지원에 관한 논의는 여기서 확인할 수 있다.
빠진 기능들을 봤을 때, 대부분의 경우 react-window만 사용해도 충분할 것이다.
웹 플랫폼의 발전
몇몇 모던 브라우저들은 현재 CSS content-visibility를 지원한다. content-visibility:auto
는 필요해지기 전에는 콘텐츠를 렌더링, 페인팅하지 않는다. 길이가 길어 렌더링에 비용이 소요되는 HTML 문서라면, 이 속성을 사용해보는 것을 고려해보자.
동적 콘텐츠 목록을 렌더링할 경우에는, 여전히 react-window 같은 라이브러리를 사용하는 것을 추천한다. 오늘날 대부분의 목록 가상화 라이브러리가 화면에 보이지 않을 때 display:none
을 적용하거나 돔 노드들을 제거하는 방식을 적극적으로 사용하고 있기 때문에 content-visbility:hidden
을 구현한 라이브러리를 찾기는 쉽지 않다.
추가 참고 자료
react-window와 react-virtualized에 대해 더 자세히 알고 싶다면 아래 자료를 참고하자: