가상 스크롤(Virtual Scroll) 완전 정복: 대용량 데이터 렌더링 최적화
📅 가상 스크롤을 도입하게 된 계기
대용량 데이터를 처리해야 하는 프로젝트에서는 수천, 수만 개의 데이터를 한 번에 렌더링하면 성능 저하로 브라우저가 버벅이는 경험을 하게 됩니다. 특히 기존 레거시 시스템이나 특정 라이브러리를 도입할 수 없는 환경에서는 성능 최적화가 필수입니다.
이에 따라 최소한의 DOM만 렌더링하고도 많은 데이터를 효율적으로 보여줄 수 있는 가상 스크롤(Virtual Scroll) 기술을 도입하여 사용자 경험과 성능을 개선하게 되었습니다.
(1) 가상 스크롤은 무엇이고 어떻게 제공 될 것인가?
가상 스크롤은 수천, 수만 개의 데이터가 있어도 화면에 보이는 부분과 약간의 버퍼만 실제 DOM에 렌더링하여 성능을 개선하는 기술로서 x-data-grid 와 같은 MUI의 Grid 시스템에서 제공하기도 하고 여러 라이브러리에서도 가상 스크롤을 무료 혹은 유료로 제공을 하고 있습니다. 제약된 환경에서도 제공이 되어야 하기때문에 핵심 아이디어를 파악하고 기능을 제공함으로서 사용자의 UX를 개선해보았습니다.
핵심 아이디어
- 한번에 1,000개 가져오는 경우 스크롤 시 버벅임으로 인하여 특정 높이를 지정하여 10~20개의 데이터 뿌림
- 페이지네이션을 사용하지 않는 제약 사항
- 스크롤의 픽셀에 의한 스크롤 깜박임 등으로 인해 여유 데이터를 가져 올 수 있는 버퍼 조건이 필요
- 버퍼 조건을 제외한 영역은 스크롤 시 새로 가져오되 스크롤의 현재 위치는 가상의 높이로 지정하여 현재 스크롤바의 위치를 보여줌
(2) 기본 개념 때려 잡기
-
"몇 번째 아이템이 화면 맨 위에 있을까?"
- 사용자가 스크롤할 때 현재 화면의 맨 위에 위치한 아이템의 인덱스를 구하는 것이 핵심입니다.
const startIndex = Math.floor(scrollTop / itemHeight);-
예시:
- 스크롤 위치(scrollTop)가 120px, 아이템 높이가 40px일 때,
120px ÷ 40px = 3→ 화면의 맨 위에 3번째 아이템이 위치
⇒ 이 의미는 **"3번째 아이템이 화면 맨 위에 정확히 위치한다"**는 뜻입니다.
아이템 0: 0px ~ 40px ← 화면 위로 완전히 사라짐 아이템 1: 40px ~ 80px ← 화면 위로 완전히 사라짐 아이템 2: 80px ~ 120px ← 화면 위로 완전히 사라짐 아이템 3: 120px ~ 160px ← 화면 맨 위에 위치! ⭐️ 아이템 4: 160px ~ 200px ...
-
“버퍼를 만들어야하는 이유가 있을까?”
- 버퍼가 없다면, 빠르게 스크롤할 때 화면에 일부 아이템이 갑자기 나타나거나 사라지는 현상이 발생합니다.
- 버퍼 없음 ❌: 화면에 보이는 아이템만 렌더링 → 스크롤 시 깜빡임 발생
- 버퍼 있음 ✅: 화면 위아래로 추가 아이템을 미리 렌더링 → 부드러운 스크롤 가능
- 버퍼를 만들지 않으면 아래와 같은 시나리오가 발생합니다.
const startIndex = Math.floor(scrollTop / virtualItemSize); 📱 화면 영역 (120px ~ 220px) ┌─────────────────────┐ │ 아이템 3 (120-160) │ ← 렌더링됨 ✅ │ 아이템 4 (160-200) │ ← 렌더링됨 ✅ │ 아이템 5 (200-240) │ ← 렌더링됨 ✅ └─────────────────────┘ 🖥️ 실제 DOM에 존재하는 것들: - <div>아이템 3</div> - <div>아이템 4</div> - <div>아이템 5</div> - <div>아이템 6</div> - <div>아이템 7</div> 🚫 DOM에 없는 것들: - 아이템 0, 1, 2는 DOM에 존재하지 않음 📱 화면 영역 (119px ~ 219px) ┌─────────────────────┐ │ 아이템 2 (80-120) │ ← 1px 보임, 그런데 DOM에 없음! ❌ │ 아이템 3 (120-160) │ ← 39px 보임 ✅ │ 아이템 4 (160-200) │ ← 완전히 보임 ✅ │ 아이템 5 (200-240) │ ← 19px 보임 ✅ └─────────────────────┘ 🖥️ DOM 상황: - 아이템 2: 없음! → 빈 공간 또는 깜빡임 💥 - 아이템 3: 있음 ✅ - 아이템 4: 있음 ✅ - 아이템 5: 있음 ✅- 그럼 버퍼를 만들어야 한다!
- 대신 버퍼가 너무 크면 불필요한 DOM 노드로 인한 성능 저하가 발생합니다.
// scrollTop = 120일 때 const startIndex = Math.floor(120 / 40); // = 3 // 렌더링: [3, 4, 5, 6, 7] // scrollTop = 119일 때 const startIndex = Math.floor(119 / 40); // = 2 // 렌더링: [2, 3, 4, 5, 6] // 문제: 아이템 2가 갑자기 필요한데 이전에는 없었음! // scrollTop = 120일 때 const startIndex = Math.max(0, Math.floor(120 / 40) - 10); // = 0 // 렌더링: [0, 1, 2, 3, 4, 5, 6, 7, ...] // scrollTop = 119일 때 const startIndex = Math.max(0, Math.floor(119 / 40) - 10); // = 0 // 렌더링: [0, 1, 2, 3, 4, 5, 6, 7, ...] // 해결: 아이템 2가 이미 있으므로 부드럽게 보임! - 버퍼가 없다면, 빠르게 스크롤할 때 화면에 일부 아이템이 갑자기 나타나거나 사라지는 현상이 발생합니다.
-
"끝 점에 대한 계산은?"
- 그리드의 높이를 더해줍니다!
설정: - scrollTop: 120px (화면의 시작점) - containerHeight: 200px (그리드의 높이) - 아이템 높이: 40px 계산: 1. scrollTop + containerHeight = 120 + 200 = 320px (화면의 끝점) 2. 320 / 40 = 8 (화면 끝까지 8개 아이템이 들어감) 3. Math.ceil(8) = 8 4. endIndex = 8 (0-based이므로 아이템 8까지) 📱 화면 영역 (120px ~ 320px) ┌─────────────────────┐ ← 120px │ 아이템 3 (120-160) │ ← 렌더링됨 ✅ │ 아이템 4 (160-200) │ ← 렌더링됨 ✅ │ 아이템 5 (200-240) │ ← 렌더링됨 ✅ │ 아이템 6 (240-280) │ ← 렌더링됨 ✅ │ 아이템 7 (280-320) │ ← 렌더링됨 ✅ └─────────────────────┘ ← 320px 결과: 화면에 보이는 모든 아이템이 렌더링됨! 🎉 const endIndex = Math.ceil((scrollTop.value + containerHeight.value) / virtualItemSize.value); -
“스페이서 없이 구현하면 어떻게 될까?”
- 실제 렌더링하지 않는 데이터들의 높이를 가상으로 만들어 스크롤바를 현실적으로 유지하는 역할입니다.
- 예를 들면 중간 데이터 값을 스크롤해서 가져왔다고 가정했을때 실제 스크롤은 10개만 딱 보여주는 정도의 스크롤만 존재하게 됩니다. 그렇기 때문에 상단과 하단에 가상의 높이를 부여합니다.
<!-- 전체 데이터: 1000개, 현재 100~110번째 아이템만 렌더링 --> <div class="virtual-list" style="height: 500px; overflow-y: auto;"> <div>아이템 100</div> <div>아이템 101</div> <div>아이템 102</div> <div>아이템 103</div> <div>아이템 104</div> <div>아이템 105</div> <div>아이템 106</div> <div>아이템 107</div> <div>아이템 108</div> <div>아이템 109</div> <div>아이템 110</div> </div> <div class="virtual-list" style="height: 500px; overflow-y: auto;"> <!-- 상단 스페이서: 0~99번 아이템들의 가상 높이 --> <div style="height: 4000px;"></div> <!-- 100개 × 40px = 4000px --> <!-- 실제 렌더링된 아이템들 --> <div style="height: 40px;">아이템 100</div> <div style="height: 40px;">아이템 101</div> <div style="height: 40px;">아이템 102</div> <div style="height: 40px;">아이템 103</div> <div style="height: 40px;">아이템 104</div> <div style="height: 40px;">아이템 105</div> <div style="height: 40px;">아이템 106</div> <div style="height: 40px;">아이템 107</div> <div style="height: 40px;">아이템 108</div> <div style="height: 40px;">아이템 109</div> <div style="height: 40px;">아이템 110</div> <!-- 하단 스페이서: 111~999번 아이템들의 가상 높이 --> <div style="height: 35560px;"></div> <!-- 889개 × 40px = 35560px --> </div> <!-- 총 높이: 4000 + 440 + 35560 = 40000px (1000개 × 40px) ✅ -->
(3) 핵심 계산 로직: 보이는 영역 결정하기
📌 화면 영역 결정
const visibleStartIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferSize);
const visibleEndIndex = Math.min(
totalItems - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + bufferSize
);(4) 스페이서(Spacer) 높이 계산: 가상 높이 만들기
📌 스페이서 높이 계산
const topSpacerHeight = visibleStartIndex * itemHeight;
const bottomSpacerHeight = (totalItems - visibleEndIndex - 1) * itemHeigh(4) 완전한 가상 스크롤 훅 구현
- 실제 구현 내용의 화면 영역과 스페이서 높이 계산, 스크롤 훅 등 생략이 되어 있습니다.
const useGridVirtualizer = ({ virtualScroll, itemHeight, bufferSize, items }) => {
const scrollTop = ref(0);
const containerHeight = ref(0);
const visibleStartIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / itemHeight) - bufferSize)
);
const visibleEndIndex = computed(() =>
Math.min(
items.length - 1,
Math.ceil((scrollTop.value + containerHeight.value) / itemHeight) + bufferSize
)
);
const visibleItems = computed(() =>
items.slice(visibleStartIndex.value, visibleEndIndex.value + 1)
);
const topSpacerHeight = computed(() => visibleStartIndex.value * itemHeight);
const bottomSpacerHeight = computed(() =>
(items.length - visibleEndIndex.value - 1) * itemHeight
);
return { visibleItems, topSpacerHeight, bottomSpacerHeight };
};

마무리
가상 스크롤은 단순해 보이지만, Math.floor/ceil의 미묘한 차이와 버퍼 관리가 부드러운 UX를 만드는 핵심입니다. 이 기술을 통해 1,000개든 10만 개든 동일한 성능으로 렌더링할 수 있으며, 실제로 많은 대형 서비스에서 활용되고 있는 검증된 최적화 기법입니다.
성능 비교:
- 일반 렌더링: 1,000개 DOM 노드 → 브라우저 버벅임
- 가상 스크롤: 20~30개 DOM 노드 → 부드러운 스크롤
리포트:
{
"설정": {
"데이터크기": 5000,
"테스트시간": "2025-07-18T06:50:37.360Z",
"브라우저": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
},
"성능메트릭": {
"virtualOff": {
"initialRender": 4502.699999928474,
"scrollPerformance": [],
"memoryUsage": 175,
"domNodeCount": 150566,
"timestamp": 1752821398699
},
"virtualOn": {
"initialRender": 136,
"scrollPerformance": [],
"memoryUsage": 105,
"domNodeCount": 596,
"timestamp": 1752821402692
}
},
"리포트": {
"📊 성능 비교 리포트": "데이터 5,000개 기준",
"🕐 초기 렌더링": {
"가상화 OFF": "4502.70ms",
"가상화 ON": "136.00ms",
"개선율": "97.0%"
},
"💾 메모리 사용량": {
"가상화 OFF": "175MB",
"가상화 ON": "105MB",
"절약량": "+70MB"
},
"🌳 DOM 노드 수": {
"가상화 OFF": "150,566",
"가상화 ON": "596",
"절약량": "149,970"
},
"🖱️ 스크롤 성능": {
"가상화 OFF": "0 FPS",
"가상화 ON": "0 FPS",
"개선": "0 FPS"
}
}
}이제 여러분도 대용량 데이터를 두려워하지 마세요! 🚀