대규모 리스트 렌더링 성능 개선: 가상 스크롤(Virtual Scroll) 직접 구현하기
"리스트에 데이터 좀 늘었다고 스크롤이 이렇게 버벅여도 되나요?" — 운영팀 문의를 받고 콘솔을 열어보니, DOM 노드 수가 12,000개를 넘어가고 있었다.
🔍 문제 상황
담당하던 관리자 페이지에는 거래 내역을 테이블 형태로 보여주는 화면이 있었다. 초기 설계 당시엔 페이지네이션으로 20건씩 끊어서 보여줬지만, 기획 변경으로 "스크롤하면서 계속 이어서 보고 싶다" 는 요구사항이 추가되며 무한 스크롤 방식으로 바뀌었다.
문제는 데이터가 누적될수록 심각해졌다.
- 데이터 500건: 큰 문제 없음
- 데이터 3,000건: 스크롤 시 미세한 끊김
- 데이터 8,000건 이상: 스크롤할 때마다 프레임이 뚝뚝 끊기고, 필터를 바꾸면 화면이 1~2초씩 멈춤
React 컴포넌트 개수가 늘어나는 만큼 브라우저가 힘들어하는 건 당연했다. 진짜 문제는 **"화면에 보이지도 않는 8,000개의 행을 전부 DOM에 그리고 있다"**는 것이었다.
1단계: 성능 병목부터 정확히 진단하기
감으로 "가상 스크롤 도입하면 되겠지"라고 바로 결정하지 않고, Chrome DevTools Performance 탭으로 먼저 원인을 숫자로 확인했다.
[리스트 8,000건 초기 렌더링 프로파일링 결과]
Scripting : 2,840ms
Rendering : 1,120ms
Painting : 640ms
Total Blocking Time : 3,200ms ← 🚨
FPS (스크롤 중) : 평균 11fps (목표: 60fps)
Total Blocking Time이 3.2초라는 건 사용자가 그 시간 동안 아무 인터랙션도 할 수 없다는 뜻이다. React DevTools의 Profiler로 렌더 트리를 열어보니, 리스트 아이템 8,000개가 각각 개별 컴포넌트로 마운트되어 있었다.
💡 왜 React.memo만으로는 부족했나? 이미 각 행에 React.memo를 적용해 불필요한 리렌더링은 막아둔 상태였다. 하지만 이건 "다시 그리는" 비용을 줄일 뿐, 애초에 8,000개의 DOM 노드가 존재하는 것 자체의 비용(레이아웃 계산, 페인팅, 메모리)은 해결하지 못했다. 근본 원인은 리렌더링이 아니라 DOM 노드 개수 그 자체였다.
2단계: 왜 가상 스크롤인가
결론은 명확했다. **"보이는 영역(viewport)의 행만 실제로 렌더링하고, 나머지는 화면에서 사라진 것처럼 처리"**하는 가상 스크롤(Virtual Scroll / Windowing)이 필요했다.
라이브러리(react-window, react-virtualized)를 붙이는 방법도 있었지만, 팀에서 사용하던 테이블에 가변 높이 행(row) 과 행 내부 확장(expand) 기능이 이미 존재해서, 커스텀 요구사항을 맞추기 위해 핵심 로직을 직접 구현하기로 했다.
가상 스크롤의 핵심 원리
전체 컨테이너 높이 = 전체 아이템 개수 × 평균 아이템 높이
(스크롤바가 정상적으로 동작하도록 "가짜 높이"를 만든다)
현재 스크롤 위치(scrollTop)를 기준으로
→ 화면에 보여야 할 시작 인덱스(startIndex)와 끝 인덱스(endIndex)를 계산
→ 그 구간의 아이템만 실제로 렌더링
→ 나머지는 위/아래 padding(spacer)으로 공간만 차지하게 만든다
3단계: 구현 — 기본 버전
function VirtualList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
// 화면에 보여줄 범위 계산
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const endIndex = Math.min(items.length, startIndex + visibleCount);
const visibleItems = items.slice(startIndex, endIndex);
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{ height: containerHeight, overflowY: 'auto', position: 'relative' }}
>
{/* 전체 스크롤 영역 확보용 spacer */}
<div style={{ height: items.length * itemHeight }}>
<div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
{visibleItems.map((item) => (
<Row key={item.id} data={item} height={itemHeight} />
))}
</div>
</div>
</div>
);
}
기본 버전을 붙이자마자 프로파일러 상 렌더링 컴포넌트 수가 8,000개 → 30개 수준으로 줄었다. 하지만 실제 사용해보니 문제가 세 가지 더 남아 있었다.
4단계: 트러블슈팅 — 세 가지 추가 이슈
문제 1) 빠르게 스크롤하면 흰 화면이 번쩍임 (Overscan 부재)
startIndex와 endIndex에 딱 맞춰서만 렌더링하니, 빠르게 스크롤할 때 새 행이 그려지기 전에 빈 공간이 순간적으로 노출됐다.
해결: 위아래로 여분의 행을 미리 렌더링하는 overscan을 추가했다.
const OVERSCAN = 5;
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - OVERSCAN);
const endIndex = Math.min(items.length, startIndex + visibleCount + OVERSCAN * 2);
문제 2) 행마다 높이가 달라서 위치 계산이 틀어짐
일부 행은 내용이 길어 2줄로 렌더링되는 등, 고정 높이 가정이 깨지는 케이스가 있었다. 처음엔 모든 행 높이를 고정값으로 계산했더니, 실제 가변 높이 행 아래로 갈수록 스크롤 위치와 실제 화면이 어긋났다.
해결: ResizeObserver로 각 행의 실제 렌더링 높이를 측정해 캐싱하고, 누적 높이 배열을 유지하는 방식으로 변경했다.
const rowHeights = useRef(new Map()); // id → 실제 높이 캐시
function measureRow(id, node) {
if (node) {
const height = node.getBoundingClientRect().height;
rowHeights.current.set(id, height);
}
}
// 누적 offset 계산 (실측 높이 기반)
function getItemOffset(index) {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += rowHeights.current.get(items[i].id) ?? estimatedHeight;
}
return offset;
}
⚠️ 매 렌더링마다 누적 합을 처음부터 다시 계산하면 O(n)이 반복되어 또 다른 병목이 생긴다. 실제로는 prefix sum 배열을 별도로 캐싱하고, 특정 행의 높이가 바뀔 때만 그 이후 구간을 갱신하도록 최적화했다.
문제 3) 필터 변경 시 스크롤 위치가 이상하게 유지됨
필터를 바꿔 리스트 내용이 완전히 달라졌는데도, 이전 scrollTop 값이 그대로 남아 엉뚱한 위치가 보이는 문제가 있었다.
해결: 필터링 조건(items 배열의 identity)이 바뀌면 스크롤 위치와 높이 캐시를 초기화하도록 useEffect로 처리했다.
useEffect(() => {
containerRef.current?.scrollTo(0, 0);
rowHeights.current.clear();
}, [filterKey]); // 필터 조건이 바뀔 때만 리셋
5단계: 결과 검증
동일한 8,000건 데이터로 Before/After를 비교했다.
지표 적용 전 적용 후
| 초기 렌더링 DOM 노드 수 | 8,000+ | 약 40개 (overscan 포함) |
| Total Blocking Time | 3,200ms | 180ms |
| 스크롤 중 평균 FPS | 11fps | 58fps |
| 초기 렌더링 소요 시간 | 2,840ms | 90ms |
| 메모리 사용량(힙) | 210MB | 68MB |
Performance 탭의 프레임 그래프도 빨간 막대(Long Task)로 가득했던 화면에서, 스크롤 내내 초록색 막대가 유지되는 그래프로 바뀌었다.
정리: 이번 경험에서 얻은 체크리스트
- "느리다"는 감을 숫자로 바꾸는 게 먼저다. Performance 탭의 Total Blocking Time, FPS, DOM 노드 수를 먼저 측정하고 병목 지점을 특정한다.
- React.memo는 리렌더링 비용을 줄일 뿐, DOM 노드 개수 자체의 비용은 줄이지 못한다. 이 둘을 구분해서 접근해야 한다.
- 가상 스크롤은 "보이는 것만 그린다"는 단순한 아이디어지만, overscan, 가변 높이, 상태 리셋 세 가지를 처리하지 않으면 실사용에서 바로 문제가 드러난다.
- 라이브러리를 쓸지 직접 구현할지는 커스텀 요구사항의 복잡도로 판단한다. 요구사항이 단순하면 라이브러리가 정답일 수 있다.
- 최적화는 항상 Before/After 수치를 남겨야 팀에 설득력 있게 공유할 수 있다.
성능 개선 작업은 체감으로 끝내면 재현도, 회고도 어렵다. 이번에도 프로파일링 데이터를 꾸준히 남겨둔 덕분에, 이후 비슷한 리스트 화면에 가상 스크롤을 적용할 때 훨씬 빠르게 판단할 수 있었다.
읽어주셔서 감사합니다. 가상 스크롤 관련해서 더 궁금하신 케이스(무한 스크롤과의 조합, 접근성 이슈 등)가 있다면 댓글로 남겨주세요 🙌
'TroubleShooting' 카테고리의 다른 글
| [TroubleShooting] 프로토타입, 디바운싱, 쓰로틀링 사례 (1) | 2026.07.15 |
|---|---|
| [TroubleShooting] 메모리 누수 디버깅하기 (1) | 2026.07.13 |
| [TroubleShooting] 대규모 리스트 렌더링 성능 개선 (0) | 2026.07.07 |
| [TroubleShooting] 레이스 컨디션 버그를 해결한 경험 (0) | 2026.07.07 |
| [TroubleShooting] 번들 사이즈 30% 줄인 과정 (0) | 2026.07.07 |