[TIL] TanStack Virtual

2026. 06. 25.Yeji Kim
Frontend

DOM 가상화 글에서 가상 스크롤의 큰 그림을 정리했다. 이번엔 한 발 더 들어가서, 왜 보이지 않는 부분을 가상으로 처리하면 연산 부담이 줄어드는지 그 원리를 알아보자! TanStack Virtual을 기준으로 고고


진짜 비용은 데이터가 아니라 DOM이다

흔한 오해부터 정리하면, 리스트가 느린 이유는 보통 배열이 커서가 아니다. 자바스크립트 배열 10만 개를 메모리에 들고 있는 건 "브라우저" 입장에서 큰일이 아니다. 진짜 비용은 그 배열을 DOM 노드로 바꾸는 순간 발생한다.

DOM 노드 하나가 화면에 올라가면 브라우저는 그 노드마다 다음 일을 한다.

  • Style - 어떤 CSS 규칙이 적용되는지 계산 (recalculate style)
  • Layout - 너비·높이·위치를 잰다 (reflow)
  • Paint - 픽셀로 칠한다
  • Composite - 레이어를 합성한다
  • 거기에 메모리 - 노드 하나당 객체가 쌓인다

여기에 React를 얹으면 reconciliation(가상 DOM diff) 비용까지 붙는다. 즉 비용은 데이터 개수가 아니라 화면에 실재하는 DOM 노드 수에 비례한다.

데이터 10만 개 → DOM 10만 개 → 위 작업을 10만 번 / 느린 건 당연할지도?


핵심: 비용을 O(전체)에서 O(화면)으로 바꾼다

가상화의 한 줄 요약은 이거다.

데이터가 몇 개든, 실제로 만드는 DOM은 화면(viewport)에 보이는 만큼만으로 고정한다.

화면에 한 번에 보이는 행이 20개라면, 데이터가 100개든 10만 개든 DOM은 항상 20여 개다. 그러면 위에서 말한 style/layout/paint/reconciliation 작업량이 데이터 총량과 무관해진다.

text
일반 리스트:  비용 ∝ 전체 데이터 수        → O(n)
가상 리스트:  비용 ∝ 화면에 보이는 수      → O(viewport)  (사실상 상수)

10만 개에서 30개로 줄이면, 브라우저가 매 스크롤·리렌더마다 만지는 노드가 3,000배 적어진다. 이게 연산 부담이 줄어든다의 핵심인데, (당연) 데이터를 줄인 게 아니라, 브라우저와 React가 실제로 일해야 하는 대상을 줄인 것이다.


그럼 '가상으로 처리한다'는 게 정확히 뭔가

안 보이는 9만 9970개는 어떻게 되나? 아예 안 만든다. 대신 안 만들었다는 티가 안 나게 두 가지를 속인다.

1. 스크롤바는 진짜처럼 - 큰 빈 컨테이너

전체 높이만큼(예: 10만 × 40px = 400만px) 높이를 가진 빈 div를 하나 둔다. 자식이 30개뿐이어도 브라우저는 "400만px짜리 긴 페이지"로 인식해서 네이티브 스크롤바를 그대로 만들어 준다. 사용자는 10만 개가 다 있다고 느낀다.

2. 보이는 것만 - 위치를 계산해서 배치

scrollTop(현재 스크롤 위치)만 보면 "지금 몇 번째 행이 보여야 하는지"를 산수로 알 수 있다.

text
startIndex = floor(scrollTop / itemHeight)
endIndex   = ceil((scrollTop + viewportHeight) / itemHeight)

이 범위(+ overscan 여유분)의 아이템만 DOM으로 만들고, 원래 자리에 있는 것처럼 transform: translateY(start)로 밀어 넣는다. 스크롤이 움직이면 범위만 다시 계산해서, 같은 DOM 노드를 재활용하며 내용과 위치만 바꾼다.


TanStack Virtual은 이 계산만 해준다

TanStack Virtual은 headless 라이브러리다. UI 컴포넌트를 주는 게 아니라, 위에서 말한 지금 무엇을, 어디에 그려야 하는지라는 숫자만 돌려준다. 마크업과 스타일은 내가 짠다.

ts
import { useVirtualizer } from "@tanstack/react-virtual";
 
const virtualizer = useVirtualizer({
  count: items.length,                       // 전체 데이터 수 (DOM 수가 아님)
  getScrollElement: () => parentRef.current, // 스크롤 컨테이너
  estimateSize: () => 40,                     // 행 하나의 예상 높이
  overscan: 5,                               // 위아래 여유분
});
 
virtualizer.getTotalSize();     // → 빈 컨테이너에 줄 전체 높이 (스크롤바용)
virtualizer.getVirtualItems();  // → 지금 그릴 아이템 + 각자의 start 위치

getVirtualItems()가 돌려주는 배열의 길이가 곧 만들 DOM 수다. count가 10만이어도 이 배열은 보통 20~30개다. (뷰포트에 잡히는 수만큼) 프레임워크(React/Vue/Svelte/Solid 등)와 무관하게 이 계산 코어만 공유한다.


레퍼런스