React TanStack Virtual + useInfiniteQuery로 5만 건 목록을 DOM 60개로 렌더링하는 가상화 무한 스크롤 구현기
이 글은 TanStack Query 기본 사용 경험이 있고, 대용량 목록 성능 문제를 마주친 프론트엔드 개발자를 위한 글입니다.
프론트엔드 개발을 하다 보면 한 번쯤은 이런 상황을 마주치게 됩니다. 제품 리스트, 피드, 로그 뷰어처럼 데이터가 수만 건씩 쌓이는 화면인데, 스크롤할수록 점점 버벅이고, 메모리는 계속 올라가고, 결국 탭이 느려지거나 죽어버리는 상황이요. 실제로 저도 피드 6만 건 화면에서 탭 메모리가 1.8GB를 넘어가던 시점을 경험했는데, 그 후로 이 문제와 제대로 싸워보기로 했습니다. 스크롤 점프 현상으로 이틀을 날리기도 했고, isFetchingNextPage 조건 하나를 빠뜨려서 무한 루프로 서버 요청이 폭발한 적도 있었습니다.
이 글에서는 @tanstack/react-virtual의 useVirtualizer와 @tanstack/react-query의 useInfiniteQuery를 연동해서, 아이템이 5만 개든 50만 개든 상관없이 DOM에 실제로 존재하는 노드 수를 항상 60~80개로 고정하는 가상화 무한 스크롤 패턴을 구현하는 방법을 공유합니다. 단순한 소개가 아니라, 실무에서 직접 삽질하면서 겪었던 스크롤 점프 문제, 무한 리렌더 이슈, 메모리 누수 상황까지 솔직하게 담아봤습니다.
핵심 개념
가상화(Virtualization)란 무엇인가
쉽게 말하면 "보이는 것만 그린다"는 개념입니다. 50,000개의 아이템이 있어도 현재 뷰포트에 실제로 보이는 건 15~20개 정도잖아요. 가상화는 그 보이는 부분 + 스크롤 예측을 위한 약간의 버퍼(overscan)만 실제 DOM에 마운트하고, 나머지는 CSS transform: translateY()로 위치만 잡아두는 기법입니다.
Windowing이라고도 불리는 이 패턴은 전체 목록 높이는 실제로 계산된 값으로 유지하면서 스크롤 위치에 따라 렌더링할 아이템 범위만 계속 바꿔치기합니다. 사용자 눈에는 전체 목록이 있는 것처럼 보이지만, DOM에는 항상 일정한 수의 노드만 존재합니다.
50,000개를 모두 렌더링하면 DOM 노드가 50,000개, 가상화를 적용하면 overscan 포함해서 40~80개 수준으로 고정됩니다. 레이아웃 재계산 비용과 메모리 사용량이 완전히 다른 차원이 됩니다.
참고로 overscan 값은 단순히 "버퍼"라고만 생각하면 나중에 낭패를 볼 수 있습니다. 너무 작으면 빠르게 스크롤할 때 렌더링 공백(흰 영역)이 깜박이고, 너무 크면 불필요한 DOM 렌더링 비용이 올라가서 오히려 성능을 깎아먹습니다. 뷰포트 속도와 아이템 렌더링 비용을 고려해 3~10 사이에서 조율하는 것이 일반적입니다.
두 라이브러리의 역할 분리
이 패턴을 이해하는 데 가장 중요한 부분입니다. 저도 처음엔 두 라이브러리가 겹치는 게 아닐까 헷갈렸는데, 역할이 완전히 다릅니다.
| 라이브러리 | 담당하는 것 | 담당하지 않는 것 |
|---|---|---|
useVirtualizer |
스크롤 위치 계산, 렌더링할 아이템 인덱스 범위 결정, translateY 값 제공 |
데이터 패칭, 네트워크 요청 |
useInfiniteQuery |
페이지 단위 비동기 패칭, data.pages 누적 관리, 다음 페이지 상태 제공 |
스크롤 계산, DOM 렌더링 제어 |
두 라이브러리를 연동했을 때의 데이터 흐름은 이렇습니다:
스크롤 이벤트
→ useVirtualizer가 마지막 가상 아이템 인덱스 감지
→ lastItem.index >= allRows.length - 1이면 fetchNextPage() 호출
→ useInfiniteQuery가 다음 페이지 패칭
→ data.pages를 flatMap해 allRows 업데이트
→ useVirtualizer에 count = allRows.length 반영
→ 새 아이템들이 가상화 범위에 들어오면 DOM에 마운트실전 적용
먼저 두 라이브러리를 설치합니다.
pnpm add @tanstack/react-query @tanstack/react-virtual예시 1: 기본 무한 스크롤 가상화 연동
가장 기본이 되는 패턴입니다. 제품 목록, 게시글 피드처럼 위에서 아래로 스크롤하면서 데이터를 계속 불러오는 케이스에 해당합니다.
💡 아래 코드에서
fetchItems는{ items: Item[], nextCursor: number | null }형태를 반환하는 비동기 함수를 가정합니다.
import { useInfiniteQuery, type InfiniteData } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useEffect } from 'react'
type Page = { items: Item[]; nextCursor: number | null }
function InfiniteList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery<Page, Error, InfiniteData<Page>, string[], number>({
queryKey: ['items'],
queryFn: ({ pageParam }) => fetchItems(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
maxPages: 5,
})
const allRows = data ? data.pages.flatMap((p) => p.items) : []
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
overscan: 5,
})
const virtualItems = virtualizer.getVirtualItems()
useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (
lastItem.index >= allRows.length - 1 &&
hasNextPage &&
!isFetchingNextPage // 이 조건이 없으면 스크롤마다 요청이 터진다
) {
fetchNextPage()
}
}, [virtualItems, allRows.length, hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index > allRows.length - 1
const row = allRows[virtualItem.index]
return (
<div
key={virtualItem.key}
ref={(node) => virtualizer.measureElement(node)}
data-index={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? <LoadingRow /> : null
) : (
<ItemComponent item={row} />
)}
</div>
)
})}
</div>
</div>
)
}코드에서 주목할 부분들을 정리하면 다음과 같습니다.
| 포인트 | 설명 |
|---|---|
count: hasNextPage ? allRows.length + 1 : ... |
다음 페이지가 있을 때 로딩 인디케이터 자리를 가상 아이템으로 예약 |
ref={(node) => virtualizer.measureElement(node)} |
콜백 형태로 연결해야 React의 cleanup(null 전달) 시점에 오작동을 피할 수 있음 |
isFetchingNextPage 체크 |
패칭 중일 때 중복 호출 방지 — 없으면 스크롤마다 요청 폭발 |
fetchNextPage를 deps에 포함 |
TanStack Query의 fetchNextPage는 stable reference라 무한 루프 위험 없음, 포함하는 것이 정확 |
| TypeScript 제네릭 | useInfiniteQuery<Page, Error, InfiniteData<Page>, ...>로 타입 추론 오염 방지 |
예시 2: 가변 높이 아이템 처리
피드 게시글이나 댓글처럼 아이템마다 높이가 다른 경우, estimateSize를 고정값으로 주면 스크롤 점프가 심하게 발생합니다. 이때 measureElement가 핵심 역할을 합니다.
예시 1에서 virtualizer 설정과 아이템 ref 부분만 아래와 같이 바꾸면 됩니다.
// virtualizer 설정
const virtualizer = useVirtualizer({
count: allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // 초기 추정값 (실제와 달라도 measureElement가 보정)
overscan: 3,
})
// 렌더링 — ref 연결은 동일
<div
key={virtualItem.key}
ref={(node) => virtualizer.measureElement(node)}
data-index={virtualItem.index}
style={{ ... }}
>
<DynamicHeightItem item={allRows[virtualItem.index]} />
</div>measureElement에 연결된 ref 콜백은 해당 DOM 노드가 마운트될 때 실제 높이를 측정하는 것에서 끝나지 않습니다. 내부적으로 ResizeObserver를 통해 크기 변화를 지속 감시하기 때문에, 이미지가 뒤늦게 로딩되거나 아코디언이 펼쳐지는 것처럼 마운트 후에 높이가 바뀌는 케이스도 자동으로 대응합니다. 이 부분을 모르면 "왜 높이가 동적으로 반응하지?"라고 한참 삽질하게 됩니다.
예시 3: maxPages로 메모리 제어
솔직히 이 옵션을 처음 봤을 때 "그냥 페이지를 제한하는 거잖아?"라고 생각했는데, 장시간 스크롤하는 실제 사용 패턴에서 얼마나 중요한지 깨달았습니다. 피드를 30분 이상 스크롤하면 수십 페이지가 메모리에 쌓이는데, 이걸 제한하지 않으면 결국 탭이 죽습니다.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
maxPages: 10, // 페이지당 20개면 최대 200개를 메모리에 유지
})maxPages: 10으로 설정하면 11번째 페이지가 패칭될 때 가장 오래된 첫 번째 페이지가 자동으로 메모리에서 제거됩니다. 뒤로 스크롤하면 해당 데이터는 재패칭됩니다. 이 트레이드오프 때문에 pageSize × maxPages 조합이 실제 사용 패턴과 허용 네트워크 비용에 맞게 균형을 잡아야 합니다.
조건에 따라 다르지만 장시간 스크롤 시나리오에서 maxPages 적용 전후로 수십%의 메모리 절감 효과를 볼 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| DOM 노드 일정 유지 | 아이템 수와 무관하게 렌더링 DOM이 ~60개로 고정되어 메모리·레이아웃 재계산 비용 절감 |
| 60FPS 스크롤 | 렌더링 병목 없이 대용량 데이터에서도 부드러운 스크롤 경험 제공 |
| 헤드리스 설계 | 마크업과 스타일을 완전히 제어 가능, 어떤 CSS 라이브러리와도 자유롭게 결합 |
| 프레임워크 독립 | React/Vue/Solid/Svelte/Angular 모두 지원, 번들 크기 10~15kb |
| 메모리 제한 가능 | maxPages 옵션으로 장시간 스크롤 시 누적 페이지 제한 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 스크롤 점프 | estimateSize와 실제 높이 차이가 클수록 스크롤 위치가 튀는 현상 |
measureElement로 실제 높이 즉시 보정, 가능한 한 실제 값에 가깝게 추정 |
maxPages 재패칭 |
페이지 제거 후 뒤로 스크롤 시 추가 네트워크 요청 발생 | 페이지 크기·maxPages 값을 사용 패턴에 맞게 조율 |
| SSR 제약 | DOM 크기 기반 동작으로 서버 렌더링 시 hydration mismatch 가능 | 클라이언트 전용 렌더링으로 분리, suppressHydrationWarning 활용 |
| SEO 불리 | 가상화된 콘텐츠는 크롤러에 비노출 상태 | SEO 중요 콘텐츠에는 가상화 대신 정적 렌더링 고려 |
| 접근성(a11y) | 스크린 리더가 비렌더링 아이템을 인식하지 못함 | aria-rowcount, aria-rowindex 등 추가 ARIA 속성 필요 |
실무에서 SEO 이슈는 결국 해당 피드 페이지를 크롤러 전용 정적 버전과 사용자 버전으로 분리해서 우회했고, a11y는 각 아이템에 ARIA 속성을 직접 달아서 해결했습니다. 우아하진 않지만 현실적인 선택이었습니다.
💡 Hydration Mismatch: 서버에서 렌더링된 HTML과 클라이언트에서 재렌더링된 결과가 다를 때 React가 경고를 발생시키는 현상입니다. 가상화는 뷰포트 크기를 기준으로 동작하기 때문에 서버 환경(뷰포트 없음)과 클라이언트 환경이 불일치합니다.
실무에서 가장 흔한 실수
-
isFetchingNextPage체크 누락 —useEffect안에서fetchNextPage()호출 조건에 이 플래그를 빠뜨리면, 스크롤 이벤트가 발생할 때마다 요청이 중복으로 쌓이면서 무한 루프에 빠집니다. 실제로 한 번 경험해봐야 알 수 있는 공포입니다. 서버 요청 그래프가 수직으로 솟구치는 걸 보게 됩니다. 반드시!isFetchingNextPage조건을 함께 확인하는 것을 권장합니다. -
estimateSize를 너무 부정확하게 설정 — 실제 아이템 높이가 64px인데estimateSize를 200px로 주면 초기 렌더링에서 스크롤 위치 계산이 완전히 틀어집니다. DevTools에서 실제 아이템 높이를 측정해서 최대한 근접한 값을 주는 것이 스크롤 점프를 줄이는 가장 확실한 방법입니다. -
position: relative컨테이너 누락 — 가상 아이템들은position: absolute+transform: translateY()로 위치가 결정됩니다. 부모 컨테이너에position: relative가 없으면 아이템들이 뷰포트 기준으로 위치를 잡아 레이아웃이 완전히 깨집니다.virtualizer.getTotalSize()로 높이를 설정한<div>에 반드시position: relative를 지정하는 것이 좋습니다.
마치며
TanStack Virtual과 useInfiniteQuery의 결합은 단순한 성능 최적화가 아니라, 대용량 데이터를 다루는 프론트엔드 프로덕션 환경에서 사실상 표준이 된 패턴입니다. 두 라이브러리의 역할 분리를 이해하고, measureElement로 가변 높이를 처리하고, maxPages로 메모리를 제어하는 세 가지만 잘 잡으면 DOM 60개로 50,000건을 처리하는 것이 충분히 가능합니다.
지금 바로 시작해볼 수 있는 3단계:
- 기본 가상화 붙이기 —
pnpm add @tanstack/react-query @tanstack/react-virtual설치 후, 기존 목록 렌더링 부분을useVirtualizer로 감싸는 것부터 시작합니다.count와getScrollElement만 맞춰도 기본 가상화가 동작합니다. - DOM 노드 수 확인 — DevTools Elements 패널을 열고 스크롤하면서 실제 DOM 노드 수가 60~80개 근처에서 고정되는지 확인합니다. 이 숫자가 고정된다면 가상화가 제대로 동작하고 있는 것입니다.
- 프로덕션 수준 완성 — 아이템 높이가 가변적이라면
ref={(node) => virtualizer.measureElement(node)}와data-index를 각 아이템에 추가하고,maxPages: 5~10옵션으로 메모리 제한까지 설정합니다.
다음 글: TanStack Table + TanStack Virtual + React Query를 조합해 수만 행의 데이터 그리드를 행·열 모두 가상화하는 대용량 테이블 구현 패턴
참고 자료
- TanStack Virtual — Infinite Scroll 예제 (React)
- TanStack Virtual — Virtualizer API
- TanStack Query — Infinite Queries
- GitHub — TanStack Virtual infinite-scroll/main.tsx
- LogRocket Blog — How to speed up long lists with TanStack Virtual
- DEV Community — Building an Efficient Virtualized Table with TanStack Virtual and React Query with ShadCN
- Hashnode — Infinite List with Tanstack: React Query & Virtual
- Makers Den — Infinite Scroll and Streaming Data with TanStack Query + React 19
- Medium — From Lag to Lightning: How TanStack Virtual Optimizes 1000s of Items Smoothly
- DeepWiki — TanStack Virtual Infinite Scrolling