본문 바로가기

# Tech/React

[번역] 리액트로 고성능 무한 스크롤 구현하기

이 게시글은 [Akshay Sharma]가 작성한 [Building High-Performance Infinite Lists in React]의 번역본입니다.


개요

인터넷은 무한 스크롤(Infinite scroll)을 적용한 제품들로 넘쳐나고 있습니다. 무한 스크롤은 정보의 소비를 매우 쉽고 중독적으로 만드는데, 트위터나 인스터그램같이 타임라인이나 피드를 소비하는 제품에 매우 적합하죠.


하지만, 자바 스크립트로 무한 스크롤을 구현하는 것은 꽤 어렵고, 특히 각각의 피드가 수천개의 구성요소로 구성되어있다면 문제는 더 복잡해집니다.


일단 문제들을 한번 나열해봅시다.


문제점

  1. 기존의 컴포넌트가 리사이징될 때, 렌더링이 매우 느려진다.

  1. 스크롤을 할 때마다 랙이 걸린다.

  1. 수천개의 컴포넌트로 가득찬 DOM은 브라우저가 버티지 못할 수 있다.

대부분의 디바이스는 프레임을 초당 60번의 속도로 새로고침하고 있습니다.

즉, 하나의 프레임이 사용할 수 있는 예산은 16ms가 조금 넘는다는 것인데, (1s/60 = 16.66ms)

어떤 프레임이 이 예산을 초과해서 사용한다면, 제 때에 프레임을 갱신할 수 없게되고, 화면끊김을 발생시킵니다.


실제 프레임 속도를 측정하고 싶다면 크롬에서 FPS meter를 사용해보세요.

컴포넌트가 잔뜩 포함된 페이지를 측정하면 분명 60보다 낮을것입니다.


해결방법

일단 페이지에 부착된 컴포넌트를 줄이고, 스크롤에 관련된 이슈를 해결하는것이 먼저입니다.

이것을 해결하기 위한 기본적인 아이디어를 살펴보면.


DOM Recyling

보이는 것만 렌더링합니다.

여기서 절약된 성능은 다른 컴포넌트를 위해 재사용될 수 있습니다.


Scroll Anchoring

보이는 것만 렌더링하더라도, 스크롤의 위치는 변하지 않아야 합니다.

즉, 스크롤의 위치를 유지시키는 가짜 컴포넌트를 만들 필요가 있습니다.

하지만 위의 아이디어를 효율적으로 구현하기 위해서는 많은 계산과 조건들이 필요한데,

이런 문제들을 쉽게 해결할 수 있는 방법을 고민해본 결과, Dan Abramov가 추천해준 react-virtualized 패키지를 찾았습니다.


react-virtualized

개요

React-Virtualized는 다음의 동작을 갖춘 가상 렌더링을 수행합니다.

  • 실제 보이는 자식 컴포넌트만 렌더링합니다.
  • 자신의 크기를 변경시켜 절대적, 상대적 위치를 갖는 컨테이너를 사용합니다.

동적 너비와 높이를 갖는 리스트를 구현하기 위해 다음 컴포넌트를 사용합니다.

  • List : 보이는 것만 렌더링하는 리스트 컨테이너입니다.
  • CellMeasurer : 보이지 않는 것을 일시적으로 렌더링하여, 크기를 측정합니다.
  • CellMeasurerCache : CellMeasurer의 결과를 부모(여기서는 List)와 공유합니다.
  • AutoSizer : 단일 아이템의 크기를 자동으로 조절하는 고차 컴포넌트입니다.
  • InfiniteLoader : 사용자가 스크롤을 올리거나 내릴때 다음 데이터를 가져오고, 이것을 캐싱합니다.

이제 이 컴포넌트들을 사용하여 실제 동적 리스트를 만들어봅시다!


예제 코드

import React from 'react';
import {
    List,
    CellMeasurer,
    CellMeasurerCache,
    InfiniteLoader,
    AutoSizer
} from 'react-virtualized';
import Item from './Item';

class InfinteList extends React.Component {
    constructor() {
        this.cellMeasurerCache = new CellMeasurerCache({
            fixedWidth: true,
            defaultHeight: 100
        });
        this.listData = [];
    }

    fetchFeed = ({ startIndex, stopIndex }) => {
        return fetch(`path/to/api?startIndex=${startIndex}&stopIndex=${stopIndex}`)
        .then(response => {
            // Store response data in this.listData
        });
    }

    rowRenderer = ({ index, parent, key, style }) => {
        return (
            <CellMeasurer
                key={key}
                cache={this.cellMeasurerCache}
                parent={parent}
                columnIndex={0}
                rowIndex={index}
            >
                <Item
                    data={this.listData[index]}
                    style={style}
                />
            </CellMeasurer>
        );
    };

    isRowLoaded = ({ index }) => {
        return !!this.listData[index];
    };

    render() {
        return (
            <div className="InfinteList">
                <InfiniteLoader
                    isRowLoaded={this.isRowLoaded}
                    loadMoreRows={this.fetchFeed}
                    rowCount={10000000}
                    ref={ref => (this.infiniteLoaderRef = ref)}
                >
                    {({ onRowsRendered, registerChild }) => (
                            <AutoSizer>
                                {({ width, height }) => (
                                    <List
                                        rowCount={this.listData.length}
                                        width={width}
                                        height={height}
                                        rowHeight={this.cellMeasurerCache.rowHeight}
                                        rowRenderer={this.rowRenderer}
                                        deferredMeasurementCache={this.cellMeasurerCache}
                                        overscanRowCount={2}
                                        onRowsRendered={onRowsRendered}
                                        ref={el => {
                                            this.listRef = el;
                                            registerChild(el);
                                        }}
                                    />
                                )}
                        </AutoSizer>
                    )}
                </InfiniteLoader>
            </div>
        );
    }
}

더 자세한 원리와 사용법을 알고싶다면 도큐먼트를 사용하세요.


캐싱 지우기

컴포넌트의 상태가 변경되어 기존의 캐싱을 사용하지 말아야 한다면, 아래의 메소드로 캐싱 결과를 지울 수 있습니다.

// Reset cached measurements for all cells.
this.cellMeasurerCache.clearAll();

// Reset any cached data about already-loaded rows
this.infiniteLoaderRef.resetLoadMoreRowsCache();

마치면서

여러분이 매우 큰 리스트를 다루는 원리를 이해하는데 이 포스팅이 도움이 되었으면 합니다.

react-virtualized 패키지는 테이블이나 그리드같은 다양한 컴포넌트도 가상화할 수 있으니 살펴보시기 바랍니다.


경량화된 대안이 필요하다면 react-window도 괜찮습니다.

이것은 Brian Vaughn이 추천해줬습니다 :)