본문 바로가기
개발/리액트

많은 데이터를 보여주는 방법에 대하여 (Tanstack query,table,react-virtual)

by 핸디(Handy) 2024. 1. 21.

들어가며

안녕하세요. 편리함을 추구하는 개발자 핸디입니다.

이번 글에서는 Tanstack query, Tanstack table,  Tanstack react-virtual로 이루어진 Tanstack 라이브러리 3형제를 이용해서 유저에게 더 많은 데이터를 편리하고 깔끔하게 보여주는 방법에 대해서 적어보았습니다.

대상독자는 무한스크롤, 혹은 페이지네이션을 이용하여 유저에게 데이터를 보여주고 싶지만, 개발하기가 귀찮아서 편리하게 만들고 싶은 개발자입니다.

시작하겠습니다.

Tanstack Table 

Tanstack Table은 테이블을 만들어주는 라이브러리입니다.

참고) 공식 페이지

여기서 주목할 점은 Headless와 React,Vue,Solid 의 환경에서도 동작한다는 점입니다.

Tanstack Table은 모든 기능 및 디자인을 수정할수 있고, 특정 환경에 종속되지 않았다는 것을 의미합니다.

이번에는 TS, React 환경에서의 Tanstack Table에 대해 다룹니다.

우선 들어가기에 앞서 Headless에 대해 살펴보고 가겠습니다.

Headless

Headless 는 최근 프론트엔드판에서 각광을 받는 용어입니다.

기존의 라이브러리들이 완성된 제품을 제공해줬다면 Headless는 완성된 제품을 제공하는 대신에

제품을 만들수 있는 logic, state, processing,  API 등을 제공합니다. 그리고 유저는 이를 통해서 나만의 제품을 만들어야 합니다.

비슷한 느낌으로는 DIY (Do it Yourself) 정도라고 보면 될 듯합니다.

Tanstack Table은 Headless 라이브러리와 Component-based 라이브러리로 해서 비교를 합니다.

Headless의 장단점

Tanstack Table 개발자들이 생각하는 Headless의 장점은

  • 마크업과 스타일을 100% 컨트롤할 수 있음.
  • 모든 스타일 패턴에 대해 서포트 (CSS, CSS-in-JS, UI libraries, etc)
  • 작은 번들 사이즈
  • 이식성, 자바스크립트 환경이면 어디든 가능

이 외 단점은 2개를 말하고 있습니다.

  • 설정하기가 귀찮음
  • 마크업과 디자인을 제공해주지 않음

하지만 저는 한 가지를 더 추가하고 싶은데요.

  • 공식문서를 보지 않고는 사용하기 어려움.

일반적으로 Component-based 라이브러리는 Example만 보고 빠르게 사용할 수 있습니다.

때론 공식문서를 안 봐도 될 정도로 깔끔하게 제공하는 라이브러리들이 많습니다.

하지만 Headless는 그게 안됩니다. 근데 당연한 것이 완제품을 안 주는데 어쩝니까.. 만들어야지

그리고 Component-based 라이브러리의 장단점은 Headless와 반대라고 생각하면 되겠습니다.

사용법

너무도 많은 사용법이 있기에 여기서 모든 것을 다루지 않겠습니다.

무한스크롤과 페이지네이션을 하기 위한 최소 기능으로 다루겠습니다.

추가로 저는 컴포넌트 라이브러리를 Chakra-ui를 사용하고 있기에 Table에 필요한 컴포넌트는 해당 라이브러리에서 가져오도록 하겠습니다.

// 기본 사용법 예시
import { Table, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";
import {
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";

export function TableExample() {
  const table = useReactTable({
    data: [],
    columns: [],
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <>
      <Table>
        <Thead position={"sticky"} top={0}>
          {table.getHeaderGroups().map((headerGroup) => (
            <Tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <Th key={header.id} colSpan={header.colSpan}>
                    {header.isPlaceholder ? null : (
                      <div>
                        {flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                      </div>
                    )}
                  </Th>
                );
              })}
            </Tr>
          ))}
        </Thead>
        <Tbody>
          {table.getRowModel().rows.map((row) => {
            return (
              <Tr key={row.id}>
                {row.getVisibleCells().map((cell) => {
                  return (
                    <Td key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </Td>
                  );
                })}
              </Tr>
            );
          })}
        </Tbody>
      </Table>
    </>
  );
}

사용법이 상당히 직관적인데 useReactTable에 해당하는 Option를 넘겨주면

table을 통해 모든 값을 컨트롤할 수 있습니다. 그래서 Head를 만드는 부분과 Row를 만드는 부분이 이렇게 나뉘어 있습니다.

그리고 데이터를 한번 불러와보겠습니다.

export function TableExample() {
  const {
    data: { list },
  } = useTableList(); // Tanstack Query로 데이터 불러옴
  
  // 필요한 값만 가져와서 Column를 만들어줌
  const columns = useMemo(
    () => [
      {
        accessorKey: "id",
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: "title",
        cell: (info) => info.getValue(),
      },

      {
        id: "edit-btn",
        cell: ({ row }) => (
            <Button>
            	{row.original.value}
            </Button>
        ),
      },
    ],
    []
  );

  const table = useReactTable({
    data: list,
    columns: columns,
    getCoreRowModel: getCoreRowModel(),
  });

 // 후략

이렇게만 하면 데이터를 불러올 수 있습니다. 

column의 accessorKey에 해당하는 값이 list 배열의 키와 동일하다면 해당하는 값으로 랜더링 됩니다. 없으면 빈값이 랜더링 돼요.

디자인을 적용하기 전에 기본 Chakra UI 사용한 예시입니다.

네 이것으로 기본 사용법을 마무리하겠습니다.

무한스크롤

일반 Tanstack Table에서 기본으로 제공하는 example이 있습니다. 

해당 예시는 @tanstack/react-virtual를 통해서 Virtualizer 기능을 제공합니다. ( tanstack를 벗어날 수 없어...)

 

React Table Virtualized Infinite Scrolling Example | TanStack Table Docs

Subscribe to Bytes Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

tanstack.com

예시코드를 통해 각각의 기능별로 코드를 살펴보고 그다음에 실사용 코드를 통해 전체를 살펴보겠습니다.

일단 코드를 보면 크게 2가지 파일로 되어있습니다.

main.tsx와 makeData.ts입니다.

main에는 @tanstack/react-table, @tanstack/react-query, @tanstack/react-virtual로 이루어진 컴포넌트 랜더링을 하는 코드이입니다.

makeData는 테스트를 위해 @faker-js/faker로 만든 더미데이터, 그리고 데이터페치를 위한 함수가 들어있습니다.

그리고 테이블의 추가기능인 칼럼단위의 Sorting을 위한 기본 코드도 함께 있습니다.

그럼 데이터를 불러오는 코드부터 살펴보겠습니다.

데이터를 불러오는 코드

// main.tsx
const { data, fetchNextPage, isError, isFetching, isLoading } =
  useInfiniteQuery<PersonApiResponse>({
    queryKey: [
      "people",
      sorting, // sorting 변경시 갱신을 위한 키값 추가
    ],
    queryFn: async ({ pageParam = 0 }) => {
      const start = (pageParam as number) * fetchSize;
      const fetchedData = await fetchData(start, fetchSize, sorting); // api 스펙에 맞춰 요청
      return fetchedData;
    },
    initialPageParam: 0,
    getNextPageParam: (_lastGroup, groups) => groups.length,
    refetchOnWindowFocus: false,
    placeholderData: keepPreviousData,
  });
    
    
// makeData.ts
export const fetchData = async (
  start: number,
  size: number,
  sorting: SortingState
) => {
  const dbData = [...data]
  if (sorting.length) { // sorting을 위한 처리 (원래라는 백엔드에서 처리를 해주지만 더미데이터니깐)
    const sort = sorting[0] as ColumnSort
    const { id, desc } = sort as { id: keyof Person; desc: boolean }
    dbData.sort((a, b) => {
      if (desc) {
        return a[id] < b[id] ? 1 : -1
      }
      return a[id] > b[id] ? 1 : -1
    })
  }

  //simulate a backend api
  await new Promise(resolve => setTimeout(resolve, 200))

  return {
    data: dbData.slice(start, start + size),
    meta: {
      totalRowCount: dbData.length, // 무한스크롤 여부 체크를 위한 전체 row 길이
    },
  }
}

데이터 호출은 이렇게 되어있습니다.

데이터를 관리하는 코드

데이터를 관리하는 코드는 불러온 데이터를 tanstack table에 맞도록 형식을 변경하고 패치타이밍을 계산하여 데이터를 호출하는 트리거들로 구성된 코드들입니다.

// 페이지별로 날라온 데이터를 단일 어레이로 만들어서 사용
const flatData = React.useMemo(
  () => data?.pages?.flatMap((page) => page.data) ?? [],
  [data]
);
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; // 전체 row 길이 계산
const totalFetched = flatData.length;

// 스크롤하여 바닥에 닿았을 때, 데이터를 가져오도록 트리거하는 함수
const fetchMoreOnBottomReached = React.useCallback( 
  (containerRefElement?: HTMLDivElement | null) => {
    if (containerRefElement) {
      const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
      if (
        scrollHeight - scrollTop - clientHeight < 500 &&
        !isFetching &&
        totalFetched < totalDBRowCount
      ) {
        fetchNextPage();
      }
    }
  },
  [fetchNextPage, isFetching, totalFetched, totalDBRowCount]
);

// 스크롤 이벤트를 감지하여, 바닥에 닿았을 때, 데이터를 가져오도록 트리거하는 함수를 실행
React.useEffect(() => {
  fetchMoreOnBottomReached(tableContainerRef.current);
}, [fetchMoreOnBottomReached]);

가끔씩 react-window 같은 코드들은 직접 바닥을 닿았을 때를 체크하지 않고 사진과 같이 loadMoreItems라는 이름으로 트리거함수를 받습니다.

만약 react-window를 사용한다면 fetchNextPage를 loadMoreItems에 넣으면 되겠네요.

다시 돌아가서 이제 데이터를 가져오는 코드와 관리하는 코드를 만들었습니다.

데이터를 랜더링 하는 코드

// 생략

const tableContainerRef = React.useRef<HTMLDivElement>(null); // fetchNextPage를 위한 ref
const table = useReactTable({
  data: flatData, // 위에서 flat하게 만든 데이터
  columns, // 랜더링하고싶은 컬럼을 담은 배열
  state: {
    sorting, // sorting을 위한 값
  },
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  manualSorting: true, // sorting을 위한 옵션
});

// sorting을 위한 함수
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
  setSorting(updater);
  if (!!table.getRowModel().rows.length) {
    rowVirtualizer.scrollToIndex?.(0);
  }
};

// sorting을 위한 후처리
table.setOptions((prev) => ({
  ...prev,
  onSortingChange: handleSortingChange,
}));

// rowVirtualizer를 위해서 Rows를 가져옴
const { rows } = table.getRowModel();

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  estimateSize: () => 33, //estimate row height for accurate scrollbar dragging
  getScrollElement: () => tableContainerRef.current,
  measureElement: // 바닥 계산을 위한 코드
    typeof window !== "undefined" &&
    navigator.userAgent.indexOf("Firefox") === -1
      ? (element) => element?.getBoundingClientRect().height
      : undefined,
  overscan: 5,
});

return (
  <div
    className="container"
    onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
    ref={tableContainerRef}
    style={{
      overflow: "auto", //our scrollable table container
      position: "relative", //needed for sticky header
      height: "600px", //should be a fixed height
    }}
  >
    <table style={{ display: "grid" }}>
      <thead
        style={{
          display: "grid",
          position: "sticky",
          top: 0,
          zIndex: 1,
        }}
      >
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id} style={{ display: "flex", width: "100%" }}>
            {headerGroup.headers.map((header) => {
              return (
                <th
                  key={header.id}
                  style={{
                    display: "flex",
                    width: header.getSize(),
                  }}
                >
                  <div
                    {...{
                      className: header.column.getCanSort()
                        ? "cursor-pointer select-none"
                        : "",
                      onClick: header.column.getToggleSortingHandler(),
                    }}
                  >
                    {flexRender(
                      header.column.columnDef.header,
                      header.getContext()
                    )}
                    {{
                      asc: " 🔼",
                      desc: " 🔽",
                    }[header.column.getIsSorted() as string] ?? null}
                  </div>
                </th>
              );
            })}
          </tr>
        ))}
      </thead>
      <tbody
        style={{
          display: "grid",
          height: `${rowVirtualizer.getTotalSize()}px`, //tells scrollbar how big the table is
          position: "relative", //needed for absolute positioning of rows
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const row = rows[virtualRow.index] as Row<Person>;
          return (
            <tr
              data-index={virtualRow.index} //needed for dynamic row height measurement
              ref={(node) => rowVirtualizer.measureElement(node)} //measure dynamic row height
              key={row.id}
              style={{
                display: "flex",
                position: "absolute",
                transform: `translateY(${virtualRow.start}px)`,
                width: "100%",
              }}
            >
              {row.getVisibleCells().map((cell) => {
                return (
                  <td
                    key={cell.id}
                    style={{
                      display: "flex",
                      width: cell.column.getSize(),
                    }}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                );
              })}
            </tr>
          );
        })}
      </tbody>
    </table>
  </div>
);

코드가 상당히 복잡해 보입니다.

근데 천천히 살펴보면 데이터를 가져오는 코드와 관리하는 코드를 이용해서 실제 리액트 컴포넌트를 랜더링 해주는 것이라고 보면 되겠습니다.

실사용 코드

예시코드의 경우 300줄 정도 됩니다. 근데 그건 컴포넌트를 분리하지 않고 해서 그렇게 된 것이고요.

너무 길어진다면 columns, 데이터 가져오는 코드와 관리하는 코드들을 분리할 수 있습니다.

저는 그 중간 레벨에서 적당히 분리했는데, 해당 코드를 같이 살펴보시죠. (전체 파일 길이는 174줄입니다.)

import { convertServerStatusToText } from "@/business/status";
import DayUtil from "@/core/day/DayUtil";
import useGroupAudioListInfiniteList from "@/services/group/useGroupAudioListInfiniteList";
import {
  Flex,
  Table,
  Tbody,
  Td,
  Th,
  Thead,
  Tooltip,
  Tr,
} from "@chakra-ui/react";
import {
  PaginationState,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import React, { useEffect, useMemo, useState } from "react";

interface GroupAudioListTableProps {
  groupId: string;
}

export function GroupAudioListTable({ groupId }: GroupAudioListTableProps) {
  const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  }); // 페이지네이션, 무한스크롤을 위한 코드

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useGroupAudioListInfiniteList({
      bulkId: groupId,
    }); // 데이터를 가져오는 코드

  const columns = React.useMemo(
    () => [
      {
        accessorKey: "title",
        cell: (info) => info.getValue(),
        header: "Song Title",
      },

      {
        accessorKey: "updatedAt",
        cell: (info) => DayUtil.formatDateFull(info.getValue()),
        header: "Updated",
      },
      {
        accessorKey: "status",
        cell: ({ row }) => {
          return (
            <Tooltip label={row.original.status}>
              <Flex>{convertServerStatusToText(row.original.status)}</Flex>
            </Tooltip>
          );
        },
        header: "Progress",
      },
    ],
    [groupId]
  );

  const totalLength = data.pages[0]?.pagination?.totalCount;
  const flatData = useMemo(
    () => data?.pages?.flatMap((page) => page.jobList) ?? [],
    [data]
  ); // 데이터를 관리하는 코드

  const table = useReactTable({
    data: flatData ?? [],
    columns,
    state: {
      pagination: {
        pageIndex: pageIndex,
        pageSize: pageSize,
      },
    },
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,
    debugTable: false,
  });

  const { rows } = table.getRowModel();

  const parentRef = React.useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: totalLength,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 34,
    overscan: 20,
  });

  useEffect(() => {
  // 추가 데이터를 가져오는 코드
    const [lastItem] = [...virtualizer.getVirtualItems()].reverse();

    if (!lastItem) {
      return;
    }

    if (
      lastItem.index >= totalLength - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage();
    }
  }, [hasNextPage, fetchNextPage, totalLength, virtualizer.getVirtualItems()]);
  return (
    <>
      <div ref={parentRef} className="container">
        <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
          <Table h="full">
            <Thead position={"sticky"} top={0}>
              {table.getHeaderGroups().map((headerGroup) => (
                <Tr key={headerGroup.id}>
                  {headerGroup.headers.map((header) => {
                    return (
                      <Th key={header.id} colSpan={header.colSpan}>
                        {header.isPlaceholder ? null : (
                          <div>
                            {flexRender(
                              header.column.columnDef.header,
                              header.getContext()
                            )}
                          </div>
                        )}
                      </Th>
                    );
                  })}
                </Tr>
              ))}
            </Thead>
            <Tbody>
              {virtualizer.getVirtualItems().map((virtualRow, index) => {
                const row = rows[virtualRow.index];
                if (row === undefined) {
                  return null;
                }
                return (
                  <Tr
                    key={row.id}
                    style={{
                      height: `${virtualRow.size}px`,
                      transform: `translateY(${
                        virtualRow.start - index * virtualRow.size
                      }px)`,
                    }}
                  >
                    {row.getVisibleCells().map((cell) => {
                      return (
                        <Td key={cell.id}>
                          {flexRender(
                            cell.column.columnDef.cell,
                            cell.getContext()
                          )}
                        </Td>
                      );
                    })}
                  </Tr>
                );
              })}
            </Tbody>
          </Table>
        </div>
      </div>
    </>
  );
}

랜더링 된 화면 이렇습니다. 아직 디자인되기 전이라.. 좀 그렇네요 ㅋㅋ

그 외

페이지네이션 테이블

이번글에서는 무한스크롤에 대해 중점적으로 다루었지만 페이지네이션의 경우도 크게 다른 점은 없습니다.

일단 페이지네이션 예시도 잘 나와있고,

데이터를 가져오는 코드단에서 자동으로 변경해 줬던 pageIndex를 페이지네이션 컴포넌트에 맞춰 해당 값을 업데이트하는 것이 거의 유일한 차이점입니다.

그 외에는 useReactTable에서 주는 옵션에서 onPaginationChange함수가 추가로 들어가는 것과 페이지네이션컴포넌트를 만드는 수고로움이 다입니다.

 

React Table Pagination Controlled Example | TanStack Table Docs

Subscribe to Bytes Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

tanstack.com

마치며

여기까지가 tanstack 라이브러리 3형제를 이용해서 데이터를 편리하고 빠르게 유저에게 보여줄 수 있는 방법을 설명한 글이었습니다.

이번 글을 쓰면서도 느꼈는데 tanstack 형님들의 라이브러리는 엄청난 것 같습니다 ㅋㅋ

시간이 날 때마다 코드를 살펴보고 있는데 보면서도 감탄 10%, 이해 안 됨 80%, 무념무상 10%로 보이는 것 같습니다.

언젠가는 저도 저런 수준의 라이브러리를 만드는 날이 오길 기대하며

이만 마칩니다.

 

읽어볼 만한 자료



댓글