스크롤 목록이 수천 개의 아이템을 그릴 때 렌더링과 페인팅 비용이 급격히 증가합니다. Virtualized List는 화면에 보이는 영역만 렌더링해 메모리 사용과 렌더링 비용을 크게 줄이는 기법입니다. 실무에서는 기본 원리를 이해한 뒤 라이브러리를 쓰는 것이 가장 빠르지만, 직접 구현해 두면 커스텀 요구사항 대응에 유리합니다.
1. 핵심 아이디어
원리는 간단합니다.
- 스크롤 컨테이너 높이는 고정합니다.
- 전체 항목 높이만큼 빈 공간을 만들고, 보이는 인덱스 구간만 실제 DOM으로 렌더링합니다.
- 렌더링된 블록을 translateY로 적절한 위치에 이동합니다.
- 약간의 overscan을 더 렌더링해 빠른 스크롤에도 깜박임을 줄입니다.
2. 최소 구현: 고정 높이 아이템 VirtualList
아이템 높이가 일정할 때 가장 구현이 쉽습니다.
import React, { useRef, useState, useCallback } from 'react';
function VirtualList({
itemCount,
itemHeight,
height,
renderItem, // (index) => JSX
overscan = 2,
className,
style,
}) {
const [scrollTop, setScrollTop] = useState(0);
const rafRef = useRef(null);
const onScroll = useCallback((e) => {
const st = e.currentTarget.scrollTop;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => setScrollTop(st));
}, []);
const totalHeight = itemCount * itemHeight;
const visibleCount = Math.ceil(height / itemHeight);
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const end = Math.min(
itemCount - 1,
start + visibleCount + overscan * 2 - 1
);
const offsetY = start * itemHeight;
const items = [];
for (let i = start; i <= end; i += 1) {
items.push(
<div key={i} style={{ height: itemHeight }}>
{renderItem(i)}
</div>
);
}
return (
<div
className={className}
onScroll={onScroll}
style={{
...style,
height,
overflowY: 'auto',
position: 'relative',
contain: 'strict', // 리페인트 범위 한정
}}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
willChange: 'transform',
}}
>
{items}
</div>
</div>
</div>
);
}
export default VirtualList;3. 사용 예시
데이터 10만 개를 부드럽게 스크롤합니다.
import React from 'react';
import VirtualList from './VirtualList';
const data = Array.from({ length: 100000 }, (_, i) => ({ id: i, title: `Row ${i}` }));
const Row = React.memo(function Row({ item }) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '0 12px',
height: '100%',
borderBottom: '1px solid #eee',
boxSizing: 'border-box',
background: item.id % 2 ? '#fff' : '#fafafa',
}}
>
<strong style={{ width: 80 }}>#{item.id}</strong>
<span>{item.title}</span>
</div>
);
});
export default function App() {
return (
<VirtualList
itemCount={data.length}
itemHeight={48}
height={400}
overscan={4}
renderItem={(index) => <Row item={data[index]} />}
/>
);
}4. 스크롤 성능 팁
- overscan: 2~6 사이로 시작해 깜박임과 메모리를 균형 있게 조정합니다.
- requestAnimationFrame: 스크롤 이벤트를 매 프레임으로 묶어 setState 빈도를 줄입니다.
- contain: CSS contain: strict로 페인팅 범위를 제한합니다.
- 컴포넌트 메모이제이션: Row를 React.memo로 감싸 re-render를 줄입니다.
- 키 안정성: key는 index로 충분하지만, 동적 삽입이 잦다면 고유 id를 권장합니다.
- 스타일: box-shadow, filter 등 페인트 비용이 큰 스타일은 지양합니다.
5. 가변 높이 아이템은 어떻게?
아이템 높이가 제각각이면 시작 인덱스 계산과 오프셋 관리가 복잡해집니다. 실무에서는 react-virtuoso, react-window의 VariableSizeList를 권장합니다. 직접 구현이 필요하면 ResizeObserver로 각 아이템 높이를 측정해 맵에 저장하고, prefix sum으로 오프셋을 계산합니다.
import React, { useLayoutEffect, useRef } from 'react';
function MeasuredRow({ index, onSize, children }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const report = () => onSize(index, el.offsetHeight);
report();
const ro = new ResizeObserver(report);
ro.observe(el);
return () => ro.disconnect();
}, [index, onSize]);
return <div ref={ref}>{children}</div>;
}
// 부모에서는 heights Map과 prefix 배열로 이진 탐색을 구현해 startIndex를 찾습니다.
// 스크롤 도중 높이가 변하면 오프셋을 업데이트하고 다시 그립니다.
// 구현 복잡도가 높으므로 라이브러리 사용을 우선 고려하세요.6. 언제 라이브러리를 쓸까?
- react-window: 경량, 고정/가변 높이 모두 지원, API 단순합니다.
- react-virtualized: 기능 풍부하지만 비교적 무겁습니다.
- react-virtuoso: 가변 높이와 동적 데이터에 강합니다.
라이브러리는 측정, 스크롤 앵커링, 리사이즈 대응 등 엣지 케이스를 잘 처리합니다.
7. 체크리스트와 흔한 문제
- 컨테이너 스타일: height 고정, overflowY: auto, position: relative를 잊지 않습니다.
- 부모 레이아웃: flex 부모일 때 높이가 0이 되지 않도록 min-height 또는 명시적 height를 지정합니다.
- 스크롤 점프: 데이터 추가/삭제 시 start 오프셋을 유지하도록 주의합니다.
- SSR: 최초 렌더 높이 의존 로직은 브라우저에서만 실행합니다.
- 접근성: 키보드 포커스 이동 시 비가시 아이템 포커스가 필요하면 스크롤을 보정합니다.
위의 최소 구현만으로도 대부분의 무한 스크롤 목록에서 큰 성능 향상을 얻을 수 있습니다. 고급 요구사항이나 가변 높이가 필요하다면 검증된 라이브러리를 도입하고, 커스텀 로직이 필요한 좁은 부분만 직접 구현하는 전략을 추천합니다.
'React' 카테고리의 다른 글
| React Router v6의 최신 기능과 사용법 (0) | 2026.04.15 |
|---|---|
| React Suspense로 데이터 로딩 경험 개선하기 (0) | 2026.04.14 |
| React Hooks로 커스텀 훅 설계하기 (1) | 2026.04.13 |
| React와 TypeScript를 함께 사용할 때의 베스트 프랙티스 (0) | 2026.04.10 |
| React에서 Context API로 전역 상태 관리하기2 (0) | 2026.04.10 |