posts

react-virtualized scroll restore & trouble shooting

Apr 23, 2026 updated Apr 23, 2026 reactreact-queryuxzustand

목차

버츄얼리스트 문서 목적 소개

이 문서를 읽으셨으면 하는 대상

해당 문서는windowing[주석 1]기능을 제공하는react-virtualized library를 사용하면서 필요했던 기능인 스크롤 복원과, 유저의 인터랙션 및 기타 상호작용에 의하여 레이아웃이 변경됨에 따라 발생하는 이슈들을 정리하고 해결하는 방법에 초점을 두고 작성되었습니다. 따라서 라이브러리의 기초적인 사용방법을 다루지 않으므로, 해당 라이브러리에 대한 구현체 또는 사용법에 관한 내용은 아래 링크를 참고하여 제공되는 샘플 및 문서를 읽어보시면 좋을 것 같습니다.

[주석 1]virtualized의 주요 기능인windowing은 뷰포트에 현재 보이는 항목만을 렌더링하고, 스크롤 등의 상호작용이 발생할 때에만 필요한 항목을 렌더링하여 성능을 최적화하는 기술입니다.

들어가기에 앞서 잠깐

이 문서는react-virtualized library에서 제공하는컴포넌트를 추가적으로 사용했으며. 해당 컴포넌트는virtualized-list의 스크롤이 윈도우 스크롤과 동기화되는 추가적인 기능을 제공합니다.

react-virtualized scroll restore & trouble shooting 이미지 1

윈도우 스크롤 버츄얼 리스트에 대한 [그림 1] [영상 1 & 2]

image-20240201-001543.png

화면 기록 2024-01-23 오후 6.44.31.mov화면 기록 2024-01-23 오후 6.45.52.mov

해당 문서는 프로젝트 구축 시에 요구사항에 맞춰를 사용해 구현하였던virtualized-list을 설명할 예정이며, 같은 케이스가 아닌경우 차이가 있을수도 있습니다.

버츄얼리스트의 스크롤 복원

해당 챕터에서는react-virtualized의 스크롤을 복원하는 방법에 대하여 설명합니다. 스크롤을 복원하기 위해 추가적으로react-query와zustand를 사용하며. 해당 라이브러리들에 대한 사용법은 따로 설명하지는 않습니다, 하지만 라이브러리들이 스크롤 복원에 어떤 방식으로 함께 사용되는지에 대한 내용은 아래에 포함되어 있습니다.

react-virtualized의 경우 자체적으로scrollToIndex,scrollToRow,scrollToPosition등 여러가지 인터페이스를 제공하고있습니다. 하지만 몇가지 기능들은 현재 제대로 작동하지 않고, 요구사항에 맞춰 구현할 수 없기때문에 다른 방식으로 스크롤을 복원하였습니다.

복원하기 전에 알아두셔야 할 내용

위에서 설명했듯이를 사용할 경우virtualized-list의 스크롤은 윈도우와 동기화됩니다.

그러나virtualized-list의 영역 위에 추가적인 영역이 존재할 경우 윈도우의 스크롤 위치(window scrollTop)와버츄얼 리스트의 스크롤 위치(virtual scrollTop)가 차이가 나게 됩니다.virtualized-list는 뷰포트를 벗어날 때부터 스크롤 값을 갖기 때문입니다.[그림 1-1]

image-20240214-021222.png

스크롤을 복원하기 위한 준비물

To Long, Didn’t read

마지막으로 보던 스크롤 위치: 스크롤 위치를 복원할 때 사용됩니다.

virtualized-list의rowsHeight: 스크롤을 복원할 때 해당 높이값을 사용합니다.

첫번째로는 화면을 떠날때 마지막으로 보던 스크롤 위치(scrollTop)입니다. 마지막으로 보던 스크롤 위치[주석 2]는 윈도우의 스크롤 위치(window scrollTop)가 아닌 버츄얼 리스트의 스크롤 위치(virtual scrollTop)가 필요하기 때문에 해당 라이브러리의 구현체에서 스크롤 값을 받아와 저장합니다. 구현체의 스크롤 값은ref object를virtualized list에 연결하여 받아오도록 할 수 있습니다.[코드 1 & 2]

[주석 2]저장할 스크롤값으로window scrollTop을 사용하지 않는 이유는virtuali scrollTop이 언제부터 존재하는지 알아야하고, 해당 건에 대한 설명은 트러블 슈팅 챕터의[이슈 1]에서 다루고 있습니다.

const windowScroller = useRef(); const updateWindowScrollerHandler = (ref: WindowScroller | null) => { if (ref) windowScroller.current = ref; };

코드 1

const VirtualScrollContainer = (props: VirtualScrollContainerProps) => { return <WindowScroller ref={(ref) => updateWindowScrollerHandler?.(ref)} />; };

코드 2

[코드 2]처럼ref를 연결했을 경우virtualized list가 스크롤링 될 때 마다ref값에서virtual scrollTop에 접근할 수 있습니다.는virtualized List의 스크롤 위치에 따라 자동으로 윈도우를 조절하는데 사용되며,window객체와 스크롤 이벤트를 사용하여 현재 스크롤 위치에 따라 내부 컨텐츠를 조절합니다.

두번째virtualized list의rowsHeight입니다. 일반적으로 사용할 때 높이가 고정값이라면 상관없습니다만, 동적으로 높이가 변하는 경우 렌더링이 시작될 때 높이값과measure이후 높이가 달라지기 때문에 마지막으로 변경된 높이값을 저장해야됩니다. 저장한 높이값은 복원하는 시점에virtualized CellMeasurerCache에 주입[코드 3]합니다.

const cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: height }); if (initRowsHeight) { (cache as any)._rowHeightCache = { ...initRowsHeight }; (cache as any)._cellHeightCache = { ...initRowsHeight }; }

코드 3

리스트가 그려지거나 갱신되거나 하는 모든 상황에서CellMeasurerCache의 값은 업데이트됩니다. 위의 코드에서defaultHeight은 렌더링이 시작될 때 모든 리스트의 행들이 갖게되는 기본 높이이며, 해당 행들이 다시 측정될때마다 값은 덮어씌워지게 됩니다. 이 시점에마지막으로 저장할 높이값을rowsCacheHeight[코드4]에 저장합니다. 이 경우에는[코드 2]처럼dom에 직접 연결하지 않고 값 저장용으로ref를 사용합니다.virtualized의는 행을 렌더링하는것을 함수 프롭스로 제공하기 때문에 프롭스에 넘기는 함수에 높이값을 저장하는 콜백함수[코드 4 & 5]를 추가합니다. 이제 매번 행을 렌더링 할 때마다rowsCacheHeight에 측정된 높이가 저장됩니다. 스크롤을 복원하고자 할 때 카드들의 높이는 마지막으로 측정된 값으로 주입되어야 렌더링을 할 때 기본 높이값을 사용하지 않고 주입된 높이값으로 세팅이 되며, 도착지점의 스크롤로 제대로 이동할 수 있습니다.

const rowsCacheHeight = useRef<Map<string, number>>(new Map()); const saveRowsHeight = ({ index }: ListRowProps) => { rowsCacheHeight.current.set(${index}-0, memoizedCache.rowHeight({ index })); };

코드 4

<List rowRenderer={(props) => { const { key, style, index, parent } = props; onRowRendered?.(props); return {({ measure }) => children()}; }} />;

코드 5

세번째위의scrollTop & rowsHeight을 저장하고 스크롤을 복원할 때 다시 주입하는 것입니다. 저장하는 방식과 시점은 구현하는 방식에 따라 달라질 수 있으며 이 글에서는zustand를 사용하여 저장합니다. 위 링크 중 ‘페이지의 마지막 상태 복원에 대한 방법' 문서에 기본적으로 마지막 상태의 저장에 관한 방법이 정리되어 있습니다.

스크롤 값 및 데이터 정보 저장은 라우트가 변경될 때ref에 저장해두었던 값들을 저장합니다.[코드 6 & 7]

getPosition과getRowHeight함수는 컴포넌트의 상태나 프로퍼티를 참조하고 있고. 상태나 프로퍼티가 변경될 때마다 새로운 값을 계산합니다. 변수로 선언할 경우, 컴포넌트가 렌더링 될 때 한 번만 계산되고 그 이후에는 업데이트되지 않기 때문에 상태나 프로퍼티가 변경되더라도 값이 반영되지 않습니다. 하지만 함수로 선언할 경우, 해당 함수가 호출될 때마다 새로운 값을 반영하기 때문에getPosition과getRowHeight는 함수로 사용합니다.

export const useVirtualScrollPropsGetter = <T>(props: Init<T>): Getter<T> => { const windowScroller = useRef<WindowScroller>(); const windowY = useRef(0); const getPosition = (): ScrollRestore => { return { windowScroll: windowY.current, virtualScroll: windowScroller.current?.state.scrollTop || 0 }; }; const getRowHeight = () => { return Object.fromEntries(rowsCacheHeight.current); }; return { getPosition, getRowHeight }; };

코드 6

const { events } = useRoute(); const { cacheSlice } = useStore(); useEffect(() => { const saveCacheDataHandler = () => { const rowsHeight = getRowHeight(); const position = getPosition(); const scrolly = position.windowScroll; const virtualScrolly = position.virtualScroll; cacheSlice.saveCacheData({ scrolly, rowsHeight, virtualScrollY: virtualScrolly }); }; events.on("routeChangeStart", saveCacheDataHandler); return () => { events.off("routeChangeStart", saveCacheDataHandler); }; }, [events]);

코드 7

[코드 6 & 7]의 경우는next.js의 환경에서 구현되었기 때문에useRoute의event객체를 사용하여 데이터를 저장하고 있습니다.react환경에서 구현할 경우unmount시점에 저장하도록 구현[코드 8]할 수 있습니다. 이벤트리스너 형식은 아니지만 라우팅 이동시 저장, 페이지 컴포넌트 언마운트시 저장 정도의 차이가 있기 때문에 크게 차이는 없습니다. 기타 함수와 변수는 프로젝트에 따라 추가적인 정보의 차이이며 저장로직과는 관계가 없습니다.

useEffect(() => { return () => { saveState({ data: _merge({}, savePageInformationEn(), virtualScrollInfoRef.current) }); }; }, [/* dependencies */]);

코드 8

스크롤을 복원해야 하는 시점에서 주의해야할 세가지

To long, Didn’t read

이후 유저가 페이지에 되돌아왔을 때 스크롤 복원이 필요해집니다. 이 시점에서 저장해두었던 데이터를 꺼내옵니다. 꺼내온 뒤에는virtualized list에 필요한 값들을 세팅하고 렌더링해야합니다. 하지만 렌더링을 하기 전 주의해야할 점이 몇 가지 있습니다.첫번째cache를 가능하면 초기화하지 않도록 해야 레이아웃에 관한 이슈[주석 3]가 생기지 않습니다.cache가 초기화되는 순간 모든 세팅이 초기화 된 뒤 다시 설정되기 때문에 확실하게 초기화 시켜야되는 경우가 있는 경우에만cacheFlag에 의존성을 추가합니다.[코드 9]

const cache = useMemo(() => { return new CellMeasurerCache({ fixedWidth: true, defaultHeight: height }); }, [cacheFlag || []]);

코드 9

두번째store에 저장했던 데이터와react-query캐시 데이터가 동일할 경우에 렌더링이 되도록 해야합니다. 캐시 데이터와 스토어 데이터가 동일하다는 판단이 이루어지기 전에 렌더링을 하게될 경우 잘못된 위치로 도착하는 현상[주석 4]이 간헐적으로 발생합니다.[코드 10]에서는 동일 비교 플래그를 배열의 길이로 체크하고 있는데, 이는infiniteQuery를 호출할 때react-query에서 캐시 데이터로 돌려받는 경우 로드했던 페이지들을 한 번에 전부 리턴하기 때문입니다.

{fetchedData?.length > storeData.length && ( <VirtualScrollContainer {...props}> {(props) => <Children {...props} />} )}

코드 10

세번째react-query에서 패칭한 데이터가 새로운 값으로 돌려받을 경우 복원하지 않도록 해야합니다. 일반적으로react-query에서는 기본값으로 5분의gcTime[주석 5], 비동기 작업이 진행중인지에 대한isLoading[주석 6]플래그를 제공합니다. 프로젝트의 설정마다 다르게 설정되기 때문에 내부적으로store에 저장하는 값과 비교[코드 11]를 할 경우 주의가 필요합니다.store의 경우 영구적으로 저장할 수도 있고,expired-time을 설정할 수도 있습니다. 만약 새로운 데이터를 패칭받은 경우에도 스토어의 데이터로 복원할 경우 이슈[주석 7]가 발생합니다.

useEffect(() => { if (isLoading) setIsExpired(true); }, [isLoading]); {fetchedData?.length > (!isExpired ? storeData.length : 0) && ( <VirtualScrollContainer {...props}> {(props) => <Children {...props} />} )}

코드 11

[주석 3]캐시가 초기화되면서 생기는 이슈에 대해서는 트러블 슈팅 챕터[이슈 3]을 참고해주세요.

[주석 4]캐시 데이터와 스토어 데이터가 동일하다는 판단이 이루어지기 전에 렌더링을 하게될 경우 잘못된 위치로 도착하는 현상은 트러블 슈팅 챕터[이슈 4]를참고해주세요.

[주석 5]gcTime은 패치받은 데이터가 캐시에 얼마동안 유지될지 결정하는 옵션입니다. 이 시간 안에 동일한 쿼리를 호출할 경우react-query는 캐시데이터를 즉시 리턴하며 네트워크 호출을 하지 않고 종료합니다.

[주석 6]isLoading은 네트워크 호출을 캐시 데이터로 리턴할 경우, 호출이 완료된 경우false로 전환됩니다.

[주석 7]새로운 데이터를 패칭받은 경우에도 스토어의 데이터로 복원할 경우의 이슈는 트러블 슈팅 챕터[이슈 9]를 참고해주세요.

스크롤 복원값 세팅과 컴포넌트 렌더링 단계

이제 스크롤을 복원하기 위해 필요한 데이터들과 주의점들을 다 체크한 뒤값을세팅하고 렌더링하는 단계입니다.

컴포넌트의 구성 및 프롭스 전달 구조는 아래 링크에 설명되어 있습니다.

View Component Logic Flow

이 문서의virtualized list는useVirtualScrollPropsGetter Hook과컴포넌트로 구성되어 있으며, 훅에서 세팅할 프롭스들을 관리하고 컨테이너에서 전달받은 프롭스들을 주입합니다. 위에서 저장하는 값들을 설명했기 때문에 어떻게 세팅하는지에 대해서만 다루겠습니다.

먼저 스크롤을 주입해야하는 곳은 라이브러리의에서 제공하는scrollTop입니다. 해당 부분은 기본적으로에서 제공하는scrollTop값을의scrollTop프롭스에 연결[코드 12]하여 윈도우의 스크롤과 버츄얼리스트의 스크롤이 싱크가 맞게 움직이도록 되어있습니다. 따라서scrollTop에 복원하고자하는 스크롤 값을 주입[코드 13]합니다.

{({ scrollTop }) => } ;

코드 12

{({ scrollTop }) => <List scrollTop={initScrollY || scrollTop} />} ;

코드 13

그러나,[코드 13]은 한가지 문제가 있습니다. 위에서 설명했듯이의scrollTop은 윈도우의 스크롤과 연결되어있기 때문에, 주입한initScrollY를 제거하지 않는다면 아무리 스크롤을 하더라도 다시 그 위치로 되돌아가게 되며, 스크롤을 복원한 뒤 값을 제거해야 정상적으로 작동하므로,initScrollY는useState로 관리합니다. 결국 프롭스로 전달받은 복원 스크롤 값은initScrollY에 세팅되고 스크롤이 도착한 뒤부터는 다시undefined로 세팅됩니다.[그림 2]

image-20240214-082757.png

스크롤 복원 챕터에서 설명하고자 했던 중요한 개념은 복원시 필요한 데이터들과 초기값 세팅에 대한 것입니다.

아래 트러블슈팅 챕터 또한 위의 개념들을 사용했기 때문에 꼭 읽어보신 후 넘어가주시면 좋을 것 같습니다.

버츄얼리스트 이슈 트러블슈팅

해당 챕터에서는react-virtualized를 사용하면서 발견된 이슈들을 이슈설명과 해결방법 그리고 추가적인 시각자료를 첨부하여 설명합니다. 위 주석들에 관련된 내용 이외에도 여러가지 이슈들을 정리합니다.

[이슈 1] 버츄얼 리스트 영역 위에 추가적인 컨텐츠 영역이 존재하는 경우

문제상황 :window virtualized List가 뷰포트를 벗어날때부터virtualize scrollTop의 값[그림 3]이 생기기 시작하며, 이는virtualized List를와 함께 사용하고 있기에 발생합니다. 때문에virtual scroll area위에 추가적인content area가 존재하는 경우에 기존처럼virtualized scrollTop값을 저장하고 복원할 경우 올바른 위치로 복원되지 않습니다.

image-20240201-003817.png

해결방법 :virtualized scrollTop값이 없는 경우window scrollTop으로 복원[코드 16]합니다. 윈도우 스크롤 값을 같이 저장하는 코드[코드 6 & 7]를 이미 설명했으므로, 두 개의 값을 어떻게 저장하는지에 대한 설명은 생략합니다. 기본적으로 추가적인content area영역은virtualized List영역과는 관련이 없기 때문에virtualized scrollTop값이 없는 경우window scrollTo로 윈도우의 스크롤을 이동시킵니다.

if (virtualScrollY) { // virtualize scrollTop setForceScrollTop(virtualScrollTop); } else if (scrollY) { // window scrollTop setTimeout(() => window.scrollTo({ top }), 10); }

코드 16

아래 링크에서 게시물 영역이virtual scroll area, 채널 소개 영역이content area이므로, 게시물 영역이 뷰포트 밖으로 나가지 않았다면 윈도우 스크롤을 복원하고 뷰포트 밖으로 나갔다면 버츄얼 스크롤 값을 복원합니다.

[이슈 2]window virtualized list에서 동영상을 전체화면으로 전환하는 경우

문제상황 :전체화면을 할 경우 브라우저의 기본 동작은 윈도우의 스크롤을 0으로 변경[주석 8]합니다. 따라서window scrollTop과 싱크를 맞추는window virtualized scrollTop의 값도 0으로 변경[그림 4]됩니다. 값이 0으로 변경되면 현재 스크롤도 0으로 이동하게 되고, 뷰포트에 현재 노드가 사라지면서 동영상의 정보가 없기 때문에 전체화면이 바로 꺼지게 됩니다.[영상 3]

[주석 8]https://www.perplexity.ai/search/When-a-video-QGlZVj45Rear5aC29uxi.Q?s=c#40695956-3e39-45e6-abe5-a0b6f6ec62f9

image-20240201-004904.png

화면 기록 2024-01-23 오후 3.28.51.mov

해결방법 :전체화면으로 전환될 때 전체화면 모드 변경 이벤트를 감지하는 리스너를 추가[코드 17]하고, 그에 따라 버츄얼스크롤의현재 스크롤을 변하지 않도록 고정해놓았다가 다시 세팅합니다. 방법은window scrollTop과window virtualized scrollTop의 연결을 잠깐 끊고 내부적으로 관리하는 스크롤 값으로 변경하는 것[코드 18]입니다.

이후 전체화면이 종료될 때 0으로 바뀐 윈도우 스크롤을 저장해두었던window scrollTop값으로 다시 보내주고,virtualized scrollTop과 다시 연결합니다.

useEffect(() => { const onFullScreenChange = () => { const inFullScreen = Boolean(document.fullscreenElement); if (inFullScreen) { isFullscreen.current = true; } else { window.scrollTo(0, Math.floor(windowScrollY.current || 0)); isFullscreen.current = false; } }; document.addEventListener('fullscreenchange', onFullScreenChange); return () => { document.removeEventListener('fullscreenchange', onFullScreenChange); }; }, []);

코드 17

const lastScrollTop = useRef(0); const windowScrollY = useRef<number | undefined>(undefined); <List onScroll={(event) => { const scrollTop = event.scrollTop; const currentScrollTop = isFullscreen.current ? lastScrollTop.current : scrollTop; lastScrollTop.current = currentScrollTop; windowScrollY.current = window.scrollY; onChildScroll({ scrollTop: currentScrollTop }); }} />;

코드 18

의onScroll은의onChildScroll과 연결되어 있는데,onChildScroll는 리스트 내부의 스크롤 이벤트를 처리하기 위한 콜백 함수를 제공합니다. 콜백 함수는 스크롤 이벤트가 발생할 때 호출되기 때문에 풀스크린 이벤트가 발생할 때 관리되는 스크롤 값들을onScroll에서 처리합니다.

[이슈 3] 캐시가 초기화되면서 레이아웃이 줄어들었다가 다시 늘어나는 경우

문제상황 :대부분virtualized list를 사용하는 경우react-query의infinite query와 함께 사용하는 경우가 많습니다. 또 구현 초기에는fetching data가 바뀌거나 또는user interaction이 일어날때마다 캐시를 초기화하면서 해당 이슈가 발생했는데, 마지막으로 측정된 높이들로 세팅된list가defaultHeight으로 되었다가 다시 측정하기 때문입니다.

해결방법 :cache.clear()를 사용하지 않는 것입니다.

image-20240207-083110.png

[이슈 4] 렌더링 시점에 스크롤 복원시 자꾸 이상한곳으로 도착하는 현상

문제상황 :화면단에 노출되는 데이터들은fetch→convert→viewModel의 순서를 거쳐vac패턴이 적용된 컴포넌트에 프롭스로 전달됩니다. 이 때convert구간에서 추가적인 로직이나 데이터들이 들어가는 경우convert작업이 끝난 뒤viewModel을 리턴받을 때까지virtualized List의 렌더링을 멈춰두어야하는데, 미리 렌더링을 할 경우 이후에 데이터들이 추가되거나 레이아웃이 변경되면서 스크롤의 위치가 알맞지 않게 됩니다.

해결방법 :캐시 데이터와 스토어 데이터가 동일하다는 판단이 이루어지기 전에 렌더링을 합니다.

{fetchedData?.length > storeData.length && ( <VirtualScrollContainer {...props}> {(props) => <Children {...props} />} )}

코드 19

[이슈 5]버츄얼 리스트의 레이아웃이 변했을 때 스크롤 영역이 사라지는 현상

문제상황 :기본적으로WindowScroller는 자동으로 스크롤 위치를 감지하고 자식 컴포넌트를 업데이트합니다. 하지만 특정 상황에서 수동으로 업데이트[영상 4]해야 할 필요가 있는데. 업데이트가 되지 않아서 레이아웃이 사라지는 현상[영상 5]이 발생합니다.

화면 기록 2024-02-08 오전 11.43.40.mov화면 기록 2024-01-23 오후 3.45.43.mov

해결방법 :수동으로updatePosition()을 호출[코드 20]하여WindowScroller가 다시 현재 스크롤 위치를 계산하고 자식 컴포넌트를 업데이트 할 수 있도록 합니다.

const Component = () => { const windowScrollerRef = useRef(); const updateScrollPostionHandler = () => { windowScrollerRef.current.updatePosition(); }; return ( <> {({ scrollTop }) => ( )} </> ); };

코드 20

[이슈 6] 버츄얼 영역이 끝난 뒤에 추가적인 요소가 더 붙어있는 경우

문제상황 :버츄얼 스크롤의 도착지점이 복원지점과 오차가 있는경우 다시 재계산하도록 되어있습니다. 그러나 추가적인 돔요소가 있는 경우 그 요소까지 오차범위에 포함되기 때문에 다시 계산을 하게되며, 복원지점으로 돌아가는 현상이 발생합니다.[영상 6]처럼 “모든 게시물을 확인했어요”라는 영역이 버츄얼 스크롤 아래에 있기 때문에 버퍼를 초과해서 계속 되돌아가는것을 확인할 수 있습니다.

화면 기록 2024-01-23 오후 5.26.09.mov

해결방법 :돔요소에 옵저버를 달아서 뷰포트에 들어오는 순간부터 요소의 높이만큼 버퍼값을 늘려줍니다. 우선 돔요소가 뷰에 들어왔는지 체크[코드 21]합니다. 다음으로 버퍼를 늘려주어 재계산을 하지 않도록합니다.[코드 22]

useEffect(() => { const checkElementInViewportHandler = (entries: IntersectionObserverEntry[]) => { _.each(entries, ({ isIntersecting }) => { if (isIntersecting) { scrollDiffThreshold.current = domRef.current?.offsetHeight || 0; } else { scrollDiffThreshold.current = 0; } }); }; const observer = new IntersectionObserver(checkElementInViewportHandler, { root: null, threshold: 0.5 }); if (domRef.current) { observer.observe(domRef.current); } return () => { if (domRef.current) { observer.unobserve(domRef.current); } }; }, [domRef.current]);

코드 21

const calculation = Math.floor(lastScrollTop.current - virtualScrollY); const isRecalculation = Math.abs(calculation) > scrollDiffThreshold + THRESHOLD.SCROLL; if (isRecalculation) { setInitScrollY(virtualScrollY); }

코드 22

[이슈 7] 스크롤이 자연스럽지 않고 끊기는 현상

문제상황 :virtualized라이브러리는 현재 viewport를 기준으로 위, 아래로 특정 개수의 element들만 DOM에 붙여놓습니다. 스크롤을 하게되면, 위 / 아래를 기준으로 설정해둔 개수만큼 DOM에 element가 추가 또는 제거가 됩니다.

이때, DOM 에특정 element가 붙으면서스크롤이 자연스럽지 않고 끊기는 현상이 발생하게 됩니다.[영상 8]

화면 기록 2024-02-15 오후 2.04.55.mov

특정 element가 DOM에 붙으면서 element 의 부모 요소나 Layout 에 영향을 미치는 CSS 적인 요소로 인해서 DOM을 다시 계산하는 것으로 예상하였습니다. 실제로 특정시점에서Layout Shit가 새로 계산되고 있었습니다.[그림 6]

스크린샷 2024-01-22 오후 12.31.50.png

스크린샷 2024-01-22 오후 12.32.02.png

reflow를 일으키는 요소 중 특정 element가 붙으면서 레이아웃을 다시 계산할만한 케이스가 있는 지 중점으로 확인하였습니다.[코드23]에서 사용된 percent 계산으로 인해, DOM에 element 가 붙으면서 padding-bottom을 요소의 높이에 대한 백분율 값을 기준으로 하여 하단 패딩을 설정하기 때문에 Reflow 가 발생하게 되었습니다.

padding-bottom: 56.16%;

코드 23

해결방법 :padding-bottom 을 사용하지 않고, 다른 방법으로 CSS를 설정하거나 고정된 pixel 값을 지정해준다.

[이슈 8] 캐시된 데이터가 만료된 이후에도 저장한 데이터가 남아있는 경우 버츄얼 리스트가 렌더링되지 않는 현상

문제상황 :장기간 자리를 비웠을경우 캐시데이터는react-query의gcTime에 의해 정리됩니다. ([주석 5]번에서gcTime에 대한 설명이 되어있습니다.) 하지만내부 스토어 저장한 데이터는 유효기간을 따로 정하지 않았다면 무기한으로 유지됩니다. 위의 스크롤 복원 부분에서 설명했듯이 내부 스토어값과 캐시데이터가 동일해질때 렌더링을 시작하기 때문에 캐시데이터가 초기화 된 경우 렌더링을 하지못해 발생합니다.

해결방법 :데이터가 만료됐을 경우 플래그로 처리하여 스크롤을 복원하지 않도록합니다.[코드 23]

useEffect(() => { if (isLoading) setIsExpired(true); }, [isLoading]); <VirtualScrollContainer {...props}> {(props) => <List ScrollTop={isLoading ? 최상단 : 복원할 스크롤값} />}

코드 23

[이슈 9] 사용자의 행동에 따라 저장한 데이터를 더 이상 사용하지 않아야하는 경우

문제상황 :데이터를 복원한 이후 저장했던 데이터는 소멸되는 구조로 만들어져야 했습니다. 하지만 구현 당시 구조는 데이터가 소멸되지 않았기 때문에 컴포넌트를 다시 렌더링하거나 값을 복원하지 않아야 하는 시점에도 계속 복원하고 있었습니다. 그렇게 될 경우 다른 정보를 보여주는 리스트에서도 이전의 스크롤값으로 복원되는 현상이 발생합니다.

해결방법 :유저의 액션 및 특정 상황에서는 저장했던 데이터을 복원하지 않도록 플래그 처리하여 스크롤을 복원하지 않도록합니다. 간단한 예시로[코드 24]는 상품들의 리스트 화면에서 필터를 변경할 경우 플래그값을 변환하고 스크롤을 상단으로 이동시키는 코드입니다.

const onClickConfirm = (items: FilterDataItems): void => { setFilterItems((prev) => _.merge({}, prev, items)); loadedStatusRef.current = true; asyncScrollToTop(); };

코드 24

또는 아래 링크처럼 탭 각각에 스크롤을 저장하고 탭 간 이동시 스크롤을 따로 복원하는 방식으로 처리할 수도 있습니다.

[이슈 10] 스크롤을 복원해도 0으로 초기화 또는 이상한 값으로 복원되는 경우

문제상황 :해당 이슈는[이슈 4]와 현상은 비슷하지만 문제가 발생하는 이유는 다릅니다. 이슈를 설명하기 위해 기존의 스크롤 복원방식[코드 25]에 대한 설명이 필요합니다. 기존의 스크롤 복원 방식은useEffect의존성 배열에 복원 스크롤 값인initScrollY를 추가하여 값이 바뀔 때마다 해당 로직을 다시 수행하게 됩니다. 이 로직은 스크롤이 이상한 위치로 복원되는 경우, 다시 로직을 수행하며 결국 올바른 복원 위치까지 도착한 뒤에 종료되도록 만들어져있습니다.

하지만 크로스 브라우징 이슈가 생겼고, 이는 사파리 브라우저에서의 스크롤 이벤트 작동 시퀀스가 다른 환경과는 다르게 작동했기 때문입니다. 특히 아이폰 13 미니에서는 아무리 다시 계산하고 복원하더라도 최종 위치가 이상한 곳으로 도착하는 이슈가 있었습니다.useState로 관리하는 스크롤 값은 짧은 시간 내에 수차례 변경되는데, 이 때마다 작동하는useEffect와 라이브러리의 스크롤 이벤트와의 복원값 세팅 로직의 순서를 보장할 수 없기 때문입니다.

useEffect(() => { if (initScrollY !== undefined) { setInitScrollY(undefined); } if (virtualScrollY && (lastScrollTop - virtualScrollY) > DIFF) { setInitScrollY(virtualScrollY); } }, [initScrollY]);

[코드 25]

해결방법 :인터벌 방식으로 50ms마다virtualized list scrollTop이 복원하려는 스크롤 값으로 잘 도착했는지 확인한 뒤 200ms에 인터벌을 종료[그림 6][코드 26]합니다.인터벌 복원 방식은useEffect와initScrollY의 의존성을 없앨 수 있습니다. 의존성을 없앰으로써 라이브러리의 스크롤 이벤트와 간섭을 배제하고, 독립적으로 로직을 수행 할 수 있습니다. 200ms는 일반적으로 사용자가 화면에 돌아왔을 때 액션을 처음 취하는 시간이며, 화면이 조정되더라도 이상함을 느끼지 않는 시간입니다. 확인된 케이스는 없지만 200ms까지도 복원이 되지 않았을 경우에는 복원하지 않습니다.

image-20240201-010231.png

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); }; }, []);

코드 26

[코드 26]의checkAndSetScrollTop()은virtualized list scrollTop이 복원지점과 오차가 있을 경우 다시 계산해서 스크롤을 재복원하는 함수[코드 27]입니다. 대부분의 경우에서 잘 복원되지만 특정 브라우저나 모바일 기기의 환경에 따라 차이가 있는 경우가 있어서 추가된 방어코드입니다.

const checkAndSetScrollTop = () => { if (virtualScrollTop) { const calculation = Math.floor(scrollRef.current - virtualScrollTop); const isRecalculation = Math.abs(calculation) > THRESHOLD.SCROLL; if (isRecalculation) { setForceScrollTop(virtualScrollTop); } } else if (scrollTop) { const top = Math.floor(scrollTop); setTimeout(() => window.scrollTo({ top }), 10); } };

코드 27

인터벌 복원 방식은 정답이 아닌 워크 어라운드입니다. 이 방식은 완벽하게 모든 케이스를 커버하지는 못 할 수도 있습니다. 하지만 프로젝트의 기능 구현 단계에서 충분한 리서치와 대체 방안을 찾을 수 없다고 판단하신 경우 참고하시면 좋겠습니다.

버츄얼리스트 컴포넌트 구현체

해당 챕터는 프로젝트에서 구현하였던window virtualized list의 일부분이며 내부 로직들은 제외하고JSX와Interface들을 위주로 설명합니다. 실제 구현에 있어서 똑같이 구현하실 필요는 없습니다.

[코드 25]는VirtualScrollContainer에 전달되는 프롭스들을 관리하는propsGetterHook입니다. 저장해야될scrollTop&rowsHeight값과 저장 콜백 및 업데이트 핸들러를 제공합니다. 제공되는 프롭스들은virtualScrollContainer[코드 26]로 전달됩니다. 실제로 컴포넌트를 호출하는 부분은[코드 27]입니다.

interface Init<T> { init: T[]; initRowsHeight?: Record<string, number | undefined>; height?: number; cacheFlag?: DependencyList; } interface State<T> { rows: T[]; cache: CellMeasurerCache; windowScroller?: WindowScroller; } interface Actions { getPosition: () => ScrollRestore; getRowHeight: () => Record<string, number>; saveRowsHeightHandler: (props: ListRowProps) => void; updateWindowScrollerHandler: (ref: WindowScroller | null) => void; } type Getter<T> = State<T> & Actions; export const useVirtualScrollPropsGetter = <T>(props: Init<T>): Getter<T> => { // Implementation of the hook... return { // Return the combined state and actions }; };

[코드 25]

export type VirtualScrollChildProps<T> = { data: T; measure: () => void; }; export interface VirtualScrollContainerProps<T> extends Pick<InfiniteLoaderProps, 'loadMoreRows'> { data: unknown[]; cache: CellMeasurerCache; children: (props: VirtualScrollChildProps<T>) => ReactNode; className?: string; windowScroll?: boolean; onWindowScrollMounted?: (ref: WindowScroller | null) => void; onRowRendered?: (props: ListRowProps) => void; scrollY?: number | null; virtualScrollY?: number | null; scrollDiffThreshold?: number; } const VirtualScrollContainer = <T,>(props: VirtualScrollContainerProps<T>) => { return ( <InfiniteLoader {...props}> {({ onRowsRendered, registerChild }) => ( <WindowScroller {...props}> {({ height: windowHeight, onChildScroll, isScrolling, scrollTop }) => { return ( <AutoSizer disableHeight={windowScroll}> {({ width }) => { return ( <List {...props}/> ); }} </AutoSizer> ); }} </WindowScroller> )} </InfiniteLoader> ); }; export default VirtualScrollContainer;

[코드 26]

<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} />} ;

[코드 27]

[코드 25][코드 26][코드 27]은 간단하게 구현체들에 대한 소스를 이해하기 쉽도록 어느정도 수정을 하여 첨부한 것이며 자세한 코드는볼텍스 프로젝트/헨스 프로젝트에서 확인하실 수 있으며, 레포지토리는 이후에도 갱신될 수 있으나 문서에는 반영이 되지 않을 수 있습니다.

발표자료

react-virtualized scroll restore & trouble shooting 이미지 11

참고자료

react-virtualized scroll restore & trouble shooting 이미지 12

Click here to expand...