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>
이 구현은 다음과 같은 특징을 제공합니다:
- 고정/동적 높이 가상 스크롤 지원
- 무한 스크롤 데이터 로딩 통합
- 성능 최적화 기법 적용
- 유연한 슬롯 시스템
- 다양한 사용 사례 대응