react-query의 useInfiniteQuery
와 Intersection Obeserver API
를 사용하여 무한 스크롤을 구현해보자.
useInfiniteQuery의 사용법을 익히기 위해 useInfiniteQuery를 사용하여 먼저 일반 페이지네이션 구현 후, 무한 스크롤을 구현해보았다.
순서대로 하나씩 살펴보자!
무한스크롤 구현 결과는 아래와 같다. 데이터가 더이상 없을 때까지 무한스크롤이 가능하다!
구현에 들어가기에 앞서 먼저 백엔드로부터 어떻게 구성된 데이터를 받는지 알아보려고 한다.
Swagger를 보면 현재 letters와 nextCursor로 이루어진 데이터를 받고 있는데, nextCursor은 다음 페이지가 있다면 다음 페이지에서 보여줄 첫번째 데이터의 id를 나타낸다.
(한 페이지에 25개씩 보여주기로 합의했고 이부분은 백엔드에서 처리했다.)
백엔드에서 보내주는 nextCursor
가 무엇인지 처음에는 전혀 이해가 가지 않았다.
검색해보니 페이지네이션에는 오프셋 기반 페이지네이션, 커서 기반 페이지네이션 이 있다고 한다. nextCursor를 사용하는 페이지네이션은 그 중 후자인 커서 기반 페이지네이션이었다.
1번 페이지에 대한 데이터를 받으면 화면에 표시할 데이터(letters)와 nextCursor를 받는다.
그리고 2번 페이지 데이터 요청을 보낼 때 경로에 1번 페이지에서 받은 nextCursor를 넣는다. (예: https://mock-server-url/api/v1/letters/next-cursor-25
)
그러면 2번 페이지에 id가 nextCursor인 데이터 이후부터 받아와서 표시하게 된다.
1페이지에서 받아온 nextCursor가 0이어서 다음 nextCursor는 25일 거라고 생각했다. 한 페이지당 데이터를 25개씩 보여주기로 했기 때문이다. 그런데 그 다음이 12였다. 이부분이 이해가 가지 않아 백엔드 팀원께 설명을 부탁드렸다.
그때 들은 설명은 맨 처음의 0이 진짜 0번째가 아니라는 것이었다. 어떤 id이든 관계없이 처음에는 0이었다. 그래서 첫번째 nextCursor과 두번째 nextCursor 사이의 관계가 없어보였던 것이었다.
무한스크롤을 위해 먼저 useInfiniteQuery를 사용하여 일반적인 페이지네이션을 구현해봤다. useInfiniteQuery 관련 공식문서를 참고했다. (https://tanstack.com/query/latest/docs/react/guides/infinite-queries)
관련 설명(react query 공식 문서)
queryFn
queryFn은 데이터를 가져오는 함수이다. queryFn을 미리 만들어둔 getLetters라는 함수로 정의해줬다.
queryFn: getLetters,
getLetters 함수의 내용은 아래와 같다. pageParam을 파라미터로 받아서 API에 넣어서 GET 요청을 보낸다.
여기서 pageParam은 아래에서 설명할 getNextPageParam이 리턴하는 값이다.
const mockServerURL =
'https://mock-server-url';
const path = '/api/v1/letters';
const apiEndpoint = `${mockServerURL}${path}`;
const getLetters = async ({ pageParam }: PageParam) => {
const url = pageParam
? `${apiEndpoint}?next-cursor=${pageParam}`
: apiEndpoint;
const response = await axios.get(url);
const letters = response.data;
return letters;
};
getNextPageParam
getNextPageParam가 반환하는 값이 다음 페이지의 pageParam이 된다. 그래서 queryFn이 GET 요청을 보내는 URL(${apiEndpoint}?next-cursor=${pageParam}
)의 pageParam에 들어간다.
getNextPageParam가 undefined를 리턴하면 마지막 페이지라는 것을 의미한다. 백엔드로부터 받는 데이터 중 nextCuror가 -1이면 마지막 페이지인 것이라고 합의를 봤으므로 nextCursor가 -1이면 undefined를 리턴하게 했다.
getNextPageParam: (lastPage) => {
return lastPage.nextCursor === -1 ? undefined : lastPage.nextCursor;
},
select
백엔드로부터 받는 데이터는 아래와 같았다.
data = {
letters: [];
nextCursor: number;
};
페이지에 보여줄 데이터는 그 중 letters였기 때문에, 이를 보여주기 위해 flatMap을 썼다. 여기서 data는 useInfiniteQuery가 리턴하는 객체이고 data.pages는 패치된 페이지에 포함된 데이터이다.
select: (data) => (data.pages ?? []).flatMap((page) => page.letters),
fetchNextPage
다음 페이지의 데이터를 불러온다.
hasNextPage
다음 페이지가 있는지 불린 값으로 보여준다.
전체 코드이다.
import { useInfiniteQuery } from '@tanstack/react-query';
const mockServerURL =
'https://mock-server-url';
const path = '/api/v1/letters';
const apiEndpoint = `${mockServerURL}${path}`;
const getLetters = async ({ pageParam }: PageParam) => {
const url = pageParam
? `${apiEndpoint}?next-cursor=${pageParam}`
: apiEndpoint;
const response = await axios.get(url);
const letters = response.data;
return letters;
};
const Page: NextPageWithLayout<Letters> = () => {
const {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
data
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: getLetters,
getNextPageParam: (lastPage) => {
return lastPage.nextCursor === -1 ? undefined : lastPage.nextCursor;
},
initialPageParam: 0,
select: (data) => (data.pages ?? []).flatMap((page) => page.letters),
});
return (
<div className="pb-[5rem]">
{data?.map((letter: LetterType, index: number) => {
return (
<QuestionBar
key={index}
letter={letter}
onClick={() => handleQuestionBarClick({ letter })}
/>
);
})}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? '로딩 중'
: hasNextPage
? '더 로드하기'
: '더 로드할 것이 없음!'}
</button>
</div>
);
};
export default Page;
일반 페이지네이션을 구현한 후, 코드를 수정하여 정방향 무한스크롤을 구현해봤다.
달라진 점은 한가지밖에 없었다. 바로 새로운 페이지를 가져오는 버튼이 없어졌다는 것이다.
기존에는 버튼을 누르면 새로운 페이지가 로드됐다. 하지만 이제는 그 버튼이 없어지고 스크롤을 끝까지 내리면 자동으로 새 페이지가 불러와진다.
그렇다면 스크롤이 끝까지 내려갔다는 정보를 알아야 했다. 이를 위해 Intersection Obeserver API를 사용했다. 스크롤을 내려 페이지의 마지막 요소에 닿았을 때를 감지하는 역할을 한다. 페이지의 마지막 요소를 ref로 가져와서 이 요소가 감지되었을 때 다음 페이지를 불러오는 함수를 실행함으로써 무한 스크롤을 구현했다.
import { useInfiniteQuery } from '@tanstack/react-query';
const mockServerURL =
'https://mock-server-url';
const path = '/api/v1/letters';
const apiEndpoint = `${mockServerURL}${path}`;
const getLetters = async ({ pageParam }: PageParam) => {
// 위와 동일
};
const Page: NextPageWithLayout<Letters> = () => {
const {
fetchNextPage,
data
} = useInfiniteQuery({
// 위와 동일
});
const useObserver = ({
target,
rootMargin = '0px',
threshold = 1.0,
onIntersect,
}: UseObserver) => {
useEffect(() => {
let observer: IntersectionObserver | undefined;
if (target && target.current) {
observer = new IntersectionObserver(onIntersect, {
root: null,
rootMargin,
threshold,
});
observer.observe(target.current);
}
return () => observer && observer.disconnect();
}, [target, rootMargin, threshold, onIntersect]);
};
const onIntersect = ([entry]: IntersectionObserverEntry[]) =>
entry.isIntersecting && fetchNextPage();
useObserver({
target: bottomRef,
onIntersect,
});
return (
<div className="pb-[5rem]">
{data?.map((letter: LetterType, index: number) => {
return (
<QuestionBar
key={index}
letter={letter}
onClick={() => handleQuestionBarClick({ letter })}
/>
);
})}
<div ref={bottomRef} />
</div>
);
};
export default Page;
react query를 처음 써봐서 무한 스크롤 구현이 큰 도전이었던 것 같다.
사실 프로젝트에서 최종적으로 구현한 것은 역방향 무한 스크롤이었는데, 포스팅이 너무 길어지는 것 같아 페이지네이션+무한스크롤 / 역방향 무한스크롤 이렇게 포스팅을 나눠서 작성하기로 했다.
다음 포스팅에서는 역방향 무한스크롤을 구현한 과정을 포스팅해보자!