01
사전 렌더링 기반 가상 스크롤
Problem
메신저의 메시지는 텍스트, 이미지, 파일 등 종류에 따라 높이가 모두 다릅니다. 기존 가상 스크롤 라이브러리는 아이템의 높이를 추정한 뒤, 실제 렌더링 후 보정하는 방식을 사용합니다. 이 과정에서 스크롤 점프와 레이아웃 시프트가 발생합니다.
Solution
렌더링 전에 숨겨진 영역에서 실제 DOM 높이를 먼저 측정하고, 정확한 높이를 기반으로 가상 스크롤을 시작합니다. 높이 추정이 필요 없으므로 스크롤 점프와 레이아웃 시프트가 원천적으로 발생하지 않습니다.
가상 스크롤이란?
1,000개의 메시지가 있다고 해도, 화면에 보이는 건 10~20개 정도입니다. 가상 스크롤은 이 점을 활용하여 보이는 영역의 아이템만 실제 DOM에 렌더링합니다. 나머지 아이템은 DOM에 존재하지 않지만, 전체 높이를 유지하여 자연스러운 스크롤바를 제공합니다.
각 아이템은 position: absolute로 배치되며, 사전에 측정된 높이를 기반으로 정확한 요소의 위치(top)가 계산됩니다. 사용자가 스크롤하면 현재 스크롤 위치에 따라 해당하는 아이템 범위를 탐색하여 렌더링합니다.
Viewport 영역
전체 리스트 중 보이는 영역만 렌더링하여 DOM 노드 수를 최소화합니다.
회색 = 렌더링 안 됨
검정 = 렌더링 됨
이진 탐색으로 첫 번째 아이템 찾기
스크롤이 발생할 때마다 "지금 화면에 보여야 할 첫 번째 아이템"을 찾아야 합니다. 모든 아이템의 높이가 동일하다면 단순 나눗셈으로 구할 수 있지만, 높이가 각각 다른 경우에는 아이템을 하나씩 순회하며 찾아야 합니다. 메시지가 10,000개라면 매 스크롤 이벤트마다 최대 10,000번의 비교가 필요합니다.
각 아이템의 위치는 높이의 누적합이므로 항상 정렬된 상태입니다. 이 특성을 활용하면 이진 탐색으로 O(log n)만에 시작 아이템을 찾을 수 있습니다. 10,000개의 메시지도 최대 14번의 비교로 충분합니다.
메시지들의 위치 배열
이 이진 탐색을 활용하면 단순히 화면에 보여줄 아이템을 찾는 것 외에도, 특정 메시지로 바로 이동하거나 안 읽은 메시지 위치를 즉시 찾아 스크롤하는 기능도 구현할 수 있습니다.
사전 높이 측정
이 라이브러리의 핵심 차별점입니다. 가상 스크롤이 시작되기 전에 모든 아이템의 실제 DOM 높이를 측정합니다. 이미지가 포함된 메시지도 렌더 전에 정확한 높이를 알 수 있습니다. 정확한 높이를 바탕으로 가상 스크롤을 구현하여, 이진 탐색의 정확도를 높이고 렌더링 후 보정을 줄여 reflow와 스크롤 버그를 최소화합니다.
visibility: hidden
heightMap (Ref)
item-0: 72
item-1: 148
item-2: 56
렌더 전 높이 확보
숨겨진 영역에서 모든 아이템을 렌더링하여 실제 DOM 높이를 측정합니다. 이미지 로드까지 대기하며, 측정 결과는 Ref에 저장하여 리렌더 없이 수집합니다.
모든 측정이 끝나면 딱 1번만 setState하여 가상 스크롤을 시작합니다.
InitialMeasure는 각 아이템을 숨겨진 영역에 렌더링하고 이미지 로드를 대기합니다 (img.onload + 5초 타임아웃 fallback). 높이는 useRef<Map>에 저장되므로 측정 중에는 React 리렌더가 발생하지 않습니다.
Measure는 렌더링된 각 아이템을 ResizeObserver로 감시합니다. 런타임에 높이가 변경되면(이미지 로드, 동적 컨텐츠 등)requestAnimationFrame으로 배치하여 같은 프레임 내 여러 변경을 1번의 위치 재계산으로 처리합니다.
높이 잠금 (Height Locking)
InitialMeasure에서 이미지 로드를 대기하여 정확한 높이(예: 148px)를 측정하지만, VirtualScroll에서 실제 렌더 시 브라우저가 이미지를 다시 로드하면 초기에 이미지 없는 높이(32px)가 되었다가 로드 완료 후 148px로 바뀌는 깜빡임이 발생합니다.
문제: 이미지 깜빡임
148px
32px !
148px
InitialMeasure → VirtualScroll (캐시 miss) → 로드 완료
해결: Height Locking
height: 148px 잠금
MutationObserver → 잠금 해제
ResizeObserver → heightMap 갱신
knownHeight는 InitialMeasure에서 측정된 값으로, heightMap에 이미 저장되어 있습니다. Measure가 마운트될 때 이 값을 인라인 height style로 적용하면, 이미지가 아직 로드되지 않았더라도 사전 측정된 높이가 유지됩니다. 내부 콘텐츠가 실제로 변경된 경우에만 잠금을 해제하여 리플로우를 허용합니다.