Vue.js 컴포넌트를 활용한 고성능 가상 스크롤 리스트 구현

가상 리스트(Virtual List)는 대량의 데이터를 효율적으로 표시하기 위한 핵심 기술입니다. 이 기술은 스크롤 시 사용자에게 보이는 영역의 아이템만 실제로 렌더링하고, 화면 밖의 아이템은 렌더링하지 않거나 부분적으로만 처리하여 애플리케이션의 렌더링 성능을 극대화합니다. 특히 수천 개 이상의 항목을 가진 리스트를 다룰 때 성능 저하 없이 부드러운 사용자 경험을 제공할 수 있습니다.

가상 리스트의 기본 원리

가상 리스트의 핵심 개념은 다음과 같습니다:

  • 가시 영역 렌더링: 스크롤 가능한 전체 목록 중 현재 화면에 보이는 항목들만 실제 DOM에 렌더링합니다.
  • 플레이스홀더 요소: 전체 목록의 실제 높이만큼 공간을 차지하는 투명한 요소를 두어 스크롤바가 정상적으로 동작하도록 합니다.
  • 동적 위치 조정: 스크롤이 발생할 때마다 가시 영역에 포함될 항목들을 계산하고, transform 속성을 사용하여 렌더링될 항목 컨테이너의 위치를 동적으로 조정합니다.

고정 높이 아이템을 위한 가상 리스트

모든 리스트 아이템의 높이가 동일하게 고정되어 있다면, 가상 리스트 구현은 비교적 간단해집니다. 각 아이템의 높이를 알고 있으므로, 현재 스크롤 위치를 통해 보이는 아이템의 시작 및 끝 인덱스를 정확하게 계산할 수 있습니다.

주요 계산식은 다음과 같습니다:

  • 전체 목록 높이: 총 아이템 수 * 단일 아이템 높이
  • 가시 영역 내 아이템 수: 뷰포트 높이 / 단일 아이템 높이 (올림 처리)
  • 현재 가시 영역 시작 인덱스: Math.floor(현재 스크롤 위치 / 단일 아이템 높이)
  • 현재 가시 영역 끝 인덱스: 시작 인덱스 + 가시 영역 내 아이템 수
  • 렌더링 오프셋: 현재 스크롤 위치 - (현재 스크롤 위치 % 단일 아이템 높이) (스크롤 위치를 아이템 높이의 배수로 정렬)

Vue.js Composition API를 활용한 고정 높이 가상 리스트 컴포넌트 예시는 다음과 같습니다:

<template>
  <div ref="containerRef" class="virtual-list-container" @scroll="handleScroll">
    <!-- 스크롤바 생성을 위한 전체 높이 플레이스홀더 -->
    <div class="virtual-list-spacer" :style="{ height: totalListHeight + 'px' }"></div>
    
    <!-- 실제 아이템이 렌더링될 컨테이너 -->
    <div class="virtual-list-content" :style="{ transform: `translate3d(0, ${offsetY}px, 0)` }">
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="virtual-list-item"
        :style="{ height: itemFixedHeight + 'px', lineHeight: itemFixedHeight + 'px' }"
      >
        {{ item.value }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

const props = defineProps({
  data: {
    type: Array,
    required: true
  },
  itemFixedHeight: { // 모든 아이템의 고정 높이
    type: Number,
    default: 50
  }
});

const containerRef = ref(null); // 스크롤 컨테이너 DOM 참조
const viewportHeight = ref(0);  // 컨테이너의 실제 뷰포트 높이
const scrollPosition = ref(0);  // 현재 스크롤 위치 (scrollTop)

// 전체 목록의 가상 높이 계산
const totalListHeight = computed(() => props.data.length * props.itemFixedHeight);

// 뷰포트 내에 표시될 수 있는 아이템의 수
const itemsInViewport = computed(() => Math.ceil(viewportHeight.value / props.itemFixedHeight));

// 현재 스크롤 위치에 따른 첫 번째 보이는 아이템의 인덱스
const firstVisibleIndex = computed(() => Math.floor(scrollPosition.value / props.itemFixedHeight));

// 현재 스크롤 위치에 따른 마지막 보이는 아이템의 인덱스
const lastVisibleIndex = computed(() => firstVisibleIndex.value + itemsInViewport.value);

// 실제로 렌더링될 아이템 데이터 (슬라이스)
const visibleItems = computed(() => {
  return props.data.slice(firstVisibleIndex.value, Math.min(lastVisibleIndex.value, props.data.length));
});

// 렌더링 컨테이너의 Y축 오프셋 (transform 값)
const offsetY = computed(() => scrollPosition.value - (scrollPosition.value % props.itemFixedHeight));

// 스크롤 이벤트 핸들러
const handleScroll = () => {
  if (containerRef.value) {
    scrollPosition.value = containerRef.value.scrollTop;
  }
};

// 컴포넌트 마운트 시 뷰포트 높이 초기화
onMounted(() => {
  if (containerRef.value) {
    viewportHeight.value = containerRef.value.clientHeight;
  }
});
</script>

<style scoped>
.virtual-list-container {
  height: 500px; /* 컨테이너 고정 높이 */
  overflow-y: auto; /* 스크롤 활성화 */
  position: relative;
  border: 1px solid #ddd;
}

.virtual-list-spacer {
  position: absolute; /* 스크롤바를 위한 전체 높이 확보 */
  left: 0;
  top: 0;
  right: 0;
  z-index: -1; /* 실제 콘텐츠 뒤로 보내 스크롤 영역에 영향 X */
}

.virtual-list-content {
  position: absolute; /* 실제 렌더링 영역 */
  left: 0;
  right: 0;
  top: 0;
  text-align: center;
  will-change: transform; /* GPU 가속 활성화 */
}

.virtual-list-item {
  box-sizing: border-box;
  border-bottom: 1px solid #eee;
  background-color: #f9f9f9;
}
</style>

동적 높이 아이템 지원

모든 아이템의 높이가 다를 때 가상 리스트를 구현하는 것은 더 복잡합니다. 각 아이템의 실제 높이를 미리 알 수 없기 때문에, 단순히 스크롤 위치와 아이템 높이로 인덱스를 계산할 수 없습니다. 이 경우 다음과 같은 접근 방식이 필요합니다:

  • 예상 높이(estimated height): 각 아이템에 초기 예상 높이를 부여합니다.
  • 아이템 위치 캐싱: 각 아이템의 실제 높이, 상단 위치, 하단 위치를 계산하여 캐시(itemPositions 배열)에 저장합니다.
  • DOM 측정: 아이템이 실제로 렌더링된 후 onUpdated 훅에서 실제 높이를 측정하고 캐시를 업데이트합니다.
  • 이진 탐색: 스크롤 위치에 해당하는 첫 번째 아이템의 인덱스를 찾을 때 캐시된 itemPositions 배열에 대해 이진 탐색을 수행합니다.
  • 오프셋 조정: 렌더링 컨테이너의 transform 오프셋은 첫 번째 보이는 아이템의 이전 아이템까지의 누적 높이를 기반으로 계산됩니다.

Vue.js Composition API를 활용한 동적 높이 가상 리스트 컴포넌트 예시는 다음과 같습니다:

<template>
  <div ref="containerRef" class="virtual-list-container" @scroll="handleScroll">
    <!-- 스크롤바 생성을 위한 전체 높이 플레이스홀더 -->
    <div class="virtual-list-spacer" :style="{ height: totalContentHeight + 'px' }"></div>
    
    <!-- 실제 아이템이 렌더링될 컨테이너 -->
    <div class="virtual-list-content" :style="{ transform: `translate3d(0, ${contentTransformOffset}px, 0)` }">
      <div
        v-for="item in visibleItems"
        :key="item.originalIndex"
        :data-index="item.originalIndex" <!-- 실제 인덱스를 DOM 데이터 속성으로 저장 -->
        class="virtual-list-item"
      >
        {{ item.value }}
        <!-- 동적 높이를 위해 실제 내용이 들어갈 수 있음 -->
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUpdated } from 'vue';

const props = defineProps({
  data: {
    type: Array,
    required: true
  },
  estimatedItemHeight: { // 동적 높이 아이템을 위한 초기 예상 높이
    type: Number,
    default: 50
  },
  bufferRatio: { // 스크롤 시 공백 방지를 위한 버퍼링 비율
    type: Number,
    default: 1
  }
});

const containerRef = ref(null);
const viewportHeight = ref(0);
const scrollPosition = ref(0);
const itemPositions = ref([]); // { index, height, top, bottom } 형태의 아이템 위치 정보 캐시

// --- 초기화 및 위치 관리 ---
// 각 아이템의 초기 예상 위치 정보 설정
const initializePositions = () => {
  itemPositions.value = props.data.map((_, index) => ({
    index,
    height: props.estimatedItemHeight,
    top: index * props.estimatedItemHeight,
    bottom: (index + 1) * props.estimatedItemHeight,
  }));
};

// 전체 콘텐츠의 가상 높이 (마지막 아이템의 bottom 값)
const totalContentHeight = computed(() => {
  if (!itemPositions.value.length) return 0;
  return itemPositions.value[itemPositions.value.length - 1].bottom;
});

// 이진 탐색을 통해 현재 스크롤 위치에 해당하는 첫 번째 아이템의 인덱스 찾기
const findFirstVisibleIndex = (currentScrollTop = 0) => {
  let low = 0;
  let high = itemPositions.value.length - 1;
  let targetIndex = 0; // 기본값은 첫 번째 아이템

  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    const midPosition = itemPositions.value[mid];

    if (midPosition.bottom > currentScrollTop) {
      targetIndex = mid;
      high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return targetIndex;
};

// 렌더링 컨테이너의 Y축 transform 오프셋 계산
const contentTransformOffset = computed(() => {
  if (firstVisibleItemIndex.value === 0) return 0;
  // 오프셋은 첫 번째 보이는 아이템 바로 이전 아이템의 bottom 값
  return itemPositions.value[firstVisibleItemIndex.value - 1]?.bottom || 0;
});

// --- 보이는 아이템 및 버퍼링 ---
const firstVisibleItemIndex = computed(() => findFirstVisibleIndex(scrollPosition.value));
const lastVisibleItemIndex = computed(() => {
  // 대략적인 뷰포트 내 아이템 수 계산 (예상 높이 기반)
  const approxVisibleCount = Math.ceil(viewportHeight.value / props.estimatedItemHeight);
  return firstVisibleItemIndex.value + approxVisibleCount;
});

// 가시 영역 위쪽에 미리 렌더링할 아이템 수 (버퍼링)
const preRenderCount = computed(() =>
  Math.min(
    firstVisibleItemIndex.value, // 시작 인덱스를 넘어서지 않도록
    Math.ceil(props.bufferRatio * (lastVisibleItemIndex.value - firstVisibleItemIndex.value)) // 뷰포트 아이템 수 * 버퍼 비율
  )
);

// 가시 영역 아래쪽에 미리 렌더링할 아이템 수 (버퍼링)
const postRenderCount = computed(() =>
  Math.min(
    props.data.length - lastVisibleItemIndex.value, // 전체 길이를 넘어서지 않도록
    Math.ceil(props.bufferRatio * (lastVisibleItemIndex.value - firstVisibleItemIndex.value))
  )
);

// 버퍼링을 포함하여 실제로 렌더링할 아이템의 시작 인덱스
const renderStartIndex = computed(() => Math.max(0, firstVisibleItemIndex.value - preRenderCount.value));
// 버퍼링을 포함하여 실제로 렌더링할 아이템의 끝 인덱스
const renderEndIndex = computed(() => Math.min(props.data.length, lastVisibleItemIndex.value + postRenderCount.value));

// 실제로 렌더링될 아이템 데이터 (버퍼링 범위 포함)
const visibleItems = computed(() => {
  return props.data.slice(renderStartIndex.value, renderEndIndex.value).map((item, i) => ({
    ...item,
    originalIndex: renderStartIndex.value + i, // 고유 키 및 위치 조회를 위한 원본 인덱스
  }));
});

// --- 스크롤 핸들링 ---
const handleScroll = () => {
  if (containerRef.value) {
    scrollPosition.value = containerRef.value.scrollTop;
  }
};

// --- 라이프사이클 훅 ---
onMounted(() => {
  if (containerRef.value) {
    viewportHeight.value = containerRef.value.clientHeight;
  }
  initializePositions(); // 컴포넌트 마운트 시 초기 위치 정보 설정
});

onUpdated(() => {
  // 데이터 변경 후 DOM이 업데이트될 때마다 실제 렌더링된 아이템의 높이를 측정
  // 측정된 높이를 바탕으로 itemPositions 캐시 업데이트
  if (!containerRef.value) return;

  const renderedNodes = containerRef.value.querySelectorAll('.virtual-list-item');
  renderedNodes.forEach(node => {
    const index = parseInt(node.dataset.index, 10);
    if (isNaN(index) || !itemPositions.value[index]) return;

    const actualHeight = node.offsetHeight; // 실제 렌더링된 높이
    const oldHeight = itemPositions.value[index].height; // 캐시된 예상 높이
    const heightDifference = oldHeight - actualHeight; // 예상 높이와 실제 높이의 차이

    if (heightDifference !== 0) {
      // 높이 변화가 있다면 해당 아이템의 캐시 업데이트
      itemPositions.value[index].height = actualHeight;
      itemPositions.value[index].bottom = itemPositions.value[index].bottom - heightDifference;

      // 해당 아이템 이후의 모든 아이템 위치 조정
      for (let k = index + 1; k < itemPositions.value.length; k++) {
        itemPositions.value[k].top = itemPositions.value[k - 1].bottom;
        itemPositions.value[k].bottom = itemPositions.value[k].bottom - heightDifference;
      }
    }
  });
});
</script>

<style scoped>
.virtual-list-container {
  height: 500px; /* 컨테이너 고정 높이 */
  overflow-y: auto; /* 스크롤 활성화 */
  position: relative;
  border: 1px solid #ddd;
}

.virtual-list-spacer {
  position: absolute; /* 스크롤바를 위한 전체 높이 확보 */
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.virtual-list-content {
  position: absolute; /* 실제 렌더링 영역 */
  left: 0;
  right: 0;
  top: 0;
  text-align: left; /* 텍스트 정렬 */
  will-change: transform; /* GPU 가속 활성화 */
}

.virtual-list-item {
  box-sizing: border-box;
  border-bottom: 1px solid #eee;
  background-color: #f9f9f9;
  padding: 10px 15px; /* 내용에 따라 동적 높이 발생 */
  min-height: 20px; /* 최소 높이 설정 */
}
</style>

스크롤 시 공백 현상 방지 (버퍼링)

동적 높이 가상 리스트 구현 시 스크롤 속도가 빠를 경우, 가시 영역 내에 렌더링될 아이템이 아직 준비되지 않아 일시적인 공백이 발생하는 '화이트 스크린' 현상이 나타날 수 있습니다. 이를 방지하기 위해 버퍼링(Buffering) 기법을 사용합니다.

버퍼링은 가시 영역 외부에 추가적인 아이템들을 미리 렌더링하여 공백 발생 가능성을 줄이는 방법입니다. bufferRatio 속성을 통해 버퍼링할 아이템의 비율을 조정할 수 있으며, 이는 가시 영역 위와 아래에 렌더링될 아이템 수(preRenderCount, postRenderCount)를 결정합니다.

위의 동적 높이 예시 코드에는 이미 bufferRatio 속성과 버퍼링 계산 로직(preRenderCount, postRenderCount, renderStartIndex, renderEndIndex)이 포함되어 있어, 빠른 스크롤에도 안정적인 사용자 경험을 제공하도록 설계되었습니다.

태그: Vue.js 가상 스크롤 프론트엔드 성능 리스트 렌더링 Composition API

8월 2일 10:10에 게시됨