리스트 가상화(List Virtualization)·윈도잉(Windowing)은 수천~수만 개 항목 중 화면에 보이는 것과 약간의 여유분만 실제 DOM(Document Object Model)에 렌더하고, 스크롤할 때 그 범위를 바꿔 끼우는 기법이다. 1만 개 목록이라도 DOM에는 20~30개만 있다.
TanStack Virtual의 동작
- 위치 측정 캐시: 항목마다
start·size·end를 계산해 둔다. 실제 크기를 쟀으면 그 값을, 아니면estimateSize를 쓴다. 다음 항목의 시작은 이전 항목의 끝 + gap이다 - 가시 범위 계산: 현재 스크롤 위치에서 시작하는 첫 항목을 이진 탐색(O(log n))으로 찾고, 뷰포트 끝까지 끝 인덱스를 늘린다
- overscan: 범위를 위아래로 몇 개 더 넓혀 빠르게 스크롤할 때 빈칸이 보이지 않게 한다
- 렌더: 전체 높이(
getTotalSize())를 가진 컨테이너 안에 보이는 항목만position: absolute+translateY(start)로 놓는다
v는 useVirtualizer({ count, getScrollElement, estimateSize })가 돌려준 객체다.
<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와 잘 맞는다(선언형과 명령형 프로그래밍)
스크롤 복원
뒤로 가기로 돌아왔을 때 위치가 어긋나는 이유는 기준이 다르기 때문이다. 브라우저 기본 복원(history.scrollRestoration = 'auto')은 문서 스크롤의 픽셀 위치를 되돌린다. 가상 목록에는 "몇 번째 항목의 몇 px 지점"이라는 논리적 위치가 필요하다. 돌아온 직후에는 데이터가 아직 없거나 항목 크기가 추정값이라, 같은 scrollTop이 다른 항목을 가리킨다.
- 떠날 때 항목 id + 항목 안 offset을 저장한다. 인덱스만 저장하면 그사이 앞쪽에 항목이 추가·삭제됐을 때 다른 항목으로 갈 수 있다
- 돌아오면 데이터와 가상화기가 준비된 뒤 id를 인덱스로 바꿔
scrollToIndex(index, { align: 'start' })로 이동하고,measureElement로 실제 크기를 잰 다음 offset을 보정한다 - TanStack Virtual은 떠날 때
takeSnapshot()(측정한 항목 크기)과scrollOffset을 저장했다가initialMeasurementsCache·initialOffset으로 넘기는 방법을 문서에 둔다(2026 기준). 픽셀 위치 기반이라 목록 내용이 그대로일 때 맞는다 - 직접 복원한다면
history.scrollRestoration = 'manual'로 브라우저 기본 복원을 꺼서 두 복원이 겹치지 않게 한다
더 가벼운 브라우저 기본 대안은 content-visibility, 무한 스크롤 데이터는 TanStack Query의 useInfiniteQuery와 함께 쓴다.
출처: MDN — History.scrollRestoration · TanStack Virtual — scrollToIndex · TanStack Virtual — measureElement · TanStack Virtual — takeSnapshot