테이블 가상화는 어떻게 동작할까?

virtual scroll table performance

lazy loading과 windowing 이해하기

들어가며

Virtual Scrolling for Billions of Rows (Sylvain Lesage) 를 읽고 가볍게 정리한 글입니다. 저자는 수십억 행을 다루는 React 테이블 컴포넌트 HighTable을 만들면서 사용한 기법들을 소개합니다.

큰 테이블이 느려지는 이유는 사실 두 가지가 섞여 있습니다. 하나는 10억 행짜리 데이터를 전부 다운로드할 수 없다는 것이고, 다른 하나는 그 데이터로 DOM 노드를 10억 개 만들 수 없다는 것입니다. 테이블 가상화는 이 둘을 각각 다른 기법으로 풉니다.

방법 1: 데이터는 lazy loading

행 하나에 100바이트씩만 잡아도 10억 행이면 1TB입니다. 이만한 데이터를 통째로 받을 수는 없으니, 화면에 보이는 행만 가져와야 합니다. 그러려면 지금 어떤 행이 보이는지 알아야 하는데, 이건 스크롤 위치로 계산합니다.

const rowStart = Math.floor(viewport.scrollTop / rowHeight)
const rowEnd = Math.ceil(
  (viewport.scrollTop + viewport.clientHeight) / rowHeight,
)

HighTable은 이걸 위해 데이터 소스를 인터페이스로 추상화했습니다.

  • 캐시에서 동기로 읽는 getCell()
  • 없는 값을 비동기로 받아와 캐시하는 fetch()
  • 로드가 끝나면 리렌더를 트리거하는 이벤트

스크롤은 화면을 즉시 그려야 하니 렌더링은 동기여야 하는데, 데이터 로딩은 네트워크를 기다리는 비동기 작업입니다. 그래서 getCell()로 보이는 셀을 즉시 그리되 없는 값은 빈 칸으로 두고, 그동안 fetch()가 받아와 채운 뒤 이벤트로 다시 그리게 합니다. 일단 그리고, 도착하면 채운다. 이 구조면 화면에 보이는 30행, 약 3KB의 데이터만 메모리에 두면 됩니다.

방법 2: DOM은 windowing

모든 데이터를 갖고있더라도 <tr> 100만 개를 한꺼번에 만들면 브라우저가 멈춥니다. 그래서 화면에 보이는 행만 HTML 요소로 렌더링하는 방법을 사용합니다.

<div class="viewport" style="overflow-y: auto;">
  <div class="spacer" style="position: relative; height: 33000000px;">
    <table style="position: absolute; top: 3000px;">
      <!-- 보이는 행만 렌더링 -->
    </table>
  </div>
</div>

요소는 세 겹으로 겹쳐 있습니다. 바깥의 viewport는 실제 스크롤이 일어나는 창이고, 그 안의 spacer는 높이만 전체 크기로 잡아 스크롤바를 진짜 길이로 만드는 빈 div입니다. 실제 <tr>이 담긴 table은 30행짜리 조각 하나뿐인데, spacer 안에 떠 있으면서 top 값으로 위치만 정해집니다.

여기서 top이 핵심입니다. 스크롤을 내리면 테이블이 화면 위로 사라지니까, 내려간 만큼 top도 같이 키워서 보이는 위치로 따라오게 합니다. 스크롤이 바뀔 때마다 바뀌는 건 딱 두 가지, 그 30행이 담는 데이터table의 **top**뿐입니다. 덕분에 실제로 그려지는 <tr>은 전체 행이 10억이든 얼마든 늘 30개로 일정합니다.

실제로는 스크롤 가장자리가 빈 칸으로 번쩍이지 않도록, 보이는 범위보다 위아래로 몇 행씩 더 그려두기도 합니다(overscan). 물론 이렇게 해도 전체 행 수와 무관하게 렌더링 수가 일정하다는 원리는 그대로입니다.

브라우저의 픽셀 한계

그런데 행이 수십만, 수백만을 넘어설 만큼 많아졌을 때 다른 문제가 생깁니다. 그 첫 번째로, 브라우저는 요소 높이에 상한이 있습니다. Firefox는 약 1,700만 픽셀이 한계라, 행 높이 33px면 spacer로는 50만 행 정도가 상한입니다.

HighTable은 spacer 높이를 800만 픽셀로 고정하고, 실제 테이블 높이와의 비율(downscale factor)로 스크롤 위치를 행 위치로 환산합니다.

const downscaleFactor =
  (fullTableHeight - clientHeight) / (maxCanvasHeight - clientHeight)
const firstVisibleRow = Math.floor(
  (viewport.scrollTop * downscaleFactor) / rowHeight,
)

이제 행 수 제한은 사라지지만 대가가 있습니다. downscale이 100배면 스크롤바 1px이 3만 행을 건너뜁니다. 마우스 휠을 살살 굴려도 수만 행씩 점프하니, 특정 행에 도달하는 게 불가능해집니다.

해법: 로컬/글로벌 이중 스크롤

이 글의 백미는 정밀도를 되찾는 방법입니다. HighTable은 스크롤 이동량에 따라 두 모드를 오갑니다.

  • 글로벌 스크롤: 이동량이 크면(스크롤바 드래그) downscale된 좌표로 점프
  • 로컬 스크롤: 이동량이 작으면(마우스 휠) 1px을 1px 그대로 반영
const delta = viewport.scrollTop - state.scrollTop
if (Math.abs(delta) > localThreshold) {
  state.localOffset = 0
  state.globalAnchor = viewport.scrollTop
} else {
  state.localOffset += delta
}

기준점(globalAnchor)은 스크롤바가 정하고, 미세 조정(localOffset)은 휠이 쌓아가는 구조입니다. 스크롤바로 대략적인 위치에 점프한 뒤 휠로 행 단위 탐색이 가능해집니다.