가상 스크롤 1부

센코·4일 전
0

동기 및 케이스 스터디

커머스 계열 회사에서 경력을 쌓고 있습니다. 이직을 준비하며, 가상 스크롤에 대해 고려해본 경험이 있는지에 대한 질문을 받게되었습니다. 회사에서는 200개 이상의 리스트를 보여줄 일이 없어 고려 대상이 아니었지만 추후 적용이나 케이스 스터디를 위해서 개발을 시작하게 되었습니다.

가장 먼저 무신사, 오늘의 집 커머스 회사의 리스트 동작을 확인했습니다.

  • 무신사 : 가상 스크롤을 지원하며, 상세 페이지 클릭할 경우, 새로운 윈도우를 열어 이전 페이지의 스크롤을 유지하는 방식을 사용하며, 새로 고침시 초기화됩니다.
  • 오늘의 집 : 가상 스크롤 지원하며 scroll의 히스토리를 유지해 앞, 뒤로가기, 새로고침을 모두 지원하며, 특정 위치의 리스트만 호출하고, prev, next에 따른 fetching을 하였습니다.

즉, 오늘의 집이 가장 저의 생각과 비슷한 형태로 동작을 하였습니다. 또한 가장 많이 참고한 글도 오늘의 집의 가상 스크롤 리팩터링 블로그 글입니다.

가상 스크롤
가상 스크롤의 경우, 무한 스크롤 등의 너무 많은 dom요소의 렌더링으로 메모리등의 자원소모로 성능저하를 막는 기법으로, 실질적으로 보이는 화면(viewport)에 보여지는 dom요소의들만 렌더링하는 기법입니다.

구현 방식 및 목표

  1. @tanstack/react-query, @tanstack/react-virtual 조합의 가상 스크롤 구현
  2. 스크롤 히스토리를 기억하며, 해당 스크롤 히스토리에 대한 fetching만을 하도록 구현
    1. 이후 fetchNextPage, fetchPreviousPage을 통한 prev, next를 호출하는 형태
  3. suspense, error-boundary 등을 지원할 수 있는 형태로 구현 및 모듈로 구성

구현과정 중 주요 로직

1) 스크롤 히스토리

  • 스크롤 히스토리는 가상 스크롤의 마지막에 관측된 요소의 index를 기억하는 방식으로합니다.
    • @tanstack/react-virtual를 통해 scrollToIndex 함수를 통해 해당 index로 스크롤 가능
  • 이를 router queryString 또는 sessionStorage에 기억해서 사용하는 형태로합니다.
// 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 패칭이후에도 item들의 스크롤 위치(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>더미 리스트를 통해서 기억된 위치까지의 가상 스크롤을 생성했습니다.

그렇다면 해당 위치로 스크롤을 이동시켜보겠습니다. 이때 주의점이 있습니다.

  • 기억된 index가 노출되기전까지 prev, next fetching을 방어가 필요

이를 방어하지 않으면 스크롤 간에 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부 마무리

이렇게 핵심 로직 및 아이디어인 1)스크롤 히스토리, 2)더미 리스트 만들기, 3)기억된 위치로 이동하기를 소개했습니다.

위의 아이디어를 먼저 소개드린 이유는 @tanstack/react-query, @tanstack/react-virtual 설정 및 사용은 @tanstack/react-virtual의 infinite-scroll 예제와 크게 다르지 않기 때문이며, 해당 방법 해결에 대한 아이디어를 공유하기 위함입니다.

2부에서는 @tanstack/react-query, @tanstack/react-virtual 설정 및 컴포넌트 구조 및 fetching 구현 등을 코드 정리해서 공유해보도록하겠습니다!

참고링크

profile
안녕하세요! 프론트엔드개발자입니다.
post-custom-banner

0개의 댓글