이번 편의 결과물: 더미 책 1,000권을 넣어도 DOM에는 화면에 보이는 만큼(약 10~15개)만 렌더링됩니다. 가상화 적용 전/후 DOM 노드 개수를 비교합니다. 다루는 개념: 윈도잉(windowing), 보이는 범위 계산, 라이브러리 대체 시 트레이드오프
이 편에서 만드는 파일
bookshelf/src/
├── utils/
│ └── generateDummyBooks.js + 신규
├── hooks/
│ └── useVirtualList.js + 신규
└── pages/
└── BookListPage.jsx ~ 가상화 적용개념 정리
책이 6권일 때는 filteredBooks.map으로 전부 렌더링해도 문제가 없습니다. 1,000권이 되면 이야기가 다릅니다. 화면에는 10여 개만 보이는데 나머지 990개도 DOM 노드로 만들어져 브라우저 레이아웃·페인트 비용을 씁니다. 윈도잉은 전체 데이터 중 스크롤 위치에 따라 화면에 보이는 범위만 실제로 렌더링하는 기법입니다.
| 요소 | 역할 |
|---|---|
| 컨테이너 높이 고정 | 스크롤이 발생할 영역을 명시적 높이로 지정 |
| 전체 높이 placeholder | 아이템 수 × 아이템 높이만큼 스크롤바 크기를 유지 |
| 보이는 범위 계산 | scrollTop과 컨테이너 높이로 시작·끝 인덱스 계산 |
translateY 오프셋 | 보이는 항목들을 실제 스크롤 위치에 맞춰 이동 |
| overscan | 화면 경계 위아래로 여유분을 몇 개 더 렌더링(스크롤 시 빈 화면 방지) |
직접 구현한 윈도잉은 아이템 높이가 고정일 때만 계산이 단순합니다. 높이가 가변이거나 그리드·가로 스크롤까지 고려하면 react-window, @tanstack/react-virtual 같은 라이브러리가 훨씬 안전합니다. 이 편은 개념을 이해하기 위해 직접 만들고, 실무에서는 라이브러리를 쓰는 판단 기준을 정리합니다.
| 상황 | 선택 |
|---|---|
| 아이템 높이 고정, 목록 하나뿐 | 직접 구현해도 충분 |
| 가변 높이, 그리드, 무한 스크롤과 결합 | 라이브러리 사용 |
| 항목 수가 애초에 적음(수십 개 이하) | 가상화 자체가 불필요 |
실습
1. 더미 데이터 생성 함수 작성
// src/utils/generateDummyBooks.js
const AUTHORS = ['김민준', '이서연', '박도윤', '최지우', '정하은'];
const COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#db2777', '#7c3aed', '#0891b2'];
const STATUSES = ['wish', 'reading', 'done'];
export function generateDummyBooks(count) {
return Array.from({ length: count }, (_, index) => ({
id: `dummy-${index}`,
title: `더미 책 ${index + 1}`,
author: AUTHORS[index % AUTHORS.length],
status: STATUSES[index % STATUSES.length],
rating: index % 6,
pages: 100 + (index % 500),
coverColor: COLORS[index % COLORS.length],
startedAt: null,
finishedAt: null,
memo: '',
}));
}2. 윈도잉 훅 작성
// src/hooks/useVirtualList.js
import { useState } from 'react';
const OVERSCAN = 4;
export function useVirtualList({ itemCount, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - OVERSCAN);
const endIndex = Math.min(itemCount, startIndex + visibleCount + OVERSCAN * 2);
return {
startIndex,
endIndex,
totalHeight: itemCount * itemHeight,
offsetY: startIndex * itemHeight,
onScroll: (e) => setScrollTop(e.target.scrollTop),
};
}startIndex는 스크롤 위치를 아이템 높이로 나눠 몇 번째부터 보여야 하는지 계산합니다. overscan만큼 앞뒤 여유를 둬 빠르게 스크롤할 때 빈 화면이 잠깐 보이는 현상을 줄입니다.
3. 목록 페이지에 적용
// src/pages/BookListPage.jsx (일부)
import { useMemo, useState } from 'react';
import BookCard from '../components/BookCard.jsx';
import { useVirtualList } from '../hooks/useVirtualList.js';
import { generateDummyBooks } from '../utils/generateDummyBooks.js';
const ITEM_HEIGHT = 96;
const CONTAINER_HEIGHT = 480;
function BookListPage() {
// ...09편의 useLocalStorage·useDebounce·filteredBooks·handleToggle·handleDelete 로직 유지
const [useDummyData, setUseDummyData] = useState(false);
const dummyBooks = useMemo(() => generateDummyBooks(1000), []);
const visibleSource = useDummyData ? dummyBooks : filteredBooks;
const { startIndex, endIndex, totalHeight, offsetY, onScroll } = useVirtualList({
itemCount: visibleSource.length,
itemHeight: ITEM_HEIGHT,
containerHeight: CONTAINER_HEIGHT,
});
const visibleBooks = visibleSource.slice(startIndex, endIndex);
return (
<section>
{/* ...09편의 검색 input 유지 */}
<button type="button" onClick={() => setUseDummyData((prev) => !prev)}>
더미 1,000권 {useDummyData ? '끄기' : '켜기'}
</button>
<div
className="book-list-viewport"
style={{ height: CONTAINER_HEIGHT, overflowY: 'auto' }}
onScroll={onScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<ul
className="book-list"
style={{ transform: `translateY(${offsetY}px)`, position: 'absolute', top: 0, width: '100%' }}
>
{visibleBooks.map((book) => (
<li key={book.id} style={{ height: ITEM_HEIGHT }}>
<BookCard book={book} onToggle={handleToggle} onDelete={handleDelete} />
</li>
))}
</ul>
</div>
</div>
</section>
);
}
export default BookListPage;더미 데이터로 만든 책은 handleToggle·handleDelete가 실제 localStorage를 건드리지 않도록, 더미 모드일 때는 두 함수 대신 빈 함수를 넘기는 방식으로 바꿔도 됩니다. 이 편은 가상화 자체에 집중하므로 더미 모드에서는 상태 변경 없이 화면 개수만 확인합니다.
4. 실행
npm run dev5. 확인
- “더미 1,000권 켜기”를 누르면 스크롤바 크기는 1,000권 분량으로 보이지만, 개발자 도구 Elements 패널에서
<li>개수를 세면 20개 안팎입니다. - 스크롤을 내리면 보이는 카드 내용이 실시간으로 바뀝니다.
- 빠르게 스크롤해도 빈 화면이 길게 나타나지 않습니다(overscan 효과).
- “끄기”를 누르면 기존 필터링된 목록으로 돌아옵니다.
직접 해보기
OVERSCAN값을0으로 바꾸고 빠르게 스크롤해 빈 화면이 나타나는지 확인해 보세요.ITEM_HEIGHT를 실제BookCard높이와 다르게 설정하면 어떤 문제가 생기는지 관찰해 보세요.
풀이 보기
OVERSCAN이 0이면 스크롤 속도가 빠를 때 브라우저가 다음 프레임을 그리기 전에 사용자가 이미 다음 범위로 스크롤해, 아직 렌더링되지 않은 빈 영역이 순간적으로 보입니다. 여유분을 두면 이 틈을 줄일 수 있습니다.
ITEM_HEIGHT가 실제 카드 높이와 다르면 totalHeight 계산이 실제 콘텐츠 길이와 어긋나 스크롤바 크기가 부정확해지고, translateY 오프셋도 실제 카드 위치와 맞지 않아 카드가 겹치거나 빈틈이 생깁니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 스크롤해도 카드가 안 바뀜 | onScroll이 실제 스크롤 컨테이너가 아닌 다른 요소에 붙음 | overflowY: auto가 걸린 요소에 직접 연결 |
| 카드끼리 겹쳐 보임 | ITEM_HEIGHT와 실제 카드 높이 불일치 | 카드 높이를 고정하거나 상수를 실제 값에 맞춤 |
| 스크롤바 길이가 이상함 | totalHeight 계산에 itemCount를 잘못 넣음 | itemCount × itemHeight 확인 |
| 목록이 짧은데도 가상화 코드가 들어감 | 항목 수가 적어 이득이 없는데 복잡성만 추가됨 | 수십 개 이하는 일반 map 렌더링 유지 |