Skip to main content

Command Palette

Search for a command to run...

react-virtuoso 라이브러리를 활용한 테이블 최적화

리스트 가상화가 필요할 때, 그리고 적용 전후의 비교

Published
5 min read
react-virtuoso 라이브러리를 활용한 테이블 최적화

2년차 프론트엔드 개발자입니다. 웹(React, Next.js)과 웹뷰, 앱(React Native) 개발 경험이 있어 플랫폼에 구애받는 개발이 가능합니다.

선 개발 후 개선에 따른 빠른 개발을 지향하고, 여러 번 QA와 테스트를 통해 기능을 개선합니다. 작업 경과를 중간, 완료 때마다 공유하고 논의해 소통 에러와 기능의 문제점을 최소화하고 있습니다.


예전에 백오피스 개발 중에 공용 2열 테이블 컴포넌트를 제작한 적이 있었다.
‘공용’인 만큼, 이 컴포넌트에서 제일 중요한 건 어떤 API가 들어와도 호환될 수 있도록 구현하는 거였는데, 컴포넌트를 사용하던 한 분께서 화면이 버벅이는 이슈가 발생했다고 말씀해주셨다.

확인해보니 계속해서 스크롤을 내려 데이터를 받을 경우 DOM에 그려지는 컴포넌트들이 누적되어 성능이 저하되는 이슈였다.

본 글에서는 그 당시 문제를 어떻게 해결했는지, 그리고 지금 돌이켜 볼때 추가로 구현한다면 어떤 점을 고려했을지에 대한 내용을 다루고자 한다.

‘리스트를 가상화하세요’

React에서 렌더링을 최적화한다면 크게 React에서 최적화를 한다면 크게 ‘렌더링 횟수를 줄이기’와 ‘렌더링될 컴포넌트 수를 줄이기’, 이 두 가지를 가리킨다.

이번 이슈는 렌더링될 컴포넌트 수의 문제였는데, 현재가 아닌 과거 React 공식 문서에는 아래와 같은 최적화 방법을 안내해주고 있다.

리스트를 가상화하세요.

애플리케이션에서 많은 리스트(수 백 또는 수 천행)를 렌더링하는 경우, ‘윈도잉(windowing)’이라는 기법을 사용하는 것을 추천합니다. 이 기법은 주어진 시간에 리스트의 부분만 렌더링하며 컴포넌트를 다시 렌더링하는 데 걸리는 시간과 생성된 DOM 노드의 수를 크게 줄일 수 있습니다.

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

리스트 가상화가 뭔데?

리스트 가상화를 가볍게 설명하자면, 사용자에게 보여지는 항목만 렌더링하는 기법이다.

이 기법을 활용하면 스크롤을 올리거나 내릴 때, 가려진 내용들은 DOM 요소에 렌더링되면서 생겨나고, 사라지는 내용들은 DOM 트리에서 지워져 렌더링이 되지 않는다.

이는 마치 스크롤을 움직일 때마다 리스트를 바라보는 창이 왔다갔다 움직인다고 하여, 윈도잉이라고 부른다.

이 기법을 활용하면 테이블과 같이 리스트들을 나열하는 컴포넌트에서 노출 사항들을 구분지어 렌더링을 시켜주므로, DOM 성능 부하에 영향을 덜 줄 수 있다.

리스트 가상화에 도움을 주는 라이브러리들, 그리고 선택한 것.

npm을 찾다보면 React와 관련하여 리스트 가상화를 도와주는 라이브러리들이 꽤 있다.
react-window, react-virtualized, tanstack-virtual, react-virtuoso 등.

요 네 개의 라이브러리들은 어떠한 차이점들이 존재할까?
라이브러리를 선택할 때 중요하게 여기는 관점들과 당시 프로젝트에서 필요했던 부분들을 기준으로 정리해봤다.

react-windowreact-virtualizedtanstack-virtualreact-virtuoso
현재 버전v1.8.8v9.22.5v3.10.8v4.7.11
번들 크기~2.5kB (gzipped)~27kB (gzipped)~8kB (gzipped)~15kB (gzipped)
유지보수 상태제한적유지보수 모드활발함활발함
무한 스크롤⚠️ (추가 작업 필요)⚠️ (추가 작업 필요)
사용 복잡도 (학습 난이도)중간어려움쉬움매우 쉬움
렌더링 성능매우 빠름빠름빠름안정적
커스터마이징제한적자유로움자유로움적절함
에러 처리기본적기본적좋음우수함
커뮤니티 활성도보통높음 (감소 중)증가 중보통
이슈 해결 속도느림매우 느림빠름빠름

여러가지를 비교해봤고, 그 결과 결정한 라이브러리는 react-virtuoso였다.

물론 표 내용만 보면 tanstack-virtual이 여러 면에서 압도적이었는데, 하지만 이 컴포넌트가 라이브에도 이미 배치되어 있는 상태였고 누군가가 이 문제를 겪고 있을 가능성을 보니 빠르게 해결하는 게 우선이라고 판단했다.

그렇다는 건 학습 곡선이 쉬우면서 안정적인 라이브러리여야 한다는 게 중요했고, react-virtuoso 라이브러리는 컴포넌트 하나에 여러 API 등을 활용하도록 소개가 잘 되어있어 이 라이브러리를 우선 써보기로 했다.

향후, 라이브 배포 후 문제가 없다면 tanstack-virtual로 마이그레이션을 고려해보기로 하고 말이다.

리스트 가상화 반영 전후, 그 결과는?

export function Table({ list, columns, titles, fetchNextPage, isFetchingNextPage, hasNextPage }: Props) {
  return (
    <TableVirtuoso
      style={{ width: '100%', height: 800 }}
      data={list}
      components={{
          // 테이블 전체에 관련한 사항 적용
        Table: (props) => {
          return (
            <table
              {...props}
            />
          );
        },
          // 테이블 각 열에 관련한 사항 적용        
        TableRow: (props) => {
          return <tr {...props} />;
        },
          // 테이블 값이 비어있을 때에 관련한 사항 적용        
        EmptyPlaceholder: () => {
          return (
            <tr>
              <td>
                {...내용 비어있을 때의 로직}
              </td>
            </tr>
          );
        },
      }}

      // 테이블 상단에 고정할 헤더 컴포넌트
      fixedHeaderContent={() => (
        <tr>
          {titles.map((제목) => (
            <th key={제목}>{제목}</th>
          ))}
        </tr>
      )}

      // 테이블에 노출될 리스트 아이템들. 내용에는 없지만 기본적으로 tr을 감싼 채로 나온다.
      itemContent={(index, data) =>
        columns.map((열내용) => (
          <td key={열내용}>
            {data[열내용]}
          </td>
        ))
      }

      // 테이블 하단에 고정할 푸터 컴포넌트
      fixedFooterContent={() => isFetchingNextPage && (<td>불러오는 중...</td>)}


      // 테이블 최하단에 도달 시에 적용될 이벤트
      endReached={() => hasNextPage && fetchNextPage()}
    />
  );
}

react-virtuoso 라이브러리에는 이처럼 테이블에 최적화된 API도 지원해주므로 이를 배치해 테이블 최적화를 진행했다.

이후, 전후 비교를 위해 테이블 데이터를 받아온 뒤 계속해서 스크롤을 내려 마지막까지 도달하는 시간을 측정해봤다.
조건은 CPU x6 감소를 적용하고, 데이터는 이전에 문제가 됐던 8,500 여 개의 데이터를 받던 API를 활용했다.

변경 전과 변경 후의 데이터를 비교해본 결과 아래와 같았다.

  • 변경 전

  • 변경 후

변경 전후의 비교 시, 다음과 같은 차이가 있었다.

  • 스크롤을 계속 내려 마지막 요소까지 도달하기까지의 시간

    • 변경 전은 3분 32초, 변경 후는 1분 8초에 마지막 요소에 도달했다.
  • 다음 데이터를 그려내기까지의 시간

    • 변경 전은 DOM 요소가 계속 늘어남에 따라 DOM 성능에 부하가 늘어나 점진적으로 시간이 늘어났다.
      마지막 데이터는 위와 같이 224밀리초가 나왔다.

    • 변경 후는 화면에 보여지는 영역만 그려지기 때문에 일정하게 48밀리초가 나왔다.

이렇게 리스트 최적화를 통해 스크롤로 늘어나는 DOM 요소들을 보이는 영역만 그리도록 함으로서 성능 최적화를 완료해냈다.

섣부른 최적화를 하지 말자.

처음부터 컴포넌트를 만들 때, 최적화를 고려하는 사람들이 분명 있을 거라고 생각한다.
이 부분은 우리가 경계해야 하는 포인트인데 성능 저하가 별로 느껴지지 않는다면, 최적화를 해도 얻는 것이 적다.
오히려 안 하는 게 나을 수 있다.

우리는 코드를 짜는 개발자이기 이전에, 이용자들이 불편해하는 점을 파악하고 개선하는 사람들이다.
그렇기 때문에 멀쩡히 잘 쓰고 있고, 부정적인 피드백도 없는 기능을 개선하는 것이 과연 좋은 걸까?
그걸로 과연 우리는 유의미한 결과를 얻을 수 있는 걸까?

무조건 최적화는 좋다라는 접근을 버리고, 정말로 필요성이 대두됐을 때 하나의 수단으로서 접근해보도록 하자.
그러면 좀 더 우리가 문제를 접근하는 방식이나 해결하는 방법을 다양하게 바라볼 수 있을 것이다.

출저

[https://ko.legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists:embed:cite]

[https://web.dev/articles/virtualize-long-lists-react-window?hl=ko:embed:cite]

[https://virtuoso.dev/:embed:cite]

[https://ridicorp.com/story/ridi-markdown-improvements/:embed:cite]

[https://techblog.pet-friends.co.kr/%EB%AA%A9%EB%A1%9D-%EA%B0%80%EC%83%81%ED%99%94%EC%9D%98-%EB%A7%88%EB%B2%95-%EC%9A%B0%EB%A6%AC-dom%EC%9D%B4-%EB%8B%AC%EB%9D%BC%EC%A1%8C%EC%96%B4%EC%9A%94-f8d0bca4681a:embed:cite]

[https://victor-log.vercel.app/post/windowing/:embed:cite]

[https://ou9999-dev.com/p/better-infinite-scroll:embed:cite]