무한 스크롤 구현하기(feat. IntersectionObserver, useInfiniteQuery)

Chex·2023년 8월 27일
1

우아한테크코스

목록 보기
16/19
post-thumbnail

'집사의 고민' 프로젝트에서 커서 기반 페이지네이션으로 무한스크롤 기능을 구현해보았습니다.(반려동물 사료 목록 조회)

커서 기반 페이지네이션

이전에 로드한 데이터 중 마지막 데이터 Id(cursor, 고유한 식별자)를 서버에 전달하여 그 이후부터 일정 개수의 데이터를 가져오는 방식입니다.

useInfiniteQuery

export const useFoodListInfiniteQuery = (payload: Parameter<typeof getFoodList>) => {
  const { data, ...restQuery } = useInfiniteQuery({
    queryKey: [QUERY_KEY.petFoods],
    queryFn: ({ pageParam = { ...payload, size: String(SIZE_PER_PAGE) } }) =>
      getFoodList(pageParam),
    getNextPageParam: (lastFoodListRes, allFoodListRes) => {
      const lastFood = lastFoodListRes.petFoods.at(-1);
      const isLastPage =
        allFoodListRes.flatMap(foodListRes => foodListRes.petFoods).length >=
          lastFoodListRes.totalCount || lastFoodListRes.petFoods.length < SIZE_PER_PAGE;
      if (!lastFood || isLastPage) return undefined;

      return { ...payload, lastPetFoodId: String(lastFood.id), size: String(SIZE_PER_PAGE) };
    },
  });

  return {
    foodList: data?.pages.flatMap(page => page.petFoods),
    ...restQuery,
  };
};
  • getNextPageParam: 서버에서 보내준 totalCount값과 현재까지의 foodList의 길이를 비교해서 마지막 페이지라면 undefined을 반환하고 마지막 페이지가 아니라면 다음 요청의 파라미터 값인 마지막 식품의 id(foodList.at(-1).id), 페이징 사이즈 그리고 필터링 옵션을 담아서 반환하도록 구현했습니다.
  • hasNextPage: 현재 페이지가 마지막페이지인지 여부를 확인합니다. getNextPageParamundefined를 반환하면 false, 그 외에는 true를 반환합니다.
  • isFetchingNextPage: 사용자가 스크롤을 다시 위로 올렸다가 내렸을 때 데이터 요청 도중 중복으로 데이터를 요청하는 문제를 해결하기 위해 데이터 요청 상태를 확인합니다.

IntersectionObserver

무한스크롤 구현 시, 다음 페이지 데이터 호출 시점을 판단하기 위해 사용하였습니다.

  • ViewPort에 Target요소가 포함 & hasNextPagetrue & isFetchingNextPagefalse인 경우 다음 페이지의 데이터를 호출합니다.

Scroll event를 감지하는 방법으로도 무한스크롤을 구현할 수 있지만 이 방식은 스크롤 위치가 바뀔 때마다 불필요한 이벤트가 발생하기 때문에 스크롤 이벤트 리스너가 빈번하게 호출되어 성능이 저하될 수 있는 문제가 있습니다. 또한 원하는 시점에 정확하게 이벤트를 제어하고 처리하는 것이 어려울 수 있습니다. 이런 문제들을 고려하여 IntersectionObserver를 사용하여 구현하였습니다.

Problem

무한 스크롤 기능 적용 후, 사용자가 사료 목록 페이지에서 필터를 적용할 때마다 사료 목록을 refetch하는 기능을 구현하면서 만났던 문제들을 정리했습니다.

무한루프가 도는 문제(사료 목록 조회 api 무한 요청)

  const queries = useValidQueryString<KeywordEn>([
    'nutritionStandards',
    'mainIngredients',
    'brands',
    'functionalities',
  ]);

// 문제의 코드
 useEffect(() => {
   refetch();
 }, [Object.values(queries)]);

queries는 사료를 필터링하는 쿼리 파라미터를 담은 객체입니다.

// queries
{ 
  brands: "퓨리나,오리젠", 
  mainIngredients: "닭고기,연어", 
  ...
}

useEffect의 의존성 배열에 Object.values(queries)를 넣어주었고 사료 목록 조회 api를 계~속 요청하면서 무한루프에 빠지는 문제가 있었습니다.

첫 화면 렌더링 후 queries는 {} ➡️ Object.values(queries)[] ➡️useEffectrefetch() 실행 ➡️ refetch의 결과로 사료 목록을 새로 가져오면서 이 사료 목록 데이터를 사용하는 컴포넌트의 렌더링을 트리거 ➡️ 무한 반복...

이러한 상황은 Object.values(queries)가 매 렌더링마다 새로운 배열을 생성하기 때문에 발생합니다. 이 새로운 배열은 이전에 생성된 배열과 메모리 주소가 다르기 때문에 항상 다른 객체로 취급되어, useEffect가 계속해서 실행되는 원인이 됩니다.

👆위 문제는 의존성 배열안에 매번 새로운 배열을 넣어서 생긴 문제였기 때문에 아래와 같이 수정하여 해결했습니다.

 useEffect(() => {
   refetch();
 }, Object.values(queries));

필터 적용 후 queries의 변경을 useEffect에서 인식하지 못하는 문제

하지만 위 코드는 또 다른 문제가 있었는데요.

주어진 코드에서 useEffect의 두 번째 파라미터로 Object.values(queries)를 넣어주면서 발생하는 문제 상황은 다음과 같습니다.

처음 필터 적용 후에만 queries의 변경을 useEffect가 인식하지 못해서 식품목록을 refetch해오지 못하는 문제가 있었습니다.

예를 들어 queries{}에서 -> { brands: "아카나" }로 바뀌는 경우 useEffect에서 queries의 변경을 인식하지 못하고 { brands: "아카나" }에서 -> { brands: "오리젠" }으로 바뀌는 경우에는 변경을 인식하여 refetch가 잘 실행되었습니다.

이러한 상황은 useEffectObejct.values(queries)를 넣어주는데 첫 렌더링 화면에서는 필터 적용이 안되어 있는 전체 사료 목록을 보여주기 때문에 queries는 빈 객체{}가 되고 Object.values({})는 빈 배열[]이 나오기 때문이었습니다.

즉, useEffect의 의존성 배열이 아래와 같이 변화하기 때문에

    1. 처음 렌더링 후: []
    1. 첫 필터링 적용 후: ["아카나"]
    1. 두번째 필터링 적용 후: ["오리젠"]

1->2의 변경사항은 인식하지 못하고 2->3의 경우에만 인식하면서 두번째 필터링 이후부터만 refetch를 해오는 것이었습니다.

이러한 문제를 해결하기 위해 Object.values(queries).join()을 한 결과를 의존성 배열에 추가했습니다.

    1. 처음 렌더링 후: [""]
    1. 첫 필터링 적용 후: ["아카나"]
    1. 두번째 필터링 적용 후: ["오리젠"]

에디 코멘트

또한 팀원인 에디에게 위와 같은 코드리뷰를 받고 remove메소드도 추가해주었습니다.

👇 수정 후

  const queriesString = Object.values(queries).join();

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

👇 최종 코드

export const useInfiniteFoodListScroll = () => {
  const queries = useValidQueryString<KeywordEn>([
    'nutritionStandards',
    'mainIngredients',
    'brands',
    'functionalities',
  ]);

  const queriesString = Object.values(queries).join();

  const {
    foodList,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    remove,
    refetch,
    ...restQuery
  } = useFoodListInfiniteQuery(queries);

  const executeFoodListInfiniteQuery = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      entries.forEach(entry => {
        const canLoadMore = entry.isIntersecting && hasNextPage && !isFetchingNextPage;

        if (canLoadMore) fetchNextPage();
      });
    },
    [hasNextPage, isFetchingNextPage, fetchNextPage],
  );

  const targetRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(executeFoodListInfiniteQuery, { threshold: 0.1 });

    if (targetRef.current) observer.observe(targetRef.current);

    return () => observer.disconnect();
  }, [executeFoodListInfiniteQuery]);

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

  return { foodList, hasNextPage, refetch, targetRef, ...restQuery };
};
profile
Fake It till you make It!

0개의 댓글

관련 채용 정보