React Query
의 useInfiniteQuery
와 Prisma
의 Cursor-based pagination
을 사용하여 무한 스크롤에 필요한 모든 것을 구현해 보겠습니다! 이번 구현에 사용한 데이터는 아래 첨부한 이미지와 같으며, 전체 코드는 이 곳에서 확인하실 수 있습니다!
효율적인 정보 전달을 위해 React Query
와 Prisma
의 기본적인 사용법은 모두 숙지 되었다는 가정하에, 무한 스크롤 구현에 필요한 기능들만 간단하게 다루겠습니다.
출발~ 😙😙😙
당연히~ 가장 먼저 서버를 구현해야 합니다. 전체 데이터 중 원하는 개수씩 순차적으로 끊어서 보내줄 수 있도록 prisma query
를 작성해 보겠습니다!
Pagenation을 어떻게 구현해야 할까?
처음에는 단순하게 개수를 기준으로 전체 데이터를 끊으려고 했습니다. 예를 들어, 다음과 같이 (유저1, 유저2, 유저3, 유저~~, 유저10) 총 10개의 데이터가 있다고 가정해 보겠습니다. 한 번에 5개씩 데이터를 보여주고 싶다면, 처음에는 1~5번 데이터를 불러오고 그 다음에는 6~10번 데이터를 불러오면 될 것입니다. 이 방법은 아래와 같은 코드로 구현할 수 있습니다.
const userList = await prisma.user.findMany({
skip: 5 * page,
take: 5,
})
15개의 데이터를 건너뛰고 16번째 데이터부터 20번째 데이터까지 불러오고 싶으면 skip에 15를, take에 5를 넣어주면 됩니다. 간단하죠? Prisma
의 공식문서에서는 해당 기능을 아래와 같은 이미지로 설명하고 있습니다.
간단하게 구현할 수 있는 방법이지만, 약간의 문제가 있습니다. 만약, 최근에 가입된 유저 순으로 정렬하여 데이터를 불러오다가 새로운 유저가 회원가입하면 어떻게 될까요? 아래 메모장의 설명처럼 유저5가 두 번 불러와 질 것입니다.
따라서 개수를 기준으로 정하는 것이 아닌, 현재 페이지의 마지막 데이터를 기준으로 다음 페이지의 데이터를 불러와야 합니다. 위의 예시에서는 유저5를 기준으로 다음 페이지의 데이터를 불러와야겠죠? Prisma
의 Cursor-based-pagination
으로 이러한 방법을 간단하게 구현할 수 있습니다.
const userList = await prisma.user.findMany({
skip: 1,
take: 5,
cursor: {
id: targetId
}
})
위 코드의 예시처럼 cursor
객체에 조건을 설정하여 해당 조건에 맞는 데이터를 기준으로 정할 수 있습니다. 도입부에 첨부된 User Table 이미지 기억나시나요? User Table은 id, nickname, password의 필드로 구성되어 있습니다. 이 중 고유한 값인 id 필드가 지정한 값(targetId)인 데이터가 기준이 됩니다. nickname 필드 또한 고유한 값이라면, id 대신 nickname을 사용할 수도 있습니다.
근데 skip은 왜 또 필요할까요? 마찬가지로 Prisma
의 공식문서를 확인해 보겠습니다.
바로 cursor
를 포함하여 take개를 가져오기 때문입니다. 저희는 현재 페이지의 마지막 데이터를 cursor
로 정하기로 했으니, skip: 1
이 필요한 것입니다.
이제 정말 구현해볼까요?!
/* /pages/api/userList.ts */
const TAKE_COUNT = 5;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<IUserResponse>
) {
const { id } = req.query;
const isFirstPage = !id;
const pageCondition = {
skip: 1,
cursor: {
id: id as string,
},
};
const userList = await client.user.findMany({
/*
where: { },
orderBy: { }
*/
take: TAKE_COUNT,
...(!isFirstPage && pageCondition),
});
const length = userList.length;
res.status(200).json({ userList: 0 < length ? userList : undefined });
}
현재 페이지 마지막 데이터의 id
를 query parameter
로 받아옵니다. 만약, 첫 페이지의 데이터를 불러오는 경우라면 당연히 id
가 존재하지 않겠죠? 따라서 isFirstPage
가 true
일 경우 skip
과 cursor
를 설정하지 않습니다.
페이지를 쭉쭉 넘겨 cursor
가 전체 데이터의 마지막 데이터가 됐을 경우, 더 이상 불러올 데이터가 없기 때문에 findMany
함수는 빈 배열을 return
합니다. 그렇기 때문에 userList
의 length
를 확인하여 0 < length
일 때만 userList
를 return
하고 그렇지 않을 경우 undefined
를 return
합니다.
살짝 싱숭생숭 하신가요?
현재 페이지 마지막 데이터의 id
를 어떻게 넘겨주는지? 왜 굳이 undefined
를 return
하는지? 이런 부분들은 다음 차례인 React Query
의 useInfiniteQuery
구현 방법을 확인해 보시면 명확해지실 겁니다!
const {
data,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = "" }) => fetchPage(pageParam),
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
})
위의 예시 코드가 구현에 필요한 전부입니다! useInfiniteQuery
는 굉장히 많은 기능들을 제공하지만, 무한 스크롤 구현을 위한 최소한의 기능들만 하나씩 살펴보겠습니다.
getNextPageParam
: pageParam
과 hasNextPage
의 값을 결정하는 함수이며, 매개변수로 lastPage
와 allPages
를 넘겨 받을 수 있습니다. lastPage
는 공식문서에서 '마지막 페이지'라는 뜻으로 사용되는데요, 저희는 지금까지 '현재 페이지'라는 단어를 사용했기 때문에 편의상 '현재 페이지'라고 부르겠습니다! getNextPageParam
은 단일 변수
혹은 undefined
를 return
해야 합니다. 이렇게 return
된 값은 queryFn
의 pageParam
과 hasNextPage
의 값으로 사용됩니다.
data
: 서버로부터 응답받은 결과이며, 우리가 원하는 TData[]
는 data.pages
에 담겨 있습니다.
hasNextPage
: 다음에 더 불러올 페이지가 있는지 확인할 수 있는 값입니다. 이 값은 getNextPageParam
에 의해 결정됩니다. getNextPageParam
가 단일 변수
를 return
할 경우 true
, undefined
를 return
할 경우 false
가 됩니다.
queryFn: ({ pageParam = "" })
: getNextPageParam
이 return
한 값이 pageParam
의 값으로 사용됩니다. 최초 요청 시 정의한 기본 값이 사용됩니다. (예시에서는 ""
)
fetchNextPage
: 다음 페이지를 요청할 때 호출하는 함수입니다.
전체적인 흐름은 다음과 같습니다.
hasNextPage = !!getNextPageParam()
hasNextPage
가 true
이면 fetchNextPage()
pageParam = getNextPageParam()
fetchPage(pageParam)
render(data.pages)
구현된 코드를 살펴보겠습니다!
/* /queries/user.ts */
export function useFetchUserList() {
const queryResult = useInfiniteQuery<IUserResponse>(
["user"],
({ pageParam = "" }) => get(`?id=${pageParam}`),
{
getNextPageParam: ({ userList }) =>
userList ? userList[userList.length - 1].id : undefined,
}
);
return queryResult;
}
const service = axios.create({
baseURL: "/api/userList",
});
function get(queryString: string) {
return service.get(queryString).then((response) => response.data);
}
기능 단위로 쪼개고 서버 코드와 함께 살펴보겠습니다.
/* /queries/user.ts */
({ pageParam = "" }) => get(`?id=${pageParam}`)
/* /pages/api/userList.ts */
const { id } = req.query;
const isFirstPage = !id;
pageParam
을 query parameter
를 통해 서버로 전송합니다. 최초 요청 시 기본 값이 ""
이기 때문에 isFirstPage
는 true
가 됩니다.
/* /queries/user.ts */
getNextPageParam: ({ userList }) =>
userList ? userList[userList.length - 1].id : undefined;
/* /pages/api/userList.ts */
const pageCondition = {
skip: 1,
cursor: {
id: id as string,
},
};
getNextPageParam
에서 현재 페이지의 userList
가 존재한다면? userList
의 마지막 데이터의 id
를 return
하여 queryFn
의 pageParam
으로 사용합니다. 이렇게 서버로 전달된 id
가 cursor
로 사용되는 것입니다.
현재 페이지의 userList
가 존재하지 않는 경우는 어떤 경우일까요?
/* /pages/api/userList.ts */
const userList = await client.user.findMany({
/*
where: { },
orderBy: { }
*/
take: TAKE_COUNT,
...(!isFirstPage && pageCondition),
});
const length = userList.length;
res.status(200).json({ userList: 0 < length ? userList : undefined });
서버 코드를 설명할 때 더 이상 불러올 데이터가 없을 경우 undefined
를 응답한다고 했습니다. 현재 페이지의 userList
가 존재하지 않는 경우는 더 이상 불러올 데이터가 없을 때이므로 getNextPageParam
는 undefined
를 return
하여 hasNextPage
가 false
가 되는 것입니다.
이제 데이터를 불러오기만 하면 됩니다!
/* /pages/index.tsx */
export default function Home() {
const { data, hasNextPage, fetchNextPage } = useFetchUserList();
const handleClick = () => {
if (hasNextPage) {
fetchNextPage();
}
};
const renderUserList = () => {
if (data && data.pages) {
const userList = data.pages.reduce<IUser[]>((prev, { userList }) => {
if (userList) prev.push(...userList);
return prev;
}, []);
return userList.map(({ id, nickname }) => (
<User key={id} id={id} nickname={nickname} />
));
}
};
return (
<Wrapper>
{renderUserList()}
{hasNextPage && <Button onClick={handleClick}>Next Page</Button>}
</Wrapper>
);
}
data.pages
는 [[], [], [], []]
와 같은 형식으로 되어있습니다. 이를 1차원 배열로 풀기 위해 renderUserList
함수에서 reduce
를 사용했습니다. 우선 데이터를 page 단위로 잘 불러오나 확인하기 위해 버튼을 두었고 이를 누를 때마다 다음 페이지 데이터를 불러오도록 하였습니다.
잘 되나 볼까요?
이제 마지막입니다! 버튼을 눌렀을 때가 아닌 스크롤을 바닥까지 내렸을 때 다음 페이지를 불러와야 합니다. 이러한 기능을 구현하기 위해 바닥에 div
태그를 두고 이 태그가 뷰포트 내에 감지됐을 때 fetchNextPage
함수를 호출할 것입니다.
Intersection Observer API
를 사용하면 특정 요소와 상위 요소의 뷰포트가 교차하는 것을 감지할 수 있습니다. 바로 구현된 코드를 확인해 보겠습니다.
/* /components/Observer.tsx */
export default function Observer({ handleIntersection }: IProps) {
const target = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]: IntersectionObserverEntry[]) => {
if (entry.isIntersecting) {
handleIntersection();
}
},
{ threshold: 1 }
);
if (target.current) {
observer.observe(target.current);
}
return () => observer.disconnect();
}, []);
return <Wrapper ref={target}>이게 보이면? 다음 데이터를!</Wrapper>;
}
IntersectionObserver
객체를 생성할 때 매개변수로, 감지됐을 때 호출될 콜백 함수를 넣어줄 수 있습니다. 이 콜백 함수는 observer.observe(target.current)
로 처음 감지를 시작했을 때도 호출 됩니다. 따라서 원하는 요소가 감지됐을 때만 fetchNextPage
를 호출하기 위해 entry.isIntersecting
로 감지 여부를 확인하는 과정이 필요합니다.
마지막으로, 이전에 추가해놓은 버튼을 제거하고 Observer
컴포넌트를 적용해 보겠습니다.
/* /pages/index.tsx */
export default function Home() {
const { data, hasNextPage, fetchNextPage } = useFetchUserList();
const handleIntersection = () => {
if (hasNextPage) {
fetchNextPage();
}
};
const renderUserList = () => {
if (data && data.pages) {
const userList = data.pages.reduce<IUser[]>((prev, { userList }) => {
if (userList) prev.push(...userList);
return prev;
}, []);
return userList.map(({ id, nickname }) => (
<User key={id} id={id} nickname={nickname} />
));
}
};
return (
<Wrapper>
{renderUserList()}
{hasNextPage && <Observer handleIntersection={handleIntersection} />}
</Wrapper>
);
}
완성 ~!
Observer
컴포넌트의 높이를 작게 하고 배경색과 동일하게 하면 아주 자연스러운 동작이 가능합니다!
최대한 간략하게 정리해보고 싶었는데 이상하게 글이 길어졌네요. 잘못된 내용은 지적해주시면 정말 감사하겠습니다.
안녕~ 😙