Lighthouse of FE biginner

[React] Table 가상 리스트(Virtual List) 도입기 본문

[WEB] 프론트엔드

[React] Table 가상 리스트(Virtual List) 도입기

[FE] Lighthouse 2024. 7. 17. 14:22

들어가며

프론트엔드에 있어서 테이블 컴포넌트는 중요한 컴포넌트 입니다.

모든 프로덕트에 테이블 컴포넌트가 들어가지는 않지만, 데이터를 다루는 프로덕트에서는 대부분 테이블 컴포넌트를 구현해서 사용하고 있습니다.

리액트에서 테이블을 다룰수 있는 방법은 여러가지 방법이 있습니다.

1. table 태그를 사용해 직접 구현하기

2. UI 라이브러리의 힘을 빌려 구현하기

3. headless UI 라이브러리의 힘을 빌려 구현하기

 

이 중 저는 headless UI 라이브러리의 힘을 빌려 테이블 컴포넌트를 구현했습니다. 테이블 컴포넌트를 구현하는 포스팅은 다음 포스팅에서 작성할 예정이고, 오늘은 작업한 컴포넌트에 가상 리스트를 도입했던 과정을 남겨보도록 하겠습니다.

 

가상 리스트란?

가상 리스트란 가상화 기술을 활용해 테이블의 로우를 가상화 하는 방식입니다. 가상 리스트는 꼭 테이블에서 사용하는 방식만은 아니고, 무수히 많은 데이터를 화면에 표현해야 할 때 사용하는 방식입니다.

 

그럼 왜 가상 리스트를 사용해야 할까요? 만약 10,000건의 데이터를 화면에 그려야 한다고 가정합니다. 서버에서 데이터 10,000건을 내려받아서 10,000건의 데이터가 담겨진 array를 map함수를 활용해 화면에 그립니다. 이 경우 문제가 발생합니다. 10,000건의 데이터를 그리기 위해 10,000개의 DOM을 랜더링 해야한다는 뜻 입니다. 이 경우 퍼포먼스에 상당히 악영향을 끼칠 수 있습니다. 사용자에게 보이지 않는 데이터는 화면에 그리는 것 자체가 무거운 행위이고 비효율적 입니다.

 

가상 리스트를 사용한다면 10,000건의 데이터를 받아왔지만 화면에는 사용자가 지정한 만큼의 DOM을 그립니다. 예를 들어 개발자가 20개의 DOM만 그리기로 설정했고 화면에는 10건의 데이터만 노출되며 다음 데이터는 스크롤을 통해서 확인이 가능하다고 하면, 가상 리스트에서는 라이브러리 API를 활용해 데이터의 인덱스만 교체해 다른 데이터를 DOM에 삽입해줍니다. 이렇게 함으로써 화면에 수많은 DOM을 그리는 일을 방지하고 퍼포먼스를 향상시켜 사용자 경험을 개선 할 수 있습니다.

 

가상 리스트 도입 이전

가상 리스트를 도입하기 이전에는 100건의 데이터를 화면에 그려주고 컬럼을 리사이징 하는 것 만으로도 이벤트가 버벅거리며 Violation이 걸렸습니다.

실제 동영상에서 제대로 표현되지는 않지만 컬럼이 커서를 따라오지 못하며 아주 버벅거리는 모습을 보여줬습니다. 이 문제를 해결하기 위해 메모이제이션을 활용해 컴포넌트를 최적화 해봤지만 여전히 해결되지 않는 모습을 보여줬습니다.

리액트 테이블의 컬럼 리사이징을 위해 인라인 태그를 활용해 width를 변경해줍니다. 만약 수많은 로우를 리사이징 한다면 수많은 DOM이 리사이징이 되면서 리랜더링을 발생시키고, width를 조정하는 과정은 브라우저 랜더링 과정 중 리플로우를 일으키기 때문에 퍼포먼스에 상당히 좋지 않습니다.

 

하여 많은 고민을 해보다가 가상 리스트를 도입하기로 결심했습니다.

 

가상 리스트를 구현할 수 있는 라이브러리

리액트에서 가상 리스트를 구현할 수 있는 라이브러리는 대표적으로 두 가지가 있습니다.

 

- react-virtualized

- @tanstack/react-virtual

 

전자의 라이브러리의 경우 9년 전에 출시됐을 만큼 상당히 역사 깊은 라이브러리 입니다. 후자의 라이브러리는 react-query, react-table의 tanstack에서 출시한 라이브러리인데 2년 밖에 안된 라이브러리지만 tanstack 생태계를 잘 활용해 다운로드가 크로스가 된 것을 확인할 수 있습니다. 또한 해당 라이브러리의 경우 아주 간단한 방법으로 가상 리스트를 구현할 수 있기 때문에 해당 라이브러리를 도입하도록 결정했습니다.

pnpm install @tanstack/react-virtual

 

다운로드 후 useVirtualizer 훅을 import 합니다. 

const rowVirtualizer = useVirtualizer({
   count: getRowModel().rows.length,
   estimateSize: () => 30,
   getScrollElement: () => bodyRef.current,
   measureElement:
      typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1
         ? (element) => element.getBoundingClientRect().height
         : undefined,
   overscan: 5,
});

 

훅의 파라미터로 객체를 넘겨줄 수 있습니다.

 

count, estimateSize, getScrollElement 옵션은 필수 값 입니다. count에는 리스트의 총 갯수를 넣어줍니다. estimateSize는 각 개체의 측정 사이즈를 입력해줍니다. getScrollElement에는 컨테이너의 ref를 리턴하는 함수를 입력해줍니다.

 

옵셔널 프로퍼티인 measureElement에는 측정될 객체의 크기를 동적으로 계산해야 할 때 넣어줄 수 있습니다. 테이블 로우는 높이를 예측할 수 없기 때문에 높이를 반환하는 함수를 넣어줬습니다. overscan의 경우 몇개의 항목을 더 스캔할 지 정해줄 수 있습니다.

공식 문서를 통해 더 많은 옵션 값을 확인할 수 있습니다.
https://tanstack.com/virtual/latest/docs/api/virtualizer

 

컴포넌트를 살펴보겠습니다.

function Table<T>(props: TableProps<T>) {
   const { data, columns } = props;
   const bodyRef = useRef<HTMLDivElement | null>(null);

   const table = useReactTable({
      data,
      columns,
      columnResizeMode: 'onChange',
      getCoreRowModel: getCoreRowModel(),
   });

   const { getRowModel } = table;
   const rows = getRowModel().rows;

   const rowVirtualizer = useVirtualizer({
      count: getRowModel().rows.length,
      estimateSize: () => 30,
      getScrollElement: () => bodyRef.current,
      measureElement:
         typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1
            ? (element) => element.getBoundingClientRect().height
            : undefined,
      overscan: 5,
   });

   return (
      <div ref={bodyRef} className={cx(styles.tableBodyViewport, styles.virtualizedTableBody)}>
         {rowVirtualizer.getVirtualItems().map((virtualRow) => {
            const row = rows[virtualRow.index] as Row<T>;
            return (
               <div
                  key={row.id}
                  data-index={virtualRow.index}
                  ref={(node) => rowVirtualizer.measureElement(node)}
                  style={{
                     transform: `translateY(${virtualRow.start}px)`,
                  }}
               >
                  {row.getVisibleCells().map((cell) => (
                     <div
                        key={cell.id}
                        className={styles.tableCell}
                        style={{ width: cell.column.getSize() }}
                     >
                        {flexRender(cell.column.columnDef.cell, cell.getContext())}
                     </div>
                  ))}
               </div>
            );
         })}
      </div>
   );
}

 

빠른 이해를 위해 Virtual List을 구현하기 위해 사용한 부분만 발췌했습니다. 먼저 ref를 가상 리스트의 컨테이터에 바인딩 합니다. 훅을 통해 반환받은 인스턴스의 getVirtualItems메서드를 통해 가상 리스트를 만들어주겠습니다. 해당 메서드를 통해 rows를 리턴받고 해당 rows를 map함수를 활용해 컴포넌트에 뿌려줍니다. 이때 virtualRow의 index를 활용해 실제 row에 접근하고 해당 row를 활용해 로우를 구현합니다. row에는 data-index, ref, style 코드가 작성되어 있는데 해당 코드들은 가상 리스트를 사용할 경우 필수로 제공해야 할 값들 입니다. data-index에는 해당 가상 로우의 인덱스를, ref에는 노드 사이즈 측정을 위해 익명 함수를, style에는 위치를 잡기 위해 스타일 코드를 넣어줍니다. 가상 리스트의 로우는 absolute로 떠있습니다. 위치를 잡아주기 위해 ref가 바인딩 된 태그에는 relative 포지션을 줘야합니다.

 

구현된 후

가상리스트가 구현된 모습을 보겠습니다. 우측 콘솔을 살펴보면 스크롤을 할 때 마다 DOM노드가 새로 생성되며 교체되는 모습을 확인할 수 있습니다. 또한 가상화를 통해 적은 DOM노드가 존재하기 때문에 컬럼을 리사이징 할 때 랜더링 퍼포먼스가 상당히 개선된 것을 확인할 수 있습니다.

마치며

@tanstack/react-virtual 라이브러리와 함께 한다면 쉽게 가상 리스트를 구현할 수 있습니다. 다음 포스팅에서는 react-table을 활용해 구현된 테이블 컴포넌트를 소개해보도록 하겠습니다.