대규모 리스트 렌더링 성능 개선 (가상 스크롤 구현)
문제 정의: 왜 10,000개의 li가 브라우저를 멈추게 하는가
리스트 아이템이 100개 정도면 그냥 .map()으로 전부 렌더링해도 문제없다. 하지만 10,000개가 되면 이야기가 달라진다.
function NaiveList({ items }) {
return (
<ul>
{items.map(item => <ListRow key={item.id} data={item} />)}
</ul>
);
}
이 코드가 느린 이유는 "JS 연산이 오래 걸려서"가 아니다. 진짜 비용은 다음 두 곳에서 발생한다.
- DOM 노드 개수 자체의 비용: 10,000개의 실제 DOM 노드가 존재하면, 브라우저는 스크롤이 발생할 때마다 이 노드들에 대한 Layout/Paint 계산 후보군을 유지해야 한다. 화면에 보이지 않는 9,900개의 노드도 메모리를 점유하고, 일부 CSS 선택자 매칭이나 이벤트 버블링 경로 계산에 영향을 준다.
- React의 재조정(Reconciliation) 비용: 리스트 데이터가 조금이라도 바뀌면(정렬, 필터, 아이템 추가) React는 10,000개 항목에 대해 diffing을 수행해야 한다.
가상 스크롤(Virtual Scroll, 혹은 Windowing)의 핵심 아이디어는 단순하다. 화면에 실제로 보이는 영역(viewport)에 해당하는 아이템만 DOM에 존재하게 만들고, 나머지는 아예 렌더링하지 않는다. 스크롤이 발생하면 "보여야 할 범위"를 다시 계산해서 그 범위의 아이템만 교체한다.
1. 핵심 알고리즘: 보이는 범위 계산
가상 스크롤의 뼈대는 사실 복잡한 라이브러리가 아니라 다음 세 가지 값의 관계를 계산하는 산수다.
scrollTop : 현재 스크롤 위치
itemHeight : 아이템 하나의 높이 (고정 높이 가정)
containerHeight : 뷰포트(스크롤 영역)의 높이
이 세 값만 있으면 "지금 화면에 보여야 할 첫 번째 인덱스"와 "마지막 인덱스"를 즉시 계산할 수 있다.
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight)
);
그리고 전체 리스트가 차지해야 할 가상의 전체 높이를 빈 컨테이너에 부여해서, 스크롤바가 "10,000개짜리 리스트를 스크롤하고 있다"는 착각을 만들어야 한다.
const totalHeight = items.length * itemHeight;
2. 최소 구현: 고정 높이 리스트
function VirtualList({ items, itemHeight = 40, containerHeight = 500 }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const endIndex = Math.min(items.length - 1, startIndex + visibleCount);
const visibleItems = items.slice(startIndex, endIndex + 1);
const totalHeight = items.length * itemHeight;
const offsetY = startIndex * itemHeight; // 보이는 첫 아이템의 실제 y좌표
return (
<div
style={{ height: containerHeight, overflowY: 'auto' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
{/* 전체 스크롤 높이를 흉내내는 빈 박스 */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* 실제로 렌더링되는 부분만 절대 위치로 이동시켜 배치 */}
<div style={{ transform: `translateY(${offsetY}px)`, position: 'absolute', width: '100%' }}>
{visibleItems.map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>
{item.label}
</div>
))}
</div>
</div>
</div>
);
}
여기서 translateY를 쓰는 이유는 앞선 CSS 렌더링 파이프라인 글에서 다룬 내용과 정확히 연결된다. top 대신 transform을 쓰면 Layout을 건너뛰고 Composite 단계만 거치므로, 빠른 스크롤 중에도 위치 갱신이 훨씬 저렴하다.
이 최소 구현만으로도 10,000개 아이템 중 실제 DOM에는 15~20개 정도만 존재하게 된다. 하지만 실무에서는 두 가지 문제가 곧바로 드러난다.
3. 문제 1: 스크롤 시 빈 공간이 깜빡인다 — Overscan 버퍼
빠르게 스크롤하면, 새 아이템이 렌더링되기 전 찰나의 순간 빈 흰 화면이 보이는 경우가 있다. 특히 스크롤 이벤트 처리와 리렌더링 사이에 프레임 지연이 생기면 뚜렷하게 나타난다.
해결책은 실제 뷰포트보다 위아래로 몇 개씩 여유 아이템을 더 렌더링해두는 것이다. 이를 Overscan이라 부른다.
const OVERSCAN = 5;
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - OVERSCAN);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + OVERSCAN
);
Overscan 개수를 너무 크게 잡으면 결국 "일반 렌더링"에 가까워져 최적화 효과가 줄어든다. 보통 화면에 보이는 개수의 20~30% 정도(예: 화면에 15개가 보이면 3~5개)를 위아래로 추가하는 것이 스크롤 속도와 성능 사이의 합리적인 지점이다.
4. 문제 2: 아이템 높이가 제각각이다 — 가변 높이 처리
실무에서 만나는 리스트는 대부분 높이가 고정적이지 않다. 채팅 메시지, 댓글, 카드형 UI처럼 내용에 따라 높이가 달라지는 경우가 훨씬 많다. 이 경우 "인덱스 → 높이"를 미리 알 수 없으므로, 앞서 쓴 단순한 나눗셈 공식이 성립하지 않는다.
4.1 측정 후 캐싱하는 전략
일반적인 해법은 일단 대략적인 예상 높이로 렌더링한 뒤, 실제 렌더링된 높이를 측정해서 캐시에 기록하고, 다음 계산부터는 캐시된 값을 사용하는 것이다.
function useVariableVirtualList(items, estimatedHeight = 50) {
const heightCache = useRef(new Map());
const [, forceUpdate] = useReducer(x => x + 1, 0);
const getItemHeight = (index) => heightCache.current.get(index) ?? estimatedHeight;
const measureItem = (index, node) => {
if (!node) return;
const height = node.getBoundingClientRect().height;
if (heightCache.current.get(index) !== height) {
heightCache.current.set(index, height);
forceUpdate(); // 캐시가 갱신되면 오프셋 재계산을 위해 리렌더 유도
}
};
// 누적 높이(offset)를 계산: 인덱스 i 이전까지의 실제/추정 높이 합
const getOffsetForIndex = (index) => {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += getItemHeight(i);
}
return offset;
};
return { getItemHeight, getOffsetForIndex, measureItem };
}
이 방식의 문제는 getOffsetForIndex가 O(n)이라는 점이다. 인덱스가 5,000이면 매번 5,000번의 덧셈을 반복해야 한다. 실무에서는 이 누적합을 **이진 탐색이 가능한 정렬된 배열(prefix sum)**로 캐싱해서 O(log n)으로 줄이는 방식을 쓴다. react-virtual(현재 @tanstack/react-virtual) 같은 라이브러리가 내부적으로 채택하는 접근이 이와 유사하다.
4.2 ResizeObserver로 측정 시점 개선
getBoundingClientRect()를 렌더링 직후 동기적으로 호출하면, 이는 앞서 CSS 렌더링 글에서 다룬 강제 동기 레이아웃을 유발한다. 아이템이 많을 때 이 비용이 누적되면 오히려 역효과가 난다. 이를 완화하기 위해 ResizeObserver를 사용해 브라우저의 다음 페인트 타이밍에 비동기적으로 높이를 측정하는 방식이 권장된다.
useEffect(() => {
const observer = new ResizeObserver((entries) => {
entries.forEach(entry => {
const index = Number(entry.target.dataset.index);
const height = entry.contentRect.height;
heightCache.current.set(index, height);
});
forceUpdate();
});
rowRefs.current.forEach(node => node && observer.observe(node));
return () => observer.disconnect();
}, [visibleItems]);
5. 문제 3: 스크롤 위치가 튄다 — Scroll Anchoring
가변 높이 리스트에서 위쪽 아이템의 예상 높이가 실제 높이와 다르면, 측정이 완료되는 순간 아래쪽 콘텐츠의 위치가 밀리면서 스크롤이 갑자기 튀는 현상이 발생한다. 채팅 앱에서 위로 스크롤해서 과거 메시지를 불러올 때 흔히 겪는 문제다.
이를 완화하는 방법은, 높이가 갱신될 때 현재 보고 있는 특정 아이템을 기준점(anchor)으로 고정하고, 그 아이템 기준으로 스크롤 위치를 보정하는 것이다.
function adjustScrollForHeightChange(scrollContainer, anchorIndex, oldOffset, newOffset) {
const delta = newOffset - oldOffset;
if (delta !== 0) {
scrollContainer.scrollTop += delta; // 기준점 위치를 유지하도록 스크롤 보정
}
}
브라우저 자체에도 overflow-anchor: auto CSS 속성이 있어 일부 케이스를 자동으로 완화해주지만, 가상 스크롤처럼 DOM을 직접 갈아끼우는 상황에서는 완전히 의존하기 어렵고 위와 같은 수동 보정이 함께 필요한 경우가 많다.
6. 언제 직접 구현하고, 언제 라이브러리를 쓸 것인가
고정 높이 리스트 정도는 위 최소 구현만으로 충분히 실무에 쓸 수 있다. 하지만 가변 높이, 그리드 레이아웃, 무한 스크롤과의 결합, 접근성(키보드 네비게이션, 스크린 리더 대응)까지 고려하면 @tanstack/react-virtual이나 react-window 같은 라이브러리를 쓰는 것이 합리적이다.
상황 선택
| 고정 높이, 단순 리스트 | 직접 구현으로 충분 (의존성 추가 불필요) |
| 가변 높이, 대규모 데이터 | 라이브러리 권장 (prefix sum, 캐싱 전략이 이미 최적화되어 있음) |
| 그리드 가상화, 수평 가상화 동시 필요 | 라이브러리 권장 |
| 접근성 요구사항이 엄격한 경우 | 라이브러리 권장 (포커스 관리, ARIA 처리 포함) |
직접 구현해본 경험이 있으면, 라이브러리를 도입할 때도 내부 동작(prefix sum, overscan, ResizeObserver 기반 측정)을 이해한 상태로 설정값을 조정할 수 있다는 점에서 이 구현 경험 자체가 실무적으로 의미가 있다.
7. 성능 검증
DevTools Performance 탭에서 다음을 비교해보면 개선 효과를 정량적으로 보여줄 수 있다.
- 초기 렌더링 시간: 일반 리스트 vs 가상 스크롤 리스트의 최초 마운트 시 Scripting 시간
- 스크롤 중 FPS: Rendering 서브패널의 "Frame Rendering Stats" 옵션으로 실시간 FPS 확인
- DOM 노드 개수: Elements 패널에서 리스트 컨테이너 하위 노드 개수를 직접 비교 (10,000개 vs 20개 내외)
이 수치들을 Before/After로 기록해두면, 왜 이 최적화가 필요했는지를 숫자로 설명할 수 있다.
참고 자료
'TroubleShooting' 카테고리의 다른 글
| [TroubleShooting] 대규모 리스트 렌더링 성능 개선 (0) | 2026.07.13 |
|---|---|
| [TroubleShooting] 메모리 누수 디버깅하기 (1) | 2026.07.13 |
| [TroubleShooting] 레이스 컨디션 버그를 해결한 경험 (0) | 2026.07.07 |
| [TroubleShooting] 번들 사이즈 30% 줄인 과정 (0) | 2026.07.07 |
| [TroubleShooting] 3D 지형 종/횡단면 분석 기능 이슈 해결 (0) | 2026.07.06 |