List Virtualization(Windowing)
일반적으로 적은 양의 데이터는 모든 데이터를 화면에 렌더링 하는데 큰 리스크가 없지만, 너무 많은 양의 데이터가 있는 경우 데이터 전체를 렌더링 하기 위해 많은 시간이 걸려 사용자 경험을 해치는 상황이 발생한다. 이러한 상황을 극복하기 위한 대표적인 방법이 페이지네이션(Pagenation), 무한 스크롤(Infinity Scroll)과 가상화 목록(Virtualized List)이다.
가상화 목록은 전체 데이터를 모두 렌더링하는 대신, 유저 스크롤에 따라 현재 보이는 데이터만을 렌더링 하는 방법이다. 사용자가 볼 때 무한 스크롤과 유사할 수 있으나, 무한 스크롤은 스크롤이 발생할수록 화면에 렌더링 되는 항목도 많아지기 때문에 속도가 느려질 수 있으나, 가상화 목록은 현재 보이지 않는 항목은 unload 하므로 이러한 문제가 발생하지 않는다.
1. 가상화 목록 만들기
- Inner Height : 목록 전체의 높이
= 각 항목의 높이 x 총 데이터 갯수 - Window Height : 현재 보이는 영역 높이
- ScrollTop : 위부터 스크롤된 수직 거리
가상화 목록의 구조는 위 그림과 같다. 위 그림에는 3가지 요소가 있는데, Inner Height, Window Height, SrollTop이다. 스크롤의 위치가 바뀔 때마다 3가지 요소 및 파생 값을 재계산하여 화면에 보여야 할 항목들을 추출하여 리스트를 재 렌더링 한다. 코드로 함께 살펴보자.
1-1. Outer Component
import { VirtualizedList as List } from "./VirtualListClass";
import "./styles.css";
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export default class App extends React.Component {
render() {
return (
<List
numItems={10}
windowHeight={120}
itemHeight={40}
renderItem={({ index, style }) => {
return (
<div key={index} className="item" style={style}>
Hello {index} / {data[index]}
</div>
);
}}
/>
);
}
}
- numItems : List가 표현할 데이터의 총 개수
- windowHeight : 리스트의 크기
= 화면에 보이는 영역의 크기
= itemHeight가 40, windowHeight가 120이므로, 위 리스트는 한 번에 3개의 항목이 보인다. - itemHeight : 각 항목의 Height
- renderItem : 각 항목의 렌더링 로직 함수
App 컴포넌트는 List Component(가상화 목록 구현 컴포넌트)의 상위 컴포넌트이다. 주목할만한 점은 render props 패턴으로 App에서 List가 렌더링 해야 하는 데이터의 렌더링 로직을 구현하여 renderItem이라는 props로 넘겨준다는 점이다.
.scroll {
width: 150px;
height: 120px;
}
style.css는 위와 같은 내용이 담겨있다.. scroll은 가상 목록에서 스크롤이 포함되어있는 div를 의미하는 것으로 App에서 List 컴포넌트의 windowHeight prop을 120으로 넘겨주었기 때문에 css에서도 List 컴포넌트의 Height가 120이 되도록 설정해준 것이다.
1-2. List
List는 위와 같은 형태의 데이터들이 상호작용하는 구조로 되어있다. 코드를 보며 이들이 어떤 상호작용을 하는지 살펴보자.
export class VirtualizedList extends React.Component {
constructor(props) {
super(props);
this.state = {
scrollTop: 0
};
}
onScroll = (e) => {
this.setState({ scrollTop: e.currentTarget.scrollTop });
};
render() {
const { numItems, itemHeight, renderItem, windowHeight } = this.props;
const scrollTop = this.state.scrollTop;
// List Elements의 총 Height
const innerHeight = numItems * itemHeight;
// 현재 화면에 보여지는 List의 부분에서 가장 상위의 항목의 index
const startIndex = Math.floor(scrollTop / itemHeight);
// 현재 화면 바로 바깥에 있는 항목의 index
// 가장 마지막 요소이거나, (스크롤한 부분 + 리스트에 한 번에 보여지는 부분) / 각 항목 Height
const endIndex = Math.min(
numItems - 1,
Math.floor((scrollTop + windowHeight) / itemHeight)
);
// 렌더링할 새로운 items 배열을 만든다.
const items = [];
for (let i = startIndex; i <= endIndex; i++) {
items.push(
renderItem({
index: i,
style: {
position: "absolute",
top: `${i * itemHeight}px`,
width: "100%"
}
})
);
}
return (
<div
className="scroll"
style={{ overflowY: "scroll" }}
onScroll={this.onScroll}
>
<div
className="inner"
style={{ position: "relative", height: `${innerHeight}px` }}
>
{items}
</div>
</div>
);
}
}
- innerHeight : 목록 전체의 높이. 전달된 props를 사용하여 결정되며, 스크롤이 변경돼도 변하지 않는 데이터이다.
= 각 항목의 높이 x 총 데이터 개수 - scrollTop : 위부터 스크롤된 수직 거리를 의미하는 상태. 스크롤 이벤트(onScroll)가 발생할 때마다 값이 변경된다.
- startIndex : 현재 화면에 보여야 하는 List의 항목 중 가장 상단 항목의 인덱스
ex. itemHeight = 40, scrollTop = 80
스크롤이 80만큼 내려가면서, 리스트의 상단부가 80만큼 화면 바깥(상단)으로 나가게 된다.
리스트의 각 항목의 높이는 40이므로, 총 2개의 아이템이 화면 밖으로 이동하였으므로,
현재 화면에 보이는 항목 중 가장 첫 번째 항목의 인덱스는 2 (80 / 2)이다. - endIndex : 현재 화면 바로 바깥(하단)에 있는 항목의 인덱스
가상 목록은 기본적으로 화면에 보이는 3가지만 렌더링 하도록 작성해야 하지만, 위 구현에서는 스크롤을 내릴 때 더 자연스럽게 보이기 위해 1개 항목을 추가로 렌더링(overscan)하여, 한 번에 총 4개 항목이 렌더링 된다.
List 컴포넌트는 전달받은 props와 스크롤 이벤트를 통해 startIndex, endIndex와 같은 값들을 계산하여, 현재 보여야 할 리스트 항목만으로 구성된 배열을 새롭게 작성한다. 그 후, 작성한 배열만을 렌더링 하여 업데이트하는 방식으로 현재 보여야 하는 항목들만을 렌더링 하고, 가려진 항목들은 unload 하는 것이다. 따라서 스크롤 이벤트가 얼마나 발생하던지 화면에는 항상 4개의 항목만이 그려져 있다.
코드의 또 다른 특징은 내부 목록은 position:relative와 계산된 innerHeight, 리스트의 각 항목은 position : absolute를 이용하여 렌더링 한다는 점이다.
직접 가상화 목록 만들기 : https://medium.com/ingeniouslysimple/building-a-virtualized-list-from-scratch-9225e8bec120
가상화 목록 개발하며 배운 것 : https://dev.to/nishanbajracharya/what-i-learned-from-building-my-own-virtualized-list-library-for-react-45ik