데굴데굴

<React> react-virtuoso로 큰 크기의 리스트 렌더링하기 (가상화, windowing) 본문

Programming/React

<React> react-virtuoso로 큰 크기의 리스트 렌더링하기 (가상화, windowing)

aemaaeng 2023. 4. 11. 18:51

리스트의 크기가 큰 경우에는 보통 페이지네이션이나 무한스크롤로 끊어서 보여줄 수 있다.
내 프로젝트에서는 로컬에 있는 json 파일을 활용하고 있었고, 솔로로 하는 토이 프로젝트였어서 서버까지 구현하기에는 예상했던 것보다 공수가 많이 들 것 같아 어떻게 해야하나 고민이었다.
Intersection Observer API를 이용한 무한스크롤 방식으로 slice해서 보여준다고 해도 결국엔 매번 json 파일 전체를 불러왔기에 최선의 방법은 아니라고 생각했다.
따라서 큰 크기의 리스트를 프론트 단에서 효율적으로 보여줄 방법이 필요했다.

리스트 가상화, windowing이란?

참조

 

react-window로 대형 리스트 가상화

react-window는 대형 리스트를 효율적으로 렌더링할 수 있는 라이브러리입니다.

web.dev

리스트 가상화는 마치 창문을 통해 바깥을 보는 것처럼 화면에 보이는 부분만 DOM 트리를 생성하는 기술이다.
10,000개의 요소를 가진 리스트를 브라우저에 렌더링해야할 때, 화면에 보이지 않는 부분까지 DOM 트리를 생성하는 것은 굉장히 비효율적이다. 어차피 안 보이는데, 굳이?

브라우저에 전부 다 렌더링하게 되면 속도가 현저히 느려질뿐더러 Lighthouse에서도 아주 낮은 퍼포먼스 점수와 함께 excessive DOM size를 주의하라는 경고를 준다.

이럴 때 가상화 라이브러리를 사용하여 windowing 기술을 적용해볼 수 있다.

가상화 라이브러리: react-window, react-virtuoso

리스트 가상화를 도와줄 수 있는 라이브러리는 여러 가지가 있다.
나는 react를 사용하고 있어서 react에서 사용 가능한 라이브러리를 찾아보았다.


리스트 가상화를 검색하면 상위 검색 결과에는 react-window가 대부분이다.
나 또한 미디엄에서 봤던 react-window에 관한 을 통해 가상화라는 개념을 처음 접했기 때문에 react-window를 적용하려고 했었다.

react-window에서 느꼈던 한계

react-window는 리스트 요소의 크기가 고정된 경우에 사용하는 FixedSizeList와 그와 반대인 경우에 사용하는 VariableSizeList를 제공한다.

요소의 너비, 높이가 고정된 경우에는 사용이 간편하지만, 그렇지 않은 경우에는 다소 까다로워진다.

VariableSizeList를 쓸 때에는 요소의 높이를 결정하는 함수를 작성해주어야 하기 때문이다.

// react-window 공식 예제
import { VariableSizeList as List } from 'react-window';
 
// These row heights are arbitrary.
// Yours should be based on the content of the row.
const rowHeights = new Array(1000)
  .fill(true)
  .map(() => 25 + Math.round(Math.random() * 50));
 
const getItemSize = index => rowHeights[index];
 
const Row = ({ index, style }) => (
  <div style={style}>Row {index}</div>
);
 
const Example = () => (
  <List
    height={150}
    itemCount={1000}
    itemSize={getItemSize}
    width={300}
  >
    {Row}
  </List>
);

react-window 예제 페이지에 있는 스니펫에서는 Math.random()으로 난수를 생성하여 높이를 랜덤으로 지정해주고 있다. (개인적인 생각이지만 실질적으로 활용할 수 있는 예제는 아니라고 느꼈다..)


내 프로젝트는 채팅 백업 사이트라 사진, 비디오, 오디오 등등 여러 요소가 복합적으로 렌더링되고 있었기 때문에 높이 예측 함수를 직접 작성하는 것이 매우매우 까다롭게 느껴졌다. react-virtualized-auto-sizer라는 라이브러리가 또 있었지만, 갈수록 복잡해져서 솔직히 잘 이해가 안 됐다.
이것저것 시도하다가 react-window로는 구현이 힘들 것 같다는 결론을 내리게 되었다.

react-virtuoso

가상화에 대해 구글링을 하다가 react-virtuoso라는 또 다른 가상화 라이브러리가 있다는 것을 알게 되었다.
공식 문서 사이트도 잘 되어있었고, 무엇보다도 이 문구를 보고 써보면 좋을 것 같다고 생각했다.

- Handles items with variable dynamic height; no manual measurements or hard-coding of item heights necessary
- Automatically handles content resizing

 

적용 방법은 직관적이다.

`<Virtuoso>` 안에 여러 속성을 적용해서 리턴하면 된다. 주요 속성은 아래와 같다.

  • `style` - 리스트에 적용할 스타일
  • `data` - 렌더링할 데이터값, 데이터를 생성하는 함수를 넣을 수도 있다
  • `itemContent` - 렌더링할 컴포넌트, map 함수처럼 동작한다

적용한 코드

import groupByMinute, { ChatMessage } from "../util/groupByMinute";
import { useEffect, useState } from "react";
import GroupedByMin from "../components/GroupedByMin";
import DateDivider from "../components/DateDivider";
import { Virtuoso } from "react-virtuoso";

function Chats() {
  const [data, setData] = useState<ChatMessage[][]>([]);

  useEffect(() => {
    fetch("data/data.json", {
      headers: {
        Accept: "application / json",
      },
      method: "GET",
    })
      .then((res) => res.json())
      .then((res) => {
        setData(Object.values(groupByMinute(res)));
      });
  }, []);

  return (
    <Virtuoso
      style={{
        height: "calc(100vh - 50px)",
        margin: "0px",
      }}
      data={data}
      itemContent={(idx, el) => {
        return (
          <div key={idx} style={{ paddingBottom: "1px" }}>
            <GroupedByMin data={el} />
          </div>
        );
      }}
    />
  );
}

export default Chats;

context 속성 활용하여 날짜 구분선 추가하기

이 프로젝트는 채팅 데이터를 보여주고 있기 때문에 

`react-virtuoso`를 적용하기 전에는 날짜 구분선을 보여주기 위해 map 메소드의 세 번째 인자 `arr`를 활용하고 있었다.

`itemContent` 속성이 `map` 메소드와 비슷하게 작동하는 것 같아 어쩌면 같은 방식으로 렌더링이 가능할 것 같아서 공식 문서를 읽어보았다.

공식 문서에 있는 itemContent를 보면 `index`, `data`, `context` 이렇게 세 가지 인자를 받고 있는 것을 볼 수 있다

하지만 `itemContent` 내부에서 `context`를 출력해보면 `context`에 아무것도 정의되어 있지 않기 때문에 `null`이 뜬다. 

 

소스 코드에서 `context`를 검색해본 결과 `context` 속성을 따로 지정해줄 수 있다는 것을 알게 되었고, 여기에 `data` 배열을 넣고 로직을 작성했더니 날짜 구분선이 의도했던 대로 표시되었다.

 

// Chats.tsx
// ...rest
  return (
    <Virtuoso
      style={{
        height: "calc(100vh - 50px)",
        margin: "0px",
      }}
      data={data}
      {/* context 부여 */}
      context={data}
      itemContent={(idx, el, arr) => {
        let isDifferentDate;
        if (idx >= 1 && idx < arr.length) {
          isDifferentDate =
            el[0].datetime.slice(0, 10) !==
            arr[idx - 1][0].datetime.slice(0, 10)
              ? true
              : false;
        }
        return (
          <div key={idx} style={{ paddingBottom: "1px" }}>
            {isDifferentDate || idx === 0 ? (
              <DateDivider date={el[0].datetime.slice(0, 10)} />
            ) : null}
            <GroupedByMin data={el} />
          </div>
        );
      }}
    />
  );

 

결과

개발자 도구를 켜보면 스크롤할 때마다 DOM 트리의 요소가 동적으로 바뀌는 것을 볼 수 있다.

퍼포먼스 점수도 30점대에서 76점으로 올랐다. (로컬 개발 서버 기준)

위에서 아래로 스크롤할 때에는 매끄럽게 잘 되는데, 역방향으로 스크롤할 때에는 조금 튈 때가 있어서 이 부분은 더 찾아봐야할 것 같다.

느낀 점

npm trends에서 두 라이브러리의 다운로드 수를 비교해보면 `react-window`가 월등히 높다. 

`react-window`랑 씨름하느라 불필요하게 시간을 낭비했는데 내 프로젝트에 더 적합했던 것은 `react-virtuoso`였다. 

라이브러리를 선택할 때 무작정 선택하기보다는 다른 후보는 없는지, 이 라이브러리가 최선인지 꼼꼼히 확인해보고 도입해야겠음을 느꼈다.

왜 개발은 의사결정의 연속이라고 하는지 이해할 수 있었던 경험이었다.

Comments