react-virtualized 를 활용한 렌더링 성능 최적화

kimjh96·2021년 2월 11일
31

Issue

목록 보기
1/1

시작하며

회사에서 운영 중인 콘텐츠형 광고 페이지에 상품의 리뷰 목록 컴포넌트를 개발하여 도입하게 되었는데 큰 문제가 생겼다.

기존에 요소를 인피니티 스크롤 방식으로 렌더링하고 있는데 앞서 얘기한 리뷰 목록 컴포넌트 도입 이후 스크롤을 내리면 내릴수록 렌더링이 심각하게 느려지는 문제가 생기게 된 것이다.

render performence(before)

render performence profiler

React Developer Tools의 Profiler를 통해 확인해보면 무려 4339.3ms가 소요되고 있다는걸 알 수 있다..

수치도 그렇고 체감하기에도 굉장히 느리다. 일반적인 이용자 입장에서는 단순히 렉이 걸렸다고 생각하고 페이지를 금방 이탈할 것이고, 어지간히 근성 있는 이용자라도 아래로 내려가면 내려갈수록 느려지는 답답함, 짜증을 못이겨 마찬가지로 결국은 이탈하게 될 것이다.

따라서 빠른 개선이 필요했고, 방법을 물색하던 중 react 공식 문서에서 해답을 찾게 되었다.

windowing 기법

바로 목록을 가상화하는 'windowing' 기법이다.

사용자에게 실제로 보이지 않는 컴포넌트는 렌더링하지 않고 영역만 차지하고 있다가 스크롤이되면 그 스크롤 위치에 있는 컴포넌트만 렌더링하여 보여주는 방식이다.

react 공식 문서에서도 긴 목록을 렌더링하는 경우 windowing 기법을 사용할 것을 추천하고 있고, 대표적인 windowing 라이브러리로는 react-window, react-virtualized가 있다.

react-window, react-virtualized

두 라이브러리의 차이점에 대해서는 react-window README에 잘 설명되어 있다. (보통 경량화된 대안으로 react-window를 추천한다고 한다.)

내가 react-window 대신 react-virtualized를 선택한 이유는 두 가지이다.

window 스크롤

react-window는 기본적으로 컨테이너의 너비와 높이를 명시적으로 지정하여 해당 컨테이너 내에서만 가상화가 가능했다.

내가 원하는 것은 페이스북이나 트위터처럼 window 스크롤을 기준으로 목록이 가상화 되도록 구현하는 것이었고, 그 역할을 react-virtualized의 WindowScroller 컴포넌트가 담당하고 있다.

요소의 동적인 height 측정

콘텐츠형 광고 페이지 내에서 렌더링되는 요소의 height는 요소의 내용에 따라 변하기 때문에 동적인 height 측정할 수 있어야 했다.

마찬가지로 react-window는 기본적으로 요소의 동적인 height를 측정할 수 있는 방법이 없었고, react-virtualized에서 CellMeasurer 라는 컴포넌트가 내가 원하는 요소의 동적인 height를 측정해주는 역할을 담당하고 있었다.

실전

각설하고 react-virtualized 의 컴포넌트를 활용하여 가상화를 구현 코드는 아래와 같다.
(테스트 데이터로 JSONPlaceholder API를 사용하였다.)

import React, { useEffect, useState, useRef } from 'react';
import {WindowScroller, CellMeasurer, CellMeasurerCache, AutoSizer, List, ListRowProps} from 'react-virtualized';

type Post = {
  id: number;
  userId: number;
  title: string;
  body: string;
};

const cache = new CellMeasurerCache({
    defaultWidth: 100,
    fixedWidth: true
});

function App() {
    const [posts, setPosts] = useState<Post[]>([]);
    
    const listRef = useRef<List | null>(null);

    const rowRenderer = ({ index, key, parent, style }: ListRowProps) => {
        return (
            <CellMeasurer cache={cache} parent={parent} key={key} columnIndex={0} rowIndex={index}>
                <div style={style}>
                    <div style={{ padding: 8, marginBottom: 8, color: 'white', backgroundColor: '#282c34' }}>
                        <div>
                            index: {index}
                        </div>
                        <div>
                            id: {posts[index].id}
                        </div>
                        <div>
                            userId: {posts[index].userId}
                        </div>
                        <div>
                            title: {posts[index].title}
                        </div>
                        <div>
                            body: {posts[index].body}
                        </div>
                    </div>
                </div>
            </CellMeasurer>
        );
    };

    const addPosts = () => {
        fetch('https://jsonplaceholder.typicode.com/posts').then((response) => {
            const data = response.json();

            data.then((newPosts) => {
                setPosts([
                    ...posts,
                    ...newPosts
                ]);
            });
        });
    };

    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/posts').then((response) => {
           const data = response.json();

           data.then((posts) => {
               setPosts(posts);
           });
        });
    }, []);

    return (
        <div className="App">
            react-virtualized example <button onClick={addPosts}>add</button> <br /><br />
            <WindowScroller>
                {({ height, scrollTop, isScrolling, onChildScroll }) => (
                    <AutoSizer disableHeight>
                        {({ width }) => (
                            <List
                                ref={listRef}
                                autoHeight
                                height={height}
                                width={width}
                                isScrolling={isScrolling}
                                overscanRowCount={0}
                                onScroll={onChildScroll}
                                scrollTop={scrollTop}
                                rowCount={posts.length}
                                rowHeight={cache.rowHeight}
                                rowRenderer={rowRenderer}
                                deferredMeasurementCache={cache}
                            />
                        )}
                    </AutoSizer>
                )}
            </WindowScroller>
        </div>
    );
}

export default App;  

각 컴포넌트의 역할은 대략 다음과 같다.

  • AutoSizer: 단일 자식의 크기를 자동 조절하는 고차 컴포넌트이다.
  • List: 요소의 목록을 렌더링한다.
  • CellMeasurer: 사용자에게 보이지 않는 방식으로 일시적으로 렌더링하여 셀의 내용을 자동으로 측정하는 고차 구성 요소이다. 고정 너비를 지정하여 동적인 height를 측정한다.
  • CellMeasurerCache: CellMeasurer의 결과를 부모(여기서는 List)와 공유한다.

자세한 컴포넌트의 역할, public 메소드 및 각 속성에 대한 설명은 react-virturalized의 문서에 잘 설명되어 있다.

이슈

요소 내 이미지가 있는 경우

요소 내에 이미지가 있는 경우, 로드된 이미지의 height를 제외한 채로 요소의 height가 측정되어 버린다.

따라서 아래와 같이 measure를 활용하여 이미지가 로드된 이후에 요소의 height가 다시 측정 될 수 있도록하여 해결했다.

    const rowRenderer = ({ index, key, parent, style }: ListRowProps) => {
        return (
            <CellMeasurer cache={cache} parent={parent} key={key} columnIndex={0} rowIndex={index}>
                {({ measure }) => (
                    <div style={style}>
                        <div style={{ padding: 8, marginBottom: 8, color: 'white', backgroundColor: '#282c34' }}>
                            <div>
                                index: {index}
                            </div>
                            <img src={''} onLoad={measure} />
                        </div>
                    </div>
                )}
            </CellMeasurer>
        );
    };

요소의 너비, 높이 등의 변화가 있는 경우

CellMeasurerCache 의 public 메소드를 사용하여 캐시되어 있는 측정을 초기화하여 재측정한다.

cache.clear(0, 0); // 특정 요소 재측정, (rowIndex, columnIndex)
cache.clearAll(); // 모든 요소 재측정

제한 및 성능 고려 사항

요소의 너비 및 높이를 결정하기 위해 한번에 하나의 측정을 하기 때문에 이용자가 한번에 여러 요소를 스크롤하면 속도가 느려질 수 있다고 한다.(빈 화면이 일시적으로 노출됨) 그리고 불행히도 현재로서는 해결 방법이 없다고 한다.

결과

render performence(after)
눈으로만 봐도 렌더링이 상당히 빨라진 모습을 확인할 수 있었다.

마치며

빨리 문제를 해결하는게 먼저인지라 필요한 컴포넌트 및 속성에 대해서만 부분 부분 간략하게 알아보고 사용한 것 같다. 이런식으로 한번 사용하고 마는건 아무 의미가 없단 걸 알고 있다. 빠른 시일 내로 한번 동작 원리부터 천천히 파헤쳐보면서 완벽히 이해하고 사용할 수 있도록 해야겠다.

Reference
https://ko.reactjs.org/docs/optimizing-performance.html
https://aerocode.net/336

3개의 댓글

comment-user-thumbnail
2021년 5월 12일

안녕하세요? :) 좋은글 감사드립니다!
결과를 보면 인피니티 스크롤까지 동작하게 하셨는데, InfiniteLoader 사용하지 않고 구현하신건가요?
windowScroller로만 동작하게 하신건지 궁금합니다 :)

2개의 답글