화면에 한 번에 보이는 리스트 아이템만 렌더링하고,
보이지 않는 영역은 빈(spacer) 요소로 대체해 렌더링·메모리 비용을 줄이는 기법이다.
| 구분 | Garbage Collection | Garbage Collector |
|---|---|---|
| 개념 | 프로세스(행위) | 컴포넌트(구현체) |
| 책임 | ‘언제’·‘어떻게’ 메모리 청소할지 결정 | 실제 청소 작업 수행 |
| 예시 | “JS 엔진이 가비지 컬렉션을 수행한다” | “V8 엔진의 GC 모듈” |
// startIndex 계산
startIndex = Math.max(0,
Math.floor(scrollTop / itemHeight) - overscan
)
// endIndex 계산
endIndex = Math.min(
totalItems,
Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan
)
// 렌더 대상 추출
visibleItems = allItems.slice(startIndex, endIndex)
// 위·아래 스페이서(spacer) 높이
topGap = startIndex * itemHeight
bottomGap = (totalItems - endIndex) * itemHeight

// VirtualScrollList.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface VirtualScrollListProps {
items: string[];
itemHeight?: number;
height?: number;
overscan?: number;
}
export default function VirtualScrollList({
items,
itemHeight = 50,
height = 500,
overscan = 5,
}: VirtualScrollListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const onScroll = useCallback(() => {
if (!containerRef.current) return;
const top = containerRef.current.scrollTop;
window.requestAnimationFrame(() => setScrollTop(top));
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener("scroll", onScroll);
return () => el.removeEventListener("scroll", onScroll);
}, [onScroll]);
const totalCount = items.length;
const startIndex = Math.max(
0,
Math.floor(scrollTop / itemHeight) - overscan,
);
const endIndex = Math.min(
totalCount,
Math.ceil((scrollTop + height) / itemHeight) + overscan,
);
const visibleItems = items.slice(startIndex, endIndex);
const spacerBefore = startIndex * itemHeight;
const spacerAfter = (totalCount - endIndex) * itemHeight;
useEffect(() => {
const el = containerRef.current;
console.log(
`[VirtualScroll] startIndex=${startIndex} `,
`endIndex=${endIndex} `,
`visibleItems=${visibleItems.length} `,
`DOM nodes=${el?.childElementCount} `,
);
}, [startIndex, endIndex, visibleItems.length]);
return (
<div
ref={containerRef}
className="relative w-full overflow-y-auto border border-gray-200"
style={{ height: `${height}px` }}
>
<div style={{ height: spacerBefore }} />
{visibleItems.map((item, idx) => (
<div
key={startIndex + idx}
className="box-border flex items-center border-b border-gray-100 px-3"
style={{ height: `${itemHeight}px` }}
>
{item}
</div>
))}
<div style={{ height: spacerAfter }} />
</div>
);
}
// page.tsx
"use client";
import VirtualScrollList from "../components/VirtualScrollList";
export default function Home() {
const data = Array.from({ length: 10000 }, (_, i) => `아이템 #${i + 1}`);
return (
<main className="p-5">
<h1 className="p-3 text-xl font-semibold">
Next.js + 가상 스크롤 예시
</h1>
<VirtualScrollList
items={data}
itemHeight={40}
height={400}
overscan={5}
/>
</main>
);
}
// 시작
let id = window.requestAnimationFrame(animate)
function animate(time) {
// time: RAF가 호출된 시점의 타임스탬프(밀리초)
// 애니메이션 로직: 요소 위치, 스타일 업데이트 등
updatePositions(time)
// 다음 프레임 예약
id = window.requestAnimationFrame(animate)
}
// 중단
window.cancelAnimationFrame(id)