본문 바로가기
항해99/프로젝트

[TIL-37] 무한 스크롤 성능 문제 - react-query useInfiniteQuery 도입

by junvely 2023. 6. 20.

무한 스크롤 성능 문제 - react-query useInfiniteQuery 도입

1. react-query 도입 이유 

성능 개선 방법들에는 네트워크 요청 및 응답 시간을 줄이기 위해 최적화된 서버 설정, 캐싱 기법, 데이터 압축 등을 고려할 수 있을 것 같다. 그 중 데이터 압축은 이미지 최적화를 진행할 때 하도록 하고, 무한 스크롤을 최적화하기 위해, 즉 스크롤 시 마다 요청되는 서버와의 데이터 통신 횟수와 속도를 개선 하기 위해서는 무엇 보다도 캐싱 기능을 고려하지 않을 수 없었다. 때문에 자동으로 캐싱 기능을 제공하는 react-query를 사용하였다.

 

2. react-query useInfiniteQuery를 도입한 이유

지금까지는 usequery 와 refetch, 그리고 IntersectionObserver API를 사용해 뷰포트 상의 교차점을 관찰하여 데이터를 업데이트 하도록 했다.

무한 스크롤을 구현하기 위해 useQuery와 IntersectionObserver API로 무한 스크롤 기능을 구현하였는데, 한 번 스크롤 시 Observer 버튼이 계속해서 뷰 포트 상에 노출되어 계속해서 [ 옵저버 중복 실행 → 페이지 상태가 계속 증가 → 데이터 통신 ]이 계속되어 페이지가 늘어나는 현상이 발생했다.

해결 방안

1안 : Observer의 prevent상태를 관리하여 옵저버가 실행되어 true인 상태일 동안은 [ 옵저버 실행 →페이지 증가 → 데이터 통신 ] 과정이 일어나지 않도록 직접 상태를 컨트롤 하여 중복 실행을 방지

2안 : useInfiniteQuery를 도입하여 페이지 상태 관리를 직접 하지 않고 fetchNextPage 함수를 이용하며, 옵저버는 isFetching(실행중)일 경우 발생하지 않도록 방지

의견 조율

두 가지 방안을 모두 사용하여 성능적으로 어떤 방안이 더 좋은지 테스트해 보았다.

1안일 경우 3가지의 문제가 있었다.

  1. 추가 구현이 필요 하다 : Intersection Observer API는 교차 여부를 감지하는 기능만 제공하므로 데이터 페칭 및 상태 관리와 관련된 로직은 직접 구현해야 한다.
  2. 이렇게 무한 스크롤 페이지 및 데이터 상태를 직접 관리하다 보니 검색 상태와 더해져, 상태 관리에 의한 잦은 렌더링으로 성능의 문제로 이어졌다. 테스트 결과 0~ 6페이지 스크롤까지 평균 20번씩의 렌더링, 검색시에는 최고 40번까지 발생하는 경우도 있었다.
  3. 간혹 연속으로 잦은 검색 시 로딩 스피너만 계속해서 돌아가는 등의 문제가 발생했다.

2안일 경우 옵션 함수로 상태를 관리 해 주기 때문에 위의 모든 문제에 대해 자유로웠다. 하지만 useInfiniteQuery 자체적으로 초기 데이터 통신이 2번 더 발생하는 문제가 있었다.

의견 결정

1안은 직접 관리해줘야 할 상태가 많기 때문에 0~ 6페이지까지 스크롤 하는데 렌더링이 평균 20회 ~ 검색 시에는 간혹 40회까지 발생했는데, 2안은 평균적으로 12회정도 발생하여 두 배정도 차이가 났다. 또 간혹 발생하는 잔 버그들이 useInfiniteQuery에서는 발생하지 않았기 때문에 이점이 더 많다고 판단되어 테스트 후 useInfiniteQuery를 사용하기로 결정 하였다.

 

3. 무한 스크롤 성능 개선 - react-query useInfiniteQuery 도입

리팩토링 코드

function MainPage() {
  const [sort, setSort] = useState('인기순');
  const observRef = useRef(null); // 옵저버 ref

  const { searchQuery, isSearched, updateSearchQuery, resetSearchQuery } =
    useContext(SearchQueryContext);
  const { sorting, district, keyword } = searchQuery;

  const {
    data,
    isLoading,
    isFetching,
    isError,
    fetchNextPage,
    hasNextPage,
    refetch,
  } = useInfiniteQuery(
    'mainPosts',
    async ({ pageParam = 0 }) => {
      const res = await getMainPosts({ ...searchQuery, page: pageParam });
      return res;
    },
    {
      getNextPageParam: (lastPage, allPages) => {
        // lastPage: 직전에 반환된 리턴값, pages: 지금까지 받아온 전체 페이지
        const totalPages = lastPage.totalPages || 0;
        const nextPage = allPages.length;
        return nextPage < totalPages ? nextPage : undefined;
      },
    },
  );

  // 옵저버 실행
  const handleObserver = useCallback(
    entries => {
      const target = entries[0];
      if (target.isIntersecting && hasNextPage && !isFetching) {
        fetchNextPage();
      }
    },
    [hasNextPage, fetchNextPage, isFetching],
  );

  useEffect(() => {
    updateSearchQuery({
      ...searchQuery,
      page: 0,
      sorting: sort,
    });
  }, [sort]);

  useEffect(() => {
    refetch();
  }, [searchQuery]);

  useEffect(() => {
    // 옵저버 생성, 연결
    const observer = new IntersectionObserver(handleObserver, {
      threshold: 0,
    });
    if (observRef.current) observer.observe(observRef.current);
    return () => {
      observer.disconnect();
    };
  }, [handleObserver]);

  return (
      {data &&
        data.pages.map(page =>
          page.content.map((post, index) => (
            <MainPost key={uuid()} post={post} />
          )),
        )}
      {hasNextPage && !isFetching && (
        <div ref={observRef} style={{ height: '2rem' }}>
          <LoadingSpinner />
        </div>
      )}
}

 

1. 잦은 렌더링 횟수 개선 ✅

useQuery 사용 시 페이지, 옵저버 상태를 직접 관리하다 보니 0~ 6페이지 스크롤까지 평균 20번씩의 렌더링, 검색 시에는 간혹 최고 48번 렌더링이 발생했다. 

❇️ useInfiniteQuery 도입 후

총 6페이지의 스크롤을 모두 내리는 동안 12번 렌더링 되었다. 즉 스크롤 시 마다 2번의 렌더링이 발생했다.

 

2. ❗데이터 통신 횟수 

❗useInfiniteQuery를 사용하였더니 초기 렌더링 시 3번의 렌더링이 발생했다. 

useInfiniteQuery는 초기 데이터 요청 이후에 추가 데이터를 가져오기 위해 여러 번의 요청을 수행한다고 한다. 각 요청은 이전 페이지의 데이터를 가져와 현재 페이지에 추가되는 방식으로 작동하고 이로 인해 초기 데이터 요청 이후 추가 데이터를 가져오기 위해 2번의 요청이 발생하는데, 이는 인피니트 스크롤 또는 페이지네이션을 지원하기 위한 특성이다.

따라서 메인 페이지 첫 로드 시에는 초기 데이터 요청과 첫 번째 추가 데이터 요청이 발생하여 총 2번의 요청이 이루어지고, refetch() 함수를 호출하여 데이터를 다시 가져오는 요청이 한 번 더 발생할 수 있다. 따라서 총 3번의 요청이 발생하는 것이다.

사실상 데이터 통신 횟수를 줄일 수 있다고 예상했지만 오히려 초기 렌더링 시 2번의 렌더링이 더 발생하는 것이다. 만약 초기렌더링을 한 번만 하고 싶다면 이전 처럼 react-query를 사용하는 것이 좋을 것 같다.

react-query 사용 시 데이터 통신 횟수
useInfiniteQuery 사용 시 초기 3번의 데이터 통신이 더 발생했다.

useInfiniteQuery 도입 후

총 6페이지의 스크롤을 모두 내리는 동안 usequery 사용 시에는 총 6번의 데이터 통신이 일어났지만 => useInfiniteQuery 사용 시에는 총 8번의 데이터 통신이 일어났다.

 

3. 서버 응답 속도

useQuery 사용 시 서버 응답 까지의 대기 시간이 평균 150ms~200ms 정도 소요됐다. 

❇️ useInfiniteQuery 도입 후

서버 응답 까지의 대기 시간이 평균 150ms, 최고 331ms 정도로 소요됐다. 응답 속도는 비슷한 것으로 판단된다.

 

 

4. 간혹 연속으로 잦은 검색 시 로딩 스피너만 계속해서 돌아가는 등의 문제, 스크롤을 내릴 때 옵저버가 간혹 연속적으로 호출되어 스크롤이 2페이지 이상 가져오는 문제

잦은 검색 시 로딩 스피너만 계속해서 돌아가는 등의 문제

useQuery 사용 시 직접 페이지와 옵저버에 관련된 상태를 관리했기 때문에

1) 스크롤을 해보면서 간혹 연속으로 잦은 검색 시 로딩 스피너만 계속해서 돌아가는 등의 문제가 발생했다.

2) 스크롤을 내릴 때 옵저버가 간혹 연속적으로 호출되어 스크롤이 2페이지 이상 가져오는 문제가 발생했다.

 

❇️ 도입 후

연속으로 검색 시에도 로딩 스피너가 계속 돌아가는 등의 문제도 없이 연속적으로 검색을 해도 문제없이 잘 실행 됐다.스크롤을 내릴 때 옵저버가 간혹 연속적으로 호출되어 스크롤이 2페이지 이상 가져오는 문제 역시도 해결 됐다. 문제 없이 한 페이지씩 가져와졌다.

아마 페이지 처리를 알아서 해주기 때문에 페이지 상태 관리 / 옵저버 노출 등에 따르는 상태 관리들이 사라져 그런 것으로 예상된다. 시간이 많이 소요되는 구간은 아마 이미지 파일 크기 때문에 그런 것 같다. 아무래도 이미지 크기 최적화도 필요할 것 같다.

 

 

✅ 결론

useQuery와 useInfiniteQuery 비교

1) 렌더링 횟수

useQuery는 직접 페이지나 옵저버 상태를 관리하는 것이 어려움, useInfiniteQuery는 알아서 페이지 상태 관리해줌으로서 렌더링 횟수가 더 적다.

2) 데이터 통신 횟수

useQuery에 비해 useInfiniteQuery의 데이터 통신 횟수가 초기 렌더링시 2회 더 많다. 

3) 서버 응답 속도

useQuery = useInfiniteQuery 같다.

4) 페이지와 옵저버 상태 관리 / 코드 관리 및 유지보수

useQuery는 직접 관리해야 하고, 유지보수도 일일히 페이지나 옵저버 상태를 신경써야 할 부분이 많다. useInfiniteQuery는 알아서 해주기 때문에 이로 인한 렌더링이나 버그가 현저히 줄어들고, 코드 관리나 유지보수에도 좋다.

때문에 데이터 통신 횟수에 민감하고 세부적으로 조절하여 무한 스크롤을 상태를 관리하고 조작하고 싶다면 useQuery를 사용하면 좋을 것 같고, 좀더 개발자에게 편리하고 코드 관리나 유지보수에 좋은 것은 useInfiniteQuery인 것 같다. 나는 useqQuery의 페이지와 옵저버 상태 관리를 useInfiniteQuery가 대신해 주어 편리해서 좋았고, 프로젝트 동안 이로 인한 각종 버그들을 해결할 충분한 시간적 여유가 되지 않기 때문에 useInfiniteQuery를 사용하는 것이 좋다고 판단했다.

 

 

 

 

❗추가 - 트러블 슈팅 ) 무한 스크롤 - 새로 검색 또는 정렬 시, 이전 스크롤 페이지 넘버가 초기화 되지 않아 0~n페이지 까지의 데이터 리스트를 모두 불러오는 문제 발생

문제 상황

인기순 에서 ~ 6페이지 까지 무한 스크롤을 하고, 최신순 또는 검색을 진행했을 때 이전의 스크롤한 페이지 값을 기억하고 있기 때문에 검색 시 검색값에 해당하는 첫 페이지만 불러오는게 아니라 0 ~ 6페이지 까지의 리스트를 모두 가져오는 문제가 발생하였다.

 

시도 한점

1. searchQuery가 변경되면 데이터를 refetch()하게 해주었는데, 이때 refetch 시 {pageParam : 0} 으로 설정해주었으나 변함이 없었다.

    refetch({ pageParam: 0 });

따라서 아예 캐싱 정보를 초기화하고 다시 받아오도록 queryClinet.invaildationQueries("mainPost")를 해주었으나 역시나 변함이 없었다. 생각해 보니 이 방법은 그전 캐싱 기록만 초기화 하고 다시 받아오는 것이랑 같아 보였다.

    queryClient.invalidateQueries('mainPosts');

 

해결 방법

쿼리 키를 아예 remove하여 캐싱 정보를 모두 지우고 다시 새로운 정보로 refetch()하도록 하니 해결 가능했다. 

이전 코드

 useEffect(() => {
    refetch();
  }, [searchQuery]);

개선 코드

useEffect(() => {
    queryClient.removeQueries('mainPosts');
    refetch();
  }, [searchQuery]);