본문 바로가기

CS

불필요한 DOM을 줄이자: Virtualization

불필요한 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

https://velog.io/@tkddn_dev8430/%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EA%B0%80%EC%83%81%ED%99%94-List-Virtualization-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

 

리스트 가상화 (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

https://velog.io/@dayoom_/Virtual-List%EA%B0%80%EC%83%81%EB%A6%AC%EC%8A%A4%ED%8A%B8%EB%9E%80-%EB%82%98%EB%8A%94-%EC%99%9C-tanstack-query-%EC%8D%BC%EB%82%98%EA%B3%A0

 

Virtual List(가상리스트)란? 나는 왜 tanstack query 썼냐고?

가상리스크 또는 가상 스크롤은 대량의 데이터를 효율적으로 렌더링하기 위한 최적화 기법입니다. 이 기술은 수천개, 수만개 이상의 리스트 항목을 한번에 렌더링할 때 발생할 수 있는 성능 문

velog.io