노트

리스트 가상화

List Virtualization

프런트엔드#performance · 연결된 개념 9개

쉽게 말하면

리스트 가상화는 기차 창밖 풍경처럼 지금 창에 보이는 항목만 실제로 그리고, 스크롤하면 그 범위를 바꿔 끼우는 거예요. 1만 개 목록이어도 DOM에는 수십 개만 있어서 느려지지 않아요.

비유가 깨지는 곳 풍경과 달리 스크롤바는 전체 목록 길이에 맞춰야 해서 항목마다 위치와 크기를 계산해 둬요. 실제 크기를 재서 고치고, 빈칸이 안 보이게 위아래로 몇 개 더 그려요(overscan).

리스트 가상화(List Virtualization)·윈도잉(Windowing)은 수천~수만 개 항목 중 화면에 보이는 것과 약간의 여유분만 실제 DOM(Document Object Model)에 렌더하고, 스크롤할 때 그 범위를 바꿔 끼우는 기법이다. 1만 개 목록이라도 DOM에는 20~30개만 있다.

TanStack Virtual의 동작

  1. 위치 측정 캐시: 항목마다 start·size·end를 계산해 둔다. 실제 크기를 쟀으면 그 값을, 아니면 estimateSize를 쓴다. 다음 항목의 시작은 이전 항목의 끝 + gap이다
  2. 가시 범위 계산: 현재 스크롤 위치에서 시작하는 첫 항목을 이진 탐색(O(log n))으로 찾고, 뷰포트 끝까지 끝 인덱스를 늘린다
  3. overscan: 범위를 위아래로 몇 개 더 넓혀 빠르게 스크롤할 때 빈칸이 보이지 않게 한다
  4. 렌더: 전체 높이(getTotalSize())를 가진 컨테이너 안에 보이는 항목만 position: absolute + translateY(start)로 놓는다
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
  <div style={{ height: v.getTotalSize(), position: 'relative' }}>
    {v.getVirtualItems().map(item => (
      <div key={item.key} style={{ position: 'absolute', top: 0, transform: `translateY(${item.start}px)` }}>
        Row {item.index}
      </div>
    ))}
  </div>
</div>
  • 스크롤은 패시브 리스너(Passive Event Listener)로, 컨테이너 크기는 ResizeObserver로 관찰한다
  • 동적 크기: 렌더된 요소를 실제로 재서 캐시를 고친다. 현재 위치보다 위쪽 항목의 크기가 바뀌면 그 차이만큼 스크롤 위치를 보정해 화면이 튀지 않게 한다
  • 계산 단계마다 의존값이 같으면 결과를 재사용해 프레임 예산 안에 끝낸다
  • transform을 쓰는 이유는 레이아웃을 다시 계산하지 않는 합성 단계에서 처리되기 때문이다(리플로우(레이아웃 재계산))
  • 훅으로 "이런 목록을 원한다"고 선언하는 구조라 React와 잘 맞는다(선언형과 명령형 프로그래밍)

더 가벼운 브라우저 기본 대안은 content-visibility, 무한 스크롤 데이터는 TanStack Query의 useInfiniteQuery와 함께 쓴다.

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • 브라우저 렌더링 파이프라인

    브라우저가 받은 HTML·CSS·JS를 화면의 픽셀로 바꾸는 순서. 처음 로드할 때도, 이후 무언가 바뀔 때도 같은 단계를 거친다. 바뀐 내용에 따라 어느 단계부터 다시 시작하는지만 다르다.

  • 서버 렌더에서 훅이 하는 일

    서버 렌더(Server-Side Rendering, SSR)에서도 컴포넌트 함수는 실제로 호출되고, 그 안의 훅도 호출은 된다. 다만 서버에는 커밋(DOM 반영)이 없어서, 렌더 중 동기적으로 값을 계산하는 훅만 일하고 커밋 뒤에 도는 훅은 아무것도 하지 않는다. "이 훅은 클라이언트에서만 동작한다"는 말은 대개 "Effect 안의 일은 클라이언트에서만 일어난다"는 뜻이다.

  • 상태 내리기와 컴포지션으로 리렌더 줄이기

    상태 내리기(State Colocation)는 state를 실제로 쓰는 컴포넌트 가까이로 옮겨, state가 바뀔 때 다시 렌더되는 범위를 좁히는 기법이다. 메모이제이션(Memoization)보다 먼저 시도할 구조적 최적화다.

  • useSuspenseQuery

    useSuspenseQuery는 TanStack Query의 결과를 suspense와 에러 경계(Error Boundary)에 맡기는 훅이다. 로딩 중이면 컴포넌트가 suspend되고, 실패하면 에러가 던져진다. 그래서 컴포넌트 안에는 성공한 경우의 코드만 남는다.

  • 레이아웃 스래싱

    DOM을 바꾸고 곧바로 레이아웃 값을 읽기를 반복해서, 브라우저가 매번 리플로우를 강제로 실행하게 되는 현상. 쓰기 → 읽기 → 쓰기 → 읽기가 반복문 안에서 일어나면 요소 수만큼 레이아웃을 계산한다.

보기 옵션