반응형

 

전 글(https://lodado.tistory.com/83)에서 table 라이브러리를 구현해야 하는데

초기에는 headless library인 Tanstack Table을 사용하려 했으나, 대용량을 처리하지 못하는 문제로 내부 구조도 자체 구현하는 방향으로 방향을 바꿨다는 글을 썼다.

 

즉, 초기 렌더링 같은 tanstack table 자체의 성능에는 만족했지만 메모리 사용량이 너무  많아서(원본 데이터의 20배 수준) tanstack table을 사용하지 않는다는 결론을 내렸었는데

 

이 글에서는

 

1. tanstack Table의 데이터 구조

2. 메모리를 많이 사용하는 이유

 

두가지를 github를 들어가서 훑어 볼 것이다. 

 

깃허브 주소는 아래와 같다.

https://github.com/TanStack/table

 

GitHub - TanStack/table: 🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Ta

🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Table, Svelte-Table - GitHub - TanStack/table: 🤖 Headless UI for building powerful tables &...

github.com

 

사용 예시 

 

우선 tanstack table에서 제공하는 예시 코드(kitchen sink)를 보고, 내부 동작을 추론해보자.

 

 

 

위 예제 코드에서 src/App.tsx를 보면 tanstack table을 사용하는 코드 예시를 볼 수 있다.

 

export const App = () => {
  const rerender = React.useReducer(() => ({}), {})[1]

  const [data, setData] = React.useState(makeData(1000))
  const refreshData = () => setData(makeData(1000))

  const [columnVisibility, setColumnVisibility] = React.useState({})
  const [grouping, setGrouping] = React.useState<GroupingState>([])
  const [isSplit, setIsSplit] = React.useState(false)
  const [rowSelection, setRowSelection] = React.useState({})
  const [columnPinning, setColumnPinning] = React.useState({})
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    []
  )
  const [globalFilter, setGlobalFilter] = React.useState('')

  const [autoResetPageIndex, skipAutoResetPageIndex] = useSkipper()

  const table = useReactTable({
    data,
    columns,
    defaultColumn,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getGroupedRowModel: getGroupedRowModel(),
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: getFacetedUniqueValues(),
    getFacetedMinMaxValues: getFacetedMinMaxValues(),
... 생략

 

위 코드를 보면 useReactTable이 tanstack Table을 사용하는 방식(hook)이고, 

data와 column을 내부에 넣어서 table이란 object로 한번 감싸서 export하는것을 볼 수 있다.

 

그리고 getCoreRowModel, getFilterRowModel 등이 있는데 

해당 model들이 table에서 각 기능 (sort, filter)등에 사용하는 것이라고 추론할 수 있다.

 

그래서 일단 tanstack Table의 데이터 구조 를 github에 들어가서 어떻게 각 Model들이 어떻게 동작하고 연계되는지 찾아볼것이다.

 

힌트를 주자면 SQL에서 select문으로 데이터를 조회하는 방식과 비슷하다.

 

https://github.com/TanStack/table

tanstack table 최상단 폴더로 가보자.

lerna, packages 등의 폴더가 보이는데 이 프로젝트가 모노레포로 되어 있다는 사실을 알 수 있다.

 

그럼 packages 안에 core란 폴더가 있을거 같은데 그 폴더에 우리가 찾고 싶은 내용이 있을것 같다.

 

 

https://github.com/TanStack/table/blob/main/packages/table-core/src/utils/getCoreRowModel.ts

 

core 폴더를 찾아서 들어가다보니 아까 본 getCoreRowModel.ts 이란 파일이 있다.

보통 라이브러리들은 리엑트, 스벨트 등 다른 라이브러리에 종속성을 없에기 위해서 내부 코어 로직을 js로 구현하고,

각 라이브러리에 포팅 과정을 거치는데 아래 예시 코드를 보니 이 라이브러리도 똑같은 과정을 거치는것을 확인할 수 있다.

 

import { createRow } from '../core/row'
import { Table, Row, RowModel, RowData } from '../types'
import { memo } from '../utils'

export function getCoreRowModel<TData extends RowData>(): (
  table: Table<TData>
) => () => RowModel<TData> {
  return table =>
    memo(
      () => [table.options.data],
      (
        data
      ): {
        rows: Row<TData>[]
        flatRows: Row<TData>[]
        rowsById: Record<string, Row<TData>>
      } => {
        const rowModel: RowModel<TData> = {
          rows: [],
          flatRows: [],
          rowsById: {},
        }
			.... (생략) 

          for (let i = 0; i < originalRows.length; i++) {
            // Make the row
            const row = createRow(
              table,
              table._getRowId(originalRows[i]!, i, parentRow),
              originalRows[i]!,
              i,
              depth,
              undefined,
              parentRow?.id
            )
			
            .... (생략) 

            rowModel.flatRows.push(row)
            rowModel.rowsById[row.id] = row
            rows.push(row)

          return rows
        }

        rowModel.rows = accessRows(data)
        return rowModel
      },
      {
        key: process.env.NODE_ENV === 'development' && 'getRowModel',
        debug: () => table.options.debugAll ?? table.options.debugTable,
        onChange: () => {
          table._autoResetPageIndex()
        },
      }
    )
}

https://github.com/TanStack/table/blob/main/packages/table-core/src/utils/getCoreRowModel.ts

 

해당 model는 테이블에서 모든 row를 꺼내는 로직을 구현한 함수로 보인다. 

해당 코드에서 중요하다고 생각한 점을 추려봤는데 

 

1. memo를 사용해서 메모라이징을 통해 최적화를 시도한점 (성능 향상 but 메모리 사용)
2. flawRow를 사용해서 차원 depth가 있는 배열을 1차원 배열로 처리해 저장한 점 (성능 향상 but 메모리 사용)

3. rowById로 row의 id를 key로 해서 BigO(1)에 찾는 함수를 JSON 형태로 구현한 점 (성능 향상 but 메모리 사용)

 

음.. 위 코드를 보면 일단 성능을 위해 메모리를 희생한 코드로 보이고 해당 부분이 대용량 데이터 처리에서 약점이 될 것으로 추측될것 같다.

그래도 위 코드에서 늘어나는 메모리가 고작 3~4배일꺼 같은데..? 일단 메모리 부분은 추후에 보기로 하고 넘어가자

 

4. 독립적인 기능 (모든 row만을 꺼내는 로직을 구현한 함수)을 담당하는 하나의 함수

 

/packages/table-core/src/utils 에서 다른 model들을 살펴보면 각 model 함수들이 filter, sorting, pagination등 

독립된 로직을 동작하는 함수임을 알 수 있다. 

 

그럼 어디서 해당 함수들을 연결해줄까?

 

타고 타고 들어가다보면... table( table-core/src/core/table.ts#L125 )이란 파일에서 model끼리 연결해주는 부분을 찾을수가 있다.

 

const features = [
  Headers,
  Visibility,
  Ordering,
  Pinning,
  Filters,
  Sorting,
  Grouping,
  Expanding,
  Pagination,
  RowSelection,
  ColumnSizing,
] as const  // 중요 !!! 아까 본 model들이 converting되어 내부에 들어 있는 object임

... 생략

export function createTable<TData extends RowData>(
  options: TableOptionsResolved<TData>
): Table<TData> {
  ... 생략
  const coreInitialState: CoreTableState = {}

  let initialState = {
    ...coreInitialState,
    ...(options.initialState ?? {}),
  } as TableState

  table._features.forEach(feature => {
    initialState = feature.getInitialState?.(initialState) ?? initialState
  })

  const queued: (() => void)[] = []
  let queuedTimeout = false

  const coreInstance: CoreInstance<TData> = {
    _features: features,
    options: {
      ...defaultOptions,
      ...options,
    },
    initialState,
    _queue: cb => {
      queued.push(cb)

      if (!queuedTimeout) {
        queuedTimeout = true

        // Schedule a microtask to run the queued callbacks after
        // the current call stack (render, etc) has finished.
        Promise.resolve()
          .then(() => {
            while (queued.length) {
              queued.shift()!()
            }
            queuedTimeout = false
          })
          .catch(error =>
            setTimeout(() => {
              throw error
            })
          )
      }
    },
 
   ... 생략 
   Object.assign(table, coreInstance)

  for (let index = 0; index < table._features.length; index++) {
    const feature = table._features[index]
    feature?.createTable?.(table)
  }

  return table
}

https://github.com/TanStack/table/blob/main/packages/table-core/src/core/table.ts#L125

 

보고 싶은 부분만 추려봤다

 

  table._features.forEach(feature => {
    initialState = feature.getInitialState?.(initialState) ?? initialState
  })

위 코드가 model들이 연계되는 부분인데 함수형 프로그래밍의 pipe를 구현한 형태로 볼 수 있다

즉, input을 넣고 각 model들의 out을 바로 다음 model의 input으로 넣고... pipe 형태로 모델들을 연계한 것으로 볼 수 있다.

이 부분은 SQL의 select 문의 동작 방식과 유사하다.

 

query의 동작 순서

 

tanstack table의 핵심 데이터 구조는 생각보다 간단한 형태로 되어있다.

실제로 사내에서 만든 테이블 라이브러리의 내부 데이터 구조는 SQL의 select문을 참고하여 1~2주만에 구현했고,

tanstack table 와 유사한 성능을 낼 수 있었다.

 

 

그럼 tanstack table이 메모리를 과다 사용하는 이유는 무엇일까?

복합적인 요인이라서 꼭 찝어 말할 수는 없지만, 아까 보았듯이 row에 getRowById같은 편의 기능을 넣어놓았는데 만약 50~100만 row가 되면 row에 있는 편의기능 함수가 row 갯수만큼 추가되서 메모리를 과다 사용되는것으로 추측된다.

 

실제로 fork해서 50만개 row에 있는 "편의기능 함수" 만을 지워보는 실험을 했더니  1.6GB 정도에서 몇백MB를 감소 시킬 수 있었던 기억이 난다. 

 

사내에서 라이브러리를 구현했을때는 row를 인자로 받아서 실행하는 함수를 독립적으로 빼서 구현해서

tanstack table에 비해 메모리 사용량을 획기적으로 줄였다.

 

사실 tanstack table은 대용량 데이터 처리는 server pagination으로 구현한다고 생각해서

메모리 부분은 생각 안했을꺼라 추론 되지만..?

 

SQL Editor같은 제품을 브라우저에 구현하는 경우에는 브라우저에 수십~수백만 데이터가 있는 경우가 있고, 

해당 케이스를 서버 페이지네이션으로만 구현하려면 오히려 번거로워 SQL select문을 벤치마킹해서

테이블에서 1000만 이하의 대용량 데이터를 문제 없이 핸들링 할 수 있도록 내부 데이터 구조를 구현했다.

 

끟. 

반응형

+ Recent posts