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 작업량이 데이터 총량과 무관해진다.
일반 리스트: 비용 ∝ 전체 데이터 수 → O(n)
가상 리스트: 비용 ∝ 화면에 보이는 수 → O(viewport) (사실상 상수)10만 개에서 30개로 줄이면, 브라우저가 매 스크롤·리렌더마다 만지는 노드가 3,000배 적어진다. 이게 연산 부담이 줄어든다의 핵심인데, (당연) 데이터를 줄인 게 아니라, 브라우저와 React가 실제로 일해야 하는 대상을 줄인 것이다.
그럼 '가상으로 처리한다'는 게 정확히 뭔가
안 보이는 9만 9970개는 어떻게 되나? 아예 안 만든다. 대신 안 만들었다는 티가 안 나게 두 가지를 속인다.
1. 스크롤바는 진짜처럼 - 큰 빈 컨테이너
전체 높이만큼(예: 10만 × 40px = 400만px) 높이를 가진 빈 div를 하나 둔다. 자식이 30개뿐이어도 브라우저는 "400만px짜리 긴 페이지"로 인식해서 네이티브 스크롤바를 그대로 만들어 준다. 사용자는 10만 개가 다 있다고 느낀다.
2. 보이는 것만 - 위치를 계산해서 배치
scrollTop(현재 스크롤 위치)만 보면 "지금 몇 번째 행이 보여야 하는지"를 산수로 알 수 있다.
startIndex = floor(scrollTop / itemHeight)
endIndex = ceil((scrollTop + viewportHeight) / itemHeight)이 범위(+ overscan 여유분)의 아이템만 DOM으로 만들고, 원래 자리에 있는 것처럼 transform: translateY(start)로 밀어 넣는다. 스크롤이 움직이면 범위만 다시 계산해서, 같은 DOM 노드를 재활용하며 내용과 위치만 바꾼다.
TanStack Virtual은 이 계산만 해준다
TanStack Virtual은 headless 라이브러리다. UI 컴포넌트를 주는 게 아니라, 위에서 말한 지금 무엇을, 어디에 그려야 하는지라는 숫자만 돌려준다. 마크업과 스타일은 내가 짠다.
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 등)와 무관하게 이 계산 코어만 공유한다.
레퍼런스
- TanStack Virtual - Introduction - headless 가상화의 개념
- TanStack Virtual - Virtualizer API -
getVirtualItems,getTotalSize - Lighthouse - Avoid an excessive DOM size - DOM 노드 수가 비용인 이유
- MDN - Render performance (the pixel pipeline) - style/layout/paint/composite 단계