Virtual Scroll + Intersection Observer를 활용하여 최적화하기

쭌로그·2025년 4월 6일
1
post-thumbnail

말머리

사내 프로젝트를 진행하다 대화 이력을 조회해야하는 로직이 있었습니다. 초기에는 성능상의 이슈가 없었지만, 대화 이력시 쌓여갈수록 많은 렌더링으로 인하여 성능이 저하되는 이슈가 있었습니다.
이를 개선하기 위해 Virtual Scroll과 Intersection Observer를 적용하기로 했습니다. 또한 대화 이력이다보니 아래로 내려가며 렌더링을 진행하는 방식이 아닌 스크롤을 위로 올릴 때 데이터를 렌더링하는 Reverse 방식이 필요했습니다.

본론

1. Virutual Scroll이란?

가상 스크롤은 화면에서 보이지 않는 부분의 내용은 출력하지 않고, 뷰포트에 보일 때만 출력하는 방식의 스크롤을 말합니다. 많은 양의 리스트가 화면에 보여지는 경우, 모든 항목이 렌더링 된다면 성능 상의 문제를 일으킬 수 있기 때문에 사용하는 방식입니다.
Virtual Scroll
https://blog.logrocket.com/virtual-scrolling-core-principles-and-basic-implementation-in-react/

Virtual Scroll 용어

용어설명
totalRowHeight, Scroll height전체 데이터가 렌더링 되었을 때의 높이
rowHeight한 행(리스트 한개)의 높이
scrollTop현재 지나온 높이
viewport사용자에게 보이는 영역
nodeCountviewport에서 사용자에게 보여질 수 있는 최대 row의 개수
nodePaddingnodeCount와 함께 추가적으로 보여질 행의 개수
visibleNodeCountnodeCount + nodePadding
scrollTumbHeight스크롤 막대의 높이 = 뷰포트의 높이
translatyeY스크롤 막대가 지나온 높이
scrollbarHeight스크롤바의 높이
startIndexvisibleNodeCount에 속하는 처음 데이터의 인덱스
endIndexvisibleNodeCount에 속하는 마지막 데이터의 다음 인덱스

1. Virtual Scroll

import { ref, computed, watch, nextTick, type Ref } from 'vue'

/**
 * useReverseVirtualScroller
 * - 동적 높이를 가진 아이템 리스트를 역순(Reverse)으로 가상 스크롤링 처리하는 Composable
 *
 * @param scrollRef - 스크롤 가능한 DOM 요소에 대한 ref
 * @param items - 렌더링할 전체 아이템 리스트 (반응형 배열)
 * @param fetchMore - 상단에 아이템을 추가로 불러오는 비동기 함수
 */
export function useReverseVirtualScroller(
  scrollRef: Ref<HTMLElement | null>,
  items: Ref<any[]>,
  fetchMore: () => Promise<any[]>
) {
  // 각 아이템의 높이 목록
  const itemHeights = ref<number[]>([])
  // 각 아이템의 오프셋 위치 목록 (상단에서부터의 누적 거리)
  const itemOffsets = ref<number[]>([])
  // index를 기반으로 아이템 DOM 참조 저장
  const itemRefs = new Map<number, HTMLElement>()
  

  // 뷰포트 높이 (현재는 사용되지 않지만 필요 시 활용 가능)
  const viewportHeight = ref(0)
  // 가시 범위 외 버퍼 개수
  const buffer = 5

  /**
   * 각 아이템 DOM 요소를 참조에 등록하는 함수
   * @param el - DOM 엘리먼트
   * @param index - 아이템 인덱스
   */
  const setItemRef = (el: HTMLElement | null, index: number) => {
    if (el) {
      itemRefs.set(index, el)
    }
  }

  /**
   * 모든 아이템의 높이와 오프셋을 계산하여 저장
   */
  const measureHeights = () => {
    // 각 요소의 높이 측정
    itemHeights.value = items.value.map((_, i) => {
      const el = itemRefs.get(i)
      return el?.offsetHeight ?? 0
    })

    // 누적 높이로 오프셋 계산
    const offsets: number[] = []
    let total = 0
    for (let h of itemHeights.value) {
      offsets.push(total)
      total += h
    }
    itemOffsets.value = offsets
  }

  /**
   * 현재 가시 영역에 포함될 아이템의 범위를 계산
   */
  const visibleRange = computed(() => {
    const scrollEl = scrollRef.value
    if (!scrollEl) return [0, 0]

    const scrollBottom = scrollEl.scrollTop + scrollEl.clientHeight
    let start = 0,
      end = items.value.length - 1

    // 시작 인덱스 탐색
    let top = 0
    for (let i = 0; i < itemHeights.value.length; i++) {
      const height = itemHeights.value[i]
      const bottom = top + height
      if (bottom >= scrollEl.scrollTop) {
        start = Math.max(0, i - buffer)
        break
      }
      top = bottom
    }

    // 종료 인덱스 탐색
    top = 0
    for (let i = 0; i < itemHeights.value.length; i++) {
      const height = itemHeights.value[i]
      const bottom = top + height
      if (bottom >= scrollBottom) {
        end = Math.min(items.value.length - 1, i + buffer)
        break
      }
      top = bottom
    }

    return [start, end]
  })

  /**
   * 실제로 화면에 렌더링할 아이템 목록 계산
   */
  const visibleItems = computed(() => {
    const [start, end] = visibleRange.value
    return items.value.slice(start, end + 1).map((item, i) => ({
      item, // 실제 데이터
      index: start + i, // 전체에서의 인덱스
      offset: itemOffsets.value[start + i] || 0, // 화면 내 위치
    }))
  })

  /**
   * 상단에 더 많은 아이템을 추가하고, 스크롤 위치를 유지
   */
  const prependItems = async () => {
    const scrollEl = scrollRef.value
    if (!scrollEl) return

    const prevHeight = scrollEl.scrollHeight
    const newItems = await fetchMore()
    items.value = [...newItems, ...items.value]

    await nextTick()
    measureHeights()

    // 높이 변화만큼 scrollTop 보정 (스크롤 위치 유지)
    nextTick(() => {
      const diff = scrollEl.scrollHeight - prevHeight
      scrollEl.scrollTop += diff
    })
  }

  // items 변경 시 높이 재계산
  watch(items, async () => {
    await nextTick()
    measureHeights()
  })
  // 요소의 총 높이를 계산하여 가상 스크롤의 높이를 계산
  const totalHeight = computed(() => {
  	const lastIndex = itemOffsets.value.length - 1
  	if (lastIndex < 0) return 0
  	const lastOffset = itemOffsets.value[lastIndex] || 0
  	const lastHeight = itemHeights.value[lastIndex] || 0
  	return lastOffset + lastHeight
  })

  return {
    setItemRef,      // DOM 등록 함수
    visibleItems,    // 현재 보여줄 아이템 리스트
    itemOffsets,     // 각 아이템의 위치 정보
    prependItems,    // 상단 아이템 추가 함수
    totalHeight
  }
}

virtual Scroll은 원본 아이템을 기반으로 각 요소들의 Height를 저장합니다. 그 이후 각 요소들의 clientHeight를 통해 offsetHeigt를 구한 후 영역에 노출시킬 수 있는 아이템들을 computed로 리턴합니다.

2. Intersection Observer

타겟 요소와 부모 혹은 viewport와의 교차 영역에 대한 변화를 비동기적으로 감지할 수 있게 도와주는 API 입니다.
비동기적으로 실행하기 때문에 메인 스레드의 영양을 주지 않으면서 변경 사항을 관찰할 수 있습니다.

import { ref, onMounted, onBeforeUnmount, watch, type Ref } from 'vue'

interface Options extends IntersectionObserverInit {
  immediate?: boolean
}

export function useIntersectionObserver(
  target: Ref<Element | null>,
  callback: IntersectionObserverCallback,
  options: Options = {}
) {
  const observer = ref<IntersectionObserver | null>(null)

  const stopObserving = () => {
    if (observer.value && target.value) {
      observer.value.unobserve(target.value)
    }
  }

  const startObserving = () => {
    if (target.value) {
      observer.value = new IntersectionObserver(callback, options)
      observer.value.observe(target.value)
    }
  }

  onMounted(() => {
    if (options.immediate !== false) {
      startObserving()
    }
  })

  onBeforeUnmount(() => {
    stopObserving()
    observer.value?.disconnect()
  })

  watch(target, (el, prevEl) => {
    if (observer.value) {
      if (prevEl) observer.value.unobserve(prevEl)
      if (el) observer.value.observe(el)
    }
  })

  return {
    startObserving,
    stopObserving,
    observer,
  }
}

이 부분에서 Intersection Observer는 단순하게 영역이 트리거가 되었을 때, callback 메서드를 호출하여 데이터를 추가로 불러오는 기능만 수행합니다.

<template>
  <div ref="scrollRef" class="scroll-container" @scroll="onScroll">
    <div ref="sentinelRef" class="sentinel"></div>
    <div :style="{ height: totalHeight + 'px', position: 'relative' }">
      <div
        v-for="item in visibleItems"
        :key="item.index"
        :ref="el => setItemRef(el, item.index)"
        class="item"
        :style="{ position: 'absolute', top: item.offset + 'px', width: '100%' }"
      >
        <img
          v-if="item.item.type === 'image'"
          :src="item.item.src"
          :alt="item.item.alt"
          class="image"
        />
        <p v-else class="text">{{ item.item.text }}</p>
      </div>
    </div>

  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useReverseVirtualScroller } from '@/composables/useReverseVirtualScroller'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'

const scrollRef = ref<HTMLElement | null>(null)
const items = ref<any[]>([])
const fetchMore = async () => {
  const more = Array.from({ length: 10 }, (_, i) => {
    const id = items.value.length + i
    return id % 3 === 0
      ? { type: 'image', src: `https://picsum.photos/id/${id}/300/150`, alt: `Image ${id}` }
      : { type: 'text', text: `아이템 id: #${id}` }
  })
  return more
}

const {
  setItemRef,
  visibleItems,
  totalHeight,
  prependItems
} = useReverseVirtualScroller(scrollRef, items, fetchMore)

useIntersectionObserver(scrollRef, () => {
  prependItems()
})

onMounted(async () => {
  items.value = await fetchMore()
})

const onScroll = () => {
	//스크롤 로직
}
</script>

<style scoped>
.scroll-container {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #ccc;
  position: relative;
}
.item {
  box-sizing: border-box;
  padding: 8px;
  border-bottom: 1px solid #eee;
  background: white;
}
.image {
  max-width: 100%;
  border-radius: 8px;
}
.text {
  font-size: 16px;
}
.sentinel {
  height: 1px;
}
</style>

실제 구현한 ReverseVirtualScroller.vue 입니다. 해당 현재는 데미 데이터를 추가하는 형식으로 구현했지만 실무에서 구현할 때에는 API를 연결하고, 데이터 최적화를 진행하는 식으로 진행해야할것 같습니다.

정리

오늘은 Virtual Scroll과 Intersection Observer를 사용하여 Reverse Infinite Scroll을 구현했습니다. 실무에서 직접 만드는 경우는 거의 없었지만 동적 요소 높이와, 디버깅이 힘들다는 이슈를 해결하기 위해 직접 구현해보았습니다. 이와 합쳐 tanstack-query를 사용하여 성능 개선을 더 진행해볼 수 있을 것 같습니다.
추가적인 개선점이나 좋은 의견이 있으면 언제든지 댓글 부탁드립니다! 감사합니다:)

profile
매일 발전하는 프론트엔드 개발자

0개의 댓글