기술 블로그

5,000건 대량 테이블의 '전체 선택' 지연 해결하기 (Virtualization) 본문

프론트엔드

5,000건 대량 테이블의 '전체 선택' 지연 해결하기 (Virtualization)

jaegwan 2026. 3. 19. 10:05
반응형

정산 시스템의 데이터 수정 모달에서 최대 5,000건의 행을 테이블로 보여줘야 하는 기능을 구현했습니다. 개별 체크박스 토글은 문제가 없었으나, 헤더의 '전체 선택'을 클릭할 때마다 화면이 수 초간 멈추는 성능 저하가 발생했습니다.

지난 포스트에서 다룬 Set 기반 최적화와 React.memo만으로는 해결되지 않았던 이 문제를 가상화(Virtualization)를 통해 해결한 과정을 정리합니다.

1. 병목 지점 분석: React.memo가 무력화되는 순간

개별 체크박스 토글은 React.memo로 최적화가 가능합니다. 5,000개 행 중 상태가 변하는 행은 단 1개뿐이기 때문입니다. 하지만 '전체 선택'은 이야기가 다릅니다.

전체 선택 시 발생하는 일

  1. selectedSeqs 상태가 0개에서 5,000개로 변경됩니다.
  2. 각 행의 isSelected 프로퍼티가 전부 falsetrue로 바뀝니다.
  3. React.memo의 비교 함수가 작동하지만, 모든 행의 상태가 바뀌었으므로 5,000개 행 전체를 리렌더링합니다.
  4. React는 5,000개 <tr>에 대한 Virtual DOM diff를 수행하고 실제 DOM을 업데이트합니다.

결과적으로 데이터가 수천 건이 넘어가면 Virtual DOM을 비교하고 실제 DOM에 반영하는 과정 자체가 브라우저 메인 스레드에 큰 부담을 주게 됩니다.


2. 해결책: 가상화 (Virtualization)

가상화의 핵심 아이디어는 "눈에 보이는 영역(Viewport)만 렌더링하자"는 것입니다. 5,000개의 데이터가 있더라도 사용자의 화면에 보이는 행은 고작 10~15개 내외입니다. 가상화를 적용하면 전체 선택을 눌러도 React는 현재 보이는 15개의 행만 비교하면 되므로 즉각적인 반응 속도를 얻을 수 있습니다.

구현: @tanstack/react-virtual

TanStack Table과 궁합이 좋은 @tanstack/react-virtual을 활용하여 가상 테이블을 구현했습니다.

import { useVirtualizer } from '@tanstack/react-virtual';

const VirtualSimpleTable = ({ data, columns, estimateRowHeight = 53 }) => {
  const parentRef = useRef<HTMLDivElement>(null);

  const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
  const { rows } = table.getRowModel();

  // 가상화 설정
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => estimateRowHeight,
    overscan: 5,
    measureElement: el => el.getBoundingClientRect().height, // 동적 높이 대응
  });

  const gridTemplateColumns = table
    .getAllColumns()
    .map(col => `${col.getSize()}fr`)
    .join(' ');

  return (
    <div ref={parentRef} style={{ maxHeight: 400, overflowY: 'auto' }}>
      {/* 가상화 적용 시 내부 컨테이너 높이를 전체 데이터 높이만큼 확보 */}
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => {
          const row = rows[virtualRow.index];
          return (
            <div
              key={row.id}
              ref={virtualizer.measureElement}
              style={{
                position: 'absolute',
                top: 0,
                transform: `translateY(${virtualRow.start}px)`,
                display: 'grid',
                gridTemplateColumns,
              }}
            >
              {row.getVisibleCells().map(cell => (
                <div key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </div>
              ))}
            </div>
          );
        })}
      </div>
    </div>
  );
};

3. 주요 구현 포인트

1) Table 대신 Div + CSS Grid 사용

가상화 라이브러리는 각 행을 absolute 포지션으로 배치합니다. 하지만 표준 <table> 태그 내에서 <tr>absolute를 적용하면 테이블 레이아웃 구조가 깨지게 됩니다. 따라서 레이아웃 유연성이 높은 <div>CSS Grid 조합으로 전환하여 컬럼 정렬과 가상화 배치를 동시에 잡았습니다.

2) 동적 행 높이 대응 (measureElement)

데이터 내용에 따라 행 높이가 달라질 수 있는 경우, estimateSize만으로는 위치 계산이 부정확할 수 있습니다. measureElement를 통해 실제 렌더링된 요소의 높이를 측정함으로써 스크롤 위치를 정확하게 유지할 수 있습니다.


4. React.memo vs 가상화 비교

구분 React.memo (행 단위) 가상화 (Virtualization)
작동 원리 변경 없는 행 렌더링 스킵 보이는 행만 DOM에 렌더링
전체 선택 시 5,000개 전수 리렌더링 (느림) 약 15개만 리렌더링 (매우 빠름)
초기 렌더링 5,000개 DOM 생성 약 15개 DOM 생성
구현 난이도 낮음 중간 (CSS 레이아웃 수정 필요)

결론

React.memo는 '변경된 행만 다시 그리는' 훌륭한 전략이지만, 전체 선택처럼 모든 행의 상태가 동시에 변하는 케이스에서는 힘을 쓰지 못합니다.

데이터가 수천 건 이상이고 사용자와의 인터랙션이 잦은 테이블이라면, 처음부터 가상화를 고려하는 것이 성능과 사용자 경험(UX) 측면에서 훨씬 유리합니다. 특히 @tanstack/react-virtual을 활용하면 기존 TanStack Table의 로직을 그대로 유지하면서 성능만 끌어올릴 수 있습니다.


반응형
Comments