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 Taylorreact-windowFixedSizeGrid를 이용해 오픈소스 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에 대해 더 자세히 알고 싶다면 아래 자료를 참고하자: