대량 데이터 테이블에 가상화(Virtualization) 도입하기

Sara Jo·2026년 2월 22일
post-thumbnail

얼마 전 대량의 데이터(수백개에서 수천개의 row)를 보여주는 테이블을 만들었는데, 스크롤을 이동하거나 필터 변경으로 테이블을 다시 렌더할 때 속도가 생각보다 너무 느렸다.

그래서 한 번에 모든 데이터를 가져오는 대신 무한스크롤 형식으로 변경할까 고민했는데, 스크롤을 내려 데이터를 추가로 계속 가져오다보면 결국에는 렌더하는 row의 개수가 많아져 똑같이 속도 이슈가 생길 것 같았다. 무한스크롤은 데이터를 나눠 가져오는 방식이기 때문에 네트워크 부담은 줄일 수 있지만, 이미 화면에 렌더링된 row 자체가 줄어드는 것은 아니라 렌더링 성능 문제를 해결해주지는 못하기 때문이다.

그래서 테이블에 가상화(virtualization) 를 도입해서 렌더링 부담을 줄이는 방법을 택했고, 체감 성능이 눈에 띄게 올라갔다 🥳


렌더링이 느려지는 요인들

일단 데이터가 많을때 렌더링이 느려지는 이유들에 대해 한 번 짚고 넘어가보자.

  • row가 많아지면 DOM 노드가 폭발한다 (tr/td/div 등)
  • 스크롤할 때 브라우저가 레이아웃 계산/페인팅을 계속 한다
  • hover, sticky, tooltip, conditional render 같은 UI가 row마다 붙는다
  • 상태 업데이트(필터/정렬/확장/선택)가 발생하면 리렌더 범위가 커진다

즉, 데이터가 많다는 건 단순히 “map을 많이 돌린다”의 수준이 아니라 브라우저가 감당해야 하는 일이 기하급수로 늘어나게 된다.


가상화란?

가상화를 한 마디로 정의하면,

“화면에 보이는 row만 렌더링하고, 나머지는 렌더링하지 않는다.”

테이블이 10,000줄이어도 실제 DOM에는 예를 들어 80줄만 존재하게 만든다(보이는 영역 + 약간의 여유분).

사용자는 스크롤하면서 자연스럽게 10,000줄이 있는 것처럼 느끼지만, 실제로는 “보이는 구간만 갈아끼우는 방식” 으로 동작한다.


가상화 동작 원리

가상화의 기본동작은 이런식이다.

  1. 전체 row의 개수와 row 높이를 파악한다(또는 추정한다)

  2. 현재 스크롤 위치(scrollTop)를 파악한다

  3. viewport 높이(보이는 영역 높이)를 파악한다

  4. 그러면 현재 화면에 보여야 하는 row index 구간을 계산한다

    • startIndex = floor(scrollTop / rowHeight)
    • endIndex = startIndex + ceil(viewportHeight / rowHeight)
  5. start/end 근처에 여유분(overscan)을 조금 추가한다 (스크롤할 때 깜빡임 방지)

  6. 렌더링하지 않은 영역만큼의 “스크롤 공간”을 유지해줘야 하는데, 위/아래에 “빈 공간”을 spacer로 넣어서 스크롤 높이를 유지한다


여기서 spacer는 보통 두 가지 방식이 있는데,

  • paddingTop / paddingBottom으로 공간 만들기
  • 상단/하단에 높이만 있는 가짜 row(spacer row)를 렌더링하기

나는 두 번째(spacer row) 방법으로 만들었다.


구현 (예시 코드)

1) 가상화 윈도우 계산 함수

type VirtualWindowResult<T> = {
  visibleRecords: T[]
  isVirtualized: boolean
  startIndex: number
  endIndex: number
  topSpacerPx: number
  bottomSpacerPx: number
}

export function computeVirtualWindow<T>(args: {
  records: T[]
  scrollTop: number
  viewportHeight: number
  rowHeight: number
  overscan: number
  threshold: number
  makeSpacer: (id: string, heightPx: number) => T
}): VirtualWindowResult<T> {
  const { records, scrollTop, viewportHeight, rowHeight, overscan, threshold, makeSpacer } = args

  const total = records.length
  if (total === 0 || total < threshold) {
    return {
      visibleRecords: records,
      isVirtualized: false,
      startIndex: 0,
      endIndex: total,
      topSpacerPx: 0,
      bottomSpacerPx: 0,
    }
  }

  const vp = viewportHeight || 400

  const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
  const endIndex = Math.min(total, startIndex + Math.ceil(vp / rowHeight) + overscan)

  const topSpacerPx = startIndex * rowHeight
  const bottomSpacerPx = (total - endIndex) * rowHeight

  const slice = records.slice(startIndex, endIndex)

  const visible: T[] = []
  if (topSpacerPx > 0) visible.push(makeSpacer('__spacer_top', topSpacerPx))
  visible.push(...slice)
  if (bottomSpacerPx > 0) visible.push(makeSpacer('__spacer_bottom', bottomSpacerPx))

  return {
    visibleRecords: visible,
    isVirtualized: true,
    startIndex,
    endIndex,
    topSpacerPx,
    bottomSpacerPx,
  }
}
  • threshold는 “이 정도 row 이하면 굳이 가상화 안 한다” 같은 안전장치로, 데이터가 크지 않다면 가상화 없이 그냥 다 렌더링하는 게 오히려 버그가 적고 단순하다.
  • overscan은 “현재 보이는 범위보다 위/아래로 추가 렌더링”이다.
    너무 작으면 스크롤할 때 row가 튀고, 너무 크면 가상화 이점이 줄어든다.

2) 실제 화면에서 쓰기 위한 Hook

import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { computeVirtualWindow } from './computeVirtualWindow'

export function useVirtualWindow<T>(params: {
  records: T[]
  rowHeight: number
  overscan?: number
  threshold?: number
  makeSpacer: (id: string, heightPx: number) => T
}) {
  const { records, rowHeight, makeSpacer } = params
  const overscan = params.overscan ?? 20
  const threshold = params.threshold ?? 300

  const viewportRef = useRef<HTMLDivElement | null>(null)
  const [scrollTop, setScrollTop] = useState(0)
  const [viewportHeight, setViewportHeight] = useState(400)

  useLayoutEffect(() => {
    const el = viewportRef.current
    if (!el) return

    const measure = () => setViewportHeight(el.clientHeight)

    const onScroll = () => setScrollTop(el.scrollTop)

    measure()
    el.addEventListener('scroll', onScroll)

    let ro: ResizeObserver | null = null
    if (typeof ResizeObserver !== 'undefined') {
      ro = new ResizeObserver(measure)
      ro.observe(el)
    }

    window.addEventListener('resize', measure)

    return () => {
      el.removeEventListener('scroll', onScroll)
      window.removeEventListener('resize', measure)
      if (ro) ro.disconnect()
    }
  }, [])

  const windowResult = useMemo(() => {
    return computeVirtualWindow<T>({
      records,
      scrollTop,
      viewportHeight,
      rowHeight,
      overscan,
      threshold,
      makeSpacer,
    })
  }, [records, scrollTop, viewportHeight, rowHeight, overscan, threshold, makeSpacer])

  return { viewportRef, ...windowResult }
}

여기까지가 가상화 계산을 위한 로직이고, 이를 실제 테이블 렌더링에 적용하면 된다.

3) 데모 테이블 (가상화 적용)

import React, { useMemo } from 'react'
import { useVirtualWindow } from './useVirtualWindow'

type Row =
  | { id: string; kind: 'spacer'; height: number }
  | { id: string; kind: 'data'; name: string; amount: number }

const ESTIMATED_ROW_HEIGHT = 32
const OVERSCAN = 30
const THRESHOLD = 300

export default function VirtualizedTableDemo() {
  const records = useMemo<Row[]>(() => {
    return Array.from({ length: 10000 }).map((_, i) => ({
      id: `row_${i}`,
      kind: 'data',
      name: `Row ${i}`,
      amount: Math.floor(Math.random() * 100000),
    }))
  }, [])

  const { viewportRef, visibleRecords, isVirtualized, startIndex, endIndex } = useVirtualWindow<Row>({
    records,
    rowHeight: ESTIMATED_ROW_HEIGHT,
    overscan: OVERSCAN,
    threshold: THRESHOLD,
    makeSpacer: (id, heightPx) => ({ id, kind: 'spacer', height: heightPx }),
  })

  return (
    <div style={{ border: '1px solid #ddd', borderRadius: 8 }}>
      <div style={{ padding: 12, borderBottom: '1px solid #eee', fontSize: 14 }}>
        {isVirtualized ? (
          <span>
            가상화 켜짐 (렌더링 구간: {startIndex} ~ {endIndex})
          </span>
        ) : (
          <span>가상화 꺼짐 (row가 적어서 전체 렌더링)</span>
        )}
      </div>

      {/* viewport */}
      <div
        ref={viewportRef}
        style={{
          height: 480,
          overflow: 'auto',
          position: 'relative',
        }}
      >
        {/* table */}
        <div style={{ minWidth: 600 }}>
          {/* header (sticky) */}
          <div
            style={{
              position: 'sticky',
              top: 0,
              zIndex: 1,
              background: '#fff',
              borderBottom: '1px solid #eee',
              display: 'flex',
              padding: '8px 12px',
              fontWeight: 600,
            }}
          >
            <div style={{ width: 200 }}>Name</div>
            <div style={{ width: 120, textAlign: 'right' }}>Amount</div>
          </div>

          {/* body */}
          {visibleRecords.map(row => {
            if (row.kind === 'spacer') {
              return <div key={row.id} style={{ height: row.height }} />
            }

            return (
              <div
                key={row.id}
                style={{
                  display: 'flex',
                  padding: '8px 12px',
                  borderBottom: '1px solid #f3f3f3',
                  height: ESTIMATED_ROW_HEIGHT,
                  boxSizing: 'border-box',
                }}
              >
                <div style={{ width: 200 }}>{row.name}</div>
                <div style={{ width: 120, textAlign: 'right' }}>{row.amount.toLocaleString()}</div>
              </div>
            )
          })}
        </div>
      </div>
    </div>
  )
}

가상화 도입 시 생각해볼 포인트들

1) overscan은 클 수록 좋은가?

크게 하면 스크롤이 부드럽긴 한데, 대신 렌더링 row 수가 늘어나게 된다.

  • 데이터가 가벼운 row면 overscan 30~50 정도가 괜찮은 것 같다.
  • row가 무거우면(tooltip, popover, conditional render 많음) overscan을 조금 더 줄이는 게 좋다.

2) 데이터 적을 때는 가상화 끄기

데이터가 크지 않은데도 굳이 가상화를 적용하게 되면 오히려 자잘한 버그가 생길 수 있어서 threshold를 두는 것이 좋다.

3) 클릭/키보드 포커스/선택 상태

가상화는 “DOM을 계속 갈아끼우는 방식”이라서, DOM에 의존하는 기능이 있으면 흔들릴 수 있다.

예를 들어

  • 특정 row에 포커스 유지
  • shift 선택, range 선택
  • 스크롤 이동 후 “이전 선택 row” 하이라이트 유지

이런 건 상태를 DOM에 두지 않고 데이터 id 기반으로 관리하는 것이 안정적이다.


✔️ 남아 있는 개선 포인트

가상화를 도입하면서 대량 데이터 렌더링에서 발생하던 스크롤 지연과 반응성 문제는 해소되었지만, 더 개선할 수 있는 점들에 대해서도 생각해봤다.

1️⃣ Row 높이를 고정값으로 가정한 점

현재 구현은 모든 row가 동일한 높이를 가진다는 가정 하에 동작하고 있다.
텍스트 길이나 컬럼 설정에 따라 row의 높이가 각각 달라질 수 있는 경우에는, 가변 높이를 지원하는 virtualization 구조가 필요하다.

다만, 가변 높이 가상화는 구현 복잡도가 크게 증가하고 유지보수 부담이 커지기 때문에 row 높이가 크게 변하지 않도록 UI를 설계하는 것이 더 현실적인 선택일 수 있다.

2️⃣ Scroll 이벤트 기반 상태 업데이트의 비용

현재처럼 onScroll에서 setScrollTop를 계속 호출하면 리렌더가 잦아져서, 스크롤이 빠르게 발생할 경우 불필요한 계산이 증가하게 되고, 가상화로 줄인 렌더링 이점을 다시 감소시켜 버릴 수 있다.

requestAnimationFrame 기반으로 업데이트를 묶어 1프레임에 1번만 갱신하는 식으로 렌더링 빈도를 더 줄이는 방식으로 개선할 수 있을 것 같다.

3️⃣ Spacer Row 방식의 구조적 트레이드오프

가상 공간을 유지하기 위해 상·하단에 spacer row를 삽입하는 방식을 사용했는데, 이 방식은 구현이 직관적인 대신 테이블 기능과의 상호작용을 별도로 처리해야 한다.

예를 들어:

  • 클릭 이벤트 제외 처리
  • hover 스타일 예외 처리
  • row index 의존 로직 보정

등이 필요하다.

(나는 rowKind === 'spacer'면 클릭 무시 처리하도록 했다.)


결론: 가상화는 ‘렌더링 비용’이 병목일 때 가장 확실한 해법이다

테이블 성능 문제를 해결할 때, 나는 순서를 보통 이렇게 가져간다.

  1. 불필요한 리렌더 줄이기 (memo, state 구조 정리)
  2. row UI를 가볍게 만들기 (tooltip/Popover 남발 줄이기)
  3. 그래도 크면 가상화 도입

그런데 데이터가 수천 줄 이상이고 기능이 많은 테이블이면 3번은 거의 필수에 가까운 것 같다. 그리고 가상화를 한 번 잘 붙여두면 이후에는 기능을 추가해도 성능이 잘 버텨주기 때문에 꽤 좋은 선택인듯 하다!

0개의 댓글