프로젝트에서 리뷰 페이지에서 무한 스크롤로 데이터를 불러오는 기능이 필요해 무한 스크롤 기능에 대해 알아보았다. 리뷰 페이지 뿐만 아니라 메인 페이지에서도 무한 스크롤 기능이 필요해 무한 스크롤 컴포넌트를 만들어 트리거에 도달하면 데이터를 추가적으로 패칭할 수 있도록 구현하기로 했다.
리액트 쿼리에서 제공하는useInfiniteQuery
훅을 사용해 적용해보자.
- SSR로 일부 리뷰 데이터를 보여줄 것
- CSR로 무한 스크롤 기능을 구현할 것
리뷰 페이지에서 서버 사이드 렌더링을 적용하기 위해 prefetchInfiniteQuery
를 사용한다. 해당 훅은 일반 쿼리처럼 미리 가져올 수 있다. 기본적으로 쿼리의 첫 번째 페이지만 prefetch 되며 지정된 queryKey 안에 저장된다. 만약 2 페이지 이상을 prefetch하려면 페이지 옵션을 사용할 수 있으며, 이때 getNextPageParam
함수도 제공해야 한다.
const prefetchProjects = async () => {
await queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // prefetch the first 3 pages
})
}
나의 경우는 처음 리뷰 페이지에 렌더링될 때 page=0인 리뷰 데이터 일부만 보여줄 예정이라 initialPageParam
만 설정했다. 처음에는 서버에서 데이터를 요청하는 함수 (getFilteredBookReviewsServer
)에서 2개의 데이터만 가져오도록 설정했었다. ➡️ 나중에 문제의 원인이 됨.
const SIZE = 2;
export const getFilteredBookReviewsServer = async (
filter: BookReviewFilter,
pageParam: number,
): Promise<BookReviewResponse> => {
const res = await fetchAPIServer(
`/api/review/search/tag?tag=${filter}&page=${pageParam}&size={SIZE}`,
'GET',
);
if (res.code === 'SUCCESS') {
return res.result;
}
throw new Error(`Failed to fetch book reviews: ${res.message}`);
};
리뷰 페이지에서 서버 사이드 렌더링을 적용하기 위해 prefetch하고 dehydrate를 적용한다.
export default async function ReviewsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: ['reviews', 'ALL'],
queryFn: ({ pageParam = 0 }) =>
getFilteredBookReviewsServer('ALL', pageParam),
initialPageParam: 0,
});
const dehydrateState = dehydrate(queryClient);
return (
<HydrationBoundary state={dehydrateState}>
<ReviewDashboard />
</HydrationBoundary>
);
}
initialPageParam
: 첫 페이지를 가져올 때 사용할 기본 페이지 매개변수getNextPageParam
: 이 쿼리에 새로운 데이터가 수신되면 무한 데이터 목록의 마지막 페이지와 모든 페이지의 전체 배열 및 pageParam 정보를 모두 수신한다. 쿼리 함수에 마지막 선택적 매개변수로 전달한 단일 변수를 반환해야 한다. 다음 페이지가 없으면 undefined 또는 null을 반환한다.getPreviousPageParam
:maxPages
: 무한 쿼리 데이터에 저장할 최대 페이지 수이다. 최대 페이지 수에 도달하면 새 페이지를 가져오면 지정된 방향에 따라 페이지 배열에서 첫 번째 또는 마지막 페이지가 제거된다.
클라이언트 컴포넌트에서는 getFilteredBookReviews
함수로 각 페이지 별로 데이터를 불러오는 로직이다.
2개의 데이터는 서버 사이드 렌더링이 잘 이루어지나, 리뷰 페이지에서 page=0&size=2
으로 데이터 2개가 이미 불러온 상태라 클라이언트 컴포넌트에서도 데이터를 요청하면 page=0에 있는 첫 번째 리뷰와 두 번째 리뷰 데이터가 중복이 되는 문제가 있었다.
import { fetchAPIClient } from '@/lib/fetchAPI.client';
import { BookReview } from '@/types/book';
import { BookReviewFilter } from '@/types/review';
export type BookReviewResponse = {
bookReviews: BookReview[];
hasNext?: boolean;
};
const SIZE = 10;
export const getFilteredBookReviews = async (
filter: BookReviewFilter,
pageParam: number,
): Promise<BookReviewResponse> => {
const res = await fetchAPIClient(
`/api/review/search/tag?tag=${filter}&page=${pageParam}&size={SIZE}`,
'GET',
);
if (res.code === 'SUCCESS') {
return res.result;
}
throw new Error(`Failed to fetch book reviews: ${res.message}`);
};
아래 코드는 클라이언트 컴포넌트에서 데이터를 불러오는 커스텀 훅이다.
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import {
BookReviewResponse,
getFilteredBookReviews,
} from '@/app/reviews/_lib/getFilteredBookReviews';
import { BookReviewFilter } from '@/types/review';
export const useReviewsInfinityQuery = (filter: BookReviewFilter) => {
return useInfiniteQuery<BookReviewResponse, Error>({
queryKey: ['reviews', filter],
queryFn: ({ pageParam = 0 }) =>
getFilteredBookReviews(filter, pageParam as number),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.hasNext) {
return allPages.length;
}
return undefined;
},
staleTime: 60 * 1000,
initialPageParam: 0,
});
};
서버 사이드 렌더링으로 불러온 리뷰 데이터와 클라이언트에서 불러오는 데이터를 병합할 때, SSR로 받아온 데이터를 삭제하지 않고 그대로 포함해야 서버 사이드 렌더링을 적용할 수 있었다. 따라서 리액트 쿼리에서 제공하는 select
옵션을 통해 서버 사이드 렌더링으로 받아온 데이터를 제외하고 병합하도록 수정했다. ➡️ 나중에 문제의 원인이 됨.
export const useReviewsInfinityQuery = (filter: BookReviewFilter) => {
return useInfiniteQuery<BookReviewResponse, Error>({
queryKey: ['reviews', filter],
queryFn: ({ pageParam = 0 }) =>
getFilteredBookReviews(filter, pageParam as number),
getNextPageParam: (lastPage, allPages) => {
const isFirstPage = allPages.length === 1;
// 처음 렌더링 되고서 리뷰 데이터가 2개가 이미 있어서 클라이언트 측에서 page = 0에서 남은 데이터를 요청하기 위해 작성
if (isFirstPage && lastPage.bookReviews.length < 10 && filter === 'ALL') {
return 0;
}
if (!isFirstPage && lastPage.hasNext) {
return allPages.length - 1;
}
return undefined;
},
staleTime: 30 * 1000,
initialPageParam: 0,
select: (data) => {
if (filter === 'ALL') {
const reviews = data.pages.flatMap((page) => page.bookReviews);
const uniqueReviews = reviews.reduce<BookReview[]>((acc, cur) => {
if (!acc.find((r) => r.id === cur.id)) {
acc.push(cur);
}
return acc;
}, []);
return {
...data,
pages: [{ bookReviews: uniqueReviews }],
};
}
return data;
},
});
}
위 코드를 설명하면 처음 리뷰 페이지에서 렌더링 시 리뷰 2개 데이터만 가져오게 되는데 클라이언트 측에서 page=0에서 남은 리뷰 데이터 8개를 더 가져와야 하는 상황이다. select
를 통해 SSR로 적용된 리뷰 데이터 2개를 제외하고 기존 데이터를 유지하면서 중복되지 않은 데이터만 넣어준 방식이다.
여러 필터를 통해 데이터를 불러온 다음, 데이터 캐싱이 끝나고 필터 기본 값인 'ALL'을 다시 클릭하면 전체 데이터 11개가 나와야 하는데 10개만 나오고 추가적인 데이터 패칭이 일어나지 않는 문제가 있었다.
데이터가 캐싱이 되어 있을 때는 잘 동작을 하나, staleTime
이 만료 후 데이터 요청 시 데이터 추가 패칭이 일어나지 않아 데이터 누락이 발생한다. isFirstPage
가 true로 getNextPageParam
에서 조건에 부합하지 않아 undefined
로 리턴되어 더 이상 패칭할 데이터가 없다고 인식한 것이다.
getNextPageParam: (lastPage, allPages) => {
const isFirstPage = allPages.length === 1;
// 처음 렌더링 되고서 리뷰 데이터가 2개가 이미 있어서 클라이언트 측에서 page = 0에서 남은 데이터를 요청하기 위해 작성
if (isFirstPage && lastPage.bookReviews.length < 10 && filter === 'ALL') {
return 0;
}
if (!isFirstPage && lastPage.hasNext) {
return allPages.length - 1;
}
return undefined;
}
우선 필터 값과 allPages
길이와 lastPage
의 길이, hasNext
값을 출력해보았다. 아래 출력 결과는 리뷰 페이지가 처음 렌더링되고 무한 스크롤 작동 시 (캐싱 유효 상태) 출력한 결과 값이다
useReviewsInfinityQuery.ts:15 allPages.length 1
useReviewsInfinityQuery.ts:16 lastPage.length 2
useReviewsInfinityQuery.ts:17 hasNext true
useReviewsInfinityQuery.ts:18 filter ALL
useReviewsInfinityQuery.ts:15 allPages.length 1
useReviewsInfinityQuery.ts:16 lastPage.length 2
useReviewsInfinityQuery.ts:17 hasNext true
useReviewsInfinityQuery.ts:18 filter ALL
ReviewList.tsx:22 isfetchNextPAge false
allPages.length === 1
: 서버 사이드 렌더링으로 받은 1개의 페이지(리뷰 데이터 2개)를 가져옴.lastPage.length === 2
: 0번째 페이지에 포함된 데이터 개수는 2개를 의미hasNext === true
: 데이터 추가 패칭 여부를 확인하는 값으로아래 출력 결과는 캐싱이 만료되고 동일한 필터를 다시 클릭했을 때 출력한 결과 값이다.
/* 데이터 캐싱이 끝난 이후 기존 필터를 다시 클릭했을 때 */
useReviewsInfinityQuery.ts:15 allPages.length 1
useReviewsInfinityQuery.ts:16 lastPage.length 10
useReviewsInfinityQuery.ts:17 hasNext true
useReviewsInfinityQuery.ts:18 filter ALL
ReviewList.tsx:22 isfetchNextPAge false
ReviewList.tsx:23 data (10)[{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]
allPages.length === 1
: 캐싱이 만료되었으므로 0번째 페이지(CSR 데이터 10개)로 데이터를 요청함.이때 새로고침한 것이 아니기 때문에 서버 사이드 렌더링이 일어나지 않음. lastPage.length === 10
: 0번째 페이지의 총 데이터 개수hasNext === true
: 추가 데이터가 존재한다고 표시됨isfetchNextPage === false
: React Query가 추가 데이터를 요청하지 않음.➡️ 그 이유는 getNextPageParam
에서 isFirstPage
값이 true이고 lastPage.hasNext
값이 true로 부합하는 조건이 없어 undefined
로 리턴이 되어 데이터를 추가로 가져오지 못한 것이였다.
1. 캐싱이 만료되고 다시 기본 필터를 누르면 서버 사이드 렌더링(SSR)이 아닌 클라이언트 사이드 렌더링(CSR)으로 0번째 페이지 데이터 요청함
2. 클라이언트 사이드 렌더링(CSR)으로 0번째 페이지에서 10개의 데이터를 가져옴
3. 총 페이지 개수는 1개여서getNextPageParam
에서 조건 미충족으로undefined
반환함
4. 따라서 추가 데이터 패칭이 일어나지 않는 문제 발생
결국 리뷰 페이지에서 서버 사이드 렌더링으로 데이터를 요청할 때 클라이언트에서 요청하는 개수와 동일하게 page = 0
(데이터 10개)으로 패칭하도록 설정했다. 이제 캐싱이 만료되어도 추가적인 데이터를 가져올 수 있게 되었다!
import { useInfiniteQuery } from '@tanstack/react-query';
import {
BookReviewResponse,
getFilteredBookReviews,
} from '@/app/reviews/_lib/getFilteredBookReviews';
import { BookReviewFilter } from '@/types/review';
export const useReviewsInfinityQuery = (filter: BookReviewFilter) => {
return useInfiniteQuery<BookReviewResponse, Error>({
queryKey: ['reviews', filter],
queryFn: ({ pageParam = 0 }) =>
getFilteredBookReviews(filter, pageParam as number),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.hasNext) {
return allPages.length;
}
return undefined;
},
staleTime: 30 * 1000,
initialPageParam: 0,
});
};
기본적으로 useInfiniteQuery 훅에서 제공하는 getNextPageParam
은 복잡하게 코드를 작성하면 안된다고 한다. 서버 사이드 렌더링으로 가져오는 데이터 개수와 클라이언트 컴포넌트에서 가져오는 데이터 개수를 동일하게 하면 커스텀 훅 코드도 간결해지고 캐싱이 사라져도 추가적인 데이터 요청이 잘 이루어진다.