react-virtualized 스크롤 복원
2024-02 Hence와 Vortex DAO 작업 중 사내에 공유한 기술 문서. windowing 라이브러리 react-virtualized의 WindowScroller로 만든 목록에서 스크롤 위치를 복원하는 방법과, 레이아웃 변화로 생긴 이슈 10건의 원인과 해결을 정리했다. 라이브러리 기초 사용법은 다루지 않는다. 그림과 영상이 있던 원문 PDF는 git 이력에 있다.
전제
WindowScroller를 쓰면 가상 목록의 스크롤이 window 스크롤과 동기화된다. 목록 위에 다른 영역이 있으면 window scrollTop과 가상 목록의 scrollTop이 달라진다. 가상 목록은 뷰포트를 벗어나는 순간부터 스크롤 값을 갖기 때문이다. 라이브러리가 제공하는 scrollToIndex, scrollToRow, scrollToPosition은 WindowScroller와 함께 쓰면 제대로 동작하지 않아 다른 방식으로 복원했다.
복원에 필요한 두 값
- 떠날 때의 가상 목록 scrollTop. window 값이 아니라 라이브러리 구현체의 값이 필요해서
WindowScroller에 ref를 걸어 읽는다. - 각 행의 측정 높이. 높이가 동적이면 렌더링 시작 때의
defaultHeight와 측정 뒤 높이가 달라서, 마지막으로 측정한 높이를 저장했다가 복원 시점에CellMeasurerCache에 주입한다.
const cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: height })
if (initRowsHeight) {
(cache as any)._rowHeightCache = { ...initRowsHeight }
(cache as any)._cellHeightCache = { ...initRowsHeight }
}
const rowsCacheHeight = useRef<Map<string, number>>(new Map())
const saveRowsHeight = ({ index }: ListRowProps) => {
rowsCacheHeight.current.set(`${index}-0`, memoizedCache.rowHeight({ index }))
}
저장은 Zustand 스토어에 한다. Next.js에서는 routeChangeStart 이벤트에서, React 단독 환경에서는 페이지 컴포넌트 unmount에서 저장한다. 위치와 높이를 돌려주는 getPosition, getRowHeight는 변수가 아니라 함수로 둔다. 변수면 첫 렌더링 값에 묶인다.
useEffect(() => {
const saveCacheDataHandler = () => {
const position = getPosition()
cacheSlice.saveCacheData({
scrolly: position.windowScroll,
rowsHeight: getRowHeight(),
virtualScrollY: position.virtualScroll,
})
}
events.on('routeChangeStart', saveCacheDataHandler)
return () => events.off('routeChangeStart', saveCacheDataHandler)
}, [events])
복원할 때 지킬 세 가지
CellMeasurerCache를 초기화하지 않는다. 초기화되면 모든 행이 기본 높이로 돌아갔다가 다시 측정되며 레이아웃이 줄었다 늘어난다. 꼭 필요할 때만cacheFlag의존성으로 새 캐시를 만든다.- 스토어에 저장한 데이터와 React Query 캐시 데이터가 같아진 뒤에 렌더링한다. 그 전에 그리면 엉뚱한 위치에 도착한다. infinite query는 캐시에서 돌려줄 때 로드했던 페이지를 한 번에 주므로 배열 길이로 비교한다.
- React Query가 새 데이터를 받아 왔으면 복원하지 않는다.
gcTime이 지나 캐시가 비면 스토어 값만 남아 영원히 렌더링되지 않을 수 있다.isLoading이 켜지면 만료 플래그를 세운다.
useEffect(() => { if (isLoading) setIsExpired(true) }, [isLoading])
{fetchedData?.length > (!isExpired ? storeData.length : 0) && (
<VirtualScrollContainer {...props}>{(props) => <Children {...props} />}</VirtualScrollContainer>
)}
복원 값은 <List scrollTop={initScrollY || scrollTop} />처럼 주입한다. List의 scrollTop은 window와 연결돼 있어 주입한 값을 지우지 않으면 스크롤해도 계속 그 위치로 돌아간다. 그래서 initScrollY는 useState로 두고 도착한 뒤 undefined로 되돌린다.
트러블슈팅
| 이슈 | 원인 | 해결 |
|---|---|---|
| 1. 목록 위에 다른 콘텐츠 영역이 있다 | 가상 목록 scrollTop은 뷰포트를 벗어난 뒤에야 생긴다 | 가상 scrollTop이 없으면 window scrollTop으로 복원한다. 채널 소개 영역 아래 게시물 영역이 있는 화면이 이 경우다 |
| 2. 목록 안 동영상을 전체 화면으로 전환하면 꺼진다 | 브라우저가 전체 화면에서 window 스크롤을 0으로 바꾸고, 동기화된 가상 scrollTop도 0이 돼 노드가 뷰포트에서 사라진다 | fullscreenchange 리스너에서 window와 가상 scrollTop 연결을 잠시 끊고 내부 값으로 고정했다가, 종료 시 저장해 둔 window scrollTop으로 되돌린다 |
| 3. 레이아웃이 줄었다 늘어난다 | 데이터나 상호작용마다 cache.clear()를 호출해 기본 높이로 돌아갔다 | cache.clear()를 쓰지 않는다 |
| 4. 복원 위치가 자꾸 어긋난다 | fetch → convert → ViewModel 순서에서 convert가 끝나기 전에 목록을 그렸다 | 캐시 데이터와 스토어 데이터가 같아진 뒤에 렌더링한다 |
| 5. 레이아웃이 바뀌면 스크롤 영역이 사라진다 | WindowScroller가 위치를 다시 계산하지 않았다 | updatePosition()을 직접 호출한다 |
| 6. 목록 아래에 “모든 게시물을 확인했어요” 같은 요소가 있으면 복원 위치로 계속 되돌아간다 | 도착 지점 오차 재계산에 그 요소 높이까지 포함된다 | IntersectionObserver로 요소가 뷰포트에 들어오면 그 높이만큼 오차 버퍼를 늘린다 |
| 7. 스크롤이 끊긴다 | 행이 DOM에 붙을 때 padding-bottom: 56.16% 같은 백분율 패딩이 reflow를 일으켰다 | 고정 픽셀 값이나 다른 CSS로 바꾼다 |
| 8. 오래 비웠다 돌아오면 목록이 안 그려진다 | gcTime으로 캐시는 비었는데 스토어 값은 남아 “같아질 때까지 대기”에 걸린다 | 만료 플래그로 복원을 건너뛴다 |
| 9. 다른 목록에서도 이전 스크롤로 복원된다 | 복원 뒤 저장 데이터를 지우지 않았다 | 필터 변경 같은 사용자 액션에서 플래그를 바꾸고 맨 위로 보낸다. 탭마다 스크롤을 따로 저장하는 방법도 있다 |
| 10. Safari, iPhone 13 mini에서 0이나 엉뚱한 값으로 복원된다 | initScrollY를 의존성으로 둔 useEffect와 라이브러리 스크롤 이벤트의 순서를 보장할 수 없다 | 50ms 간격으로 도착 여부를 확인하고 200ms에 멈추는 인터벌 방식으로 바꿔 의존성을 없앤다. 200ms는 사용자가 돌아와 첫 동작을 하기까지의 시간이다 |
useEffect(() => {
let ms = 0
const intervalId = setInterval(() => {
if (ms <= INTERVAL_TIME.TWO_HUNDRED_MS) {
checkAndSetScrollTop()
ms += INTERVAL_TIME.FIFTY_MS
} else {
clearInterval(intervalId)
setForceScrollTop(undefined)
}
}, INTERVAL_TIME.FIFTY_MS)
return () => clearInterval(intervalId)
}, [])
const checkAndSetScrollTop = () => {
if (virtualScrollTop) {
const diff = Math.floor(scrollRef.current - virtualScrollTop)
if (Math.abs(diff) > THRESHOLD.SCROLL) setForceScrollTop(virtualScrollTop)
} else if (scrollTop) {
setTimeout(() => window.scrollTo({ top: Math.floor(scrollTop) }), 10)
}
}
인터벌 복원은 정답이 아니라 우회책이다. 모든 경우를 덮지는 못하지만 대체 방안이 없을 때 참고할 만하다. 이슈 10은 QA 티켓 QAH-906, QAH-747로 들어왔다.
구현체 인터페이스
useVirtualScrollPropsGetter 훅이 scrollTop과 행 높이, 저장 콜백, WindowScroller ref 핸들러를 돌려주고, VirtualScrollContainer가 그 값을 InfiniteLoader, WindowScroller, AutoSizer, List에 주입한다.
interface VirtualScrollContainerProps<T> extends Pick<InfiniteLoaderProps, 'loadMoreRows'> {
data: unknown[]
cache: CellMeasurerCache
children: (props: VirtualScrollChildProps<T>) => ReactNode
windowScroll?: boolean
onWindowScrollMounted?: (ref: WindowScroller | null) => void
onRowRendered?: (props: ListRowProps) => void
scrollY?: number | null
virtualScrollY?: number | null
scrollDiffThreshold?: number
}
<VirtualScrollContainer
data={rows}
cache={cache}
loadMoreRows={() => fetchNextPage()}
onWindowScrollMounted={updateWindowScrollerHandler}
onRowRendered={saveRowsHeightHandler}
scrolly={!expiredRef.current ? loaded?.scrollY : 0}
virtualScrollY={!expiredRef.current ? loaded?.virtualScrollY : 0}
scrollDiffThreshold={loaded?.scrollDiffThreshold ? ref?.offsetHeight : 0}
>
{(props) => <ChildComp {...props} />}
</VirtualScrollContainer>
참고
- 오늘의집 블로그, 무한 스크롤 개발기
- LogRocket, Implementing scroll restoration in ecommerce React apps
- web.dev, 대규모 목록 가상화