본문으로 건너뛰기

[Lv3] Virtual Scroll 구현: 대량 데이터 렌더링 처리

페이지에 1000건 이상의 데이터를 렌더링해야 할 때, Virtual Scroll은 DOM 노드를 1000+에서 20-30개로 줄이고, 메모리 사용을 80% 감소시킵니다.


면접 상황 문제

Q: 화면에 테이블이 하나가 아니고, 각각 100건 이상의 데이터가 있으면서 동시에 빈번한 DOM 업데이트 이벤트가 있다면, 어떤 방법으로 이 페이지의 성능을 최적화하시겠습니까?


문제 분석 (Situation)

실제 프로젝트 시나리오

플랫폼 프로젝트에서 대량 데이터를 처리해야 하는 페이지:

📊 특정 거래 내역 페이지
├─ 충전 기록 테이블: 1000건 이상
├─ 출금 기록 테이블: 800건 이상
├─ 베팅 기록 테이블: 5000건 이상
└─ 각 기록 8-10개 필드 (시간, 금액, 상태 등)

❌ 미최적화 문제
├─ DOM 노드 수: 1000건 × 10필드 = 10,000개 이상 노드
├─ 메모리 점유: 약 150-200 MB
├─ 첫 렌더링 시간: 3-5초 (빈 화면)
├─ 스크롤 버벅거림: FPS < 20
└─ WebSocket 업데이트 시: 전체 테이블 리렌더링 (매우 느림)

문제 심각성

// ❌ 전통적 방법
<tr v-for="record in allRecords"> // 1000건 이상 전부 렌더링
<td>{{ record.time }}</td>
<td>{{ record.amount }}</td>
// ... 8-10개 필드
</tr>

// 결과:
// - 초기 렌더링: 10,000개 이상 DOM 노드
// - 사용자가 실제로 보는 것: 20-30건
// - 낭비: 99%의 노드를 사용자가 전혀 볼 수 없음

솔루션 (Action)

Virtual Scrolling (가상 스크롤)

Virtual Scroll 최적화를 먼저 고려하면, 크게 두 가지 방향이 있습니다. 하나는 공식 지원 서드파티 패키지인 vue-virtual-scroller를 선택하여 파라미터와 요구사항에 따라 가시 범위의 row를 결정하는 것입니다.

// 가시 영역의 row만 렌더링, 예:
// - 100건 데이터에서 가시적인 20건만 렌더링
// - DOM 노드 수를 대폭 감소

다른 하나는 직접 구현하는 것이지만, 실제 개발 비용과 커버해야 할 시나리오를 고려하면 공식 지원 서드파티 패키지를 채택하는 것이 더 나을 것 같습니다.

데이터 업데이트 빈도 제어

해결법 1: requestAnimationFrame (RAF) 개념: 브라우저는 초당 최대 60회 리페인트(60 FPS)하며, 더 빠르게 업데이트해도 사람의 눈으로 볼 수 없으므로 화면 주사율에 맞춰 업데이트

// ❌ 기존: 데이터를 수신하면 즉시 업데이트 (초당 100회 가능)
socket.on('price', (newPrice) => {
btcPrice.value = newPrice;
});

// ✅ 개선: 데이터를 모으고, 화면 주사율에 맞춰 한 번에 업데이트 (초당 최대 60회)
let latestPrice = null;
let isScheduled = false;

socket.on('price', (newPrice) => {
latestPrice = newPrice; // 최신 가격 임시 저장

if (!isScheduled) {
isScheduled = true;
requestAnimationFrame(() => {
btcPrice.value = latestPrice; // 브라우저가 리페인트할 때 업데이트
isScheduled = false;
});
}
});

해결법 2: Throttle (절류) 개념: 업데이트 빈도를 강제 제한, 예를 들어 "100ms당 최대 1회 업데이트"

// lodash의 throttle (프로젝트에서 사용하는 경우)
import { throttle } from 'lodash-es';

const updatePrice = throttle((newPrice) => {
btcPrice.value = newPrice;
}, 100); // 100ms당 최대 1회 실행

socket.on('price', updatePrice);

Vue3 전용 최적화

Vue3의 일부 문법 설탕이 성능 최적화를 제공합니다. 예를 들어 v-memo이지만, 이 시나리오는 개인적으로 거의 사용하지 않습니다.

// 1. v-memo - 자주 변하지 않는 열 메모이제이션
<tr v-for="row in data"
:key="row.id"
v-memo="[row.price, row.volume]"> // 이 필드가 변경될 때만 리렌더링
</tr>

// 2. 정적 데이터 동결, 반응성 오버헤드 방지
const staticData = Object.freeze(largeDataArray)

// 3. shallowRef로 대규모 배열 처리
const tableData = shallowRef([...]) // 배열 자체만 추적, 내부 객체는 추적하지 않음

// 4. key로 diff 알고리즘 최적화 (고유 id로 각 item 추적, DOM 업데이트를 변경된 노드로 한정)
<tr v-for="row in data" :key="row.id"> // 안정적인 key**

RAF: 화면 주사율에 맞춤 (약 16ms), 애니메이션, 스크롤에 적합 Throttle: 커스텀 간격 (예: 100ms), 검색, resize에 적합

DOM 렌더링 최적화

// CSS transform 사용 (top/left 대신)
.row-update {
transform: translateY(0); /* GPU 가속 트리거 */
will-change: transform; /* 브라우저에 최적화 힌트 */
}

// CSS containment로 렌더링 범위 격리
.table-container {
contain: layout style paint;
}

최적화 성과 (Result)

성능 비교

지표최적화 전최적화 후개선 폭
DOM 노드 수10,000+20-30↓ 99.7%
메모리 사용150-200 MB30-40 MB↓ 80%
첫 렌더링3-5초0.3-0.5초↑ 90%
스크롤 FPS< 2055-60↑ 200%
업데이트 응답500-800 ms16-33 ms↑ 95%

실제 효과

✅ Virtual Scroll
├─ 가시적인 20-30건만 렌더링
├─ 스크롤 시 가시 범위 동적 업데이트
├─ 사용자 인지 불가 (경험 매끄러움)
└─ 메모리 안정 (데이터량에 따라 증가하지 않음)

✅ RAF 데이터 업데이트
├─ WebSocket 초당 100회 업데이트 → 최대 60회 렌더링
├─ 화면 주사율과 동기화 (60 FPS)
└─ CPU 사용 60% 감소

✅ Vue3 최적화
├─ v-memo: 불필요한 리렌더링 방지
├─ shallowRef: 반응성 오버헤드 감소
└─ 안정적인 :key: diff 알고리즘 최적화

면접 포인트

자주 묻는 확장 질문

Q: 서드파티 라이브러리를 사용할 수 없다면? A: Virtual Scroll의 핵심 로직을 직접 구현합니다:

// 핵심 개념
const itemHeight = 50; // 각 행 높이
const containerHeight = 600; // 컨테이너 높이
const visibleCount = Math.ceil(containerHeight / itemHeight); // 가시 수량

// 현재 어떤 항목을 표시해야 하는지 계산
const scrollTop = container.scrollTop;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;

// 가시 범위만 렌더링
const visibleItems = allItems.slice(startIndex, endIndex);

// padding으로 높이 보상 (스크롤바 올바르게 표시)
const paddingTop = startIndex * itemHeight;
const paddingBottom = (allItems.length - endIndex) * itemHeight;

핵심 포인트:

  • 가시 범위 계산 (startIndex → endIndex)
  • 데이터 동적 로딩 (slice)
  • 높이 보상 (padding top/bottom)
  • 스크롤 이벤트 감시 (throttle 최적화)

Q: WebSocket 연결 끊김 재연결은 어떻게 처리하나요? A: 지수 백오프 재연결 전략 구현:

let retryCount = 0;
const maxRetries = 5;
const baseDelay = 1000; // 1초

function reconnect() {
if (retryCount >= maxRetries) {
showError('연결할 수 없습니다. 페이지를 새로고침해 주세요');
return;
}

// 지수 백오프: 1s → 2s → 4s → 8s → 16s
const delay = baseDelay * Math.pow(2, retryCount);

setTimeout(() => {
retryCount++;
connectWebSocket();
}, delay);
}

// 재연결 성공 시
socket.on('connect', () => {
retryCount = 0; // 카운트 리셋
syncData(); // 데이터 동기화
showSuccess('연결이 복구되었습니다');
});

Q: 성능 최적화 효과를 어떻게 테스트하나요? A: 여러 도구 조합:

// 1. Performance API로 FPS 측정
let lastTime = performance.now();
let frames = 0;

function measureFPS() {
frames++;
const currentTime = performance.now();
if (currentTime >= lastTime + 1000) {
console.log(`FPS: ${frames}`);
frames = 0;
lastTime = currentTime;
}
requestAnimationFrame(measureFPS);
}

// 2. Memory Profiling (Chrome DevTools)
// - 렌더링 전 스냅샷
// - 렌더링 후 스냅샷
// - 메모리 차이 비교

// 3. Lighthouse / Performance Tab
// - Long Task 시간
// - Total Blocking Time
// - Cumulative Layout Shift

// 4. 자동화 테스트 (Playwright)
const { test } = require('@playwright/test');

test('virtual scroll performance', async ({ page }) => {
await page.goto('/records');

// 첫 렌더링 시간 측정
const renderTime = await page.evaluate(() => {
const start = performance.now();
// 렌더링 트리거
const end = performance.now();
return end - start;
});

expect(renderTime).toBeLessThan(500); // < 500ms
});

Q: Virtual Scroll의 단점은 무엇인가요? A: Trade-off 주의:

❌ 단점
├─ 브라우저 네이티브 검색(Ctrl+F) 사용 불가
├─ "전체 선택" 기능 사용 불가 (특수 처리 필요)
├─ 구현 복잡도 높음
├─ 고정 높이 또는 사전 높이 계산 필요
└─ 접근성(Accessibility) 추가 처리 필요

✅ 적합 시나리오
├─ 데이터량 > 100건
├─ 각 데이터 구조 유사 (높이 고정)
├─ 고성능 스크롤 필요
└─ 조회 위주 (편집 아님)

❌ 부적합 시나리오
├─ 데이터량 < 50건 (과잉 설계)
├─ 높이 가변 (구현 어려움)
├─ 대량 인터랙션 필요 (다중 선택, 드래그 등)
└─ 전체 테이블 인쇄 필요

Q: 높이가 일정하지 않은 목록은 어떻게 최적화하나요? A: 동적 높이 Virtual Scroll 사용:

// 방안 1: 예상 높이 + 실제 측정
const estimatedHeight = 50; // 예상 높이
const measuredHeights = {}; // 실제 높이 기록

// 렌더링 후 측정
onMounted(() => {
const elements = document.querySelectorAll('.list-item');
elements.forEach((el, index) => {
measuredHeights[index] = el.offsetHeight;
});
});

// 방안 2: 동적 높이 지원 패키지 사용
// vue-virtual-scroller의 dynamic-height 지원
<DynamicScroller
:items="items"
:min-item-size="50" // 최소 높이
:buffer="200" // 버퍼 영역
/>

기술 비교

Virtual Scroll vs 페이징

비교 항목Virtual Scroll전통적 페이징
사용자 경험연속 스크롤 (더 좋음)페이지 전환 필요 (중단)
성능항상 가시 범위만 렌더링페이지당 전부 렌더링
구현 난이도비교적 복잡간단
SEO 친화낮음높음
접근성특수 처리 필요네이티브 지원

권장 사항:

  • 백오피스 시스템, Dashboard → Virtual Scroll
  • 공개 웹사이트, 블로그 → 전통적 페이징
  • 혼합 방안: Virtual Scroll + "더 보기" 버튼