회사에서 운영 중인 콘텐츠형 광고 페이지에 상품의 리뷰 목록 컴포넌트를 개발하여 도입하게 되었는데 큰 문제가 생겼다.
기존에 요소를 인피니티 스크롤 방식으로 렌더링하고 있는데 앞서 얘기한 리뷰 목록 컴포넌트 도입 이후 스크롤을 내리면 내릴수록 렌더링이 심각하게 느려지는 문제가 생기게 된 것이다.
React Developer Tools의 Profiler를 통해 확인해보면 무려 4339.3ms가 소요되고 있다는걸 알 수 있다..
수치도 그렇고 체감하기에도 굉장히 느리다. 일반적인 이용자 입장에서는 단순히 렉이 걸렸다고 생각하고 페이지를 금방 이탈할 것이고, 어지간히 근성 있는 이용자라도 아래로 내려가면 내려갈수록 느려지는 답답함, 짜증을 못이겨 마찬가지로 결국은 이탈하게 될 것이다.
따라서 빠른 개선이 필요했고, 방법을 물색하던 중 react 공식 문서에서 해답을 찾게 되었다.
바로 목록을 가상화하는 'windowing' 기법이다.
사용자에게 실제로 보이지 않는 컴포넌트는 렌더링하지 않고 영역만 차지하고 있다가 스크롤이되면 그 스크롤 위치에 있는 컴포넌트만 렌더링하여 보여주는 방식이다.
react 공식 문서에서도 긴 목록을 렌더링하는 경우 windowing 기법을 사용할 것을 추천하고 있고, 대표적인 windowing 라이브러리로는 react-window, react-virtualized가 있다.
두 라이브러리의 차이점에 대해서는 react-window README에 잘 설명되어 있다. (보통 경량화된 대안으로 react-window를 추천한다고 한다.)
내가 react-window 대신 react-virtualized를 선택한 이유는 두 가지이다.
react-window는 기본적으로 컨테이너의 너비와 높이를 명시적으로 지정하여 해당 컨테이너 내에서만 가상화가 가능했다.
내가 원하는 것은 페이스북이나 트위터처럼 window 스크롤을 기준으로 목록이 가상화 되도록 구현하는 것이었고, 그 역할을 react-virtualized의 WindowScroller 컴포넌트가 담당하고 있다.
콘텐츠형 광고 페이지 내에서 렌더링되는 요소의 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;
각 컴포넌트의 역할은 대략 다음과 같다.
자세한 컴포넌트의 역할, 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(); // 모든 요소 재측정
요소의 너비 및 높이를 결정하기 위해 한번에 하나의 측정을 하기 때문에 이용자가 한번에 여러 요소를 스크롤하면 속도가 느려질 수 있다고 한다.(빈 화면이 일시적으로 노출됨) 그리고 불행히도 현재로서는 해결 방법이 없다고 한다.
눈으로만 봐도 렌더링이 상당히 빨라진 모습을 확인할 수 있었다.
빨리 문제를 해결하는게 먼저인지라 필요한 컴포넌트 및 속성에 대해서만 부분 부분 간략하게 알아보고 사용한 것 같다. 이런식으로 한번 사용하고 마는건 아무 의미가 없단 걸 알고 있다. 빠른 시일 내로 한번 동작 원리부터 천천히 파헤쳐보면서 완벽히 이해하고 사용할 수 있도록 해야겠다.
Reference
https://ko.reactjs.org/docs/optimizing-performance.html
https://aerocode.net/336
안녕하세요? :) 좋은글 감사드립니다!
결과를 보면 인피니티 스크롤까지 동작하게 하셨는데, InfiniteLoader 사용하지 않고 구현하신건가요?
windowScroller로만 동작하게 하신건지 궁금합니다 :)