<

/>

imageinfinite

가상화 하여 더 나은 무한스크롤 구현하기

오늘의집과 같은 무한스크롤 구현 과정과 후기

2024년 03월 05일

개요

센스있게 느껴진 오늘의집 콘솔화면

센스있게 느껴진 오늘의집 콘솔화면

무한 스크롤 페이지를 개발하면서 겪었던 문제점과 해결법을 공유하려고 합니다.
또한 많은 사용자들을 끌어모아 서비스하고 있는 오늘의집은 어떤 식으로 무한 스크롤을 구현했는지 관찰 해볼것입니다.

문제점

기존의 무한 스크롤은 react-query와 react-intersection-observer를 활용하여 스크롤의 끝에 다다르면
데이터를 추가적으로 받아온 뒤, DOM 노드를 더 추가합니다. 이렇게만 보면 문제없는 무한스크롤 같지만,
한 페이지의 데이터가 100개 1000개를 넘어 계속해서 추가된다면,
데이터가 추가된 갯수만큼 무한한 갯수의 불필요한 DOM 노드를 렌더링할것입니다.

페이지를 무한으로 스크롤할수 있게 설계했지만, 스크롤을 내리면 내릴수록 성능이 저하되어 버벅이고 렉걸리는 모순된 페이지가 만들어진것입니다.

가상화

문제점을 파악하자 많은 사용자를 상대로 서비스하는 무한 스크롤 페이지는 어떻게 설계 했는지 많이 궁금해졌습니다.
그렇게 제가 자주 사용하는 오늘의집 무한 스크롤 페이지를 살펴보았습니다.

페이지를 보고 관찰한 점만 적어뒀습니다. 오늘의집 개발자가 아니기 때문에, 실제 내용과는 많이 다를수도 있습니다.

오늘의집 무한스크롤 페이지는 스크롤을 계속 내리면 데이터가 추가되는, 평범한 무한 스크롤 페이지로 보였습니다.
하지만 개발자도구를 사용하여 관찰해보면 굉장히 흥미롭게 작동하는걸 확인할 수 있습니다.

<div
  class="virtualized-list"
  style="padding-top: 동적px; padding-bottom: 동적px; transform: translateY(동적px);"
>
  <div class="item-content" />
  {/* ... item-content은 30개 고정 */}
  <div class="item-content" />
</div>

무한스크롤 div 부분입니다. item-content DOM 노드는 스크롤을 얼마나 내리든 30개 고정이었습니다.
스크롤을 내릴수록 virtualized-list 의 padding이 동적으로 바뀌어서 사용자가 무한 스크롤 페이지를 보듯이 구현한것입니다.

스크롤에 따라 padding y값이 동적으로 변한다 (좌측 상단 style 확인)



Not Rendered

Not Rendered

----------- viewport

Rendered

Rendered

----------- viewport

Not Rendered

Not Rendered

이렇게, 보이는 부분만 렌더링하는 동작법을 가상화 기법이라고 합니다. (윈도잉 기법이라고도 합니다.)
사용자가 볼수있는 화면은 제한적인만큼, 불필요한 렌더링을 하지않는것입니다.

가상화의 필요성과 원리를 파악했으니 이제 직접 구현해볼 차례입니다.

구현

Dependencies

  • tanstack/react-query@5.22.2
  • react-virtuoso@4.7

React-Virtuoso

React 에서 가상화를 구현하기 위한 react-window, react-virtualized, react-virtuoso 3가지 라이브러리가 있습니다. 각 라이브러리를 간단하게 비교해 봤을때 다음과 같습니다.

react-windowreact-virtualizedreact-virtuoso
패키지 크기6.4KiB27.4KiB15.5KiB
업데이트 (03.02 기준)3개월 전10개월 전일주일 전 (업데이트 활발함)
기본 제공 기능기본 기능만 제공많은 기능 제공많은 기능 제공
구성필수적인 props 많음필수적인 props 많음적은 필수 props (간단한 구성)

저는 업데이트가 활발하고, 공식 문서가 가장 좋았던 react-virtuoso를 선택했습니다.

import { Virtuoso } from "react-virtuoso";
 
export default function App() {
  return (
    <Virtuoso
      style={{ height: "400px" }} // 전체 높이 (virtualized-list)
      totalCount={200} // 가상화 리스트 총 index
      itemContent={(index) => <div>item {index}</div>} // 렌더링할 컨텐츠 (item-content)
    />
  );
}
기본 예제 작동방식

기본 예제 작동방식

react-virtuoso의 기본 예제입니다. 스크롤을 내릴수록 index값이 증가하는걸 확인할 수 있고,
virtualized-list div의 길이가 400px이니, itemContent 리스트는 400px의 높이만큼만 렌더링됩니다.
totalCount로 가상화 리스트의 총 index값을 설정할 수 있습니다.

아래는 상세한 구현 예제입니다.

const { charactersData, fetchNextPage } = useRickAndMortyCharacterQuery(); // useInfiniteQueryHooks
 
const loadMore = useCallback(() => {
  return setTimeout(() => {
    fetchNextPage();
  }, 200); // 디바운스
}, []);
 
<Virtuoso
  style={{ height: "calc(100vh - 50px)", margin: "0px" }} // 높이 지정
  useWindowScroll // 개별 스크롤대신 브라우저 스크롤 이용
  totalCount={charactersData.info.count}
  data={charactersData.pages} // data 지정
  endReached={loadMore} // 끝에 다다르면 불러올 함수
  itemContent={(index, data) => (
    <div>
      <p>index {index}</p>
      {data.results.map((character) => (
        <CharacterBox key={"char" + character.id} />
      ))}
    </div>
  )}
/>;
REST api + react-query 구현 예제 작동방식

REST api + react-query 구현 예제 작동방식

REST api와 react-query의 useInfiniteQuery를 사용한 예제입니다.
data prop(배열형태)를 받아서 itemContent의 두번째 인자로 data를 전달할수 있습니다.
해당 index(스크롤 위치)에 맞게 data를 전달하며 itemContent의 끝에 도달하면 loadMore 함수로 데이터와 다음 index에 맞는 itemContent를 불러옵니다.

endReached prop 에 fetchNextPage를 바로 전달할수도 있지만, setTimeout을 사용하는 이유는 스크롤 이벤트가 너무 빠르게 발생하여 많은 요청을 보내는 것을 방지하기 위함입니다. 자세한건 디바운스 예제를 확인해주시길 바랍니다.

아래는 결과 영상입니다.

스크롤에 따라 padding y값이 동적으로 변한다

Layers dev tool

Layers dev tool

크롬 Layers로 확인한 결과 현재 viewport에 해당하는 itemContent 만 렌더링하는것을 확인할 수 있었습니다.

스크롤 복원

무한 스크롤 => 다른 페이지 => 무한 스크롤

위와 같이 무한 스크롤 페이지에서 다른 페이지로 이동했다가 다시 돌아온다면,
가상화된 무한 스크롤 페이지는 사용자가 보고 있던 스크롤 위치를 기억할수가 없습니다.
맨 처음부터 페이지를 내려서 사용자가 전에 보던 스크롤 위치로 매번 내려야한다면 불편한 경험을 선사할것입니다.

그렇기에 가상화 무한 스크롤 페이지에서도 스크롤을 복원할수있는 방법이 필요했습니다.
react-virtuoso에는 initialItemCount,initialScrollTop 라는 props와 scrollToIndex라는 메서드를 제공합니다.
진입시의 아이템 카운트와 스크롤 위치, 그리고 직접 스크롤을 해당 index로 옮겨주는 함수입니다.

이제, 스크롤을 멈추고 다른 페이지에 진입할때의 index만 알수있다면 스크롤을 복원할수있습니다.
아래는 현재의 index를 계산하는 방법 구현 코드와 작동방식입니다.

const virtuosoRef = useRef(null);
const dataKnownSize = 6128; // itemContent height
const currentIndex = Number(sessionStorage.getItem("index")); // 진입시 세션스토리지 접근
 
const handleIndex = () => {
  virtuosoRef.current.scrollToIndex({
    index: currentIndex,
    align: "start",
  });
}; // 저장해놓은 index 값으로 이동
 
const hadnleScroll = () => {
  const currentIndexMath = Math.round(scrollY / dataKnownSize);
  sessionStorage.setItem("index", String(currentIndexMath));
}; // 세션스토리지에 현재 index 저장
 
useEffect(() => {
  handleIndex();
  const clearSessionStorage = () => sessionStorage.clear();
  window.addEventListener("beforeunload", clearSessionStorage); // 새로고침시 세션 스토리지 초기화
}, []);
 
return (
  <Virtuoso
    ref={virtuosoRef}
    isScrolling={() => hadnleScroll()}
    initialItemCount={currentIndex}
    initialScrollTop={currentIndex}
  />
);
현재 index 계산하는 법 (index 4 위치일때)

현재 index 계산하는 법 (index 4 위치일때)

  1. 현재 저의 itemContent의 height는 6128px(dataKnownSize) 입니다.
  2. 현재 스크롤 위치(window.scrollY)는 index 3 과 4 사이에 위치하여(4에 가깝게) 23794px이 나옵니다.
  3. 스크롤 시작후 스크롤을 멈출때(디바운스) 함수를 실행합니다.
  4. 현재 스크롤 위치와 dataKnownSize 를 나눈값을 반올림하여 현재 스크롤에 가까운 index 값을 구합니다.
  5. 세션 스토리지에 해당값을 저장합니다.
  6. 무한스크롤 페이지에 진입시 세션 스토리지에 저장된 index 값으로 스크롤을 이동시킵니다.

주의할 점

  1. 새로고침시 세션 스토리지를 초기화 시키지 않으면 오류가 발생합니다.
  2. 데이터 캐싱 작업을 하지 않으면 오류가 발생합니다.

위와 같은 방법으로 스크롤 복원을 구현할 수 있었습니다.

스크롤 복원

마치며

오늘의집 SEO

오늘의집 SEO

오늘의집은 쇼핑 페이지의 SEO를 어떻게 구성할까? 라는 호기심이 생겼었습니다.
쿠팡, 네이버 쇼핑 같은 다른 서비스들은 많은 리스트들을 무한스크롤이 아닌
페이지네이션으로 구성했기때문에 SEO를 비교적 쉽게 설정할수 있었지만,
무한 스크롤에다가 가상화까지 추가한 오늘의집은 SEO 설정이 힘들것 같다고 생각했습니다.

검색하고 비교해본 결과 SEO를 위해서 무한 스크롤과 별개인 컴포넌트를 따로 구현한것으로 보였습니다.
(사실 추천 상품일것같은데 너무 개발자의 시선으로 본거같기도 합니다.)

또한, 예전에는 "알고리즘이 실무에 크게 도움이 될까?" 와 같은 생각을 했었는데, 스크롤을 복원하는 부분은 알고리즘 문제를 풀었던 기억에서 떠올렸습니다. 많은 개발자들이 알고리즘을 공부하는 이유를 작게나마 알게된것같습니다.

참조

2024﹒©

 OU9999

Powered by Next.js﹒Vercel