가상화 — 10만 개 리스트를 60fps로 렌더링하기
10만 개의 항목을 모두 DOM에 렌더링하면 어떤 일이 벌어질까요?
10만 개의 <div>를 DOM에 넣으면 브라우저는 멈춥니다. 메모리는 수 GB를 소비하고, 스크롤은 끊기며, 초기 렌더링에만 수 초가 걸립니다. 가상화(Virtualization)는 이 문제를 "보이는 것만 렌더링"하는 방식으로 해결합니다.
가상화의 원리
가상화 없이
10만 개 항목 → 10만 개 DOM 노드 → 브라우저 렌더링 폭발
가상화 적용
10만 개 데이터 → 화면에 보이는 ~20개만 DOM 노드 생성
스크롤 → 보이는 범위 변경 → DOM 노드 재활용/교체
화면에 보이는 영역(viewport)과 약간의 여유 영역(overscan)만 실제 DOM에 렌더링합니다. 스크롤할 때 뷰포트 밖으로 나간 항목은 제거하고, 새로 보이는 항목을 추가합니다.
react-window — 가벼운 가상화
npm install react-window
고정 높이 리스트
import { FixedSizeList } from 'react-window';
function VirtualList({ items }) {
const Row = ({ index, style }) => (
<div style={style} className="list-item">
<span>{items[index].name}</span>
<span>{items[index].email}</span>
</div>
);
return (
<FixedSizeList
height={600} // 컨테이너 높이
itemCount={items.length} // 전체 항목 수
itemSize={50} // 각 항목의 고정 높이
width="100%"
>
{Row}
</FixedSizeList>
);
}
핵심 props
height: 스크롤 컨테이너의 높이itemCount: 전체 항목 수itemSize: 각 항목의 높이 (FixedSizeList는 고정값)overscanCount: 뷰포트 밖에 미리 렌더링할 항목 수 (기본 1)
가변 높이 리스트
항목마다 높이가 다를 때는 VariableSizeList를 사용합니다.
import { VariableSizeList } from 'react-window';
function ChatMessages({ messages }) {
const listRef = useRef();
// 각 메시지의 높이를 계산하는 함수
const getItemSize = (index) => {
const message = messages[index];
// 간단한 높이 추정 (실제로는 더 정교한 계산 필요)
const lineCount = Math.ceil(message.text.length / 50);
return 40 + lineCount * 20;
};
const Row = ({ index, style }) => (
<div style={style} className="message">
<strong>{messages[index].sender}</strong>
<p>{messages[index].text}</p>
</div>
);
return (
<VariableSizeList
height={500}
itemCount={messages.length}
itemSize={getItemSize}
width="100%"
ref={listRef}
>
{Row}
</VariableSizeList>
);
}
그리드
2차원 가상화도 지원합니다.
import { FixedSizeGrid } from 'react-window';
function SpreadsheetView({ data, columns, rows }) {
const Cell = ({ columnIndex, rowIndex, style }) => (
<div style={style} className="cell">
{data[rowIndex]?.[columnIndex] ?? ''}
</div>
);
return (
<FixedSizeGrid
columnCount={columns}
columnWidth={120}
rowCount={rows}
rowHeight={35}
height={600}
width={800}
>
{Cell}
</FixedSizeGrid>
);
}
TanStack Virtual — 유연한 가상화
npm install @tanstack/react-virtual
TanStack Virtual은 react-window보다 유연하며, 직접 스크롤 컨테이너를 제어합니다.
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 추정 높이
overscan: 5,
});
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
동적 높이 측정
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // 초기 추정치
// measureElement를 사용하면 실제 DOM 높이를 측정
});
// 각 항목에 measureElement 연결
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
ref={virtualizer.measureElement} // 실제 높이 측정
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<DynamicContent item={items[virtualRow.index]} />
</div>
))}
measureElement를 사용하면 각 항목이 렌더링된 후 실제 DOM 높이를 측정하여 정확한 가상화를 적용합니다.
무한 스크롤 + 가상화
데이터를 점진적으로 로드하면서 가상화를 적용하는 패턴입니다.
function InfiniteVirtualList() {
const parentRef = useRef(null);
const [items, setItems] = useState([]);
const [hasNextPage, setHasNextPage] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const fetchNextPage = async () => {
if (isLoading || !hasNextPage) return;
setIsLoading(true);
const page = Math.floor(items.length / 50);
const newItems = await api.getItems({ page, size: 50 });
setItems((prev) => [...prev, ...newItems.data]);
setHasNextPage(newItems.hasMore);
setIsLoading(false);
};
const allItems = hasNextPage ? [...items, null] : items; // null = 로딩 표시용
const virtualizer = useVirtualizer({
count: allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
overscan: 5,
});
// 마지막 항목이 보이면 다음 페이지 로드
useEffect(() => {
const lastItem = virtualizer.getVirtualItems().at(-1);
if (!lastItem) return;
if (lastItem.index >= items.length - 1 && hasNextPage && !isLoading) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), hasNextPage, isLoading, items.length]);
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = allItems[virtualRow.index];
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{item ? (
<ItemRow item={item} />
) : (
<div className="loading">로딩 중...</div>
)}
</div>
);
})}
</div>
</div>
);
}
react-window vs TanStack Virtual
| 특성 | react-window | TanStack Virtual |
|---|---|---|
| 번들 크기 | ~6KB | ~3KB |
| 스크롤 컨테이너 | 라이브러리가 생성 | 직접 제어 |
| 동적 높이 측정 | 수동 구현 필요 | measureElement 제공 |
| 수평 가상화 | FixedSizeGrid | useVirtualizer |
| 프레임워크 | React만 | React, Vue, Svelte 등 |
| API 스타일 | 컴포넌트 기반 | Hook 기반 |
선택 기준
- react-window: 단순한 고정 크기 리스트에 빠르게 적용하고 싶을 때
- TanStack Virtual: 동적 높이, 커스텀 스크롤 컨테이너, 다양한 레이아웃이 필요할 때
가상화의 한계와 주의사항
검색 기능
가상화된 리스트에서 Ctrl+F(브라우저 검색)는 렌더링되지 않은 항목을 찾지 못합니다. 별도의 검색 기능을 구현해야 합니다.
접근성
스크린 리더가 전체 목록 크기를 인식할 수 있도록 적절한 ARIA 속성을 추가해야 합니다.
<div role="list" aria-label={`${items.length}개 항목`}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div role="listitem" aria-setsize={items.length} aria-posinset={virtualRow.index + 1}>
{/* ... */}
</div>
))}
</div>
스크롤 위치 복원
페이지를 떠났다가 돌아올 때 스크롤 위치를 복원하려면 별도 처리가 필요합니다.
정리
가상화는 "보이는 것만 렌더링"하는 단순한 원리로 대규모 리스트의 성능 문제를 해결합니다.
- **가상화의 핵심 **: 뷰포트에 보이는 항목만 DOM에 렌더링하고 나머지는 생략합니다
- react-window: 간단하고 가벼운 가상화. 고정/가변 크기 리스트와 그리드 지원
- TanStack Virtual: Hook 기반의 유연한 가상화. 동적 높이 측정과 커스텀 스크롤 컨테이너 지원
- ** 무한 스크롤과 조합 **: 데이터를 점진적으로 로드하면서 DOM 노드 수를 일정하게 유지합니다
- 브라우저 검색, 접근성, 스크롤 위치 복원은 별도로 처리해야 합니다
주의할 점
가상화된 리스트에서 브라우저 Ctrl+F 검색이 작동하지 않음
DOM에 렌더링되지 않은 항목은 브라우저 내장 검색으로 찾을 수 없습니다. 별도의 검색/필터 UI를 제공하거나, 검색 시 가상화를 일시적으로 해제하는 전략이 필요합니다.
가변 높이 항목에서 스크롤 점프
항목 높이가 동적인데 정확한 높이를 제공하지 않으면, 스크롤 위치가 갑자기 점프하는 현상이 발생합니다. TanStack Virtual의 measureElement로 실제 DOM 크기를 측정하여 해결할 수 있습니다.
항목이 100개 이하라면 가상화 없이도 충분합니다. 가상화는 수백~수만 개 이상의 항목을 다룰 때 적용해야 합니다.