도서 검색 페이지에서 특정 keyword로 검색 시 keyword와 연관성이 있는 도서가 render 되도록 구현해야 했다. 더 나아가 무한 스크롤을 기능을 통해 스크롤을 내릴 때 마다 새로운 도서가 render 되도록해야 했다. 오늘은 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('데이터 패치 실패');
}
};
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
},
);
};
ref
와 해당 Element의 보임 여부에따라 다른 boolean 값을 반환하는 inView
를 사용한다.['book', 'search', 'result', 'list', searchKeyword]
: query key 컨벤션은 팀마다 다르겠지만 개인적으로 단어별로 끊어서 작성하는게 가독성이 좋다는 판단하에 해당 컨벤션을 사용했다.({ pageParam = 1 }) => getBookSearchResultData(searchKeyword, pageParam)
: pageParam을 통해 특정 페이지의 데이터를 fetch 할 수 있다. 처음 검색 하였을 때 첫 번째 페이지에 해당하는 도서 데이터가 render 되어야 하므로 1로 설정해주었다.{
// 현재 페이지에 해당하는 데이터 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]);
🔎 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>
);
불러올 추가적인 데이터가 있고(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를 향상 작업을 경험할 수 있어서 좋았다!
감사합니다. 이런 정보를 나눠주셔서 좋아요.