정의
수천 개짜리 긴 목록을 스크롤할 때 브라우저가 느려지는 것을 막고 싶을 때 쓰는 기법이다. 데이터는 수천 개라도 실제로 화면에 그리는 DOM(브라우저가 다루는 문서 요소 트리) 행은 지금 눈에 보이는 소수만 유지하는 것이 핵심이다.
정확히 말하면, 리스트 가상화(windowing)는 수백~수천 개 항목이 있는 목록에서 현재 뷰포트(브라우저 화면에서 실제로 보이는 영역)(또는 스크롤 컨테이너)에 보이는 행만 DOM에 렌더하고, 스크롤 위치에 따라 행을 재배치·교체해 메모리 사용량과 레이아웃·페인트 비용을 일정하게 유지하는 UI 기법이다. 무한 스크롤이 "얼마나 많은 데이터를 로드할지"를 제어한다면, 가상화는 "로드된 데이터 중 실제 DOM에 올릴 행"을 제한한다.
왜 필요한가
모든 항목을 한 번에 DOM에 두면 초기 페인트·스크롤·리플로우(reflow, 요소 위치·크기를 다시 계산하는 브라우저 작업) 비용이 항목 수에 비례해 커진다. 무한 스크롤만 적용하면 사용자가 스크롤할수록 로드된 항목 수는 늘지만, 결국 DOM 노드 수도 같이 늘어나 장시간 스크롤 후에는 브라우저가 느려질 수 있다. 가상화는 데이터 배열은 길어도 DOM은 뷰포트 근처의 소수 행만 유지한다. critical-rendering-path 관점에서 layout/paint 대상 노드 수를 제한하는 효과가 있다.
동작 원리
- Flatten row model: 연·월 그룹처럼 nested UI는 virtualizer 입력에 맞지 않는 경우가 많다.
month-header|item같은 1차원 row 배열로 펼친 뒤count와getItemKey로 관리한다. - Window virtualizer: 페이지 전체가 스크롤 컨테이너면
useWindowVirtualizer를 쓰고, 고정 높이 패널 안이면useVirtualizer를 쓴다. 상단 고정 헤더·필터가 있으면scrollMargin으로 offset을 맞춘다. - 가변 높이:
estimateSize로 초기 높이를 추정하고, 렌더 후measureElement로 실제 높이를 보정한다. 추정이 틀리면 스크롤 점프가 발생한다. - 무한 스크롤 결합: 스크롤 끝에서
visibleCount(또는 page)를 늘리고, virtualizer의count도 함께 갱신한다. 데이터 노출과 DOM 상한을 분리해 제어한다. - SSR + virtual: SSR(Server-Side Rendering, 서버가 HTML을 미리 만들어 보내는 방식)/초기 렌더에서 virtualizer만 쓰면 뷰포트 밖 행이 HTML에 빠질 수 있다. 첫 paint·SEO·hydration(서버가 만든 HTML에 React가 이벤트를 붙여 실제 동작하게 만드는 과정)이 중요하면 정적 첫 페이지를 SSR로 보장한 뒤,
useLayoutEffect이후 virtual list로 전환한다(ssr-hydration-mismatch).
실무 적용
- 서버는 목록에 필요한 메타만 직렬화하고 본문은 제외한다(single-source-of-truth-content-metadata). 클라이언트는
visibleCount로 점진 노출한다. - slice 시 월 헤더가 잘리지 않게, visible TIL slug 집합에 해당하는
month-header만 남기는 로직을 둔다. - 태그 필터 등 조건이 바뀌면
<VirtualList key={filter}>로 내부 상태를 리셋한다. - 항목이 수만 건 이상이면 클라이언트 전체 배열 대신 API 페이징(
/api/items?offset=&limit=)을 검토한다.
트레이드오프
| 선택 | 장점 | 단점 |
|---|---|---|
| 가상화 없음 | 구현 단순, SEO·접근성 예측 쉬움 | 항목 수 증가 시 성능 저하 |
| virtualizer만 SSR | 코드 단순 | 첫 HTML·hydration에 항목 누락 가능 |
| 정적 gate + virtual | 첫 paint·hydration 안정 | 두 렌더 경로 유지 비용 |
| 라이브러리(예: TanStack Virtual) | 가변 높이·window scroll 지원 | 학습·번들 비용 |
사용하면 안 되는 경우
- 항목이 수십 개 이하로 DOM 비용이 무시 가능한 경우.
- 전체 목록이 SEO·인쇄·스크린 리더에서 한 번에 읽혀야 하는 경우(가상화는 DOM에 없는 행을 스킵한다).
- 행 높이가 극단적으로 불규칙하고 측정 보정 없이 추정만 쓰는 경우(스크롤 점프).
흔한 실수
- nested
<section>구조를 virtualizer에 직접 얹어 slice·키·측정이 복잡해진다 → flatten row로 전환한다. - 무한 스크롤만 쓰고 가상화를 생략해, 로드된 항목이 많아지면 DOM이 계속 커진다.
- virtualizer만 SSR해 첫 화면 HTML이 비거나 hydration 불일치가 난다.
estimateSize만 쓰고measureElement를 생략해 가변 높이 카드에서 스크롤이 튄다.
관련 개념
- ssr-hydration-mismatch — 클라이언트 전용 UI(virtual 전환)를 마운트·layout effect 이후로 미루는 원칙.
- critical-rendering-path — DOM 규모 축소가 초기·스크롤 렌더 비용에 미치는 영향.
- single-source-of-truth-content-metadata — 목록 메타만 서버에서 직렬화해 RSC(React Server Components, 서버에서만 실행되는 React 컴포넌트) 경계 페이로드를 줄이는 패턴.