유저가 작성한 리뷰를 받아와야 하는데, 만약에 리뷰가 100, 200, 300개... 이런식으로 점점 많아질수록 한번에 받아와야 할 데이터가 늘어나게 된다.
그리고 보통 리뷰 같은 데이터는 끝까지 보지 않고 중간에 이탈할 가능성이 높기 때문에 굳이 데이터를 한번에 가져올 필요가 없다고 판단했다.
따라서 사용자가 리뷰를 더 보려는 의지가 있을때 데이터를 불러와 보여주는 방식으로 구현하기로 했다. 이걸 react-query 와 supabase 를 활용해 무한스크롤로 구현해보기로 했다.
페이지네이션을 구현하려면 페이지 정보가 필요하다. 그래서 보통 서버로부터 응답을 받을때 페이지 정보를 담은 meta 정보를 같이 전달 받는데, supabase 로는 어떻게 페이지 정보를 받아와야 하나 고민이 있었다.
그러다 공식 문서에서 메서드를 하나 발견했는데, explain() 메서드를 사용하면 응답값으로 총 데이터 갯수를 받아올 수 있었다.
문서에 나와있다시피 기본적으로 explain 메서드는 비활성화 되어 있기 때문에 먼저 활성화를 시켜줘야 한다.
supabase의 SQL Editer 에 아래와 같이 작성한 후 실행시켜 주면 활성화 된다.
그 다음 아래와 같이 페이지 정보가 필요한 곳에 explain 메서드를 사용하면 된다. 이때 format 옵션을 추가해줘야 json 형식으로 받아올 수 있다.
export const getReviewPageInfo = async () => {
const { data } = await supabase.auth.getSession();
if (!data.session) return;
const user_id = data.session.user.id;
const { data: reviewData, error } = await supabase
.from('review')
.select('*')
.eq('user_id', user_id)
.explain({ format: 'json', analyze: true });
if (error) {
throw new Error(error.message);
}
return reviewData;
};
받아온 데이터를 콘솔에 찍어보니 아래와 같이 나오는데, 여기서 내가 필요한 데이터는 'Actual Rows' 으로 총 review 의 갯수이다.
explain 메서드로 받아온 데이터를 페이지네이션에 필요한 데이터로 가공하기 위해 usePagination 이라는 훅을 만들었다.
총 데이터 갯수 (totalRows) / 한번에 받아올 데이터 갯수 (pageSize) 를 나눈 후에 올림해주면 총 페이지 수가 된다. 이 값을 그대로 리턴해준다.
export const usePagination = (pagination: any, pageSize: number) => {
const [totalPage, setTotalPage] = useState(0);
useEffect(() => {
if (Array.isArray(pagination)) {
const plan = pagination[0].Plan as any;
const totalRows = plan.Plans[0]['Actual Rows'];
const totalPage = Math.ceil(totalRows / pageSize);
setTotalPage(totalPage);
}
}, [pageSize, pagination]);
return { totalPage };
};
이제 페이지 정보를 알았으니 이를 활용해 무한스크롤을 구현해야 하는데, react-query 의 useInfiniteQuery 훅을 사용하기로 했다.
※ 참고로 TanStack Query v5 버전이다.
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 1,
...options,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) =>
firstPage.prevCursor,
})
블로그 글마다 버전이 달라서 공식 문서를 참고했다.
사용법은 useQuery 와 비슷한데 핵심적인 차이점을 나열하자면 아래와 같다.
hasNextPage : 다음 페이지가 있다면 true
fetchNextPage : 다음 페이지를 불러오는 함수
isFetchingNextPage : fetchNextPage 실행 되어 다음 페이지를 불러오는 상태라면 true
initialPageParam : 초기 pageParam 값을 설정할 수 있다.
getNextPageParam : 이 함수의 리턴값이 pageParam 값으로 들어간다.
const { data, hasNextPage, fetchNextPage } = useInfiniteQuery({
queryKey: ['reviewList'],
queryFn: ({ pageParam }) => getUserReviewsPaginated(pageParam, pageSize),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPageParam < totalPage) {
return lastPageParam + 1;
}
},
select: (data) => {
return data.pages.flat();
},
});
getNextPageParam 함수는 useInfiniteQuery 에서 다음 페이지를 가져올 때 사용할 매개변수를 결정하는 역할을 한다.
이 함수는 마지막 페이지의 데이터, 전체 페이지 배열, 마지막 페이지 매개변수, 전체 페이지 매개변수
를 받는다. 이를 통해 다음 페이지가 있는지 여부를 결정하고, 다음 페이지를 가져오기 위해 필요한 매개변수를 반환하는데, 다음 페이지가 없으면 undefined 또는 null을 반환한다.
즉, 이 함수를 이용해서 조건문을 작성해 다음 페이지가 있다면 lastPageParam + 1 를 리턴하도록 했다. 그럼 이 값이 pageParam 으로 들어간다.
useInfiniteQuery 의 반환 값을 살펴보면, data.pages, data.pageParams 로 구성된 배열이다. 이 값을 필요에 맞게 가공하기 위해 select 함수를 사용했다.
flat() 메서드를 사용하여 배열의 모든 하위 요소를 평탄화(Flatten)하여 새로운 배열로 생성한다. 이렇게 하면 무한 스크롤로 새로운 데이터를 불러올 때마다 이전 데이터들이 누적되어 화면에 나타난다.
{pages : Array(1), pageParams : Array(1)}
supabase 의 range 함수를 이용하면 필요에 맞게 데이터를 끊어서 가져올 수 있다.
이때 pageParam 매개변수는 useInfiniteQuery 의 pageParam 값을 받아오고, pageSize 는 한번에 받아올 데이터 갯수를 나타낸다. 나는 데이터를 15개씩 가져오도록 했다.
앞서 initialPageParam 으로 초기값을 1로 지정해줬기 때문에 처음에 함수가 실행되면 range(0,14) 가 된다. 즉 0~14개의 데이터를 가져온다.
// 사용자에 대한 리뷰 목록을 페이지 단위로 가져오기
export const getUserReviewsPaginated = async (pageParam: number, pageSize: number) => {
const { data } = await supabase.auth.getSession();
if (!data.session) return;
const user_id = data.session.user.id;
console.log('pageParam', pageParam);
const { data: reviewList, error } = await supabase
.from('review')
.select('*')
.eq('user_id', user_id)
.range((pageParam - 1) * pageSize, pageParam * pageSize - 1);
if (error) {
throw new Error(error.message);
}
return reviewList;
};
미리 만들어놓은 useInfinteScroll 훅을 이용해 스크롤이 하단에 닿았을때 hasNextPage 가 true 라면, 즉 다음 페이지가 있다면 fetchNextPage 함수가 실행되어 다음 데이터를 가져오게 된다.
const { observerEl } = useInfiniteScroll({
callbackFn: fetchNextPage,
hasNextPage: hasNextPage,
});
return (
(생략..)
<div ref={observeEl} />
기존 코드보다 훨씬 직관적으로 바뀌었다.
'use client';
const MyReviewList = () => {
const router = useRouter();
const pageSize = 15;
const [rate, setRate] = useState<number>(0);
const { data: pageInfo } = useQuery({
queryKey: ['reviewPagination'],
queryFn: () => getReviewPageInfo(),
});
const { totalPage } = usePagination(pageInfo, pageSize);
const { data, hasNextPage, fetchNextPage } = useInfiniteQuery({
queryKey: ['reviewList'],
queryFn: ({ pageParam }) => getUserReviewsPaginated(pageParam, pageSize),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPageParam < totalPage) {
return lastPageParam + 1;
}
},
select: (data) => {
return data.pages.flat();
},
});
const { observerEl } = useInfiniteScroll({
callbackFn: fetchNextPage,
hasNextPage: hasNextPage,
});
return (
<div>
<div>
/// 내용 자리
</div>
<div ref={observerEl} />
</div>
);
};
export default MyReviewList;