본문 바로가기

[리액트] react-virtualized을 활용한 무한 스크롤 구현

이전에 리액트 무한 스크롤 라이브러리를 하나 뜯어보고 간소화한 버전으로 구현해보았다.

 

바로 이 글이다.

 

[리액트] 스크롤 위치 복원, 무한 스크롤 Hook 구현

회사 실무에서 유용하게 쓰이는 훅을 공부하면서 (깃허브에 올릴만큼은 아닌) 기능 단위의 간이 코드를 coesandbox에서 작성한 것들을 기록함 1. 스크롤 위치 복원 기능 (from 회사 프로젝트) 라우팅�

coffeeandcakeandnewjeong.tistory.com

그런데 회사에서 무한 스크롤과 관련하여 이슈가 나온 것이 있었는데 무한스크롤되는 요소가 극단적으로 10만개 이상이 되고, 일정 개수만큼 추가로 로드해와도 렌더링 속도가 느려 화면에 요소가 그려지는 것이 지연되는 문제였다.

 

그래서 데이터를 로드한 것이 완료되었어도 잠시 빈 공간이 생긴 것처럼 보였다가 렌더링되는 현상이 있다.

이 현상을 조사해주신 팀원 분이 찾으신 이유에 따르면 브라우저의 레이아웃과 페인트 시간이 오래 걸리면서 빠른 스크롤 동작 시 렌더링이 지연되는 부분이 노출되었다고 한다.

[2020-08-07] 브라우저의 프레임을 그리는 방법에 대한 강의를 듣다가 생각난 것
브라우저는 기본적으로 싱글 메인 스레드이나 Compositor Thread를 두어 레이어들의 Composite 과정을 별도의 스레드에서 수행한다.

Compositor Thread의 장점으로는 스크롤 이벤트는 메인 스레드의 도움 없이도 화면을 갱신할 수 있게 되어 스크롤링 속도가 대폭 빨라지게 되었는데 Compositor 스레드가 보유한 레이어들의 정보에 해당하는 영역만을 렌더링할 수 있고, 정보가 없는 영역은 그릴 수 없어 흰 화면이 노출된다.

따라서 무한 스크롤의 경우 메인 스레드에서 추가 요소에 대한 레이어들을 생성해주어야만 Compositor Thread가 레이어 정보를 받아 합성하여 화면에 그릴 수 있기 때문이 아닐까 싶다.

VSync 기반 렌더링

[2020-08-22] 그림에서 오류 발견
Compositor Thread에서 Composite는 Paint 이후에 발생하니까 Main Thread의 Paint/Record 시점 이후에 실행되어야 맞다.

그래서 이전에 react-infinite-scroll-component를 보고 필요한 기능만을 담아서 구현한 버전을 Profiler로 어떻게 렌더링되나 확인해보니 데이터를 추가로 로드하는 부분에서 이전 요소들까지 다시 렌더링이 발생하는 것을 확인하였다.

(좌) 무한스크롤 전 (우) 무한 스크롤 후

현재는 mobx의 observer를 무한 스크롤을 사용하는 컴포넌트에 적용하여 메모이제이션을 통해 렌더링 성능을 높였으나 좀 더 근본적으로 InfiniteScroll 컴포넌트에서 해결할 순 없을까 고민되었다.

[참고] 또다른 방법으로는 Elem 컴포넌트를 React.memo()로 메모이제이션할 수도 있으나 빈번하게 변경되는 props를 가진 컴포넌트에서는 소용없을 거 같다.

따라서 여러 방법을 찾다가 다음의 결론에 이르렀다.

 

네이버 FE 플랫폼에서 소개한 무한 DOM 렌더링 최적화에 관련한 글에서 수백 수만개의 DOM 엘리먼트를 모두 렌더링하는 것 보다 사용자 화면에서 볼 수 있는 DOM 요소만을 두어 렌더링 비용을 줄이는 것을 읽고 뷰포트에 보이는 부분만을 렌더링할 수 있다면 굳이 보이지 않는 요소까지 전부 렌더링해올 필요가 없다고 생각했다.

 

리액트에서 해당 기능을 제공하는 라이브러리인 react-virtualized를 활용하여 해당 기능을 구현하였다.

다만 가상 스크롤 사용을 위해서는 전체 height와 단일 요소의 height가 필수로 주어져야 해서 무한 스크롤 컴포넌트 사용을 위해서 새로운 Props가 추가되었다. 추가로 react-virtualized의 List가 필요로 하는 props인 rowRenderer도 전달해주어야 한다.

<InfiniteScroll
  dataLength={items.length}
  hasMore={hasMore}
  next={fetchMoreData}
  loader={
    <div style={{
      display: "flex",
      justifyContent: "center",
      alignItems: "center"
    }}>
    loading...
    </div>
  }
  height={400}
  elementHeight={70}
  rowRenderer={rowRenderer}
  children={items}
/>

이제 사용자 뷰포트에 보이는 영역에 존재하는 DOM 요소만을 렌더링하는 것을 확인할 수 있다.

 

Profiler로 퍼포먼스를 검사해보면 필요한 DOM 요소만을 렌더링하기 때문에 전체 commit의 개수는 증가하였으나 개별 렌더링 속도를 보면 약 9~10배 빨라졌음을 알 수 있다. (25.3ms -> 3ms)

가시적인 Elem 컴포넌트만을 렌더링

하지만 아직은 실무에 바로 제안드리기는 어려워보인다.

 

무한 스크롤을 사용하는 모든 컴포넌트에서 새롭게 수정된 props를 적용해야하는 공수가 불가피하게 들 것이고,

높이가 가변적인 요소들의 경우 아래 예시처럼 개별 요소의 size를 별도로 get하는 함수를 구현해야 한다.

<AutoSizer disableHeight>
  {({width}) => (
    <List
      // ...중략...
      rowHeight={
        useDynamicRowHeight ? this._getRowHeight : listRowHeight
      }
    />
  )}
</AutoSizer>

_getRowHeight({index}) {
  return this._getDatum(index).size;
}

좀 더 유연한 컴포넌트가 될 수 있도록 더 튜닝해볼 필요가 있다 :)

 

정확한 예시는 아래의 codesandbox에서 확인할 수 있다.


2020.08.06. Updated

리팩토링하면서 고민했던 항목

  • 고민 1. scroll container의 height가 필수값 --> 필수값으로 안두고 싶음
  • 고민 2. rowRenderer 함수를 InfiniteScroll 컴포넌트에 두고 싶음. 따라서 무한 스크롤을 사용하는 다른 컴포넌트는 자신의 props에만 집중할 수 있도록 하고 싶음
  • 고민 3. loader 위치가 어색함 (reflow가 일어남) --> 원래처럼 미리 렌더링할 순 없나
  • 고민 4. 가변적인 rowHeight 계산 --> 지금은 고정 height만 가능한데 가변적인 height또한 가능하게끔

다음은 각 고민들에 대해 나름대로 해결한 방법들이다.

 

고민 1. scroll container의 height는 필수값

먼저 scroll container의 height는 react-virtualized의 동작 원리 상 어쩔 수 없이 무조건 주어져야 하는 값이다.

 

InfiniteScroll 컴포넌트에서 height가 없으면 window.screen.availHeight으로 설정할까 고민했었지만 부수 효과가 많을 듯 하여 어쩔 수 없이 필수 props로 두었다.

고민 2. rowRenderer 함수를 InfiniteScroll 컴포넌트에 위치시키기

List 컴포넌트가 필요로 하는 rowRenderer()는 parent, index, key, style 등 react-virtualized 에서 설정한 값들을 인자로 받는다.



하지만 InfiniteScroll 컴포넌트를 쓰는 외부 컴포넌트는 이 데이터가 뭔지도 모를 것이고 오로지 자신이 관심있어하는 데이터만 바라보는 게 이상적이다. (이걸 무슨 용어라고 했는데... 단일 책임 원칙? 맞나?)

 

따라서 rowRenderer 함수는 InfiniteScroll에 두고, 외부 컴포넌트는 자신이 관심있어하는 데이터만 인자로 받을 수 있도록 index 값만 인자로 주어 item[index] 를 활용할 수 있도록 하였다.

// in Example.js
/* 
  # before
  index, key, style은 react-virtualized에서 설정된 값
*/
const rowRenderer = ({index, key, style}) => 
  <Elem key={key} i={index} style={style} {...items[index]} />

/* 
  # after
  index만 react-virtualized에서 주었고, 외부 컴포넌트는 item[index]에만 집중
*/
const renderer = ({index}) => <Elem i={index} {...items[index]} />

// in InfiniteScroll.js
const rowRenderer = ({ parent, key, index, style }) => {
    return (
      <div style={style}>
        {renderer({
          index,
        })}
      </div>
    );
  };

고민 3. reflow 없이 loader 컴포넌트 렌더링

발견한 가장 이상적인 방법은 다음과 같다.

 

먼저 rowCount를(원래는 요소 갯수로 설정) 요소 갯수 + 1로 설정한다.

 

InfiniteScroll 함수의 rowRenderer 함수에서 index가 chilren.length 보다 크거나 같고 데이터를 더 불러와야 한다면 loader 컴포넌트를 렌더링하도록 한다.

const rowRenderer = ({ key, index, style }) => {
  let content;

  if (index >= children.length && hasMore) { // 로더
    content = loader
  } else if (index >= children.length && !hasMore) { // End of List
    content = "";
  } else { // 원래의 요소
    content = renderer({
      index,
    })
  }

  return (
    <div key={key} style={style}>
      {content}
    </div>
  );
};

고민 4. 가변적인 rowHeight 계산하기

react-virtualized에서 제공하는 CellMeasurer과 CellMeasurerCache를 활용하였다.

 

CellMeasurer과 CellMeasurerCache를 활용하면 가상 스크롤 상에서 가변적인 row 높이를 실시간으로 구하여 캐싱할 수 있다. 

 

주의할 점은 width, height 둘 중 하나를 고정(fixed)해야 다른 하나의 값을 구할 수 있다.

 

따라서 cache를 설정할 때 new CellMeasurerCache()의 인자가 되는 객체의 속성 중 fixedHeight혹은fixedWidth둘 중 하나의 값이 true여야 한다.

 

CellMeasurer과 CellMeasurerCache의 자세한 props는 여기를 참조하자.

 

사용 방법에 맞춰 가변적인 rowHeight을 계산하여 row를 렌더링한다.

let _cache = useMemo(() => new CellMeasurerCache({
  defaultHeight: minHeight,
  fixedWidth: true,
}), [minHeight])

const rowRenderer = ({ parent, key, index, style }) => {
  let content;

  if (index >= children.length && hasMore) {
    content = loader
  } else if (index >= children.length && !hasMore) {
    content = "";
  } else {
    content = renderer({
      index,
    })
  }

  return (
    <CellMeasurer
      cache={_cache}
      columnIndex={0}
      key={key}
      parent={parent}
      rowIndex={index}
      width={_mostRecentWidth}
    >
      <div style={style}>
        {content}
      </div>
    </CellMeasurer>
  );
};

한가지 더 추가해야 할 사항이 있다. 무한 스크롤에서 children의 개수가 증가하였을 때인데 이 떄에는 새로운 요소들이 렌더링되기 때문에 _cache를 clear한 후 재계산해주어야 한다.

 

따라서 이전 children props의 length와 현재의 children props의 length가 다르면 _cache를 clear한 후 List의 public 메소드인 recomputeRowHeights()을 호출하여 재계산한다.

 

또한 무한 스크롤 컨테이너의 width가 달라질 때를 대비해서 가장 최근의 width를 저장하고 있는

_mostRecentWidth 변수를 두어 width가 달라지면 _cache를 재계산하는 로직 또한 추가하였다.*

[참고] * : 이 로직은 아직 케이스를 만들어보진 못해서 확인이 필요함

 

Hook으로 개발했기 떄문에 componentDidUpdate(prevProps, nextProps)와 같이 이전 props를 참조하는 값을 두기 위해 이전에 만들어둔 usePrevious custom hook을 함께 사용하였다.

const _list = useRef();
const prevLength = usePrevious(children.length);
let _mostRecentWidth = 0;
let _resizeAllFlag = useRef(false);

let _cache = useMemo(() => new CellMeasurerCache({
  minHeight: minHeight,
  fixedWidth: true,
}), [minHeight])

useEffect(() => {
  if (_resizeAllFlag.current) {
    _resizeAllFlag.current = false;
    _cache.clearAll();
    if (_list.current) {
      _list.current.recomputeRowHeights();
    }
  } else if (prevLength && prevLength !== children.length) {
    const index = prevLength;
    _cache.clear(index, 0);
    if (_list.current) {
      _list.current.recomputeRowHeights(index);
    }
  }
}, [children, _resizeAllFlag]);

const _resizeAll = () => {
  _resizeAllFlag.current = false;
  _cache.clearAll();
  if (_list.current) {
    _list.current.recomputeRowHeights();
  }
}

return (
  <>
    <AutoSizer disableHeight>
      {({ width }) => {
        if (_mostRecentWidth && _mostRecentWidth !== width) {
          _resizeAllFlag.current = true;
          setTimeout(_resizeAll, 0);
        }

        return <List
          deferredMeasurementCache={_cache}
          rowCount={children.length + 1}
          width={width}
          height={height}
          rowHeight={_cache.rowHeight}
          rowRenderer={rowRenderer}
          overscanRowCount={5}
          onScroll={throttleScrollListener}
          ref={_list}
        />
      }}
    </AutoSizer>
  </>
);

위에서 왜 _cache가 useMemo로 감싸져 있냐면 처음엔 _cache를 그냥 선언하였더니 children이 변하면서 Hook을 재렌더링하는 과정에서 _cache또한 재생성되면서 마지막 요소로부터 3~5개 정도 누락된 다음에 요소가 그려지는 문제가 있었다.

 

예를 들어 20번째 요소에서 next()가 호출되면 21번째부터가 아닌 25번째부터 시작하는 문제가 있다...이에 따라 스크롤도 이상하게 움직였었다.

 

정확한 이유는 찾지 못했지만 예상하기로는 컴포넌트가 rerender되면서 _cache또한 재정의되서 그렇지 않을까 싶어 _cache를 defaultHeight이 변하지 않는 이상 재생성되지 않도록 메모이제이션하였다.

 

이제 최종 코드는 아래처럼 구현되었다.

외부 컴포넌트는 아래처럼 InfiniteScroll을 호출하면 될 것이다.

const renderer = ({index}) => <Elem i={index} {...items[index]} />

return (
  <div>
    <h3>
      InfiniteScroll with react-virtualized
    </h3>
    <div>
    <InfiniteScroll
      dataLength={items.length}
      hasMore={hasMore}
      next={fetchMoreData}
      loader={<Loader />}
      height={400}
      renderer={renderer}
      children={items}
    />
    </div>
  </div>
);

정확한 화면 예시는 아래의 codesandbox에서 확인할 수 있다.

사실 아직도 좀 거슬리는 건 scroll target 엘리먼트가 List 컴포넌트에 국한되어 전체 스크롤과는 별개로 동작한다는 것인데 이것을 해결할 수 있는지는 라이브러리를 뜯어보지 않는 이상은 어려울 듯 싶다 ㅠㅠ

 

내일까지 좀 더 살펴봐야겠다.


참고한 링크