useInfiniteQuery로 무한 스크롤 구현하기(feat. TanStack Query)

박민형·2023년 8월 12일
0
post-thumbnail

도서 검색 페이지에서 특정 keyword로 검색 시 keyword와 연관성이 있는 도서가 render 되도록 구현해야 했다. 더 나아가 무한 스크롤을 기능을 통해 스크롤을 내릴 때 마다 새로운 도서가 render 되도록해야 했다. 오늘은 useInfiniteQuery를 통해 어떻게 무한 스크롤을 구현하는지 포스팅 하려한다!

📌 useInfiniteQuery

useInfiniteQuery는 TanStack Query에서 제공하는 무한스크롤을 보다 쉽게 구현할 수 있게 하는 Hook이다. 서버 데이터를 fetch, 캐싱, server state로 관리할 수 있기 때문에 직접 구현하거나 무한 스크롤 라이브러를 사용하는 것보다 효율적이라는 판단하에 해당 Hook을 도입했다. useInfiniteQuery Hook에서 반환하는 객체의 property 및 함수를 알아보자!

🔎 useInfiniteQuery Hook에서 반환하는 객체의 주요 property 및 함수

  • data : 무한 스크롤에 사용되는 데이터의 배열을 담고 있는 객체이다. 이 배열은 페이지별로 나뉘어져 있으며, 각 페이지는 pages 배열 안에 객체로 저장된다.
  • fetchNextPage : 다음 페이지의 데이터를 가져오는 함수이다. 이 함수를 호출하면 다음 페이지의 데이터를 가져온다.
  • hasNextPage : 추가적으로 데이터를 더 가져올 수 있는지 여부를 나타내는 boolean 값이다.
  • isFetchingNextPage : 다음 페이지 데이터를 가져오는 중인지 여부를 나타내는 boolean 값이다.
  • status : 현재 쿼리의 상태를 나타내는 문자열 값으로, "loading", "error", "success" 중 하나이다.

📌 무한스크롤 구현하기

👉 getBookSearchResultData 메소드

export const getBookSearchResultData = async (
  query: string,
  page: number,
): Promise<BookSearchResultListProps> => {
  try {
    const {
      data: {
        documents,
        meta: { is_end },
      },
    } = await axios.get<{
      documents: BookSearchResulListItem[];
      meta: { is_end: boolean };
    }>(`${KAKAO_BOOK_SEARCH_API_URL}?query=${query}&size=7&page=${page}`, {
      headers: {
        Authorization: `KakaoAK ${KAKAO_REST_API_KEY}`,
      },
    });

    if (documents.length > 0) {
      return { is_end, documents };
    } else {
      return { is_end: true, documents: [] };
    }
  } catch {
    throw new Error('데이터 패치 실패');
  }
};
  • getBookSearchResultData는 카카오 도서 API를 통해 도서 데이터를 fetch 하는 함수이다.
  • 카카오 측에서 도서 데이터(documents) 및 마지막 페이지 여부(is_end)를 응답해주기 때문에 해당 데이터를 fetch 후 반환하도록 했다.
  • 조회되는 도서 데이터가 없을 때 사용자에게 도서 검색 결과가 없다는 것을 알려야 하기 때문에 다음과 같이 처리했다.(return { is_end: true, documents: [] };)

👉 useInfiniteQuery Hook 부분

const InfinityScrollLists = ({ searchKeyword }: InfinityScrollListsProps) => {
  const { ref, inView } = useInView();
  const [page, setPage] = useRecoilState(searchInfinityScrollPageAtom);

  const {
    data: bookSearchResultLists,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery(
    ['book', 'search', 'result', 'list', searchKeyword],
    ({ pageParam = 1 }) => getBookSearchResultData(searchKeyword, pageParam),
    {
      onSuccess: () => setPage((prev) => prev + 1),
      onError: (error) => console.error(error),
      getNextPageParam: ({ is_end }) => {
        if (is_end) return;

        return page;
      },
      enabled: !!searchKeyword
    },
  );
};
  • useInview Hook을 통해 Bottom Element에 설정할 ref와 해당 Element의 보임 여부에따라 다른 boolean 값을 반환하는 inView를 사용한다.
  • page 데이터를 atom을 통해 전역상태로 관리
  • ['book', 'search', 'result', 'list', searchKeyword] : query key 컨벤션은 팀마다 다르겠지만 개인적으로 단어별로 끊어서 작성하는게 가독성이 좋다는 판단하에 해당 컨벤션을 사용했다.
  • ({ pageParam = 1 }) => getBookSearchResultData(searchKeyword, pageParam) : pageParam을 통해 특정 페이지의 데이터를 fetch 할 수 있다. 처음 검색 하였을 때 첫 번째 페이지에 해당하는 도서 데이터가 render 되어야 하므로 1로 설정해주었다.
  • option code
{
  // 현재 페이지에 해당하는 데이터 fetch 성공 시 page 번호를 update 한다.
  onSuccess: () => setPage((prev) => prev + 1),
  onError: (error) => console.error(error),
    
  // 위에서 언급한 pageParam 값을 해당 option을 통해 수정할 수 있다.
  // 카카오 API를 통해 응답 받은 is_end를 통해 이 데이터가 true이면 마지막 페이지이므로 return 한다.
  // 마지막 페이지가 아니라면 pageParam 값을 onSuccess를 통해 update한 page 상태 값으로 수정한다.
  getNextPageParam: ({ is_end }) => {
    if (is_end) return;

    return page;
  },
    
  // 한 글자 이상 keyword로 도서 검색을 한 경우에만 해당 쿼리가 활성화 되도록 설정했다.
  enabled: !!searchKeyword
},

🔎 useInview Hook을 사용한 이유

  • 제작 초기에는 커스텀 훅을 통해 Bottom Element를 관찰하는 Hook을 제작했다.
  • Hook 제작 후 useInfiniteQuery와 연동 하는 과정에서 예상치 못한 error가 많이 발생했고 해당 작업에 있어 생각보다 시간 소요가 많이 되었다.
  • 팀원들과 회의 후 react-intersection-observer 라이브러리를 도입했다.
  • 다른 팀원들도 무한 스크롤을 구현해야했고 무한 스크롤을 구현하면서 error를 처리하는 시간 소요를 줄이는 것과 더불어 해당 라이브러리가 많이 무겁지 않아 도입하게 되었다.

🔎 page를 전역 상태로 관리한 이유

  • 다른 페이지에 갔다가 다시 돌아왔을 때 이전에 render 했던 내용이 그대로 유지되도록 하기 위해서이다.
  • 다시 돌아왔을 때 데이터가 유지되면 UX 향상으로 이어질 수 있다는 판단을 했다.

👉 useEffect Hook 부분

  useEffect(() => {
    if (inView && bookSearchResultLists) {
      const { pages } = bookSearchResultLists;
      const { is_end } = pages[pages.length - 1];

      !is_end && fetchNextPage();
    }
  }, [fetchNextPage, inView]);

  useEffect(() => {
    searchKeyword && setPage(1);
  }, [searchKeyword, setPage]);
  • Bottom Element가 viewport에 보이고 마지막 페이지가 아닌 경우 다음 페이지의 독서 데이터를 fetch 한다.
  • searchKeyword가 update 될 때 page 상태 값을 1로 update 한다.(setPage는 App 컴포넌트가 render 될때 1번만 생성되므로 dependency 배열에 설정해도 render 성능에 큰 영향을 주지 않는다.)

🔎 page 상태 값을 1로 update 한 이유

  • 사용자가 "넛지"라는 keyword로 도서 검색 후 스크롤을 내리면서 도서를 구경하다가 "미움 받을 용기"라는 도서로 검색했을 때를 가정해보겠다. 새로운 도서 검색 시 새로운 도서에 해당하는 제일 첫 페이지의 데이터를 보여주어야하고 스크롤 시 정상적으로 다음 데이터가 render 되어야 한다.
  • page 값을 업데이트 하지 않고 새로운 keyword로 검색시 pageParam 초기 값 설정에 의해 첫 페이지는 render 되지만 그 다음부터는 2페이지에 해당하는 데이터가 render 되지 않고 6,7 등등 이전 page 상태를 기반으로 render 되는 문제가 발생했다.

👉 JSX 부분

  return (
    <Container>
      {status === 'success' && (
        <React.Fragment>
          {bookSearchResultLists.pages.map(({ documents }, index) =>
            documents.length > 0 ? (
              <SearchResultList key={index} listData={documents} />
            ) : (
              <NoSearchResult key='not-search-result'>
                검색 결과가 없습니다.
              </NoSearchResult>
            ),
          )}
          <Bottom ref={ref}>
            {isFetchingNextPage && hasNextPage && 'Loading...'}
          </Bottom>
        </React.Fragment>
      )}
    </Container>
  );
  • 데이터를 정상적으로 가져오고 특정 페이지의 데이터가 1개이상이면 도서 검색 결과 리스트를 render 한다.
  • 검색결과가 없을 경우 사용자에게 검색결과가 없다고 알려준다.
  • Bottom Element에 ref를 설정한 후 해당 Element가 보이고 불러올 추가적인 데이터가 있고(hasNextPage) 다음 데이터를 불러오고 있다면(isFetchingNextPage) Loading Text를 render 한다.

🔎 보완해야 할 부분 : SearchResultList 컴포넌트 key props에 index 전달

  • key props에 배열의 index 값을 넣어주는 것은 render 측면에서 예상치 못한 문제를 야기시킬 수 있다.
  • 필자도 이 부분에 있어 uuid 라이브러리를 도입할까 등의 고민을 했지만 일단 MVP를 구현하는 것이 가장 높은 우선순위였기 때문에 그대로 두었다. 적절한 식별자를 부여할 수 있는 방법을 다시 한번 고민해봐야할 것 같다.

📌 결론

useInfiniteQuery Hook을 처음 사용해봐서 각 개념을 이해하고 적용하는 것이 쉽지는 않았다. error 발생이 빈번해서 작업 소요시간이 길어졌지만 여러 상황에서 어떻게 대처해야 할지를 배웠던 것 같다! 이번 포스팅에서 작성한 글의 코드는 무한스크롤 제작 초기 상태여서 프로젝트를 진행하면서 기초 제작 외의 critical한 오류들을 많이 만날 수 있었다.(블로그 포스팅을 해두었다! Infinity Scroll 리스트 아이템 데이터 수정 안되는 문제(Feat. useInfiniteQuery, fetchNextPage, hasNextPage), 검색 페이지에서 발생한 오류 해결) 이번 작업을 통해 TanStack Query의 유용함을 다시 한번 알 수 있었고 여러 상황에서 사용자의 입장을 고려해 오류를 해결하고 보완함으로써 UX를 향상 작업을 경험할 수 있어서 좋았다!

1개의 댓글

comment-user-thumbnail
2023년 8월 12일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

답글 달기