dynamic scroll

Dynamic Scroll

메신저 서비스를 개발하면서, 각 요소의 높이를 알 수 없는 상황에서도 사용할 수 있는 가상 스크롤 라이브러리입니다.

기존 가상 스크롤 라이브러리는 요소의 높이를 추정한 뒤 렌더링 후 보정하는 방식으로, 스크롤 점프와 레이아웃 시프트가 불가피했습니다. 사전 렌더링을 통해 요소의 높이를 먼저 측정하여 이 문제를 해결하고, 이를 바탕으로 무한 스크롤과 채팅 기능을 구현했습니다.

대화방

3명 참여중

로딩 중...

How it works

작동 원리

각 핵심 기능의 구현 방식과 해결한 문제를 설명합니다.


스크롤

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번의 비교로 충분합니다.

메시지들의 위치 배열

[0]0
[1]50
[2]120
[3]200
[4]350
[5]420
[6]500
[7]580
[8]700
현재 스크롤 위치:340→ 인덱스 3 반환 (top: 200, bottom: 350)
1범위 [0, 7], mid=3, 중심=275오른쪽 →[3, 7]
2범위 [3, 7], mid=5, 중심=460← 왼쪽[3, 5]
3범위 [3, 5], mid=4, 중심=385← 왼쪽[3, 4]
4범위 [3, 4], mid=3, 중심=275찾음!idx 3

이 이진 탐색을 활용하면 단순히 화면에 보여줄 아이템을 찾는 것 외에도, 특정 메시지로 바로 이동하거나 안 읽은 메시지 위치를 즉시 찾아 스크롤하는 기능도 구현할 수 있습니다.

사전 렌더링 측정이진 탐색 O(log n)Height LockingResizeObserverDOM 최소화

사전 높이 측정

이 라이브러리의 핵심 차별점입니다. 가상 스크롤이 시작되기 전에 모든 아이템의 실제 DOM 높이를 측정합니다. 이미지가 포함된 메시지도 렌더 전에 정확한 높이를 알 수 있습니다. 정확한 높이를 바탕으로 가상 스크롤을 구현하여, 이진 탐색의 정확도를 높이고 렌더링 후 보정을 줄여 reflow와 스크롤 버그를 최소화합니다.

visibility: hidden

72px
148px
56px

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로 바뀌는 깜빡임이 발생합니다.

문제: 이미지 깜빡임

img 로드됨

148px

img 없음

32px !

img 로드됨

148px

InitialMeasure → VirtualScroll (캐시 miss) → 로드 완료

해결: Height Locking

1마운트

height: 148px 잠금

2DOM 변경 감지

MutationObserver → 잠금 해제

3높이 감지

ResizeObserver → heightMap 갱신

knownHeight는 InitialMeasure에서 측정된 값으로, heightMap에 이미 저장되어 있습니다. Measure가 마운트될 때 이 값을 인라인 height style로 적용하면, 이미지가 아직 로드되지 않았더라도 사전 측정된 높이가 유지됩니다. 내부 콘텐츠가 실제로 변경된 경우에만 잠금을 해제하여 리플로우를 허용합니다.

02

Sticky Group Header

Problem

채팅 앱에서 같은 날짜의 메시지를 스크롤할 때, 현재 보고 있는 날짜가 상단에 떠 있으면 맥락을 잃지 않아 좋습니다. 이 "떠다니는 날짜 라벨"을 가상 스크롤 위에서 어떻게 구현할 수 있을지가 문제였습니다.

Solution

Slack의 코드를 분석하면서 해결 방법을 찾았습니다. 스크롤 위치에 영향을 주지 않으면서 그룹 전체 높이를 가지는 요소(GroupWrapper)를 만들고, 그 자식으로 position: sticky 라벨을 넣으면 떠다니는 날짜 블록을 구현할 수 있습니다. 여기에 그룹 최상단에 스크롤 위치에 영향을 주는 구분선(Separator)을 추가하면, 구분선과 sticky 라벨이 하나처럼 자연스럽게 동작하는 UI를 만들 수 있습니다.

원하는 동작

1월 15일 수요일

스크롤해도 날짜가 상단에 떠 있음

GroupWrapper (레이아웃 무영향)

GroupWrapper height: 그룹 전체

└─ 날짜 라벨 sticky

스크롤 위치에 영향 없이 sticky 라벨이 동작

Separator (레이아웃 반영)

Separator 일반 아이템으로 측정

1월 16일

sticky 라벨과 하나처럼 자연스럽게 연결

2024년 1월 15일
1월 16일

GroupWrapper + Separator

GroupWrapper는 스크롤 위치에 영향을 주지 않으면서 그룹 전체 높이를 가집니다. 그 안에서 sticky 날짜 라벨이 동작하며, 그룹 경계에서 자연스럽게 push-up됩니다.

Separator는 스크롤 위치에 영향을 주는 일반 아이템으로 측정되어, sticky 라벨과 하나처럼 연결됩니다.

왜 이중 구조인가?

GroupWrapper (오버레이)

sticky 날짜 라벨의 활동 범위를 제한합니다. absolute 위치로 레이아웃에 영향 없이 배치되며, height를 그룹 내 아이템 높이의 합으로 설정합니다. CSS sticky가 이 범위 안에서만 동작하므로 그룹 끝에서 자연스럽게 push-up됩니다.pointer-events: none으로 클릭은 아래 아이템으로 투과됩니다.

Separator (구분선)

일반 아이템으로 취급되어 Measure로 래핑됩니다. heightMap에 높이가 기록되어 childPositions에 반영되므로, 다른 아이템들의 position에 영향을 줍니다. 그룹 높이에 separator 높이도 포함되어 GroupWrapper의 height가 정확하게 설정됩니다. 아이템이 추가/제거되면 높이가 자동으로 재계산됩니다.

sticky 착지 효과

GroupWrapper의 height가 정확해야 착지 타이밍이 맞습니다. 그룹 A의 끝에 도달하면 "1월 15일" 라벨이 밀려 내려오고, separator와 만나면서 "1월 16일" 라벨이 새로 떠오릅니다. separator가 없으면 날짜 라벨이 그냥 사라져서 어색합니다.

스크롤 중 (그룹 A 영역)

1월 15일 수요일
1월 16일

날짜 라벨이 상단에 떠 있음

그룹 A 끝 도달 (push-up)

1월 15일 수요일 ↑
1월 16일

"1월 15일"이 밀려 내려오고 "1월 16일"이 떠오름

GroupWrapper의 height 계산

GroupWrapper의 높이는 그룹 내 모든 아이템 높이의 누적합으로 계산됩니다. separator도 일반 아이템으로 취급되어 heightMap에 높이가 기록되므로, 그룹 높이에 자연스럽게 포함됩니다.

각 그룹의 높이를 매번 순회하며 계산하는 대신,누적합을 미리 계산해두어 특정 그룹의 시작 위치나 높이를 O(1)로 바로 조회할 수 있도록 최적화했습니다.

sep-A 32px
msg-1 64px
msg-2 48px
msg-3 120px
sep-B 32px
msg-4 64px
msg-5 48px

GroupWrapper A

top = 0, height = 32 + 64 + 48 + 120 = 264px

GroupWrapper B

top = 264px, height = 32 + 64 + 48 = 144px

Slack 코드 분석GroupWrapper + Separator 이중 구조CSS sticky누적합 높이 계산Push-up 효과
채팅

03

양방향 무한 스크롤

채팅 앱처럼 상단(과거)과 하단(미래) 양방향으로 데이터를 로드할 때, 스크롤 위치를 정확하게 보존하는 것이 핵심 과제입니다.

prepend → scrollTop += diff

append → stick-to-bottom 차단

가드 Ref로 위치 보존

위로 스크롤 시 과거 메시지가 prepend되면 scrollTop을 보정하여 화면 점프를 방지합니다.

아래로 스크롤 시 새 메시지가 append되면 stick-to-bottom을 차단하여 유저가 보던 위치를 유지합니다.

Backward (prepend): 위로 스크롤해서 과거 메시지를 로드하면 위에 컨텐츠가 추가됩니다. scrollTop은 그대로인데 기존 아이템의 위치가 아래로 밀리므로 화면이 점프합니다.

Forward (append): 아래로 스크롤해서 새 메시지를 로드하면 stick-to-bottom이 활성화되어 있을 때 새 아이템 추가 시 자동으로 맨 아래로 끌려갑니다. 유저가 보고 있던 위치를 잃게 됩니다.

해결: 방향별 가드 ref + totalHeight effect

도달 감지 (scrollTop 변경 시)

상단 도달

backwardLoadingRef = true

prevScrollHeightRef = scrollHeight

onStartReached() 호출

하단 도달

forwardLoadingRef = true

onEndReached() 호출

새 아이템 도착 → 측정 시작 → 측정 완료 → totalHeight 변경

totalHeight effect (측정 완료 시)

backward?

diff = 새 scrollHeight - 저장값

el.scrollTop += diff

→ 위치 보존 완료

forward?

아무것도 안 함

stick-to-bottom 차단

→ 측정 완료 후 해제

둘 다 아님?

isAtBottom이면

stick-to-bottom 유지

→ 하단 자동 유지

가드 ref의 생명주기

Backward (prevScrollHeightRef)

설정: 상단 도달 시 현재 scrollHeight 저장. 유지: isMeasuring 중에는 아직 정확한 높이를 모르므로 유지. 해제: !isMeasuring이고 diff 보정 완료 시. prepend된 만큼 scrollTop을 보정하여 유저 시점에서 화면 변화가 없습니다.

Forward (forwardLoadingRef)

설정: 하단 도달 시 true. 유지: isMeasuring 중에는 높이가 확정되지 않으므로 유지. 해제: !isMeasuring 시 false로 변경. stick-to-bottom을 차단하여 유저가 직접 스크롤해서 내려가야 합니다.

두 가드 ref 모두 도달 감지에서 설정하고 totalHeight effect에서 해제하는 동일한 패턴을 따릅니다. 이렇게 하면 비동기 데이터 로드 → 측정 → 위치 보정까지의 전체 사이클이 하나의 가드로 보호됩니다..finally()에서 해제하면 React 리렌더 전에 가드가 풀려 중복 호출이 발생할 수 있으므로, 반드시 측정 완료 후 useLayoutEffect에서 해제해야 합니다.

scrollTop 보정stick-to-bottom 차단가드 Ref 패턴onStartReached / onEndReacheduseLayoutEffect 해제

04

Chat App Patterns

가상 스크롤 라이브러리는 "받은 데이터를 가상화"하는 엔진입니다. 채팅 앱 특유의 데이터 로딩/네비게이션 패턴은 소비자가 라이브러리 API를 조합하여 구현합니다.

1
2
3
...
80
81
82
...
108
109
110
...
198
199
200

로드됨 (items)

미로드 (서버)

부분 로딩 윈도우

채팅은 전체 메시지의 일부만 로드합니다. 라이브러리는 받은 items만 가상화하며, "뒤에 더 있는지"는 소비자가 관리합니다.

메시지 전송, 검색 이동 등 모든 액션이 isLastMessageLoaded 상태에 따라 분기됩니다.

채팅은 전체 메시지의 일부분만로드합니다. 라이브러리는 받은 items가 전부인 줄 알고 가상화하며, "뒤에 더 있는지"는 소비자가 관리합니다.

패턴 1: 마지막 읽은 메시지에서 열기

서버에서 lastRead 기준 위 20개 + 아래 10개를 fetch하고, initialScrollPosition으로 lastRead 위치에서 채팅을 엽니다. 위로 스크롤하면 onStartReached로 과거 메시지를, 아래로 스크롤하면 onEndReached로 최신 메시지를 로드합니다.

패턴 2: 메시지 전송 시 분기

isLastMessageLoaded = true

197
198
199
200
+

items에 append

stick-to-bottom이 처리

isLastMessageLoaded = false

80
81
82
198
199
200
+

최신 범위 re-fetch + append

scrollToBottom() (큐잉 지원)

패턴 3: 메시지 이동 (로드된 vs 로드 안된)

로드된 메시지로 이동

80
81
82
83
84

scrollRef.scrollToItem(idx, "center")

→ 즉시 실행 (측정 불필요)

로드 안된 메시지로 이동

10
11
12
13
14
← fetch 필요

setMessages(fetched)

scrollRef.scrollToItem(idx, "center")

→ 자동 큐잉 (측정 완료 후 실행)

핵심 차이: 로드 안된 메시지는 items 교체 → 높이 재측정이 필요합니다. ref의 imperative API(scrollToItem, scrollToBottom)는 내부에 큐잉 로직이 있어서, 측정 중에 호출해도 측정 완료 후 자동 실행됩니다. 소비자가 타이밍을 신경 쓸 필요가 없습니다.

큐잉 로직이란?

로드되지 않은 메시지로 이동하려면, 새로운 데이터를 fetch한 뒤 사전 렌더링으로 높이를 측정해야 합니다. 그런데 측정이 끝나기 전에 scrollToItem을 호출하면, 아직 높이를 모르는 상태에서 스크롤이 실행되어 엉뚱한 위치로 이동합니다. 큐잉 로직은 이 문제를 해결하기 위해, 측정 중에 호출된 스크롤 요청을 저장해두었다가 측정이 완료된 후 자동으로 실행합니다.

단일 슬롯 방식으로 마지막에 요청된 스크롤 동작만 유지하므로, 측정 중에 여러 번 호출되더라도 가장 마지막 요청만 실행되어 불필요한 스크롤이 발생하지 않습니다.

// 스크롤 액션을 저장할 단일 슬롯

const pendingScrollRef = useRef<(() => void) | null>(null);

// scrollToItem 호출 시

const action = () => innerRef.current?.scrollToItem(index, align);

isMeasuring ? (pendingScrollRef.current = action) : action();

// 측정 완료 시 자동 실행

if (!isMeasuring && pendingScrollRef.current) {

pendingScrollRef.current();

pendingScrollRef.current = null;

}

측정 중이면 액션을 저장하고, 측정이 아니면 즉시 실행합니다. 측정이 완료되면 저장된 액션이 자동으로 실행되어, 소비자는 측정 타이밍을 전혀 신경 쓸 필요가 없습니다.

라이브러리 vs 소비자 책임

라이브러리가 제공하는 것

  • scrollToItem(index, align) — 큐잉 지원
  • scrollToBottom(behavior) — 큐잉 지원
  • onStartReached / onEndReached
  • initialScrollPosition
  • onAtBottomChange

소비자가 구현하는 것

  • isLastMessageLoaded 상태 관리
  • isFirstMessageLoaded 상태 관리
  • 데이터 fetch 로직
  • 메시지 전송 시 분기 처리
  • 검색 시 범위 판단 + fetch
  • 로드 범위 (start/end) 추적

라이브러리에 채팅 도메인 로직을 넣으면 범용성이 떨어집니다. 라이브러리는 가상화 엔진 + 스크롤 제어 API만 제공하고, 소비자가 이를 조합하여 채팅 UX를 구현하는 것이 헤드리스 설계 원칙에 맞습니다.

부분 로딩 윈도우isLastMessageLoaded 분기scrollToItem 큐잉헤드리스 설계initialScrollPosition