웹 성능 최적화를 위한 useSWRInfinite 활용: 무한 스크롤 적용 방법

Yejin Yang·2024년 1월 30일
2

[TIL]

목록 보기
64/69
post-thumbnail

개요

목적은 페이지 진입 속도 개선 입니다. 페이지 최초 진입 시에 많은 양의 데이터를 한번에 가져오는 것이 아니라 특정 오프셋 값 대로 나누어서 데이터를 가져오는 방식인 무한 스크롤을 구현하고자 했습니다.
무한 스크롤 구현에는 여러 방법이 있지만 SWR을 사용하는 프로젝트에서 무한스크롤 구현해야 하는 경우가 생겨서, 찾아보니 SWR에서 페이지네이션과 무한스크롤과 같은 일반적인 UI 패턴을 지원하는 전용 API useSWRInfinite를 제공하고 있었습니다.

useSWRInfinite

import { useSWRInfinite } from 'swr'
 
const { data, error, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite(
  getKey, fetcher?, options?
)

동작 방식

  1. useSWRInfinite Hook 호출: getKey 함수와 fetcher 함수를 인자로 받습니다.
  2. 키 생성: getKey 함수는 인자로 페이지 인덱스와 이전 페이지 데이터를 받아 키를 반환합니다.
  3. 데이터 가져오기: fetcher 함수는 키를 인자로 받아 데이터를 가져옵니다.
  4. 데이터 저장: 가져온 데이터는 data에 저장됩니다.
  5. 오류 처리: 요청 중 오류가 발생하면, error에 저장됩니다.
  6. 페이지 수 설정: size는 로드된 페이지 수를 나타내고, setSize로 로드할 페이지 수를 설정합니다.
  7. 페이지 로드: 페이지를 로드할 때마다 getKey 함수와 fetcher 함수가 반복적으로 호출됩니다.

예시 - 커서, 오프셋 기반 페이지네이션 API

현재 프로젝트 백엔드에서 제공하는 API 구조는 처음 호출할 때만 "커서 값" 을 빈 문자열로 요청해야합니다.
두 번째 호출부터는 첫 번째 데이터의 마지막 아이템 id와 기준이 되는 키 값 (여기에서는 created_at 입니다.) 을 넘겨줘야 커서 해당 데이터 기준으로 다음 리스트를 제공합니다. (커서 값 기준은 API 요청 값에 따라 달라질 수 있습니다.)

아래에 제시된 예시는 현재 백엔드 API 구조에 따른 사용 사례입니다.
더 간단한 예제는 SWR 공식 문서를 참조하시면 됩니다.

getKey

getKey 함수는 인자로 페이지 인덱스와 이전 페이지 데이터를 받아 키를 반환합니다.

예시 코드

export const getKey = (
  pageIndex,
  previousPageData,
  channelId,
  offset,
  value // 캐싱 관리를 위한 키값
) => {
 
 // 끝에 도달
  if (previousPageData && !previousPageData?.items?.length) return null;
  
 // 최초 호출: nextCursor 값은 빈 문자열 endPoint에 활용할 값들을 return
  if (pageIndex === 0) return [channelId, "", "", offset, value];
  
  const lastItem = previousPageData?.items[previousPageData?.items.length - 1];

// 마지막 item 추출한 값으로 return
  return [channelId, lastItem?.created_at, lastItem?.id, offset, value];
};

fetcher

fetcher 함수는 키를 인자로 받아 데이터를 가져옵니다.

예시 코드

  const fetcher = (...args) => {
    
    // getKey를 통해 전달 받은 인자들
    const [channelId, lastCursor, lastId, offset] = args[0];
    return api.getInstance().getChannelListPaginantion(
      channelId,
      lastCursor,
      lastId,
      offset
    );
  };

useSWRInfinite hook 사용

getKey, fetcher 함수를 인자로 받아 data를 가져옵니다.

  const {
    data,
    size,
    setSize,
    isLoading
  } = useSWRInfinite(
    (pageIndex, previousPageData) =>
      getKey(pageIndex, previousPageData, channelId, offset, "post"),
    fetcher,
    { 
      onSuccess: () => {
        setIsLoading(false);
      },
      errorRetryCount: 0,
      revalidateOnFocus: false,
      revalidateFirstPage: false,
    }
  );
  1. getKey: 함수를 전달해줍니다. key는 인자로써 fetcher에 전달됩니다.
  2. fetcher: SWR의 key를 받고 데이터를 반환하는 비동기 함수를 전달해줍니다.
  3. options: 프로젝트에 필요한 옵션들을 설정해줍니다.

반환 값

data: 각 페이지의 응답 값의 배열
error: useSWR의 error와 동일
isLoading: useSWR의 isLoading과 동일
isValidating: useSWR의 isValidating과 동일
mutate: useSWR의 바인딩 된 뮤테이트 함수와 동일하지만 데이터 배열을 다룸
size: 가져올 페이지 및 반환될 페이지의 수
setSize: 가져와야 하는 페이지의 수를 설정

data

data는 여러 API 응답의 배열입니다.

// `data`는 이렇게 생겼을 것입니다
[
  [
    { name: 'Alice', ... },
    { name: 'Bob', ... },
    { name: 'Cathy', ... },
    ...
  ],
  [
    { name: 'John', ... },
    { name: 'Paul', ... },
    { name: 'George', ... },
    ...
  ],
  ...
]

concat 메서드로 두 개 이상의 배열을 병합합니다.

// 공식 문서 예제
const issues = data ? [].concat(...data) : [];

// data로 받은 데이터 구조 안에 items라는 객체배열 구조라 아래와 같이 했습니다.
  const postList: PostItem[] = data
    ? ([] as PostItem[]).concat(
        ...data.map((item) => page.item as PostItem[])
      )
    : [];

concat으로 병합해서 새로운 배열로 나온 postList 를 사용해주면 됩니다.

IntersectionObserver

useObserver 라는 custom hook을 만들어서 사용했습니다.

export const useObserver = ({
  target,
  onIntersect,
  root = null,
  rootMargin = "0px",
  threshold = 1.0,
}: {
  target: RefObject<Element>;
  onIntersect: IntersectionObserverCallback;
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
}) => {
  
  useEffect(() => {
    let observer: IntersectionObserver | null = null;

    if (target.current) {
      observer = new IntersectionObserver(onIntersect, {
        root,
        rootMargin,
        threshold,
      });
      observer.observe(target.current);
    }

    return () => {
      if (observer) {
        observer.disconnect();
      }
    };
  }, [target, rootMargin, threshold, onIntersect]);
  
  // 사용
    useObserver({
    target: observerRef,
    onIntersect: ([target]: IntersectionObserverEntry[]) => {
      const canLoadMore =
        target.isIntersecting &&
        !isLoading &&
        data &&
        data[data.length - 1].has_next;

      if (canLoadMore) {
        setSize((prev) => prev + 1);
      }
    },
  });

  
};

타겟 지점 설정

 const observerRef = useRef(null);

return (
 ...//
 	 <div
         ref={observerRef}
         style={{
           display: "hidden",
           marginTop: "30px",
         }}
      />
)

다음 데이터 불러올 때 하단에서(타겟 지점) 로딩바 보여주기

  const isLoadingMore =
    isLoading || (size > 0 && data && typeof data[size - 1] === "undefined");
  const isReachingEnd = data && data[data.length - 1]?.items?.length < +offset;

공식 문서를 참고하여 isLoadingMore와 불러올 데이터가 끝났음을 체크하는 isReachingEnd 변수를 선언해주었습니다.

  return (
  ...//
   {isLoadingMore && !isReachingEnd ? (
        <div className="flex justify-center items-center mt-10 sm:mt-10">
          <BeatLoader color="#ff2777" size={8} />
        </div>
      ) : (
        <div
          ref={observerRef}
          style={{
            display: "hidden",
            marginTop: "30px",
          }}
        />
      )}
)
  

문제점

1) 문제 발생

Network 탭에서 데이터 페칭 테스트를 하는데 중간 중간에 처음 key 값이 페칭되는 것을 확인했습니다.(커서 값이 빈 문자열)

해결

버그가 아니라 useSWRInfinite의 옵션 기능인 것을 알게 되었고, 현재 프로젝트에서는 불필요한 것 같아, 따로 options에 revalidateFirstPage 값을 false로 지정했습니다.

options:

revalidateFirstPage = true: always try to revalidate the first page(항상 첫 페이지의 유효성을 검사)


2) 문제 발생

무한 스크롤을 여러 곳에서 구현하면서 API구조가 동일하다 보니, getKey를 공통 함수로 분리했습니다.

하지만, 이렇게 하니 다른 페이지에서도 무한 스크롤 데이터가 동일하게 보이는 문제가 생겼습니다.

SWR은 각 페이지 데이터와 함께 특수 캐시 키로 페이지 데이터를 저장하기 때문에, getKey 함수가 받는 인수가 동일하다 보니 이런 문제가 발생했습니다.

해결

이 문제를 해결하기 위해, getKey에 특정 값이 될만한 value를 추가로 전달하게 했습니다. 이 값은 실제로 fetcher에서는 사용하지 않습니다.
(더 좋은 방법이 있다면 댓글로 알려주시면 감사하겠습니다.)

export const getKey = (
  pageIndex,
  previousPageData,
  channelId,
  offset,
  value // 캐싱 관리를 위한 키값
) => {
  
  
  // useSWRInfinite Hook
  useSWRInfinite(
    (pageIndex, previousPageData) =>
      getKey(pageIndex, previousPageData, channelId, offset, "post"),
  

예를 들어, post list를 가져오는 곳에서는 "post"라는 값을 넘겨주고, 다른 곳에서는 "gallery"와 같은 방식으로 값을 넘겨줍니다.

결과: 퍼포먼스 향상

Lighthouse 총 퍼포먼스 수치가 49에서 57로 올라갔습니다. (모바일 기준)
FCP(First Contentful Paint) 지수는 1.9초에서 1.7초로 개선되었습니다.

Amazon은 웹사이트 로딩 시간이 100밀리초 지연될 때마다 매출이 1% 감소한다는 내부 연구 결과를 발표했습니다. Google 역시 검색 결과 페이지의 로딩 시간을 0.5초 증가시켰을 때 검색량이 현저히 감소했다는 연구 결과를 공유했습니다.

확실히 페이지 진입 시 모든 데이터를 한 번에 받아오지 않아도 되니 체감상 진입 속도가 빨라진 게 느껴집니다.
사용자가 유의미한 정보를 볼 수 있는 시간이 단축된 걸 알 수 있었습니다.

마무리

  • useSWRInfinite를 활용하면 페이지나 커서 기반의 데이터를 효율적으로 로드할 수 있습니다.
  • 웹 성능 최적화로 원하는 목표에 달성할수 있었습니다.
profile
Frontend developer

0개의 댓글