Front-End

Virtualize List with react-window

나른한 노치 2023. 12. 17. 15:41

 

글또 9기로써 첫 글이다. 저번 달부터 갑작스럽게 살고 있는 집이 경매로 넘어가버려서 시작부터 1회차를 패스하였는데, 이제 글을 제대로 써보려고 한다.

 

올해 5월 회사에서 LCK Korea에서 사용할 웹 사이트를 제작할 때의 일이다.

 

그 당시 제품의 필터 구조에서 <리그 Select />(list)와 <게임 Select />(list)가 있었다. 리그(예시: 2023 LCK Summer)를 선택하면 해당 리그의 모든 경기를 <게임 Select /><게임 Select-Item />으로 보여주는 방식이었다. 한 리그를 onClick하면 <게임 Select />에서 약 90개 정도의 <게임 Select-Item />을 보여주었다. 그때까진 웹에서 크게 부담이 없었고, 무사히 넘어갈 수 있다고 생각했다.

 

그러나 쉽게 넘어가는 날이 없었다. 고객사 측에서 연락이 왔다.

 

<리그 Select />에 [전체]라는 list item을 추가하고 <게임 Select />에는 지금까지 있었던 모든 게임을 보여주세요.”

 

생각보다 답은 간단했지만, 버틸 수 있을지 고민이 들었다. 그러나 일단 만들어보고 생각해보자라는 마음에 [전체]의 <리그 Select-Item />을 구현하였다. 그리고 <게임 Select />를 여는데 걸리는 시간. 15초가 걸렸다. <Select /> 에서 paging 기법을 처리하자는 이야기가 있었으나 갑작스럽게 item이 추가되면서 생기는 불쾌함이 있었다. 그러나 해결책이 생각이 없는 것은 아니었다. 당시 구글이 운영하는 블로그인 web.dev를 시간날 때마다 읽어보았는데 스쳐지나간 것이 하나 있었다.

 

Virtualize large lists with react-window(react-window와 함께 대형 리스트 가상화)”

 

나는 이 글을 읽었을 때, 가상화의 필요성을 느낀 적이 없어서 쓸 생각을 해본 적은 없고, 한번도 써보지 않았기 때문에 프로덕트에 바로 적용하는 것에 대한 두려움이 있었다. 그러나 시작하는 첫 문장과 예시는 너무나 매력적이었다.

 

 

나는 react-window 라이브러리를 도입하였고, 당시 4000개가 넘는 item을 가진 <게임 Select />이 15초에서 1초도 안되는 속도로 줄어들었다. 속도가 매우 빨라지자 최근 경기로 이동하는 auto scroll까지 구현할 수 있게 되었다.

 

오늘 나를 구해준 리스트 가상화(Virtualize List)가 무엇인지 정리해보자.

 

참고.
가상화라고 했지만 사실 windowing이 더 이해하기 쉬운 표현 일 것이다. 아래 공식 문서에 나온 표현에서는 windowing으로 표기한다.

react-windowreact-virtualized는 널리 알려진 windowing 라이브러리입니다. 목록, 그리드 및 표 형식 데이터를 표시하기 위한 몇 가지 재사용 가능한 컴포넌트를 제공합니다. 애플리케이션의 특정한 활용 사례에 더 적합한 것을 원한다면 Twitter처럼 자신만의 windowing 컴포넌트를 만들 수 있습니다.

 

리스트 가상화

리스트 가상화는 리스트의 1000여개의 요소들을 한번에 렌더링하지 않고 사용자에게 보이는 아이템만 렌더링하는 데에 중점을 두고 있다.

 

유명한 라이브러리는 react-virtualizedreact-window가 있다.

 

 

react-window 의 경우, react-virtualized의 저자가 용량은 더 작고, 더 빠르고, tree-shakable하게 만든 라이브러리로 용량을 크게 줄일 수 있는게 특징이다. 두 라이브러리는 대부분 비슷하지만 react-window 가 조금 더 간단하다.

Code 분석

그렇게 오늘은 react-window<FixedSizeList /> 를 살펴보자.

// 출처: https://react-window.vercel.app/#/examples/list/fixed-size
import { FixedSizeList as List } from 'react-window';

const Row = ({ index, style }) => (
  <div className={index % 2 ? 'ListItemOdd' : 'ListItemEven'} style={style}>
    Row {index}
  </div>
);

const Example = () => (
  <List
    className="List"
    height={150}
    itemCount={1000}
    itemSize={35}
    width={300}
  >
    {Row}
  </List>
);

 

<FixedSizeList />를 이용하면 높이가 고정된 리스트 가상화 된 코드를 사용할 수 있다. 그리고 <Row /> 의 코드는 기존 컴포넌트와 달리 indexstyleprops로 받고 있다.

 

// 출처: https://github.com/bvaughn/react-window/blob/master/src/FixedSizeList.js
import createListComponent from './createListComponent';

const FixedSizeList = createListComponent({ ... })

 

FixedSizeListcreateListComponent를 이용하는 함수라는 것을 알 수 있다. 핵심만 보기 위해서 render() 에서 어떻게 가상화를 하는지 간추려 보았다.

 

// 출처: https://github.com/bvaughn/react-window/blob/master/src/createListComponent.js

render() {
  const {
    // ...
    children,
    className,
    height,
    itemCount,
    style,
    width,
    // ...
  } = this.props;

  // ...생략...
  const [startIndex, stopIndex] = this._getRangeToRender();

  const items = [];
  if (itemCount > 0) {
    for (let index = startIndex; index <= stopIndex; index++) {
      items.push(
        createElement(children, {
          data: itemData,
          key: itemKey(index, itemData),
          index,
          isScrolling: useIsScrolling ? isScrolling : undefined,
          style: this._getItemStyle(index),
        })
      );
    }
  }

  // ...생략...

  return createElement(
      outerElementType || outerTagName || 'div',
      // ...생략...
      ,
    },
    createElement(innerElementType || innerTagName || 'div', {
      children: items,
      // ...생략...
    })
);
}

 

위 코드에서 this._getRangeToRender()를 통해 startIndex stopIndex를 통해 생성해야할 Element를 조절하는 것을 볼 수 있으니 해당 함수는 어떻게 이루어져 있는지 찾아보자.

 

// 출처: https://github.com/bvaughn/react-window/blob/master/src/createListComponent.js

_getRangeToRender(): [number, number, number, number] {
  // ...중략...

    // Math.min(itemCount - 1, Math.floor(offset / itemSize)을 이용하여 index 계산.
  const startIndex = getStartIndexForOffset(
    this.props,
    scrollOffset,
    this._instanceProps
  );
  const stopIndex = getStopIndexForStartIndex(
    this.props,
    startIndex,
    scrollOffset,
    this._instanceProps
  );


    // overscanCount는 사용자가 직접 넣지 않으면 default가 2 입니다.
  const overscanBackward = !isScrolling || scrollDirection === 'backward' ?
    Math.max(1, overscanCount) :
    1;
  const overscanForward = !isScrolling || scrollDirection === 'forward' ?
    Math.max(1, overscanCount) :
    1;

  return [
    Math.max(0, startIndex - overscanBackward), // startIndex
    Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)), // stopIndex
    startIndex,
    stopIndex,
  ];
}

 

해당 코드에서는 startIndexarray[2]에 있는 것을 볼 수 있다. 이는 실제 위치보다 앞 뒤로 1~2개 더 생성하여 보기에 불편함이 없도록 하기 위하여 startIndexoverscanBackward 를 계산한 값 사용한 것이다. 그리하여 const [startIndex, stopIndex] = this._getRangeToRender()에서 어떤 값이 들어가는지 알 수 있게 되었다.

 

const Row = ({ index, style }) => (
  <div className={index % 2 ? 'ListItemOdd' : 'ListItemEven'} style={style}>
    Row {index}
  </div>
);

 

해당 코드에는 propsindexstyle을 가지는 것을 볼 수 있다.

 

// render() 

for (let index = startIndex; index <= stopIndex; index++) {
  items.push(
    createElement(children, {
      data: itemData,
      key: itemKey(index, itemData),
      index,
      isScrolling: useIsScrolling ? isScrolling : undefined,
      style: this._getItemStyle(index),
    })
  );
}

 

아까 전에 보았던 render() 의 일부이다. 여기서 style: this._getItemStyle(index) 를 이용하여 받는 것을 볼 수 있다.

 

{ // 이건 린팅 때문에 이쁘게 만들려고 { 추가함
  // ...생략...

  _getItemStyle = (index: number): Object => {
    const {
      direction,
      itemSize,
      layout
    } = this.props;

    // ...생략...

    let style;
    if (itemStyleCache.hasOwnProperty(index)) {
      style = itemStyleCache[index];
    } else {
      const offset = getItemOffset(this.props, index, this._instanceProps);
      const size = getItemSize(this.props, index, this._instanceProps);

      // TODO Deprecate direction "horizontal"
      const isHorizontal =
        direction === 'horizontal' || layout === 'horizontal';

      const isRtl = direction === 'rtl';
      const offsetHorizontal = isHorizontal ? offset : 0;
      itemStyleCache[index] = style = {
        position: 'absolute',
        left: isRtl ? undefined : offsetHorizontal,
        right: isRtl ? offsetHorizontal : undefined,
        top: !isHorizontal ? offset : 0,
        height: !isHorizontal ? size : '100%',
        width: isHorizontal ? size : '100%',
      };
    }
  }

  return style;
};

 

list-item은 _getItemStyle 메서드를 통하여서 style을 생성하는 것을 볼 수 있다. position을 이용하여 계산된 위치에 그려져 모든 컴포넌트를 그리지 않더라도 해당 위치에 스크롤이 제대로 동작할 수 있게 한 것을 알 수 있다.


 

내부에 다른 기능들이 있지만 핵심이 “어떻게 windowing을 하는 것인가?”이기 때문에 코드 분석은 저거 하나로 충분하다고 생각한다. 해당 라이브러리의 장점은 가상화를 통해 보여주는 부분만 렌더링하여 성능적으로 매우 효율적이다라고 할 수 있다. 그러나 단점이 없는 것은 아니다. 내가 생각한 단점은 아래와 같다.

react-window의 단점

[내용이 일부 검색 엔진에 노출되지 않는다.]

가상화 형식을 보면 보이는 부분 외에는 컴포넌트를 그리지 않는다. SEO를 위해서 찾아오는 bot은 페이지에서 생성된 dom을 기준으로 검색엔진에 영향을 주는데 컴포넌트를 생성하지 않아 노출이 되지 않는다는 단점이 있다.

 

[목록이 전체가 생성 되지 않아서 Dom을 접근이 제한적이다.]

가상화를 사용하기 전까지 auto-scroll을 하기 위해선 그려진 컴포넌트의 idclassName을 찾아서 구현하였다. 그러나, 리스트 가상화를 도입하게 되면서 보이지 않는 컴포넌트는 생성하지 않다보니 접근이 불가능하다.

 

[고정된 크기를 입력 받다보니 보여주는 표시할 수 있는 양이 제한적이다.]

itemSize={35}

다른 라이브러리에서는 동적으로 크기를 구현할 수 있다고 하지만 react-window에서는 크기를 입력 받는다. 그래서 정보가 너무 많은 경우 다음과 같은 코드를 사용하였다.

 

/* 
  출처: https://velog.io/@ainochi95/%ED%95%9C-%EC%A4%84-%EC%B2%98%EB%A6%AC-%EB%B0%8F-...-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-udjk65wk 
*/

.한줄처리_및_점세개 {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

 

이렇게 하면 보이는 줄바꿈은 되지 않고 넘치는 부분은 처리가 된다. 처음에는 아이템들의 크기가 일정하여 일관성이 있어서 좋았지만 문제는 해당 처리를 하지 않고 내용을 반드시 보여줘야 하는 경우에는 어려움을 겪을 수 있다.


오늘 virtualize list 또는 windowing 라이브러리인 react-window에 대해서 살펴보았다. 해당 라이브러리가 어떤 구조로 만들어져 있는지 분석한 글을 찾기가 힘들어서 이해하기 쉽게 간단 요약을 했는데도 불구하고 이해하기 어려울 수 있다.

 

그러나 글을 쓰게 되면서 전달을 하기 위한 용도로 정리하다보니 더 이해가 잘 되는 것 같다. 다음에도 글을 써보고자 한다.

'Front-End' 카테고리의 다른 글

React State 관리: 10년간의 교훈  (0) 2024.03.03
React-Native에 대해서 알아보자!  (0) 2024.02.17
Next.js Project Structure  (1) 2024.02.03
2024년 상태 관리의 종결 : zustand  (0) 2024.01.19
[FE RoadMap] 00.Internet 좀 봐봐  (0) 2023.03.27