커머스 계열 회사에서 경력을 쌓고 있습니다. 이직을 준비하며, 가상 스크롤에 대해 고려해본 경험이 있는지에 대한 질문을 받게되었습니다. 회사에서는 200개 이상의 리스트를 보여줄 일이 없어 고려 대상이 아니었지만 추후 적용이나 케이스 스터디를 위해서 개발을 시작하게 되었습니다.
가장 먼저 무신사, 오늘의 집 커머스 회사의 리스트 동작을 확인했습니다.
즉, 오늘의 집이 가장 저의 생각과 비슷한 형태로 동작을 하였습니다. 또한 가장 많이 참고한 글도 오늘의 집의 가상 스크롤 리팩터링 블로그 글입니다.
가상 스크롤
가상 스크롤의 경우, 무한 스크롤 등의 너무 많은 dom요소의 렌더링으로 메모리등의 자원소모로 성능저하를 막는 기법으로, 실질적으로 보이는 화면(viewport)에 보여지는 dom요소의들만 렌더링하는 기법입니다.
1) 스크롤 히스토리
// index가 포함된 page 찾기
const getPageByIndex = (itemIndex: number, pageSize: number) => {
return Math.floor(itemIndex / pageSize) + 1;
};
2) 더미 리스트 만들기
현재 스크롤 히스토리를 기억하며, 해당 스크롤 히스토리에 대한 fetching만을 하도록 구현
을 목표로 하기에 마지막 관측 index에 따라 2, 3 페이지 등 중간에서 리스트가 시작될 수 있습니다.
더미 리스트를 만들어주지 않아도 물론 사용자에게 보여주는 것은 문제가 되지 않았지만 의도하지 않는 추가적인 fetching이 발생했으며, prev-fetching
의 시점을 잡는 것이 어려웠습니다.
더미 리스트가 없을 경우 문제 중 하나의 시나리오로
리스트의 최상단을 찍을 경우, 이전 페이지를 호출하겠다
라는 로직을 구현했을때 3page로 접근할때 최초 3page의 첫 item을 관측되면서 2page를 호출하고
2page가 렌더되면서 첫 item을 관측되면서 1page를 호출하는 경우가 발생했습니다.
따라서 마지막 관측 index
에 해당하는 page * size
만큼의 더미 리스트 생성하고 prev-fetching
의 결과 값에 해당하는 index를 교체해서 보여주는 방식을 사용하였습니다.
해당 방식의 장점은 크게 두가지가 있었습니다.
prev-fetching
시점을 잡기 수월하며, 특정 페이지 즉, 3페이지의 index로 진입할 경우 3페이지만 fetching할 수 있도록합니다.const makeDummyList = <T>(
size: number,
defaultValue: T | ((index: number) => T)
) => {
return Array.from({ length: size }, (_, index) => {
const value = isFunction(defaultValue) ? defaultValue(index) : defaultValue;
return { id: index, ...value };
});
};
// @tanstack/react-query의 infinityQuery를 사용하기에 해당
// fetching을 통해 온 데이터를 인덱스에 맞게 교체
const listWithDummy = ({ page, size, infiniteData, defaultValue,}: {
page: number;
size: number;
infiniteData?: InfiniteData<TypeList>;
defaultValue: TypeList["list"][number];
}) => {
const dummy = makeDummyList<TypeGood>(size * page, (index) => ({
...defaultValue,
id: index,
}));
if (!infiniteData) return dummy;
infiniteData.pages?.forEach((page) => {
dummy.splice((page.meta.page - 1) * size, page.list.length, ...page.list);
});
return dummy;
};
3) 기억된 위치로 이동하기
<1>더미 리스트를 통해서 기억된 위치까지의 가상 스크롤을 생성했습니다.
그렇다면 해당 위치로 스크롤을 이동시켜보겠습니다. 이때 주의점이 있습니다.
이를 방어하지 않으면 스크롤 간에 prev, next에 대한 fetching이 될 수 있습니다.
저는 1page가 fetching되는 버그가 있었습니다.
const isObserveLastPosition = useRef(false);
const virtualItems = rowVirtualizer.getVirtualItems();
// 최초의 한번만 스크롤를 이동시키기
useEffectOnce(
() => {
rowVirtualizer.scrollToIndex(lastPosition.current);
},
[rowVirtualizer], !!scrollElement
);
useEffect(() => {
// 가상 스크롤의 item에 기언된 index가 있는지 확인하고 관측된것을 기억
const hasLastPosition = isObserveLastPosition.current || virtualItems
.find((item) => item.index === lastPosition.current);
isObserveLastPosition.current = hasLastPosition;
// 히스토리 index 요소가 관측된적이 없다면 next, pref fetching을 방어
if (!isObserveLastPosition.current) return;
if (isNeedNextPage) {
onFetchNextPage();
}
if (isNeedPreviousPage) {
onFetchPreviousPage();
}
}, [virtualItems]);
...
이렇게 핵심 로직 및 아이디어인 1)스크롤 히스토리, 2)더미 리스트 만들기, 3)기억된 위치로 이동하기를 소개했습니다.
위의 아이디어를 먼저 소개드린 이유는 @tanstack/react-query, @tanstack/react-virtual 설정 및 사용은 @tanstack/react-virtual의 infinite-scroll 예제와 크게 다르지 않기 때문이며, 해당 방법 해결에 대한 아이디어를 공유하기 위함입니다.
2부에서는 @tanstack/react-query, @tanstack/react-virtual 설정 및 컴포넌트 구조 및 fetching 구현 등을 코드 정리해서 공유해보도록하겠습니다!
참고링크