10만 개의 항목을 모두 DOM에 렌더링하면 어떤 일이 벌어질까요?

10만 개의 <div>를 DOM에 넣으면 브라우저는 멈춥니다. 메모리는 수 GB를 소비하고, 스크롤은 끊기며, 초기 렌더링에만 수 초가 걸립니다. 가상화(Virtualization)는 이 문제를 "보이는 것만 렌더링"하는 방식으로 해결합니다.

가상화의 원리

가상화 없이

PLAINTEXT
10만 개 항목 → 10만 개 DOM 노드 → 브라우저 렌더링 폭발

가상화 적용

PLAINTEXT
10만 개 데이터 → 화면에 보이는 ~20개만 DOM 노드 생성
스크롤 → 보이는 범위 변경 → DOM 노드 재활용/교체

화면에 보이는 영역(viewport)과 약간의 여유 영역(overscan)만 실제 DOM에 렌더링합니다. 스크롤할 때 뷰포트 밖으로 나간 항목은 제거하고, 새로 보이는 항목을 추가합니다.

react-window — 가벼운 가상화

BASH
npm install react-window

고정 높이 리스트

JSX
import { FixedSizeList } from 'react-window';

function VirtualList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      <span>{items[index].name}</span>
      <span>{items[index].email}</span>
    </div>
  );

  return (
    <FixedSizeList
      height={600}        // 컨테이너 높이
      itemCount={items.length}  // 전체 항목 
      itemSize={50}       //  항목의 고정 높이
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

핵심 props

  • height: 스크롤 컨테이너의 높이
  • itemCount: 전체 항목 수
  • itemSize: 각 항목의 높이 (FixedSizeList는 고정값)
  • overscanCount: 뷰포트 밖에 미리 렌더링할 항목 수 (기본 1)

가변 높이 리스트

항목마다 높이가 다를 때는 VariableSizeList를 사용합니다.

JSX
import { VariableSizeList } from 'react-window';

function ChatMessages({ messages }) {
  const listRef = useRef();

  // 각 메시지의 높이를 계산하는 함수
  const getItemSize = (index) => {
    const message = messages[index];
    // 간단한 높이 추정 (실제로는 더 정교한 계산 필요)
    const lineCount = Math.ceil(message.text.length / 50);
    return 40 + lineCount * 20;
  };

  const Row = ({ index, style }) => (
    <div style={style} className="message">
      <strong>{messages[index].sender}</strong>
      <p>{messages[index].text}</p>
    </div>
  );

  return (
    <VariableSizeList
      height={500}
      itemCount={messages.length}
      itemSize={getItemSize}
      width="100%"
      ref={listRef}
    >
      {Row}
    </VariableSizeList>
  );
}

그리드

2차원 가상화도 지원합니다.

JSX
import { FixedSizeGrid } from 'react-window';

function SpreadsheetView({ data, columns, rows }) {
  const Cell = ({ columnIndex, rowIndex, style }) => (
    <div style={style} className="cell">
      {data[rowIndex]?.[columnIndex] ?? ''}
    </div>
  );

  return (
    <FixedSizeGrid
      columnCount={columns}
      columnWidth={120}
      rowCount={rows}
      rowHeight={35}
      height={600}
      width={800}
    >
      {Cell}
    </FixedSizeGrid>
  );
}

TanStack Virtual — 유연한 가상화

BASH
npm install @tanstack/react-virtual

TanStack Virtual은 react-window보다 유연하며, 직접 스크롤 컨테이너를 제어합니다.

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

function VirtualList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,  // 추정 높이
    overscan: 5,
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            {items[virtualRow.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}

동적 높이 측정

JSX
const virtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 80, // 초기 추정치
  // measureElement를 사용하면 실제 DOM 높이를 측정
});

// 각 항목에 measureElement 연결
{virtualizer.getVirtualItems().map((virtualRow) => (
  <div
    key={virtualRow.key}
    ref={virtualizer.measureElement}  // 실제 높이 측정
    data-index={virtualRow.index}
    style={{
      position: 'absolute',
      top: 0,
      left: 0,
      width: '100%',
      transform: `translateY(${virtualRow.start}px)`,
    }}
  >
    <DynamicContent item={items[virtualRow.index]} />
  </div>
))}

measureElement를 사용하면 각 항목이 렌더링된 후 실제 DOM 높이를 측정하여 정확한 가상화를 적용합니다.

무한 스크롤 + 가상화

데이터를 점진적으로 로드하면서 가상화를 적용하는 패턴입니다.

JSX
function InfiniteVirtualList() {
  const parentRef = useRef(null);
  const [items, setItems] = useState([]);
  const [hasNextPage, setHasNextPage] = useState(true);
  const [isLoading, setIsLoading] = useState(false);

  const fetchNextPage = async () => {
    if (isLoading || !hasNextPage) return;
    setIsLoading(true);

    const page = Math.floor(items.length / 50);
    const newItems = await api.getItems({ page, size: 50 });

    setItems((prev) => [...prev, ...newItems.data]);
    setHasNextPage(newItems.hasMore);
    setIsLoading(false);
  };

  const allItems = hasNextPage ? [...items, null] : items; // null = 로딩 표시용

  const virtualizer = useVirtualizer({
    count: allItems.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60,
    overscan: 5,
  });

  // 마지막 항목이 보이면 다음 페이지 로드
  useEffect(() => {
    const lastItem = virtualizer.getVirtualItems().at(-1);
    if (!lastItem) return;

    if (lastItem.index >= items.length - 1 && hasNextPage && !isLoading) {
      fetchNextPage();
    }
  }, [virtualizer.getVirtualItems(), hasNextPage, isLoading, items.length]);

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => {
          const item = allItems[virtualRow.index];
          return (
            <div
              key={virtualRow.key}
              style={{
                position: 'absolute',
                top: 0,
                width: '100%',
                height: `${virtualRow.size}px`,
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              {item ? (
                <ItemRow item={item} />
              ) : (
                <div className="loading">로딩 중...</div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

react-window vs TanStack Virtual

특성react-windowTanStack Virtual
번들 크기~6KB~3KB
스크롤 컨테이너라이브러리가 생성직접 제어
동적 높이 측정수동 구현 필요measureElement 제공
수평 가상화FixedSizeGriduseVirtualizer
프레임워크React만React, Vue, Svelte 등
API 스타일컴포넌트 기반Hook 기반

선택 기준

  • react-window: 단순한 고정 크기 리스트에 빠르게 적용하고 싶을 때
  • TanStack Virtual: 동적 높이, 커스텀 스크롤 컨테이너, 다양한 레이아웃이 필요할 때

가상화의 한계와 주의사항

검색 기능

가상화된 리스트에서 Ctrl+F(브라우저 검색)는 렌더링되지 않은 항목을 찾지 못합니다. 별도의 검색 기능을 구현해야 합니다.

접근성

스크린 리더가 전체 목록 크기를 인식할 수 있도록 적절한 ARIA 속성을 추가해야 합니다.

JSX
<div role="list" aria-label={`${items.length}개 항목`}>
  {virtualizer.getVirtualItems().map((virtualRow) => (
    <div role="listitem" aria-setsize={items.length} aria-posinset={virtualRow.index + 1}>
      {/* ... */}
    </div>
  ))}
</div>

스크롤 위치 복원

페이지를 떠났다가 돌아올 때 스크롤 위치를 복원하려면 별도 처리가 필요합니다.

정리

가상화는 "보이는 것만 렌더링"하는 단순한 원리로 대규모 리스트의 성능 문제를 해결합니다.

  • **가상화의 핵심 **: 뷰포트에 보이는 항목만 DOM에 렌더링하고 나머지는 생략합니다
  • react-window: 간단하고 가벼운 가상화. 고정/가변 크기 리스트와 그리드 지원
  • TanStack Virtual: Hook 기반의 유연한 가상화. 동적 높이 측정과 커스텀 스크롤 컨테이너 지원
  • ** 무한 스크롤과 조합 **: 데이터를 점진적으로 로드하면서 DOM 노드 수를 일정하게 유지합니다
  • 브라우저 검색, 접근성, 스크롤 위치 복원은 별도로 처리해야 합니다

주의할 점

가상화된 리스트에서 브라우저 Ctrl+F 검색이 작동하지 않음

DOM에 렌더링되지 않은 항목은 브라우저 내장 검색으로 찾을 수 없습니다. 별도의 검색/필터 UI를 제공하거나, 검색 시 가상화를 일시적으로 해제하는 전략이 필요합니다.

가변 높이 항목에서 스크롤 점프

항목 높이가 동적인데 정확한 높이를 제공하지 않으면, 스크롤 위치가 갑자기 점프하는 현상이 발생합니다. TanStack Virtual의 measureElement로 실제 DOM 크기를 측정하여 해결할 수 있습니다.

항목이 100개 이하라면 가상화 없이도 충분합니다. 가상화는 수백~수만 개 이상의 항목을 다룰 때 적용해야 합니다.

댓글 로딩 중...