Vue 3 가상 스크롤을 활용한 대용량 리스트 최적화 구현

1. 고정 높이 가상 리스트 컴포넌트

대용량 데이터를 효율적으로 렌더링하기 위한 Vue 3 가상 스크롤 컴포넌트를 구현합니다. 화면에 보이는 항목만 렌더링하여 성능을 최적화합니다.

<template>
  <div
    ref="wrapperRef"
    class="virtual-scroll-container"
    @scroll="onScrollHandler"
  >
    <div
      class="virtual-scroll-spacer"
      :style="{ height: scrollableHeight + 'px' }"
    ></div>
    
    <div
      class="virtual-scroll-viewport"
      :style="{ transform: `translateY(${yOffset}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="getRowKey(row)"
        class="virtual-scroll-row"
        :style="{ height: rowHeight + 'px' }"
      >
        <slot :item="row" :index="row.__index" />
      </div>
    </div>
  </div>
</template>

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

const props = defineProps({
  dataSource: {
    type: Array,
    required: true
  },
  rowHeight: {
    type: Number,
    default: 50
  },
  viewportHeight: {
    type: Number,
    default: 400
  },
  overscanCount: {
    type: Number,
    default: 5
  },
  keyField: {
    type: [String, Function],
    default: 'id'
  }
})

const wrapperRef = ref(null)
const currentScroll = ref(0)

const visibleRange = computed(() => {
  const startIdx = Math.max(
    0,
    Math.floor(currentScroll.value / props.rowHeight) - props.overscanCount
  )
  const endIdx = Math.min(
    props.dataSource.length - 1,
    startIdx + Math.ceil(props.viewportHeight / props.rowHeight) + props.overscanCount * 2
  )
  return { startIdx, endIdx }
})

const visibleRows = computed(() => {
  const { startIdx, endIdx } = visibleRange.value
  return props.dataSource.slice(startIdx, endIdx + 1).map((item, idx) => ({
    ...item,
    __index: startIdx + idx
  }))
})

const scrollableHeight = computed(() => props.dataSource.length * props.rowHeight)

const yOffset = computed(() => visibleRange.value.startIdx * props.rowHeight)

const getRowKey = (row) => {
  if (typeof props.keyField === 'function') {
    return props.keyField(row)
  }
  return row[props.keyField]
}

const onScrollHandler = (event) => {
  currentScroll.value = event.target.scrollTop
}

const jumpToIndex = (index) => {
  if (wrapperRef.value) {
    wrapperRef.value.scrollTop = index * props.rowHeight
  }
}

const jumpToItem = (targetItem) => {
  const idx = props.dataSource.findIndex(item => getRowKey(item) === getRowKey(targetItem))
  if (idx !== -1) {
    jumpToIndex(idx)
  }
}

defineExpose({
  jumpToIndex,
  jumpToItem
})
</script>

<style scoped>
.virtual-scroll-container {
  height: v-bind('props.viewportHeight + "px"');
  overflow: auto;
  position: relative;
}

.virtual-scroll-spacer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtual-scroll-viewport {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtual-scroll-row {
  position: absolute;
  width: 100%;
  box-sizing: border-box;
}
</style>

2. 동적 높이 가상 리스트

항목마다 높이가 다른 경우, 실제 DOM 높이를 측정하여 정확한 위치를 계산합니다.

2.1 위치 추적 시스템

<script setup>
import { ref, computed, nextTick, watch } from 'vue'

const props = defineProps({
  dataSource: Array,
  viewportHeight: Number,
  estimatedRowHeight: { type: Number, default: 50 },
  overscanCount: { type: Number, default: 5 },
  keyField: { type: [String, Function], default: 'id' }
})

const wrapperRef = ref(null)
const rowRefs = ref([])
const scrollPos = ref(0)

const positionCache = ref([])
const heightStore = ref(new Map())

function initializePositions() {
  positionCache.value = props.dataSource.map((item, idx) => {
    const key = resolveKey(item)
    const cached = heightStore.value.get(key)
    return {
      idx,
      key,
      top: idx * props.estimatedRowHeight,
      height: cached || props.estimatedRowHeight,
      bottom: (idx + 1) * props.estimatedRowHeight
    }
  })
}

function rebuildPositions() {
  let accTop = 0
  positionCache.value = props.dataSource.map((item, idx) => {
    const key = resolveKey(item)
    const h = heightStore.value.get(key) || props.estimatedRowHeight
    const entry = { idx, key, top: accTop, height: h, bottom: accTop + h }
    accTop += h
    return entry
  })
}

const totalContentHeight = computed(() => {
  if (positionCache.value.length === 0) return 0
  return positionCache.value[positionCache.value.length - 1].bottom
})

function findBoundary(scrollValue) {
  let lo = 0, hi = positionCache.value.length - 1, result = -1
  while (lo <= hi) {
    const mid = Math.floor((lo + hi) / 2)
    if (positionCache.value[mid].bottom >= scrollValue) {
      result = mid
      hi = mid - 1
    } else {
      lo = mid + 1
    }
  }
  return result === -1 ? positionCache.value.length - 1 : result
}

const visibleSegment = computed(() => {
  const startIdx = Math.max(0, findBoundary(scrollPos.value) - props.overscanCount)
  const endIdx = Math.min(
    props.dataSource.length - 1,
    findBoundary(scrollPos.value + props.viewportHeight) + props.overscanCount
  )
  return { startIdx, endIdx }
})

const activeItems = computed(() => {
  const { startIdx, endIdx } = visibleSegment.value
  return props.dataSource.slice(startIdx, endIdx + 1).map((item, idx) => ({
    ...item,
    __index: startIdx + idx
  }))
})

function resolveKey(item) {
  if (typeof props.keyField === 'function') return props.keyField(item)
  return item[props.keyField]
}

function handleScroll(e) {
  scrollPos.value = e.target.scrollTop
}

function measureRowHeights() {
  nextTick(() => {
    let changed = false
    rowRefs.value.forEach(el => {
      if (!el) return
      const idx = parseInt(el.dataset.index)
      const item = props.dataSource[idx]
      if (!item) return
      const key = resolveKey(item)
      const actualHeight = el.offsetHeight
      const prevHeight = heightStore.value.get(key)
      if (prevHeight !== actualHeight) {
        heightStore.value.set(key, actualHeight)
        changed = true
      }
    })
    if (changed) rebuildPositions()
  })
}

watch(() => props.dataSource, () => {
  initializePositions()
}, { deep: true })

watch(activeItems, () => {
  measureRowHeights()
})

onMounted(() => initializePositions())
</script>

3. 무한 스크롤 가상 리스트

3.1 데이터 로딩 통합

<template>
  <div class="infinite-scroll-container">
    <VirtualScroll
      ref="scrollRef"
      :data-source="displayData"
      :row-height="rowHeight"
      :viewport-height="containerHeight"
      :overscan-count="overscanCount"
      @scroll="onVirtualScroll"
    >
      <template #default="{ item, index }">
        <slot :item="item" :index="index" :is-loading="item.__loading" />
      </template>
    </VirtualScroll>
    
    <div v-if="isLoading" class="spinner-area">
      데이터 불러오는 중...
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import VirtualScroll from './VirtualScroll.vue'

const props = defineProps({
  dataFetcher: { type: Function, required: true },
  rowHeight: { type: Number, default: 50 },
  containerHeight: { type: Number, default: 400 },
  overscanCount: { type: Number, default: 10 },
  batchSize: { type: Number, default: 50 },
  loadThreshold: { type: Number, default: 100 }
})

const emit = defineEmits(['load-more', 'data-loaded'])

const scrollRef = ref(null)
const fullData = ref([])
const page = ref(0)
const isLoading = ref(false)
const hasMoreData = ref(true)
const currentScrollTop = ref(0)

const displayData = computed(() => fullData.value)

async function fetchNextBatch() {
  if (isLoading.value || !hasMoreData.value) return
  
  isLoading.value = true
  try {
    page.value++
    
    // 임시 로딩 표시 추가
    fullData.value.push({
      id: `loading-${Date.now()}`,
      __loading: true
    })
    
    const freshData = await props.dataFetcher(page.value, props.batchSize)
    
    // 로딩 표시 제거
    fullData.value = fullData.value.filter(item => !item.__loading)
    
    if (freshData.length === 0) {
      hasMoreData.value = false
      return
    }
    
    fullData.value.push(...freshData)
    emit('data-loaded', freshData, page.value)
    
    // 초기 화면 채우기
    if (fullData.value.length < props.batchSize * 2) {
      await fetchNextBatch()
    }
  } catch (err) {
    console.error('데이터 로딩 실패:', err)
    fullData.value = fullData.value.filter(item => !item.__loading)
    page.value--
  } finally {
    isLoading.value = false
  }
}

function onVirtualScroll(event) {
  currentScrollTop.value = event.target.scrollTop
  
  const { scrollHeight, clientHeight } = event.target
  const bottomGap = scrollHeight - currentScrollTop.value - clientHeight
  
  if (bottomGap < props.loadThreshold && hasMoreData.value && !isLoading.value) {
    emit('load-more', page.value + 1)
    fetchNextBatch()
  }
}

async function refresh() {
  fullData.value = []
  page.value = 0
  hasMoreData.value = true
  await fetchNextBatch()
}

function scrollToStart() {
  scrollRef.value?.jumpToIndex(0)
}

defineExpose({ refresh, scrollToStart })

onMounted(() => fetchNextBatch())
</script>

4. 성능 최적화 전략

4.1 ShallowRef 활용

import { shallowRef } from 'vue'

const massiveDataset = shallowRef([])

function bulkUpdate(newSet) {
  massiveDataset.value = newSet
}

4.2 스크롤 이벤트 디바운스

import { throttle } from 'lodash-es'

const throttledScroll = throttle((e) => {
  currentScroll.value = e.target.scrollTop
}, 16)

4.3 CSS 최적화

.virtual-scroll-viewport {
  will-change: transform;
}

.virtual-scroll-row {
  contain: strict;
}

5. 통합 데모

<template>
  <div class="app-demo">
    <div class="toolbar">
      <button @click="resetData">새로고침</button>
      <button @click="goTop">맨 위로</button>
      <span>로딩된 항목: {{ dataset.length }}개</span>
    </div>
    
    <InfiniteScrollList
      ref="listRef"
      :data-fetcher="loadPageData"
      :row-height="80"
      :container-height="600"
      :batch-size="30"
      @load-more="onPageLoad"
      @data-loaded="onBatchLoaded"
    >
      <template #default="{ item, index, isLoading }">
        <div v-if="isLoading" class="skeleton">
          불러오는 중...
        </div>
        <div v-else class="card">
          <div class="card-header">
            <h3>{{ item.title }}</h3>
            <span class="badge">#{{ index + 1 }}</span>
          </div>
          <p class="card-body">{{ item.summary }}</p>
          <div class="card-footer">
            <span>{{ item.creator }}</span>
            <span>{{ item.createdAt }}</span>
          </div>
        </div>
      </template>
    </InfiniteScrollList>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import InfiniteScrollList from './components/InfiniteScrollList.vue'

const listRef = ref(null)
const dataset = ref([])

async function loadPageData(pageNum, size) {
  await new Promise(resolve => setTimeout(resolve, 600))
  
  const base = (pageNum - 1) * size
  return Array.from({ length: size }, (_, i) => ({
    id: base + i + 1,
    title: `항목 ${base + i + 1}`,
    summary: `가상 스크롤을 사용한 대용량 리스트 최적화 예제 ${base + i + 1}번 항목`,
    creator: `작성자 ${(base + i) % 10 + 1}`,
    createdAt: new Date(Date.now() - Math.random() * 1e9).toLocaleString()
  }))
}

function onPageLoad(page) {
  console.log(`${page} 페이지 로딩 시작`)
}

function onBatchLoaded(newBatch, page) {
  dataset.value = [...dataset.value, ...newBatch]
  console.log(`${page} 페이지 완료, 총 ${dataset.value}개`)
}

function resetData() {
  dataset.value = []
  listRef.value?.refresh()
}

function goTop() {
  listRef.value?.scrollToStart()
}
</script>

이 구현은 다음과 같은 특징을 제공합니다:

  • 고정/동적 높이 가상 스크롤 지원
  • 무한 스크롤 데이터 로딩 통합
  • 성능 최적화 기법 적용
  • 유연한 슬롯 시스템
  • 다양한 사용 사례 대응

태그: vue3 가상스크롤 무한스크롤 대용량리스트 성능최적화

6월 29일 16:29에 게시됨