EPUB 리더를 개발하면서 마주한 문제가 있었습니다. 수백 개의 목차 항목이 있는 책에서 스크롤이 버벅거리고 메모리 사용량이 급증하는 현상이었죠. 이 문제를 해결하기 위해 가상 스크롤링(Virtual Scrolling)을 도입한 과정과 성능 개선 결과를 공유해보겠습니다.
// ❌ 기존: 모든 목차 항목을 DOM에 렌더링
function SimpleToc({ toc }: { toc: NavItem[] }) {
const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());
const renderTocItems = (items: NavItem[], level = 0): JSX.Element[] => {
return items.map((item, index) => (
<div key={item.href} style={{ paddingLeft: `${level * 20}px` }}>
<div className="toc-item" onClick={() => navigate(item.href)}>
{item.hasChildren && (
<button onClick={() => toggleExpand(index)}>
{expandedItems.has(index) ? '▼' : '▶'}
</button>
)}
<span>{item.label}</span>
</div>
{/* 🔥 문제: 모든 자식 요소를 재귀적으로 렌더링 */}
{item.hasChildren && expandedItems.has(index) && (
<div>
{renderTocItems(item.subitems || [], level + 1)}
</div>
)}
</div>
));
};
return (
<div className="toc-container" style={{ height: '400px', overflow: 'auto' }}>
{renderTocItems(toc)}
</div>
);
}
가상 스크롤링(Virtual Scrolling)은 대용량 리스트의 성능 문제를 해결하는 핵심 기법입니다.
일반 스크롤링에서는 1000개의 목차가 있으면 1000개의 DOM 요소를 모두 생성하고 메모리에 유지합니다. 이는 다음과 같은 문제를 야기합니다:
📱 화면 (400px 높이)
┌─────────────────┐
│ ✅ Item 1 │ ← DOM 생성됨
│ ✅ Item 2 │ ← DOM 생성됨
│ ✅ Item 3 │ ← DOM 생성됨
│ ✅ Item 4 │ ← DOM 생성됨
└─────────────────┘
✅ Item 5 ← 안 보이지만 DOM 생성됨
✅ Item 6 ← 안 보이지만 DOM 생성됨
✅ Item 7 ← 안 보이지만 DOM 생성됨
✅ ... ← 996개 더 생성됨!
✅ Item 1000 ← 메모리 낭비!
반면 가상 스크롤링에서는 화면에 보이는 영역 + 버퍼만큼의 DOM 요소만 생성합니다:
📱 화면 영역 (400px)
┌─────────────────┐
│ ✅ Item 15 │ ← 실제 DOM 렌더링
│ ✅ Item 16 │ ← 실제 DOM 렌더링
│ ✅ Item 17 │ ← 실제 DOM 렌더링
│ ✅ Item 18 │ ← 실제 DOM 렌더링
└─────────────────┘
⬆️ 가상 영역 (560px) - DOM 없음, 높이만 계산
⬇️ 가상 영역 (3440px) - DOM 없음, 높이만 계산
총 높이: 4000px (1000개 × 40px)
실제 렌더링: 4개 요소만!
메모리 사용량: 99.6% 절약!
transform: translateY()로 올바른 위치에 배치🔴 일반 스크롤링:
메모리: [DOM][DOM][DOM]...[DOM] (1000개)
렌더링: 전체 한 번에 처리 → 느림
스크롤: 모든 요소 추적 → 버벅거림
🟢 가상 스크롤링:
메모리: [DOM][DOM][DOM][DOM] (10개만)
렌더링: 필요한 부분만 → 빠름
스크롤: 계산된 위치로 이동 → 부드러움
이제 구체적인 구현 방법을 살펴보겠습니다!
// 트리 구조를 평면 배열로 변환
const flattenToc = useMemo(() => {
const flattenItems = (
items: NavItem[],
level = 0,
parentNumbering = ""
): FlattenedTocItem[] => {
const result: FlattenedTocItem[] = [];
items.forEach((item, index) => {
const numbering = parentNumbering
? `${parentNumbering}.${index + 1}`
: `${index + 1}`;
const flatItem: FlattenedTocItem = {
...item,
level,
index: result.length,
hasChildren: Boolean(item.subitems?.length),
numbering,
isExpanded: expandedItems.has(result.length),
};
result.push(flatItem);
// ✅ 확장된 경우에만 자식 추가
if (flatItem.hasChildren && flatItem.isExpanded) {
const children = flattenItems(
item.subitems!,
level + 1,
numbering
);
result.push(...children);
}
});
return result;
};
return flattenItems(toc);
}, [toc, expandedItems]);
const virtualItems = useMemo(() => {
const itemHeight = 40;
const containerHeight = 400;
// 화면에 보이는 아이템 수 계산 (+ 버퍼)
const visibleItemCount = Math.ceil(containerHeight / itemHeight) + 2;
// 현재 스크롤 위치에서 시작 인덱스 계산
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + visibleItemCount, flattenToc.length);
return {
items: flattenToc.slice(startIndex, endIndex), // 실제 렌더링할 데이터
startIndex,
endIndex,
totalHeight: flattenToc.length * itemHeight, // 전체 스크롤 높이
offsetY: startIndex * itemHeight, // 상단 오프셋
};
}, [flattenToc, scrollTop, itemHeight, containerHeight]);
function VirtualizedToc() {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
setScrollTop(target.scrollTop); // 스크롤 위치 업데이트
}, []);
return (
<div className={className}>
<div
ref={scrollContainerRef}
className="relative overflow-auto"
style={{ height: finalHeight }}
onScroll={handleScroll}
>
{/* 🎯 전체 높이를 가진 가상 컨테이너 */}
<div style={{ height: virtualItems.totalHeight, position: "relative" }}>
{/* 🎯 보이는 영역만 실제 렌더링 */}
<div
style={{
transform: `translateY(${virtualItems.offsetY}px)`,
position: "absolute",
top: 0,
left: 0,
right: 0,
}}
>
{virtualItems.items.map((item) => (
<VirtualizedTocItem
key={`${item.href}-${item.index}`}
item={item}
onNavigate={handleNavClick}
onToggleExpand={toggleExpand}
isActive={currentSection?.href === item.href}
style={{ height: itemHeight }}
/>
))}
</div>
</div>
</div>
</div>
);
}
실제 Chrome DevTools Performance 탭에서 측정한 결과입니다.
🔴 변경 전 (모든 목차 렌더링)
🟢 변경 후 (가상 스크롤)
| 지표 | 변경 전 | 변경 후 | 변화 |
|---|---|---|---|
| 총 실행 시간 | 14,076ms | 13,321ms | 5%↓ |
| 렌더링 시간 | 714ms | 549ms | 23%↓ |
| 페인팅 시간 | 590ms | 373ms | 37%↓ |
| DOM 노드 수 | 500+ | 10~15개 | 97%↓ |
가상 스크롤링의 진짜 장점은 런타임 성능입니다:
translateY만 변경초기 측정에서는 스크립팅 시간이 증가했는데, 이는 다음 이유 때문입니다:
하지만 실제 사용자 경험에서는 다음과 같은 개선을 확인할 수 있습니다:
// 각 아이템의 높이가 다를 수 있는 경우
const [itemHeights, setItemHeights] = useState<Map<number, number>>(new Map());
const measureItemHeight = useCallback((index: number, height: number) => {
setItemHeights(prev => new Map(prev).set(index, height));
}, []);
// 누적 높이 계산
const cumulativeHeights = useMemo(() => {
const heights: number[] = [];
let totalHeight = 0;
flattenToc.forEach((_, index) => {
heights[index] = totalHeight;
totalHeight += itemHeights.get(index) || defaultItemHeight;
});
return heights;
}, [flattenToc, itemHeights]);
// 현재 활성 섹션으로 자동 스크롤
useEffect(() => {
if (currentSection && scrollContainerRef.current) {
const currentIndex = virtualItems.items.findIndex(
(item) => item.href === currentSection.href
);
if (currentIndex !== -1) {
const targetScrollTop = (virtualItems.startIndex + currentIndex) * itemHeight;
scrollContainerRef.current.scrollTop = targetScrollTop;
}
}
}, [currentSection, virtualItems, itemHeight]);
const toggleExpand = useCallback((index: number) => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
// 하위 항목들도 함께 축소
flattenToc.forEach(item => {
if (item.parentIndex === index) {
newSet.delete(item.index);
}
});
} else {
newSet.add(index);
}
return newSet;
});
}, [flattenToc]);
const VirtualizedTocItem = memo(({ item, onNavigate, isActive }) => {
// 렌더링 로직
}, (prevProps, nextProps) => {
// 커스텀 비교 함수로 불필요한 리렌더링 방지
return (
prevProps.item.href === nextProps.item.href &&
prevProps.isActive === nextProps.isActive
);
});
const debouncedScrollTop = useDebounce(scrollTop, 16); // 60fps
const virtualItems = useMemo(() => {
// 계산 로직
}, [flattenToc, debouncedScrollTop]);
// 화면 밖 아이템들을 백그라운드에서 미리 준비
const preloadItems = useMemo(() => {
const bufferSize = 10;
const preloadStart = Math.max(0, virtualItems.startIndex - bufferSize);
const preloadEnd = Math.min(
flattenToc.length,
virtualItems.endIndex + bufferSize
);
return flattenToc.slice(preloadStart, preloadEnd);
}, [flattenToc, virtualItems]);
가상 스크롤링 도입으로 다음과 같은 성과를 얻었습니다:
특히 대용량 목차가 있는 기술 문서나 소설에서 눈에 띄는 성능 향상을 체감할 수 있었습니다.
가상 스크롤링은 복잡도가 증가하는 트레이드오프가 있지만, 대용량 데이터를 다루는 React 애플리케이션에서는 필수적인 최적화 기법이라고 생각합니다.
여러분의 프로젝트에서도 비슷한 성능 문제를 겪고 계신다면, 가상 스크롤링을 고려해보시기 바랍니다! 🚀
💡 추가 자료