[TIL] DOM 가상화 - 10만 행을 30개 DOM으로 보여주기

2026. 05. 03.Yeji Kim
FrontendPerformance

들어가며

리스트가 1만 개를 넘기 시작하면 React app이 갑자기 느려진다. 처음엔 useMemoReact.memo를 의심하지만, 사실 진짜 범인은 브라우저가 관리해야 할 DOM 노드 수 자체일 때가 많다.

이걸 푸는 기법이 DOM 가상화 (list virtualization / windowing / virtual scrolling)이다.

데이터는 10만 개여도 DOM은 30~100개만 유지하고, 스크롤 위치에 따라 "보이는 척" 위치만 바꾼다.


1. 왜 필요한가

DOM 노드가 많아지면 브라우저가 짊어지는 비용이 많다.

  • DOM 생성 비용
  • CSS style recalculation
  • layout / reflow
  • paint
  • 메모리 사용량
  • querySelectorAll 같은 DOM query
  • 그 위에 React라면 reconciliation 비용까지

Lighthouse 문서도 "큰 DOM은 런타임 성능과 메모리를 악화시키며, 사용자가 상호작용할 때 노드들의 위치/스타일을 계속 재계산하게 만든다"고 명시한다. React에서는 반복 요소가 많을 때 react-window 같은 windowing 라이브러리를 권장한다.

GitHub 코드 뷰 리뷰 글도 좋은 사례다. syntax highlighting된 HTML을 모든 라인에 그대로 그렸더니 약 500라인부터 LCP/TTI가 증가, 2,000라인부터는 라인 하이라이트·접기 같은 단순 인터랙션도 느려졌다고 한다.

즉 DOM 가상화는 단순한 React 최적화가 아니라 브라우저 렌더링 파이프라인 전체의 작업량을 줄이는 최적화다.


2. 기본 구조

평범한 리스트:

tsx
{items.map(item => <Row item={item} />)}

가상화된 리스트:

tsx
const visibleItems = getVisibleItems(scrollTop, viewportHeight);
 
return (
  <div style={{ height: totalHeight, position: "relative" }}>
    {visibleItems.map(item => (
      <div
        style={{
          position: "absolute",
          transform: `translateY(${item.start}px)`,
        }}
      >
        <Row item={items[item.index]} />
      </div>
    ))}
  </div>
);

겉으로는 전체 리스트가 다 있는 듯 스크롤바를 만들기 위해 height: totalHeight 인 큰 가짜 컨테이너를 두고, 실제 자식 DOM은 viewport 근처 아이템만 만든다.


3. Fixed vs Dynamic height

고정 높이

아이템 높이가 항상 같으면 인덱스 계산이 쉽다.

ts
const itemHeight = 35;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + viewportHeight) / itemHeight);

react-windowFixedSizeList가 이 계열이다.

가변 높이

채팅, 카드, 검색 결과처럼 높이가 제각각이면 단순 나누기가 안 된다. 보통 다음 3가지를 조합한다.

  • 예상 높이 - estimateSize: () => 80 같은 추정으로 시작
  • 실제 측정 - DOM이 렌더되면 getBoundingClientRect() 또는 ResizeObserver로 측정 후 캐시
  • 누적 높이 / prefix sum - 각 아이템의 시작 위치를 빠르게 찾기 위해 binary search 또는 segment tree류
text
index 0 start = 0
index 1 start = height[0]
index 2 start = height[0] + height[1]
...

스크롤 위치 5,000px일 때 누적 배열에서 5,000px 근처의 인덱스를 binary search로 찾는 식이다.


4. overscan - 빈 영역을 막는 여유분

viewport 안 아이템만 정확히 그리면 빠른 스크롤 시 빈 공간이 보일 수 있다. 그래서 위아래로 몇 개를 더 그린다 - 이게 overscan이다.

text
실제 viewport:
[ 100 101 102 103 104 105 ]
 
overscan 5 적용:
[ 95 96 97 98 99 100 101 ... 105 106 107 108 109 110 ]

trade-off:

  • overscan 작음 → DOM 적음 → 빠름 → 빠른 스크롤에서 빈 영역 가능
  • overscan 큼 → 빈 영역 안 보임 → DOM 늘어남 → 렌더링 비용 증가

TanStack Virtual의 기본 overscan 값은 1.


5. 스크롤바를 속이는 트릭

가상화의 핵심은 이거다.

tsx
<div className="scroll-container">
  <div className="inner" style={{ height: totalSize }}>
    {visibleItemsOnly}
  </div>
</div>

브라우저는 inner의 높이가 3,500,000px이면 자식이 30개뿐이어도 "긴 페이지"로 인식한다. 자연스러운 네이티브 스크롤바가 생긴다.

그리고 보이는 아이템은 자기 원래 위치에 있는 것처럼 transform으로 옮긴다.

tsx
<div
  style={{
    position: "absolute",
    transform: `translateY(${virtualItem.start}px)`,
  }}
>
  Row {virtualItem.index}
</div>

이 방식의 가장 큰 장점은 브라우저 native scroll을 그대로 쓴다는 점이다. wheel event를 직접 가로채서 fake scroll을 만드는 방식보다 접근성·관성 스크롤·트랙패드·모바일 등 호환성이 훨씬 좋다.


6. GitHub 코드 뷰 사례 - 가상화는 검색·접근성과 충돌한다

가상화를 단순 성능 기법으로만 보면 놓치는 게 많다. GitHub의 새 코드 뷰 사례는 이걸 잘 보여준다.

GitHub의 글에 따르면 최종 구조는 두 조각이다.

  1. 전체 raw text를 담은 invisible textarea - 접근성, 키보드 탐색, 복사, 브라우저 기본 Ctrl+F 검색 담당
  2. 가상화된 syntax-highlighted overlay - 실제 눈에 보이는 코드 라인. 마우스 이벤트와 검색에서는 숨김

왜 두 조각? 가상화된 DOM에는 화면 근처 텍스트만 존재한다. 단순히 코드 라인만 가상화하면 Ctrl+F가 전체 파일을 검색 못 한다. 그래서 raw text는 invisible textarea에 넣고 시각용 overlay는 별도로 가상화한 것.

극단 케이스 결과 (18,000라인 CODEOWNERS 파일에서 End 키로 끝까지 이동):

방식keyup 처리JS main thread blocking
React가 모든 DOM 관리870ms3,700ms
HTML 문자열 직접 생성 + 가상화80ms700ms

10배 정도 차이가 나는데, 가상화는 DOM 개수만의 문제가 아니라, 검색·접근성·복사·선택·키보드·syntax highlighting·React reconciliation까지 같이 설계해야 하는 문제다.


7. 주요 라이브러리

TanStack Virtual

요즘 가장 많이 언급되는 선택지. headless - 컴포넌트가 아니라 계산만 제공하고 마크업/스타일은 직접 짠다.

ts
const virtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 80,
  overscan: 5,
});
 
const totalSize = virtualizer.getTotalSize();
const virtualItems = virtualizer.getVirtualItems();

React/Vue/Svelte/Solid/Lit/Angular 어디서든 쓸 수 있다. table/grid/list/chat 같이 마크업이 다양한 케이스에 유리하다.

react-window

react-virtualized의 후속, 더 가벼운 라이브러리. fixed size 리스트에 가장 단순하다.

tsx
<FixedSizeList height={400} itemSize={35} itemCount={1000}>
  {({ index, style }) => <div style={style}>Row {index}</div>}
</FixedSizeList>

각 row에 전달되는 style은 절대 위치/높이가 들어있어 반드시 그대로 적용해야 한다.

React Virtuoso

자동 처리 성향이 강함. 가변 높이 아이템을 수동 측정 없이 지원하고, 채팅 메시지 리스트, grouped mode, sticky headers, masonry, table까지 고수준 컴포넌트로 제공한다. 직접 low-level 계산을 덜 해도 됨.

react-virtualized

오래된 대표. Table·Grid·AutoSizer 등 생태계가 풍부하지만 무겁다. 새 프로젝트엔 위 셋 중 하나가 보통 더 낫다.


8. 어려운 지점들

가상화를 도입하면 새로운 문제가 따라온다.

Ctrl+F 검색

가상화된 DOM엔 화면 근처 텍스트만 있어서 브라우저 검색이 전체를 못 찾음. GitHub처럼 invisible textarea 같은 우회가 필요할 수 있음.

접근성

스크린리더도 DOM에 없는 아이템은 못 읽음. aria-rowcount, aria-rowindex, focus 관리, selection/copy 동작까지 같이 설계해야 함.

focus 관리

스크롤로 row가 unmount되면 그 안의 input·button focus가 사라짐. active item·selected row·editing state는 DOM 내부가 아니라 외부 state로 관리해야 한다.

tsx
const [focusedId, setFocusedId] = useState<string | null>(null);

scroll jump

예상 높이와 실제 높이가 다르면 측정 후 위치가 밀린다. 위쪽 아이템 높이가 바뀌면 viewport 안 콘텐츠가 갑자기 점프. TanStack Virtual엔 shouldAdjustScrollPositionOnItemSizeChange 옵션이 있다.

sticky header

visible range 밖에 있어도 렌더해야 할 수 있음. TanStack Virtual의 rangeExtractor로 visible range에 sticky 항목을 강제로 추가.

table virtualization

<table>은 sticky column/header, column width sync 때문에 div 리스트보다 어렵다. TanStack Table은 가상화를 내장하지 않으므로 TanStack Virtual이나 react-window와 조합한다.


9. 최소 구현 - 원리 이해용

라이브러리 없이 fixed-height 리스트를 직접 만들어보면 원리가 잡힌다.

tsx
import { useMemo, useState } from "react";
 
const ITEM_HEIGHT = 36;
const OVERSCAN = 5;
 
export function VirtualList<T>({
  items,
  height,
  renderItem,
}: {
  items: T[];
  height: number;
  renderItem: (item: T, index: number) => React.ReactNode;
}) {
  const [scrollTop, setScrollTop] = useState(0);
  const totalHeight = items.length * ITEM_HEIGHT;
 
  const { startIndex, visibleItems } = useMemo(() => {
    const rawStart = Math.floor(scrollTop / ITEM_HEIGHT);
    const rawEnd = Math.ceil((scrollTop + height) / ITEM_HEIGHT);
    const startIndex = Math.max(0, rawStart - OVERSCAN);
    const endIndex = Math.min(items.length - 1, rawEnd + OVERSCAN);
    return {
      startIndex,
      visibleItems: items.slice(startIndex, endIndex + 1),
    };
  }, [items, scrollTop, height]);
 
  return (
    <div
      style={{ height, overflow: "auto", position: "relative" }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: "relative" }}>
        {visibleItems.map((item, offset) => {
          const index = startIndex + offset;
          return (
            <div
              key={index}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                right: 0,
                height: ITEM_HEIGHT,
                transform: `translateY(${index * ITEM_HEIGHT}px)`,
              }}
            >
              {renderItem(item, index)}
            </div>
          );
        })}
      </div>
    </div>
  );
}

이 코드는 동작하지만 실무용으론 부족하다 - dynamic height, keyboard, focus, resize, scrollToIndex, sticky, smooth scroll 보정 등 다 빠져있음. 실제 제품에선 라이브러리가 안전하다.


11. 어떤 라이브러리를 고를까

text
간단한 fixed-height 리스트
  → react-window
 
커스텀 UI / table / grid / 직접 제어 / headless
  → TanStack Virtual
 
채팅 / variable height / grouped / masonry / table 빠르게
  → React Virtuoso
 
기존 프로젝트가 이미 쓰고 있으면
  → react-virtualized 유지 가능, 새 도입은 신중

원리를 이해하려면 TanStack Virtual을 직접 써보는 게 좋다. headless라 내부가 비교적 투명하게 드러난다.


정리

DOM 가상화는 데이터를 줄이는 기술이 아니라 DOM과 React가 실제로 관리해야 하는 화면 객체 수를 줄이는 기술 이다.

핵심 원리는 단순하다.

text
전체 데이터 개수는 유지한다.
전체 스크롤 높이도 유지한다.
DOM은 viewport 근처만 만든다.
스크롤 위치 → index range를 계산한다.
원래 위치에 있는 것처럼 transform/absolute로 배치한다.

GitHub 사례처럼 깊게 들어가면 검색·접근성·복사·선택·syntax highlighting·React reconciliation까지 함께 설계해야 한다. 그 지점부터 가상화는 단순 성능 기법이 아니라 브라우저와 사용자 경험 사이의 꽤 깊은 설계 문제가 된다.


레퍼런스