불필요한 DOM이란?
// 아이템 10,000개를 그냥 렌더링
{items.map(item => <div key={item.id}>{item.name}</div>)}
사용자가 화면에서 보는 건 20~30개뿐인데 DOM에는 10,000개의 노드가 전부 존재합니다.
보이지 않는 9,970개의 노드가 바로 불필요한 DOM입니다.
불필요한 DOM을 왜 줄여야 할까요?
1. 메모리 점유
HTML을 파싱하면 브라우저는 각 태그를 노드 객체로 만들고 트리구조로 메모리에 저장합니다.
노드 객체 하나에는 단순히 "텍스트 정보"만 있는 게 아니라 태그 이름, 속성 등 모든 것이 합쳐져서 하나의 객체로 묶여 메모리에 올라갑니다. 그래서 노드가 많으면 많은 정보를 담은 객체가 메모리에 올라가게 되니 메모리를 많이 점유하게 됩니다.
2. 렌더링 비용 증가
페이지의 초기 렌더링 중 CSS가 페이지에 적용되면 CSSOM이 생성되는데요, CSS 선택자의 구체성이 증가하면 CSSOM이 더 복잡해지고 웹페이지를 화면에 그리는 데 필요한 레이아웃, 스타일 지정, 컴포지션, 페인트 작업을 실행하는 데 더 많은 시간이 필요합니다. 또 브라우저는 보이지 않는 영역도 레이어 정보를 유지합니다. 불필요한 노드가 많을수록 GPU 메모리와 렌더링 파이프라인에 부담이 가게 됩니다.
데이터 리스트 가상화
실제로 보이는 영역에 해당하는 노드만 DOM에 존재시키자
아까 말했듯이 어차피 한 번에 30개밖에 못 보기 때문에 30개만 진짜로 만들고 나머지는 있는 척 속이면 됩니다.
그러면 pagination처럼 데이터를 나중에 들고오는 것이 아니라 DOM을 나중에 만들어주는 것이라고 생각하면 됩니다.
과정
1. 전체 높이를 가진 컨테이너로 스크롤바 속이기
// 실제 DOM에는 아무것도 없지만, 높이만 전체 크기로 설정
<div style={{ height: totalHeight }}>
{/* 실제 렌더링되는 아이템들 */}
</div>
스크롤바는 DOM의 실제 높이를 기반으로 그려지기 때문에 전체 아이템 10,000개가 있는 것처럼 스크롤 높이를 가진 빈 컨테이너를 만듭니다.
totalHeight = itemCount × itemHeight
= 10,000 × 50px
= 500,000px
하지만 텍스트 길이에 따라 높이가 달라지는 카드처럼 아이템마다 높이가 다르다면 각 아이템의 실제 높이를 측정하고 누적합으로 전체 높이를 계산해야합니다. 실제 렌더링 전까지 정확히 알지 못하므로 처음에는 예상값으로 계산하고 렌더링 후 교체하는 방식을 사용합니다.
2. 스크롤 위치로 어떤 아이템 보여줄지 계산 (무엇을 렌더링할지 고르기)
지금 스크롤 위치가 어딘지 보고, 거기서 viewport에 걸리는 아이템이 몇 번째부터 몇 번째인지 계산합니다. 스크롤 이벤트가 발생할 때마다 아래 계산을 실행해서 렌더링할 아이템 범위를 동적으로 결정합니다.
고정 높이일 때는 단순 나누기로 바로 계산하고
// 현재 scrollTop을 기준으로 viewport에 보여야 할 아이템의 범위(startIndex ~ endIndex) 계산
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + viewportHeight) / itemHeight);
동적 높이일 때는 이진탐색해서 startIndex를 찾습니다.
// 이진 탐색으로 startIndex 찾기 (O(log n))
// "start가 scrollTop 이하인 아이템 중 가장 마지막 것"을 찾는다
function findStartIndex(scrollTop) {
let low = 0;
let high = measurements.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (measurements[mid].start < scrollTop) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return high;
}
3. 정확한 위치에 배치 (어디에 놓을지 계산)
실제로 렌더링되는 아이템들을 정확한 픽셀 위치에 absolute로 배치합니다.
{visibleItems.map((item) => {
const measurement = measurements[item.index];
return (
<div
key={item.id}
style={{
position: 'absolute',
top: measurement.start, // 이 아이템이 있어야 할 절대 위치
height: measurement.size,
width: '100%',
}}
>
{item.name}
</div>
);
})}
실제 적용
React에서 가상 리스트를 구현할 수 있는 라이브러리는 대표적으로 react-virtualized, @tanstack/react-virtual로 두가지가 있는데요, 최신 기술 기반이며 유지보수가 지속적으로 이루어지고 있는 TanStack을 사용해서 실제 적용 예시를 소개해보겠습니다.
설치
npm install @tanstack/react-virtual
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
useVirtualizer는 스크롤 위치를 실시간으로 읽어야 하기 때문에 어떤 DOM요소를 기준으로 스크롤을 감지할지 알려주기 위해서 ref를 만듭니다.
const virtualizer = useVirtualizer({
count: items.length, // 전체 아이템 수
getScrollElement: () => parentRef.current, // 스크롤 감지할 컨테이너
estimateSize: () => 50, // 아이템 예상 높이
overscan: 5, // 위아래 버퍼
});
가상화 로직이 전부 담긴 virtualizer instance를 생성합니다.
- count로 전체 높이를 계산하기 위해 전체 아이템이 몇 개인지 작성합니다.
- getScrollElement는 아까 만든 ref와 연결해서 요소의 스크롤을 감지합니다.
- estimateSize는 아이템의 높이 예상값으로, 처음에는 이 값으로 위치를 계산하고 실제 렌더링 후에 측정값으로 교체됩니다.
- overscan은 viewport 위아래로 몇 개씩 미리 렌더링할지 결정하여 스크롤 시 깜빡임을 방지합니다.
const virtualItems = virtualizer.getVirtualItems();
현재 viewport에 보여야 할 아이템 목록을 돌려줍니다. 스크롤할 때마다 목록이 바뀌겟죠?
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
ref 연결하는 코드입니다. useVirtualizer가 이 요소의 스크롤 위치를 감지해서 virtualItems를 업데이트합니다.
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
outer 컨테이너 코드입니다.
getTotalSize()가 전체 아이템의 높이 합산값을 돌려줍니다. 이걸 높이로 주면 위에서 설명한 스크롤바를 속이는 컨테이너 완성~
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
}}
>
실제 아이템들을 담는 inner 컨테이너 코드입니다.
translateY로 첫 번째 아이템이 있어야 할 위치만큼 밀어냅니다.
예를 들어 30번째 아이템부터 보여야한다면 translateY(1500px)로 컨테이너 전체를 아래 1500px 이동시킵니다. 그안의 아이템은 그냥 위에서부터 순서대로 쌓이게 됩니다.
top으로 잡으면 레이아웃을 재계산해야하기 때문에 transform(GPU가 처리해서 재계산X)을 사용했습니다.
{virtualItems.map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
>
{items[virtualItem.index].name}
</div>
))}
아이템을 렌더링하는 코드입니다.
받은 virtualItems를 map으로 순회하면서 실제 아이템을 렌더링합니다.
virtualItem.index롤 전체 아이템 배열에서 몇 번째인지 계산하고
items[virtualItem.index]로 실제 데이터에 접근합니다.
data-index는 TanStack이 어떤 DOM노드가 몇 번째 아이템인지 추적하는데 사용합니다.
ref={virtualizer.measureElement}는 렌더링된 아이템의 실제 높이를 측정해서 캐싱합니다.
그래도 왜 이렇게 헷갈리는지...
다시 생각해보면 React가 렌더링하면서 DOM에 노드를 추가하잖아요
가상화에서는 보이는 것만 DOM에 넣는다고 했죠? 그러면 스크롤해서 벗어나면 DOM에서 지우고 다시 새로 보이면 DOM에 추가하는 방식으로 계속 교체됩니다.
지연 로딩:
DOM에 div 10,000개 먼저 전부 추가
→ 스크롤해서 보일 때 이미지만 추가
가상화:
DOM에 아무것도 없음
→ 스크롤해서 보일 때 그 아이템을 DOM에 추가 + 렌더링 동시에
→ 스크롤해서 벗어나면 DOM에서 제거
lazy loading은 DOM에 추가되는 시기랑 이미지가 렌더링되는 시기가 다른 것이고, 가상화는 그런 분리가 없이 DOM 추가와 렌더링이 항상 같이 일어납니다.
아하!
참고
https://web.dev/articles/dom-size-and-interactivity?hl=ko
큰 DOM 크기가 상호작용에 미치는 영향과 이에 대해 취할 수 있는 조치 | Articles | web.dev
큰 DOM 크기는 상호작용이 빠른지 여부에 영향을 미칠 수 있습니다. DOM 크기와 INP의 관계, DOM 크기를 줄이기 위해 취할 수 있는 조치 및 페이지에 DOM 요소가 많은 경우 렌더링 작업을 제한하는 다
web.dev
리스트 가상화 (List Virtualization) 알아보기
우아콘의 한 세션에서 앱 내 웹 뷰에서 리스트 컴포넌트를 리스트 가상화 (List Virtualization)를 통해 최적화 했다라는 이야기를 들었다.그 발표를 계기로 리스트 가상화에 대해서 궁금해졌고, 이게
velog.io
https://bttrthn-ystrdy.tistory.com/139
tanstack react-virtual을 사용하여 무한 스크롤 성능 최적화 하기
windowing 기법 또는 list virtualization 기법을 적용해 무한 스크롤 성능을 최적화하기 위해 tanstack의 react-virtual 라이브러리를 사용하면서 위의 YouTube 클립의 도움을 많이 받았다. 위의 클립에서 faker-js
bttrthn-ystrdy.tistory.com
https://kangs-develop.tistory.com/29
[React] Table 가상 리스트(Virtual List) 도입기
들어가며프론트엔드에 있어서 테이블 컴포넌트는 중요한 컴포넌트 입니다.모든 프로덕트에 테이블 컴포넌트가 들어가지는 않지만, 데이터를 다루는 프로덕트에서는 대부분 테이블 컴포넌트를
kangs-develop.tistory.com
Virtual List(가상리스트)란? 나는 왜 tanstack query 썼냐고?
가상리스크 또는 가상 스크롤은 대량의 데이터를 효율적으로 렌더링하기 위한 최적화 기법입니다. 이 기술은 수천개, 수만개 이상의 리스트 항목을 한번에 렌더링할 때 발생할 수 있는 성능 문
velog.io
'CS' 카테고리의 다른 글
| [JavaScript] 실행 컨텍스트 (1) | 2026.05.10 |
|---|---|
| JavaScript가 변수와 함수를 다루는 방법 (2) | 2026.05.03 |
| 번들 크기를 줄이자: Tree Shaking/Code Splitting (3) | 2026.05.02 |
| 왜 useEffect는 무한루프에 빠질까? (0) | 2026.05.01 |
| 이미지 아무거나 쓰지 말자: 이미지 최적화 (2) | 2026.04.26 |