개발 기술/사소하지만 놓치기 쉬운 개발 지식

브라우저 렌더링 최적화를 위한 Virtual Scroll - 구현 코드 살펴보기

by GicoMomg 2025. 11. 16.

0. 들어가며

  • 1편에서는 Virtual Scroll이 어떤 원리로 렌더링 비용을 줄이는지,
  • 그리고 실제 Chrome Performance Trace에서 Layout·Paint·GPU Memory가 얼마나 절감되는지를 살펴보았다.
  • Virtual Scroll은 얼핏 보면 “DOM을 줄이고, translateY로 위치만 바꾸는” 단순한 구조처럼 보인다.
  • 하지만 실제로 구현해보면 다음과 같은 고민들이 바로 등장한다.
1. 스크롤이 발생할 때 어떤 index부터 렌더링해야 할까?
2. 화면에 필요한 DOM pool 크기는 어떻게 계산할까?
3. Pool DOM은 어떤 방식으로 위치만 이동시켜 재사용할까?
4. 이미지 로딩처럼 아이템 높이가 바뀌면 offset을 어떻게 갱신해야 할까?
5. scroll 이벤트가 아주 자주 발생하는데, render 호출은 어떻게 제어해야 할까?

  • 이번 시간(2편)에는 1편에서 다뤘던 Virtual Scroll의 구조를 기반으로,
  • Virtual Scroll이 실제로 어떤 코드로 동작하는지를 단계별로 분석해보았다.

브라우저 렌더링 최적화를 위한 Virtual Scroll - 원리/성능 비교(링크)
브라우저 렌더링 최적화를 위한 Virtual Scroll - 구현 코드 살펴보기 ← 이번 글






1. Virtual Scroll 구조를 만드는 코드 살펴보기

1) 구성 요소

Virtual Scroll의 핵심 구성 요소는 아래 세 가지이다.

  1. Item Pool — 화면에 실제로 보이는 만큼만 존재하는 재사용 DOM(약 20–40개)
  2. Container — 스크롤이 발생하는 영역
  3. Spacer — 전체 리스트 높이를 대신 만드는 투명 div


그렇다면 스크롤이 발생했을 때 Virtual Scroll 내부에서는 어떤 일이 벌어질까?

  • 겉으로 보기엔 “수천 개의 DOM이 한 번에 모두 렌더링된 긴 리스트”처럼 보이지만,
  • 실제로는 세 요소가 다음 역할을 수행해 긴 리스트를 만든다.

① Spacer — 전체 높이를 만드는 역할

  • Spacer는 빈 div이지만, “전체 아이템 개수 × 아이템 높이”만큼의 높이를 가진다.
  • 브라우저는 이 높이를 보고 스크롤바를 만든다.
  • 덕분에 사용자는 “정말 긴 리스트를 스크롤하고 있다”고 자연스럽게 인식한다.
  • 하지만 실제 DOM은 여전히 몇 개뿐이다.

② Item Pool — 고정된 수의 DOM만 유지

  • 화면에 보이는 영역 + buffer 만큼의 DOM(약 20–40개)만 만든다.
  • 스크롤해도 DOM을 새로 생성하거나 삭제하지 않는다.
  • 대신 “이 DOM이 어떤 데이터를 표현할지”만 계속 교체한다.

③ 스크롤 위치에 맞춰 Pool DOM의 역할을 교체

  • 스크롤 값(scrollTop)을 기반으로 “현재 어떤 index가 화면에 보여야 하는지” 계산한다.
  • 그리고 Pool DOM 각각의 translateY로 위치를 옮기고 innerHTML을 사용해 새 데이터로 교체한다.
  • DOM 개수는 그대로지만, 사용자 눈에는 “새로운 DOM이 등장한 것처럼” 보이게 된다.

정리하면 Virtual Scroll은 다음 사이클을 계속 반복한다.

(1) DOM은 그대로 유지 → (2) translateY로 위치 이동 → (3) 내용만 교체

  • 그 결과, 화면에는 실제 DOM이 20~40개뿐이지만, 사용자 눈에는 “수천 개의 아이템이 모두 렌더링된 것처럼 보이게 된다.
  • 아래는 Virtual Scroll을 구성하는 주요 코드 역할 7가지다.
역할 코드 설명
전체 높이 만들기 _createSpacer() 스크롤바가 생기도록 Spacer div 생성
DOM 풀 만들기 _initPool() 필요한 개수만큼 DOM을 미리 만들고 재사용
스크롤 이벤트 최적화 _onScroll() rAF로 한 프레임 당 1번만 렌더
시작 인덱스 계산 _findStartIndex() 스크롤 위치 기반 이진 탐색
DOM 배치 render() translateY로 DOM을 실제 위치처럼 이동
높이 업데이트 _updateOffsets() 각 아이템 top offset 및 spacer height 계산
동적 높이 대응 _observeResize() ResizeObserver로 아이템 높이 변화 감지
  • 이제 다음 문단에서 각 역할이 어떻게 구현되는지 살펴보자. (전체 코드는 여기에서!)




1) Spacer 생성 (_createSpacer)

  • Virtual Scroll에서는 화면에 실제로 렌더링되는 DOM이 약 20~40개뿐이다.
  • 하지만 데이터가 10,000개라면 실제 리스트 높이는 10,000 × 40px = 400,000px에 달한다.
  • 문제는 여기서 시작된다.
  • 실제 DOM은 30개뿐이기에, 브라우저 입장에서는 “리스트가 길다” 는 사실을 알 수 없다.
  • 즉, 스크롤할 수 있는 공간이 없으니 스크롤바도 생성되지 않는다.
  • 하지만 스크롤바가 없다면 사용자는 리스트 끝까지 탐색할 수 없다.

(1) Spacer가 만들어내는 “가짜 전체 높이”

  • 아래 그림처럼, 실제 화면에는 약 15개의 DOM만 렌더링된다.
  • 하지만 Spacer는 전체 아이템 개수 × 아이템 높이 만큼의 커다란 빈 영역을 만든다.

  • 브라우저는 이 height를 기준으로 스크롤바를 만든다.
  • 사용자는 실제 DOM이 거의 없다는 사실을 모른 채, “정말 긴 리스트를 스크롤하고 있다”는 착각을 경험하게 된다.
  • 즉, Spacer는 스크롤 가능한 영역을 만드는 핵심 요소다.

(2) 코드 살펴보기

  • 아래는 _createSpacer()가 Spacer DOM을 생성하는 코드다.
private _createSpacer() {
  this.spacer = document.createElement('div')
  this.spacer.style.cssText = `position: relative; width: 100%;`
  this.container.appendChild(this.spacer)
}
  • 여기서는 단순히 Spacer의 뼈대만 만든다.
  • Spacer의 실제 높이(= 리스트 전체 길이)는 이후 _updateOffsets() 단계에서 계산되어 적용된다.




2) Item Pool 생성 (_initPool)

(1) 왜 Pool이 필요한가?

  • Virtual Scroll의 핵심은 “화면에 보이는 만큼만 DOM을 유지하는 것”이다.
  • 그렇다면 스크롤할 때마다 화면에 들어온 DOM은 새로 만들고, 화면 밖으로 나간 DOM은 삭제하면 되지 않을까?

하지만 이 방식은 오히려 성능을 크게 떨어뜨린다.

그 이유는 다음과 같다.

  • DOM 생성/삭제는 매우 비싼 연산이다.
    createElement, appendChild, removeChild 같은 조작은 브라우저가 많은 내부 작업을 수행해야 한다.

  • DOM 트리가 변할 때마다 Layout → Paint 전체가 다시 계산된다.
    연속적인 DOM 추가·삭제는 렌더링 파이프라인 비용을 폭증시킨다.

  • 빠른 스크롤에서 jank(튐)가 발생한다.
    스크롤 속도 > DOM 생성/삭제 속도가 되는 순간 화면이 끊긴다.


이 문제를 해결하기 위한 구조가 바로 Item Pool이다.
Pool은 “필요한 수(20~40개)만큼 DOM을 미리 만들어두고, 이 DOM을 계속 재사용하는 고정된 DOM 묶음”이다.

  • Pool을 사용하면 스크롤 시 DOM을 새로 만들거나 삭제할 필요가 없다.
  • 대신 두 가지만 바꾸면 된다.
    1. 이 DOM이 어떤 데이터를 표시할지(index) 재할당
    2. translateY로 DOM의 실제 위치만 이동
  • 즉, DOM 자체는 그대로지만 역할만 계속 바뀐다.
  • 사용자 입장에서는 매번 새로운 아이템이 등장하는 것처럼 보인다.

  • 아래 그림은 같은 DOM이 스크롤에 따라 다른 아이템을 표현하는 과정이다.



(2) 코드 살펴보기

  • 이제 실제로 Pool DOM을 만드는 코드를 살펴보자.
  • _initPool()은 재사용 가능한 DOM 집합을 생성해 Pool에 담아둔다.
private _initPool() {
  const children = document.createDocumentFragment()
  const poolSize = this._calcVisibleCount() + this.config.buffer // [1]

  for (let i = 0; i < poolSize; i++) {
    const el = document.createElement('div')
    el.className = this.config.itemClass
    el.style.cssText = `
      position: absolute;
      top: 0;
      width: 100%;
      will-change: transform;
    ` // [2]

    children.appendChild(el)
    this.pool.push(el)

    this._observeResize(el)
  }
  this.container.appendChild(children)
}
  • [1] 화면에 보이는 DOM 수 + buffer 만큼만 Pool DOM을 생성한다.
    → 데이터가 10,000개여도 실제 DOM 수는 20~40개로 고정된다.

  • [2] 모든 DOM은 position:absolute로 띄워두고,
    will-change: transform을 통해 GPU transform 최적화를 유도한다.

이렇게 만들어진 Pool DOM들은 스크롤 중 절대 제거되지 않는다.
대신 innerHTML + translateY만 바뀌며 매 프레임 “새로운 아이템”으로 보이도록 역할만 교체된다.




3) Scroll 이벤트 처리 (_addScrollEvent / _onScroll)

  • Virtual Scroll에서는 스크롤이 발생할 때마다
    • 화면에 어떤 index가 보여야 하는지 계산하고
    • Pool DOM의 translateY 위치를 업데이트하며
    • 필요한 DOM 내용(innerHTML)을 교체하는
  • render() 작업이 필요하다.

문제는 scroll 이벤트가 ms 단위로 매우 빈번하게 발생한다는 점이다.

  • 브라우저는 스크롤 중 수십 번의 이벤트를 연속적으로 발행하기에,
  • 매 호출마다 render()를 직접 실행하면 Layout → Paint → Composite 사이클이 과도하게 반복되어 성능 저하와 jank로 이어진다.
  • 따라서 스크롤 이벤트는 반드시 프레임 단위로 제어(throttling) 해야 한다.

(1) requestAnimationFrame으로 프레임당 1회 렌더링

private _onScroll() {
  if (this.rAFId != null) return

  this.rAFId = requestAnimationFrame(() => {
    this.render()
    this.rAFId = null
  })
}
  • requestAnimationFrame은 다음 브라우저 프레임 직전에 콜백을 실행한다.
  • _onScroll()에서 rAF가 이미 예약된 상태라면 추가 호출은 무시하고,
  • 한 프레임(약 16ms)에 딱 한 번만 render()가 실행되도록 제한한다.
  • 그래서 스크롤 이벤트가 수십 번 들어와도, 렌더는 60fps 상한선 내에서만 실행된다.

(2) passive: true로 스크롤 반응성 향상

private _addScrollEvent() {
  this.scrollTarget.addEventListener('scroll', this._onScroll, { passive: true })
}
  • 브라우저는 기본적으로 이벤트 핸들러가 preventDefault()를 호출할 수 있는지 확인할 때까지 스크롤 동작을 잠시 보류한다.
  • 이 작은 지연이 쌓이면 스크롤 반응성이 떨어지고, 체감되는 약간의 버벅임이 발생한다.
  • 하지만 Virtual Scroll에서는 preventDefault()를 사용할 일이 없다.
  • 따라서 스크롤 이벤트는 passive 모드로 등록하는 것이 최적이다.
  • passive: true 처리를 하면 브라우저가 스크롤을 즉시 처리하고 이벤트를 나중에 전달한다.




4) 스크롤 위치로 시작 인덱스 찾기 (_findStartIndex)

  • Virtual Scroll은 스크롤이 움직일 때마다 “현재 화면에는 어떤 아이템부터 보여야 하는가?” 즉, startIndex를 계산해야 한다.
  • 이 값을 빠르게 찾기 위해 각 아이템의 Y좌표(top offset) 를 미리 계산해 itemOffsets 배열에 저장해둔다.
  • itemOffsets 배열은 정렬된 상태이므로, 스크롤 위치(scrollTop)가 주어지면 그 위치를 넘어서는 첫 번째 아이템 index를 빠르게 찾을 수 있다.
  • index를 찾을 때는 이진 탐색(binary search)을 사용해 속도를 보장해준다.

(1) 코드 살펴보기

private _findStartIndex(scrollTop: number): number { // [1]
  if (this.itemOffsets.length === 0) return 0

  let low = 0
  let high = this.itemOffsets.length - 1 // [2]
  let startIndex = 0

  while (low <= high) {
    const mid = Math.floor((low + high) / 2)
    if (this.itemOffsets[mid] < scrollTop) {
      startIndex = mid + 1
      low = mid + 1
    } else {
      high = mid - 1
    }
  }
  return Math.max(0, startIndex - 1) // [3]
}
  • [1] 스크롤 위치(scrollTop)를 기준으로 “현재 화면에 노출되어야 하는 첫 번째 index”를 찾는다.
  • [2] itemOffsets는 모든 아이템의 top 위치를 순서대로 저장한 배열이다.
  • [3] 스크롤이 아이템 경계에 걸쳐 있는 경우를 고려해 startIndex - 1을 보정해준다.
    (스크롤 경계에서 마지막 아이템 일부가 보이는 상황 대비)

이렇게 구한 startIndex는 이후 render() 단계에서 Pool DOM을 어떤 아이템부터 배정할지 결정하는 기준이 된다.




5) DOM 재배치와 데이터 교체: Virtual Scroll의 핵심 동작 (render)

  • Virtual Scroll이 “실제 DOM은 20~40개뿐인데도 수천 개의 아이템이 모두 렌더링된 것처럼” 보이는 이유는 바로 이 render() 함수 때문이다.
  • 스크롤이 발생하면 Virtual Scroll은 다음 두 가지 작업을 수행한다.
    1. 지금 화면에 어떤 index의 아이템을 보여줘야 하는지 계산하고
    2. Pool DOM을 재배치(translateY) + 재사용(innerHTML 교체) 한다.
  • 이 과정에서 DOM 추가/삭제는 한 번도 일어나지 않는다.
  • 오직 위치 이동 + 내용 교체만 이뤄지고, 이 덕분에 높은 FPS를 유지할 수 있다.

(1) 코드 살펴보기

for (let i = 0; i < this.pool.length; i++) {
  const itemIndex = startIndex + i
  const el = this.pool[i]

  if (itemIndex >= endIndex) {
    el.style.display = 'none'
    continue
  }

  el.style.display = 'block'
  ;(el as any).__virtualIndex = itemIndex

  const top =
    this.itemOffsets[itemIndex] ?? (itemIndex * this.config.itemHeight)
  el.style.transform = `translateY(${top}px)`

  const content = this.config.renderItem(this.data[itemIndex])
    if (content instanceof HTMLElement) {
      el.replaceChildren(content)
    } else {
      const contentStr = String(content ?? '')
      if (el.innerHTML !== contentStr) {
        el.innerHTML = contentStr
      }
    }
}

(2) render의 동작 살펴보기

① Pool DOM에 “어떤 index의 아이템을 맡길지” 결정한다

  • 스크롤로 계산한 startIndex부터 Pool DOM에 순서대로 데이터를 배정한다.
if (itemIndex >= endIndex) {
  el.style.display = 'none'
  continue
}
  • 화면 밖의 DOM은 display: none 처리하여 불필요한 페인트를 방지한다.
  • 이렇게 DOM 개수는 그대로 유지되지만, 각 DOM이 표현하는 “데이터 역할”만 계속 바뀐다.

② translateY로 DOM을 실제 위치처럼 보이게 이동한다

const top = this.itemOffsets[itemIndex] ?? (itemIndex * this.config.itemHeight)
el.style.transform = `translateY(${top}px)`
  • itemOffsets에는 각 아이템의 “진짜 리스트에서의 Y좌표”가 들어있다.
  • Pool DOM은 position:absolute 상태이므로, translateY만 바꿔도 마치 해당 위치에 실제 DOM이 있는 것처럼 보인다.
  • transform 이동은 GPU가 처리하므로 Layout/Reflow가 발생하지 않는다.

③ DOM의 내용(innerHTML)만 교체한다

...

const content = this.config.renderItem(this.data[itemIndex])

if (content instanceof HTMLElement) {
  el.replaceChildren(content)
} else {
  const contentStr = String(content ?? '')
  if (el.innerHTML !== contentStr) {
    el.innerHTML = contentStr
  }
}
  • DOM을 새로 만들지 않고, 내용만 변경한다.
  • innerHTML 비교까지 하는 이유는 불필요한 DOM 조작을 막기 위해서다.
  • 결국 DOM의 물리적 개수는 변하지 않고, 사용자 눈에는 매번 새로운 아이템이 등장하는 것처럼 보인다.




6) 전체 아이템의 top 위치와 spacer 높이 계산 (_updateOffsets)

  • Virtual Scroll이 정확하게 동작하려면, 각 아이템이 리스트에서 어느 Y 좌표에 있어야 하는지(top 위치)를 알고 있어야 한다.
  • 그래야 스크롤 위치에 따라 Pool DOM을 정확한 위치(translateY)로 이동시킬 수 있다
  • 이 역할을 수행하는 함수가 바로 _updateOffsets()다.

(1) 코드 살펴보기

private _updateOffsets() {
  let offset = 0

  this.itemOffsets = this.data.map((_, i) => {
    const h = this.itemHeights.get(i) ?? this.config.itemHeight
    const currentOffset = offset
    offset += h
    return currentOffset
  })
  this.spacer.style.height = `${offset}px`
}

(2) 각 아이템의 ‘정확한 위치(Y좌표)’를 계산한다

  • itemOffsets[i]는 “i번째 아이템이 리스트 상단으로부터 얼마나 떨어져 있는지”를 의미한다.
  • Virtual Scroll은 이 값을 이용해 Pool DOM을 정확한 위치로 translateY로 옮긴다.
  • 예를 들어 모든 아이템 높이가 40px이라면:
index 0 → top: 0px  
index 1 → top: 40px  
index 2 → top: 80px  
index 3 → top: 120px …
  • 이처럼 정렬된 Offset 배열이 있으면:
    • findStartIndex()에서 이진 탐색으로 시작 index를 빠르게 찾고
    • render()에서도 translateY 위치 계산이 매우 단순해진다.

(3) 동적 높이(dynamic height)까지 대응한다

  • 리스트의 아이템 높이가 항상 동일한 것은 아니다.
    • 이미지 로딩 후 늘어나는 레이아웃
    • “더보기”를 눌러 텍스트 확장
    • 아코디언 UI처럼 접힘/펼침이 존재하는 경우
  • 이런 변화가 발생하면 itemHeights에 저장된 값이 달라지고,
  • _updateOffsets()가 이를 반영하여 offsets를 다시 계산한다.
  • 즉, Virtual Scroll은 고정 높이 + 가변 높이 모두를 처리할 수 있도록 설계되어 있다.

(동적 높이 변화 감지는 아래 ResizeObserver 단계에서 설명한다.)


(4) Spacer의 전체 높이도 여기서 결정된다

  • offset은 모든 아이템 높이를 누적한 값이며, 이는 곧 리스트 전체의 높이가 된다.
this.spacer.style.height = `${offset}px`
  • 브라우저는 이 값으로 스크롤바 길이를 결정한다.
  • _createSpacer()에서는 단순히 div만 만들고, 실제 “긴 리스트의 높이”는 _updateOffsets()에서 채워진다.
  • 즉, Spacer는 이 단계에서 실제 스크롤 가능한 공간을 갖게 되며,
  • 사용자는 만 개 이상의 리스트를 스크롤하는 것처럼 자연스러운 경험을 하게 된다.




7) ResizeObserver로 ‘동적 높이 아이템’ 자동 처리 (_observeResize)

  • Virtual Scroll은 기본적으로 itemHeight(고정 높이)를 기준으로 설계되지만,
  • 실제 서비스에서는 모든 아이템이 고정 높이를 유지하지 않는다.
    • 이미지 로딩 후 크기가 늘어나는 경우
    • 긴 텍스트가 줄바꿈되면서 공간이 커진 경우
    • 아코디언 UI가 펼쳐져 높이가 변하는 경우
  • 이처럼 아이템의 실제 높이가 바뀌면 기존에 계산해둔 itemOffsets가 모두 틀어지게 된다.
  • 이를 즉시 감지하고 보정해주는 것이 바로 ResizeObserver다.

(1) 코드 살펴보기

private _observeResize(el: HTMLElement) {
  const observer = new ResizeObserver(() => {
    const index = (el as any).__virtualIndex
    if (index == null) return

    const prev = this.itemHeights.get(index)
    const now = el.offsetHeight

    if (prev !== now) {
      this.itemHeights.set(index, now)
      this._updateOffsetsFrom(index)
      this.render()
    }
  })
  observer.observe(el)
}
  • ResizeObserver는 DOM의 실제 렌더링된 높이가 변화할 때 자동으로 콜백을 실행한다.
  1. 현재 Pool DOM이 표현 중인 index 확인
    • Pool DOM은 스크롤할 때마다 __virtualIndex에 “현재 그리는 데이터 index”를 저장해둔다.
  2. 이전 높이(prev)와 현재 높이(now) 비교
    • 이미지가 늦게 로드되거나 텍스트가 늘어나는 순간 이 값이 달라진다.
  3. 변경된 높이를 itemHeights에 반영
  4. 해당 index부터 이후 아이템의 itemOffsets만 부분 업데이트
    • 전체 오프셋을 전부 다시 계산하지 않고 변화된 구간만 재계산한다.
    • 긴 리스트에서도 성능을 유지할 수 있는 핵심 설계.
  5. 즉시 render() 호출해 translateY 재배치
    • 한 프레임 안에서 자연스럽게 위치가 보정된다.




8) Virtual Scroll 전체 흐름 정리

  • Virtual Scroll은 스크롤 이벤트 → index 계산 → DOM 재배치 → 높이 변화 대응까지 하나의 렌더링 루프가 끊임없이 이어지는 구조다.
scroll → rAF → startIndex 탐색 → Pool 배정 → translateY → 내용 교체
         ↓
   ResizeObserver → 높이 반영 → offsets/spacer 업데이트
  • 아래는 Virtual Scroll이 한 프레임(frame) 안에서 수행하는 전체 사이클을 정리한 것이다.

(1) 스크롤 이벤트 감지 — rAF로 프레임 단위로 묶기

  • 사용자가 스크롤하면 scroll 이벤트가 수십 번 발생한다.
  • Virtual Scroll은 이를 requestAnimationFrame(rAF)로 묶어 “한 프레임당 한 번만” render()가 실행되도록 만든다.
scroll → (여러 번)
    ↓
requestAnimationFrame → render()는 단 한 번

(2) 현재 스크롤 위치에서 시작 index 계산

  • _findStartIndex()scrollTopitemOffsets를 기반으로 지금 화면 상단에 등장해야 하는 첫 번째 아이템의 index를 찾는다.

  • 오프셋 배열이 정렬되어 있으므로 이진 탐색(binary search)가 가능하다.

  • 스크롤 속도와 상관없이 항상 O(log N)으로 빠르게 계산할 수 있다.

(3) Pool DOM에 ‘어떤 index를 그릴 것인지’ 배정

  • Pool DOM(20~40개)에는 각각 “지금 너는 index #321을 그려라”와 같은 역할이 주어진다.
  • 인덱스를 벗어나는 DOM은 display:none 처리해 불필요한 페인트를 막는다.
  • DOM 추가·삭제는 일어나지 않는다.

(4) translateY로 ‘실제 리스트 위치’를 흉내내기

  • Pool DOM은 모두 absolute로 떠 있기 때문에, 레이아웃에 영향을 주지 않는다.
  • 각 DOM은 아래처럼 지정된 위치로 이동한다.
el.style.transform = translateY(itemOffsets[itemIndex])
  • transform은 GPU에서 처리되어 Layout/Reflow 없이 즉시 적용된다.
  • 이 덕분에 긴 리스트가 실제 렌더링된 것처럼 자연스럽게 보인다.

(5) DOM 내용만 교체해 새로운 데이터로 보이게 만들기

  • DOM 자체를 만들거나 제거하지 않고, innerHTML 또는 replaceChildren()으로 현재 index에 해당하는 데이터를 그린다.
  • DOM 개수는 그대로지만, 사용자 눈에는 “새로운 아이템이 등장”한 것처럼 보인다.

(6) ResizeObserver가 높이 변화를 감지

  • 이미지 로드, 텍스트 확장 등으로 DOM 높이가 변하면 즉시 감지한다.
  • 해당 index의 실제 높이를 갱신하고, 그 지점 이후의 itemOffsets만 부분적으로 재계산한다.
  • 안정적인 dynamic height를 제공하는 핵심이다.

(7) spacer 높이도 함께 업데이트

  • 모든 아이템의 누적 높이를 기반으로 spacer의 높이를 갱신한다.
  • 브라우저는 이 spacer를 기준으로 스크롤바를 유지한다.
  • 즉, 실제 DOM 개수와 관계없이 스크롤 가능한 긴 리스트가 완성된다.

  • 앞선 과정을 그림으로 표현하면 다음과 같다.






3. 마치며…

Virtual Scroll은 얼핏 보면 “DOM을 줄이는 간단한 최적화 기법”처럼 보이지만,

실제로 구현해보면 여러 요소가 촘촘하게 맞물려 동작하는 구조라는 걸 알 수 있다.

정리하면 Virtual Scroll은 다음 사이클을 지속적으로 반복한다.

1. 스크롤 위치로 시작 index 계산
2. Pool DOM에 새로운 역할 배정
3. translateY로 실제 위치 이동
4. ResizeObserver로 동적 높이 감지
5. offsets 및 spacer 높이 재계산

이 흐름이 프레임 단위(≈16ms) 안에서 안정적으로 순환하기에, 실제 DOM은 20~40개에 불과하지만

사용자는 수천~수만 개의 리스트가 렌더링된 것처럼 자연스러운 스크롤을 경험하게 된다.

결국 Virtual Scroll은 “DOM을 줄인다”라는 단순한 아이디어를 넘어서, 브라우저 렌더링 파이프라인 전체를 고려한 최적화 패턴이라고 볼 수 있다.

반응형

댓글