리스트 가상화 (List Virtualization) 알아보기

_sw_·2026년 2월 11일
post-thumbnail

우아콘의 한 세션에서 앱 내 웹 뷰에서 리스트 컴포넌트를 리스트 가상화 (List Virtualization)를 통해 최적화 했다라는 이야기를 들었다.

그 발표를 계기로 리스트 가상화에 대해서 궁금해졌고, 이게 실제로 어떤 원리로 최적화가 이루어질 수 있는지 알아보자.

리스트 가상화 (List Virtualization)?

리스트 가상화는 동적 리스트를 랜더링할 때 전체 리스트를 랜더링하지 않고 화면에 보이는 콘텐츠만 랜더링하는 방식을 말한다.

리스트 가상화는 어떻게 동작할까?

1. 어떤 아이템을 렌더링할지 계산

  1. 각 아이템의 높이를 캐싱 (측정값 또는 예상값)
  2. 현재 스크롤 위치(scrollOffset)를 기준으로 이진 탐색
  3. 화면에 보이는 범위의 startIndex, endIndex 계산
    • 이진 탐색으로 시작 인덱스를 빠르게 찾음 (O(log n))
  4. overscan으로 위아래 버퍼 추가 (깜빡임 방지)

TanStack Virtual 구현:

// packages/virtual-core/src/index.ts
private calculateRange() {
  // 이진 탐색으로 startIndex 찾기
  const startIndex = this.findStartIndex(scrollOffset);

  // 뷰포트 끝까지 endIndex 확장
  const endIndex = this.findEndIndex(startIndex, scrollOffset + outerSize);

  // overscan 적용 (스크롤 시 깜빡임 방지)
  return {
    startIndex: Math.max(0, startIndex - overscan),
    endIndex: Math.min(count - 1, endIndex + overscan)
  };
}

2. 스크롤바 높이 유지

  • 실제로는 10개만 렌더링하지만
  • 스크롤바는 10,000개처럼 보이게 하는 트릭

TanStack Virtual 구현:

// 전체 리스트의 가상 높이 계산
getTotalSize() {
  const measurements = this.getMeasurements();
  return measurements[measurements.length - 1]?.end ?? 0;
}

3. 동적 높이 처리

  • ResizeObserver로 실제 렌더링된 요소 크기 측정
  • 측정값을 캐싱해서 다음 계산에 활용

TanStack Virtual 구현:

// 각 아이템의 크기 측정 및 캐싱
measureElement(element: HTMLElement, index: number) {
  const size = element.getBoundingClientRect().height;

  // 캐시에 저장
  this.itemSizeCache.set(index, size);

  // 예상 크기와 다르면 재계산 트리거
  if (this.measurementsCache[index]?.size !== size) {
    this.notify(false); // 3번부터 다시 계산
  }
}

// 측정값 활용
getMeasurements() {
  return this.options.count.map((_, index) => {
    // 캐시에 있으면 사용, 없으면 추정값 사용
    const size = this.itemSizeCache.get(index)
      ?? this.options.estimateSize(index);

    return { start, size, end: start + size };
  });
}

전체 흐름

직접 측정해보기

실제로 리스트 가상화가 어떻게 향상된 사용자 경험을 제공하는지 직접 프로젝트를 통해서 알아봤다.

리스트 가상화 체험해보기 : list-virtualization
Repo 주소 : https://github.com/SangWoo9734/list-virtualization.git

성능 측정 로직 설명

1. FPS (Frames Per Second) 측정

let frameCount = 0;
let lastTime = performance.now();

const measureFPS = () => {
  frameCount++;
  const currentTime = performance.now();
  const elapsed = currentTime - lastTime;

  if (elapsed >= 1000) {  // 1초마다
    const fps = Math.round((frameCount * 1000) / elapsed);
    // fps 업데이트
  }
  requestAnimationFrame(measureFPS);
};
  • requestAnimationFrame이 호출될 때마다 프레임 카운트 증가
  • 1초(1000ms)가 지나면 "1초 동안 몇 프레임이 그려졌는지" 계산

2. DOM 노드 개수 측정

const domNodes = document.getElementsByTagName('*').length;
  • 페이지의 모든 HTML 요소 개수를 센다
  • Virtualized: 보이는 부분만 렌더링 → 적은 노드 수 (~100-200개)
  • Regular List: 전체 아이템 렌더링 → 많은 노드 수 (10,000개 = 수만 개)

3. 메모리 사용량 측정

if ('memory' in performance) {
  const memory = performance.memory;
  const usedMB = memory.usedJSHeapSize / 1048576;
}
  • Chrome에서만 지원되는 기능
  • JavaScript 힙 메모리 사용량을 MB 단위로 표시
  • 많은 DOM 노드 = 더 많은 메모리 사용

결과

리스트 요소 100개

리스트 요소 1000개

리스트 요소 10000개

  • 가상화를 적용하지 않은 리스트의 경우 리스트 요소에 비례하게 DOM 노드의 개수가 증가하는 것을 볼 수 있다.
  • 가상화가 적용된 리스트의 경우 리스트의 개수가 늘어나더라도 동일한 노드의 개수를 갖는 것을 알 수 있다.
  • 그 외 다른 지표들의 경우 리스트 요소 개수에 의해서 크게 영향을 받는 느낌은 아니었다.

이제 여기서 리플로우 / 리페인트를 발생시켜보면 조금 더 체감이 될 것 같다.

10000개를 기준으로 2초마다 반복적으로 스타일을 변경하도록 해서 부하를 추가해서 확인해보았다.

  • 가상화 적용(전)
  • 가상화 적용(후)

가상화 전에는 스타일 변경이 시작되자마자 스크롤이 되지 않을 정도로 부하가 많이 걸리는 느낌이 확실히 들었다.

반면에 가상화를 적용했을 때는 부하를 주기 전과 거의 동일한 느낌으로 자연스러웠다.

이런 경험 뿐만 아니라 메모리 사용량도 차이가 많이 나는 것을 확인할 수 있었다.

다만 가상화를 적용한 리스트에서 약간 어색했던 부분은 리스트 전체 요소에 스타일이 적용되도록 해 두었는데 가상화 리스트의 경우 리스트 밖의 요소가 리스트에 보일때 랜더링을 하기 때문에 기존에 리스트에 있던 요소와 스타일이 변하는 타이밍이 달라지는 부분이 있었다.

왜 이렇게 성능 차이가 날까?

DOM 노드가 많을수록:

  • 브라우저가 관리해야 할 요소 증가 → 메모리 사용량 증가
  • 스타일 변경 시 리플로우/리페인트 계산 범위 증가 → FPS 저하
  • 이벤트 리스너, 레이아웃 계산 등 모든 작업이 느려짐

가상화는 "DOM 노드 개수를 일정하게 유지"하여 이 문제를 해결

Reference

https://patterns-dev-kr.github.io/performance-patterns/list-virtualization/

https://tanstack.com/virtual/latest

0개의 댓글