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

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

무한 스크롤에 쌓이는 DOM을 가상화로 줄인 과정

개요

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

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

무한 스크롤 페이지를 개발하면서 겪었던 문제와 해결 방법을 공유하려고 한다. 많은 사용자가 이용하는 오늘의집은 무한 스크롤을 어떤 식으로 구현했는지도 함께 살펴본다.

이 글은 2024년 3월, react-virtuoso@4.7.x@tanstack/react-query@5.22.2를 사용한 기록이다. 라이브러리 API와 오늘의집 구현은 현재 달라졌을 수 있으며, 외부 서비스에 대한 내용은 당시 특정 화면을 개발자 도구로 관찰한 결과다.

문제점

내가 처음 구현한 무한 스크롤은 TanStack Query와 react-intersection-observer를 활용했다. 스크롤의 끝에 다다르면 데이터를 추가로 받아온 뒤 DOM 노드를 계속 더하는 방식이었다.

이렇게만 보면 문제없는 무한 스크롤 같지만, 데이터가 100개, 1000개를 넘어 계속 쌓이면 DOM 크기와 메모리·레이아웃 비용도 함께 늘어날 수 있다.

내 구현에서는 스크롤을 오래 내릴수록 성능이 떨어지고 버벅였다. 끝없이 탐색할 수 있게 만든 페이지가 오래 탐색할수록 느려진 셈이다.

가상화

문제를 파악하고 나니 많은 사용자가 이용하는 무한 스크롤 페이지가 어떻게 설계되어 있는지 궁금해졌다. 그래서 내가 자주 사용하는 오늘의집 무한 스크롤 페이지를 살펴봤다.

2024년 3월 데스크톱 화면에서 특정 쇼핑 목록을 관찰한 내용만 적었다. 오늘의집 개발자가 아니기 때문에 실제 구현 의도와는 다를 수 있다.

오늘의집 무한 스크롤 페이지는 스크롤을 계속 내리면 데이터가 추가되는 평범한 페이지로 보였다. 하지만 개발자 도구로 살펴보니 굉장히 흥미롭게 작동하고 있었다.

<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이 동적으로 바뀌며 사용자가 긴 목록을 보는 것처럼 동작했다. 이 개수는 화면 크기와 overscan, 배포 버전에 따라 달라질 수 있다.

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



Not Rendered
Not Rendered
----------- viewport 주변
Rendered
Rendered
----------- viewport 주변
Not Rendered
Not Rendered

이처럼 viewport와 그 주변의 제한된 범위만 DOM에 유지하는 방식을 가상화 기법이라고 하며, 윈도잉 기법이라고도 한다. 실제 구현은 빠른 스크롤에서 빈 화면이 보이지 않도록 viewport 밖의 일부 항목도 overscan으로 렌더링할 수 있다.

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

구현

Dependencies

React Virtuoso

React에서 가상화를 구현할 때 사용할 수 있는 라이브러리로 react-window, react-virtualized, react-virtuoso가 있다. 아래 표는 2024년 3월 2일 문서와 내 구현 요구를 빠르게 비교한 개인적인 판단이다. 출처와 압축 기준을 남기지 않았던 패키지 크기와 업데이트 간격은 정확한 비교로 보기 어려워 제외했다.

react-windowreact-virtualizedreact-virtuoso
당시 받은 인상핵심 가상화 기능에 집중한 구성기능이 많고 설정할 부분도 많았음필요한 기능을 문서에서 찾기 쉬웠음
내 구현의 기준추가 구현이 더 필요하다고 판단요구보다 구성이 크다고 판단무한 로딩과 복원 API를 함께 검토 가능

내가 필요했던 무한 로딩과 스크롤 복원 API를 문서에서 가장 쉽게 찾을 수 있어 react-virtuoso를 선택했다.

import { Virtuoso } from "react-virtuoso";
 
const renderItem = (index: number) => {
  return <div>item {index}</div>;
};
 
const App = () => {
  return (
    <Virtuoso
      className="h-[400px]" // 가상화 viewport 높이
      totalCount={200} // 전체 항목 수, index 범위는 0~199
      itemContent={renderItem}
    />
  );
};
 
export default App;
기본 예제 작동방식

기본 예제 작동방식

react-virtuoso의 기본 예제다. totalCount={200}은 마지막 인덱스가 200이라는 뜻이 아니라 전체 항목이 200개라는 뜻이며, 인덱스 범위는 0부터 199까지다. 높이 400px인 viewport와 그 주변을 채우는 데 필요한 항목만 DOM에 유지된다.

아래는 조금 더 상세한 구현 예제다.

const CharacterList = () => {
  const {
    charactersData,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useRickAndMortyCharacterQuery();
  const characters = charactersData.pages.flatMap((page) => page.results);
 
  const handleLoadMore = () => {
    if (!hasNextPage || isFetchingNextPage) {
      return;
    }
 
    void fetchNextPage();
  };
 
  const renderCharacter = (index: number, character: Character) => (
    <div>
      <p>index {index}</p>
      <CharacterBox character={character} />
    </div>
  );
 
  return (
    <Virtuoso
      useWindowScroll
      data={characters}
      endReached={handleLoadMore}
      itemContent={renderCharacter}
    />
  );
};
REST API와 TanStack Query 구현 예제 작동 방식

REST API와 TanStack Query 구현 예제 작동 방식

REST API와 TanStack Query의 useInfiniteQuery를 사용한 예제다. 페이지 배열을 캐릭터 배열로 평탄화해 캐릭터 하나를 Virtuoso 항목 하나에 대응시킨다. data를 전달하면 전체 항목 수는 배열 길이에서 정해지므로 별도의 totalCount를 섞지 않는다.

이전 코드의 setTimeout은 기존 타이머를 취소하지 않아 디바운스가 아니었다. hasNextPageisFetchingNextPage를 확인해 다음 페이지가 있고 진행 중인 요청이 없을 때만 fetchNextPage를 호출한다.

아래는 결과 영상이다.

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

Layers dev tool

Layers dev tool

크롬 Layers에서는 현재 viewport 주변이 어떻게 그려지는지 확인할 수 있었다. 다만 Layers는 합성·페인트 레이어를 보는 도구이므로 React 항목의 마운트 여부를 증명하지는 않는다. DOM 노드 수는 Elements나 Performance Monitor에서 따로 확인해야 한다.

스크롤 복원

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

브라우저는 보통 history의 스크롤 위치를 복원하지만, 돌아왔을 때 목록 데이터와 높이 측정값이 아직 준비되지 않았다면 가상화된 목록은 이전 위치를 만들지 못할 수 있다. 내 구현에서도 데이터와 목록 상태가 초기화되어 맨 처음부터 다시 내려가야 했다.

처음에는 전체 스크롤 값을 큰 항목 높이로 나눠 인덱스를 계산하고 initialItemCount, initialScrollTop, scrollToIndex를 함께 사용했다. 하지만 initialItemCount는 SSR에서 최초로 렌더링할 항목 수이고, initialScrollTop은 픽셀 단위라 인덱스를 넣는 용도가 아니었다. isScrolling도 시작과 종료에 boolean을 전달하므로 인자를 무시한 기존 코드는 양쪽에서 저장 함수를 실행했다.

단순히 근처 항목으로 돌아가려면 rangeChanged가 알려주는 렌더 범위의 startIndex를 저장하고, 목록을 다시 만들 때 initialTopMostItemIndex로 전달할 수 있다. overscan을 사용한다면 이 값은 화면에 정확히 처음 보이는 항목보다 앞설 수 있다.

"use client";
 
import { useEffect, useState } from "react";
import { Virtuoso, type ListRange } from "react-virtuoso";
 
const STORAGE_KEY = "character-list-index";
 
const renderCharacter = (index: number, character: Character) => {
  return <CharacterBox character={character} index={index} />;
};
 
const CharacterList = ({ characters }: CharacterListProps) => {
  const [savedIndex, setSavedIndex] = useState<number | null>(null);
 
  const handleRangeChanged = ({ startIndex }: ListRange) => {
    sessionStorage.setItem(STORAGE_KEY, String(startIndex));
  };
 
  /** 브라우저에서만 이전 목록 index를 읽는다. */
  useEffect(() => {
    const storedIndex = Number(sessionStorage.getItem(STORAGE_KEY) ?? 0);
    const nextIndex = Number.isInteger(storedIndex) && storedIndex >= 0
      ? storedIndex
      : 0;
 
    setSavedIndex(nextIndex);
  }, []);
 
  if (savedIndex === null || characters.length === 0) {
    return null;
  }
 
  return (
    <Virtuoso
      data={characters}
      initialTopMostItemIndex={Math.min(savedIndex, characters.length - 1)}
      rangeChanged={handleRangeChanged}
      itemContent={renderCharacter}
    />
  );
};

주의할 점

  1. 저장한 인덱스가 가리키던 데이터도 query cache 등에서 함께 복원되어야 한다.
  2. 인덱스가 현재 배열 길이를 넘지 않도록 범위를 확인해야 한다.
  3. 항목 중간의 정확한 픽셀 위치와 측정값까지 복원해야 한다면 getStaterestoreStateFrom을 사용한다.
  4. 저장값을 지울 때는 sessionStorage.clear() 대신 이 기능이 소유한 key만 removeItem으로 제거한다.
  5. 필터나 정렬 조건이 여러 개라면 각 목록 상태가 섞이지 않도록 storage key에도 조건을 포함한다.

이 방식은 대략적인 항목 위치를 복원한다. 새로고침이나 페이지 이탈 이벤트에 의존하지 않고, Virtuoso가 알려주는 렌더 범위를 저장한다는 점이 이전 코드와 다르다.

스크롤 복원

마치며

오늘의집 SEO

오늘의집 SEO

오늘의집은 쇼핑 페이지의 SEO를 어떻게 구성할까? 이런 호기심이 생겼다.

쿠팡이나 네이버 쇼핑 같은 다른 서비스는 목록을 페이지네이션으로 나누고 있었다. 다만 페이지네이션 자체가 SEO를 보장하지는 않는다. 각 페이지에 고유 URL이 있고 크롤러가 따라갈 수 있는 <a href> 링크로 연결되어야 한다. 무한 스크롤도 같은 방식으로 목록을 URL 단위의 묶음으로 나누면 색인 가능한 구조를 만들 수 있다.

오늘의집 화면에서는 무한 스크롤 목록과 별개의 목록 DOM을 관찰했다. 처음에는 SEO를 위한 컴포넌트라고 추측했지만, 캡처만으로는 추천 상품인지 검색 노출용 콘텐츠인지 확인할 수 없었다. 외부에서 알 수 있는 관찰은 여기까지였다.

예전에는 "알고리즘이 실무에 크게 도움이 될까?"라고 생각했지만, 스크롤을 복원하는 방법을 떠올릴 때 알고리즘 문제를 풀었던 기억이 도움이 됐다. 적어도 내가 공부한 내용이 구현의 실마리가 되는 순간은 분명히 있었다.

참조