sehyun.dev
Vue성능개선

가상 스크롤(Virtual Scroll) 완전 정복: 대용량 데이터 렌더링 최적화

2025년 7월 18일

📅 가상 스크롤을 도입하게 된 계기

대용량 데이터를 처리해야 하는 프로젝트에서는 수천, 수만 개의 데이터를 한 번에 렌더링하면 성능 저하로 브라우저가 버벅이는 경험을 하게 됩니다. 특히 기존 레거시 시스템이나 특정 라이브러리를 도입할 수 없는 환경에서는 성능 최적화가 필수입니다.

이에 따라 최소한의 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 };
};

image.png

image.png

마무리

가상 스크롤은 단순해 보이지만, 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"
    }
  }
}

이제 여러분도 대용량 데이터를 두려워하지 마세요! 🚀