Windowing 기법 활용한 무한스크롤 성능 최적화 구현

Windowing 기법 활용해 무한스크롤 성능을 최적화하는 방법을 알아보자.

Windowing기법 활용한 무한스크롤 성능 최적화

최근 프로젝트에서 무한 스크롤 기능을 구현해야 하는 요구사항이 있었습니다. 단순히 데이터를 추가적으로 불러오는 것뿐만 아니라, 많은 양의 데이터를 효율적으로 처리하기 위해 usehooks-ts의 useIntersectionObserver@tanstack/react-virtual의 useWindowVirtualizer를 활용하여 윈도잉(Windowing) 기법을 적용했습니다. 이번 글에서는 이를 통해 구현한 내용을 공유하며, 학습한 내용을 정리했습니다.

1. 문제 정의

1.1 기존 무한 스크롤의 한계

기존 무한 스크롤은 데이터를 무조건 화면에 렌더링하는 방식으로 동작합니다. 초기에는 성능에 큰 문제가 없지만, 데이터가 쌓이면 브라우저가 처리해야 하는 DOM 요소가 많아지고 성능 문제가 발생합니다. 특히, 다음과 같은 문제점이 있었습니다

  • DOM 과부하: 렌더링된 DOM의 양이 많아질수록 렌더링 성능이 저하됩니다.
  • 메모리 사용량 증가: 모든 데이터를 화면에 유지하면 메모리 사용량이 급격히 증가합니다.
  • 스크롤 성능 저하: 브라우저의 스크롤 이벤트 처리 성능이 느려집니다.

1.2 해결 방안

이 문제를 해결하기 위해, 두 가지 접근 방식을 결합했습니다

  1. IntersectionObserver를 활용한 무한 스크롤.
  2. Windowing 기법을 적용해 화면에 보여지는 데이터만 렌더링.

2. 주요 라이브러리 소개

2.1 useIntersectionObserver(usehooks-ts)

useIntersectionObserver는 DOM 요소의 가시성(Intersection)을 감지하기 위해 브라우저의 IntersectionObserver API를 간단히 사용할 수 있도록 도와주는 커스텀 훅입니다. 이를 사용하면 스크롤 트리거를 쉽게 구현할 수 있습니다.

주요 기능

  • 특정 요소가 뷰 포트에 진입했는지 여부 감지.
  • 스크롤 이벤트 없이 효율적인 무한 스크롤 구현.
  • 다양한 옵션(threshold, root, rootMargin)을 통해 세밀하게 동작 설정 가능.
  • onChange 콜백을 제공하여 요소의 교차 상태를 즉시 처리.

2.2 useWindowVirtualizer (@tanstack/react-virtual)

useWindowVirtualizer는 윈도잉(Windowing) 기법을 쉽게 구현할 수 있도록 제공되는 훅입니다. 윈도잉 기법은 화면에 보이는 데이터만 렌더링하여 성능을 최적화합니다.

주요 기능

  • 현재 뷰포트에 보이는 항목만 렌더링.
  • overscan 옵션으로 추가적인 데이터 렌더링 범위 설정 가능.
  • 각 항목의 크기를 유동적(dynamic) 또는 고정적(fixed)으로 설정 가능.
  • 대규모 리스트에서 메모리와 렌더링 성능을 획기적으로 개선.

3. 구현 과정

3.1 주요 요구사항

  1. 스크롤이 아래로 내려갈 때 새로운 데이터를 가져온다.
  2. 현재 뷰포트에 보이는 데이터만 렌더링한다.

3.2 코드 구현

커스텀 훅: useInfiniteScroll

import { useCallback } from 'react';
import { useIntersectionObserver } from 'usehooks-ts';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
 
function useInfiniteScroll(hasMorePage, setPage, itemCount) {
  const onIntersect = useCallback(
    (isIntersecting) => {
      if (isIntersecting && hasMorePage) {
        setPage((prev) => prev + 1);
      }
    },
    [hasMorePage, setPage]
  );
 
  const { ref, isIntersecting } = useIntersectionObserver({
    threshold: 1,
    onChange: onIntersect,
  });
 
  const virtualizer = useWindowVirtualizer({
    count: itemCount,
    estimateSize: () => 150, // 각 아이템의 높이
    overscan: 3, // 상하로 더 렌더링 할 아이템 갯수
  });
 
  return { ref, isIntersecting, virtualizer };
}

적용

import React, { useState, useEffect } from 'react';
import useInfiniteScroll from './useInfiniteScroll';
 
export default function VirtualizedInfiniteScroll() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [isLoading, setIsLoading] = useState(false);
  const [hasMorePage, setHasMorePage] = useState(true);
 
  const fetchItems = async (start, limit) => {
    const response = await fetch(
      `https://pokeapi.co/api/v2/pokemon?offset=${start}&limit=${limit}`
    );
    const data = await response.json();
    return data.results;
  };
 
  useEffect(() => {
    const loadMoreItems = async () => {
      if (isLoading || !hasMorePage) return;
 
      setIsLoading(true);
      const newItems = await fetchItems(items.length, 20);
 
      if (newItems.length === 0) {
        setHasMorePage(false);
      } else {
        setItems((prev) => [...prev, ...newItems]);
      }
 
      setIsLoading(false);
    };
 
    loadMoreItems();
  }, [page]);
 
  const { ref, virtualizer } = useInfiniteScroll(
    hasMorePage,
    setPage,
    items.length
  );
 
  return (
    <div className='flex flex-col items-center min-h-screen'>
      <h1 className='text-xl font-bold my-4'>Virtualized Infinite Scroll</h1>
 
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
            }}
            ref={virtualItem.index === items.length - 1 ? ref : null}
            className='p-4 border border-gray-300 bg-white rounded-lg shadow-sm'
          >
            {items[virtualItem.index]?.name || 'Loading...'}
          </div>
        ))}
      </div>
 
      {isLoading && <div className='mt-4 text-gray-500'>Loading...</div>}
    </div>
  );
}

transformgetTotalSize()의 역할

  1. transform: translateYvirtualItem.start

    • 구체적인 역할
      • transform: translateY(${virtualItem.start}px)는 리스트 항목이 화면에 정확히 위치하도록 가상으로 위치를 조정합니다.
      • virtualItem.start 값은 해당 항목이 전체 리스트에서 어디에 위치해야 하는지 픽셀 단위의 시작 지점을 나타냅니다.
      • DOM에서 실제로 항목들을 렌더링하지 않고, 화면에서 보이는 항목들만 브라우저에 표시되도록 합니다.
    • 효과
      • 렌더링 성능 최적화: 화면에 보이는 항목만 렌더링하므로 DOM 크기가 제한됩니다.
      • 메모리 절약: 불필요한 DOM 요소를 제거해 브라우저의 메모리 사용량을 줄입니다.
      • 사용자 경험: 스크롤 시 화면에서 보이는 항목들이 자연스럽게 나타나므로 부드러운 경험을 제공합니다.
  2. getTotalSize()

    • 구체적인 역할 -getTotalSize()**는 전체 스크롤 영역의 높이를 계산합니다.
      • 이 값은 virtualizer가 전체 데이터 개수와 각 항목의 예상 크기(estimateSize)를 곱한 결과입니다.
      • 브라우저는 이 값을 기반으로 스크롤바의 높이와 스크롤 가능한 전체 영역을 설정합니다.
    • 장점
      • 스크롤 컨텍스트 유지: 사용자는 스크롤바를 통해 전체 데이터의 길이를 직관적으로 확인할 수 있습니다.
      • 성능 유지: 데이터가 추가되더라도 전체 높이는 자동으로 갱신되므로 효율적입니다.
    • 단점
      • 무한 스크롤의 UX: 스크롤바가 미리 전체 데이터를 포함하는 높이로 설정되므로, 사용자가 스크롤이 끝이 없다고 느낄 수 있습니다.
      • 대안: getTotalSize()를 제거하고, 데이터를 추가적으로 로드하면서 스크롤 영역을 동적으로 조정하면 자연스러운 UX를 제공합니다.

4. 결과 분석

4.1 성능 향상 포인트

  • 윈도잉(Windowing): 화면에 보이는 데이터만 렌더링하여 렌더링 성능과 메모리 사용량을 크게 개선했습니다.
  • IntersectionObserver: 스크롤 이벤트를 사용하지 않고 효율적으로 스크롤 트리거를 구현했습니다.

4.2 확인된 성능 개선

  • 수천 개의 데이터가 로드되었음에도 불구하고 렌더링 성능이 유지되었습니다.
  • 브라우저 메모리 사용량이 감소하여 스크롤 성능이 향상되었습니다.

5. 요약 및 느낀 점

요약

  • useIntersectionObserver를 활용해 무한 스크롤 트리거를 효율적으로 구현했습니다.
  • useWindowVirtualizer를 통해 윈도잉 기법을 적용하여 성능 최적화를 달성했습니다.
  • DOM 과부하 문제를 해결하고 대규모 데이터 처리에도 부드러운 사용자 경험을 제공할 수 있었습니다.

느낀 점

이번 구현을 통해 무한 스크롤과 윈도잉 기법의 결합이 대규모 데이터를 처리하는 데 얼마나 강력한 도구인지 배웠습니다. 특히, transformgetTotalSize()의 활용에 따라 성능과 사용자 경험이 크게 달라질 수 있다는 점을 알게 되었습니다. 이러한 경험을 토대로 다양한 상황에서 성능 최적화 방안을 고민하고 적용할 수 있을 것 같습니다.


참고