React-Query로 무한스크롤 구현하기
react query에서 무한스크롤을 구현하려면 useInfiniteQuery라는 훅을 제공합니다.
하지만 먼저 서버쪽에서 페이지네이션을 구현해야 합니다. 현 HLOG서버의 get /post의 반환값은 모든 포스트를 반환하게 됩니다.
이를 페이지네이션을 반환하는 방식으로 변환해보도록 하겠습니다.
페이지네이션 방식에는 offset방식과 cursor방식이 있는데, offset방식은 limit, offset쿼리를 사용하여서 페이지를 구분합니다.
<< 1 | 2 | 3 >>이런 방식에 클릭하는 네이게이션을 사용할 때 주로 사용하는 방식이죠.
cursor기반 방식은 무엇일까요? 최근 idx를 기준으로 구분하는 방식입니다. 이는 저희가 구현하려는 무한스크롤 방식에 적합합니다.
const page = Number(req.query.page) || 1;
const size = Number(req.query.size) || 10;
다음과 같이 기본값을 지정해줍니다.
TypeORM을 사용하고 있으므로 queryBuilder에서 지원하는 유용한 메서드가 있습니다.
take와 skip입니다.
const posts =
await postRepository
.createQueryBuilder("posts")
.select([
"posts.id",
"posts.createdAt",
"posts.updatedAt",
"posts.postThumbnail",
"posts.postTitle",
"posts.postSummary",
"user.username",
"user.id",
"user.profileUrl",
])
.leftJoin("posts.user", "user")
.leftJoinAndSelect("posts.like", "like")
.orderBy('posts.createdAt', 'DESC')
.orderBy('posts.updatedAt', 'DESC')
.take(size)
.skip(page)
.getMany();
위와 같이 take와 skip으로 가져 오려는 자원을 제한할 수 있습니다.
http://localhost:8080/post/recent?page=2&size=5
그럼 다음과 같은 쿼리로 동작이 가능합니다.
또, 페이지의 마지막임을 알려주는 기능도 있으면 좋을 것 같습니다.
const totalPostCount = await postRepository.count();
const lastPage = Math.ceil(totalPostCount / size);
전체 게시글의 개수를 먼저 구합니다.
이때, 마지막 페이지를 구하는 공식은 전체 게시글의 개수를 size (한번에 가져올 데이터 개수)로 나눈 값을 사용했습니다.
이제, 해당 응답이 마지막 페이지인지 확인하는 방법은 다음과 같이 작성하면 됩니다.
setJsonResponser(res, {
code: 200,
message: "모든 포스터조회 성공",
payload: {
posts,
isLast: lastPage <= page
},
});
요청한 페이지가 lastPage보다 크거나 같다면 더이상 다음에 응답할 포스트가 없으므로 isLast가 true로 설정되게 구현하였습니다.
이제 서버측 코드 기능은 구현된 것 같습니다. 클라이언트측 코드를 보도록 하겠습니다.
크게 두가지의 기능을 사용할 것입니다. useInfiniteQuery와 IntersectionObserver입니다.
intersection Observer에 관해서 알아봐야 할 것 같습니다. 타켓 엘리먼트와 타켓의 부모 혹은 상위 엘리먼트의 뷰포트가 교차되는 부분을 비동기적으로 관찰하는 API입니다. 말이 너무 복잡한 것 같습니다.
여기서 뷰포트란, 화면의 영역이라고 보시면 됩니다. 뷰포트 바깥의 부분은 스크롤 하기 전까지 보이지 않습니다.
쉽게 말해서 화면상에서 지정한 타겟 엘리먼트가 보이는지 관찰하는 것입니다.
Scroll Event와 Intersection Observer의 차이점은 무엇일까요?
기존에 프로젝트를 진행했을 때 저는 Scroll Event로 무한스크롤을 구현했었습니다. 특정 위치에서 스크롤이 일어날 때 데이터를 fetch하는 코드를 작성했었죠. 하지만 이벤트 핸들링 되는 영역에서 스크롤을 비비게되면 요청이 수십, 수백개가 가게됩니다. 이는 매우 불필요한 요청이고 debounce, throttle하는 작업이 필요하겠죠. 또한, 성능저하도 발생하게 됩니다. Reflow라는 작업은 웹 상에서 엘리먼트들의 수치값을 계산하는 작업을 하게되는데, scrollTop을 접근하게 되면 매번 새로운 layout을 계산해야 하겠죠.
이제 직접 사용해보도록 하겠습니다. 예제는 리액트 코드로 작성되었습니다.
먼저 List를 렌더링하는 코드에 다음과 같이 작성해 주었습니다.
return (
<>
<SEOHelmet
title="HLOG | 최신 게시글"
/>
<PageLayout>
<HomeTab />
<ErrorBoundary fallback={<PostListError />}>
<Suspense fallback={<PostFallbackLoader />}>
<PopularPostList />
</Suspense>
<div ref={target}>Target</div>
</ErrorBoundary>
</PageLayout>
</>
);
Target엘리먼트를 만들고 이 엘리먼트가 보여질 때마다 요청을 하게 만들것입니다.
컴포넌트가 마운트 되었을 때, Intersection옵저버 인스턴스를 만들어봅시다. 이때, 콜백과 옵션을 전달해주게 되는데
Callback은 관찰이 감지되면 실행될 함수를 선언합니다. 이때 인자로 entries, observer가 전달됩니다.
entries는 인스턴스의 배열입니다. 타게팅 된 요소가 배열의 형태로 전달되게 됩니다.
observer는 콜백이 실행되는 해당 인스턴스를 참조하게 됩니다.
Option에는 root, rootMargin, threshold를 받게됩니다.
root는 뷰포트 대신 사용할 요소 객체를 지정합니다. 기본값은 null이고, null일 경우에 브라우저의 뷰포트를 사용하게 됩니다.
rootMargin은 Root의 범위를 확장, 축소할 수 있습니다.
threshold는 타겟의 가시성(사물 또는 대상을 보다)이 얼마나 필요한지를 백분율로 표현합니다.
이제 메서드들을 알아보도록 하겠습니다.
observe메서드는 대상 요소의 관찰을 시작하게 됩니다.
그에 반해 unobserve는 대상 요소의 관찰을 중지하게 됩니다.
disconnect는 인스턴스가 관찰하는 모든 요소의 관찰을 중지하게 됩니다.
그러면 리액트에서는 다음과 같이 코드를 작성할 수 있겠네요.
useEffect(() => {
const observer = new IntersectionObserver(onIntersect, {
threshold: 0.5,
});
observer.observe(target.current);
return () => observer.disconnect();
}, []);
useEffect시, 타깃 객체를 observe합니다. 또한, 언마운트 시 모든 옵저버를 제거합니다.
감지되었을 때에는 onIntersect가 실행되는데요, 그 함수를 한번 보도록 하겠습니다.
const onIntersect = async ([entry], observer) => {
if (entry.isIntersecting) {
observer.unobserve(entry.target);
await new Promise((resolve) => {
setTimeout(() => {
console.log('Observing');
resolve(true);
}, 1000);
});
observer.observe(entry.target);
}
};
인터섹트 콜백에 첫번째 인자에 배열이 전달된다고 했었죠.
구조분해할당 문법으로 첫번째 요소의 entry만 받아와서 그 요소가 감지되고 있다는 boolean값은 isIntersecting이라는 것으로 참조가 가능합니다.
감지되고 있다면, observer를 임시로 해제합니다. 해제하지 않으면 감지되는 동안 계속 옵저버가 중첩되기때문입니다.
1초를 기다리고 완료가 되면 Observing이라는 문구를 출력하고 다시 옵저버를 연결합니다.
이런식으로 구현하면 쉽게 무한스크롤을 구현할 수 있습니다. 1초를 기다리는 부분을 서버API와 연동해서 게시글들을 받아오면 되겠죠.
그 부분은 위에 서버 단에서 처리를 해주었으니까 클라이언트에서는 useInfiniteScroll로 쉽게 구현을 해보겠습니다.
useIntersection훅을 따로 만들어서 재사용 가능하게 코드를 분리하였습니다.
import { useRef, useEffect } from 'react';
const useIntersection = (
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) => {
const target = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
observer.observe(target.current);
return () => observer.disconnect();
}, [callback, options]);
return target;
};
export default useIntersection;
이제 해당 Hook를 기반으로 InfiniteQuery를 작성해보겠습니다.
import { useInfiniteQuery } from 'react-query';
import customAxios from '@/utils/customAxios';
const usePopularPostInfinite = () => {
const fetchPost = async ({ pageParam = 1 }) => {
const result = await customAxios.get(`/post/popular?page=${pageParam}&size=5`);
const { posts, isLast } = result.data.payload;
return {
result: posts,
nextPage: pageParam + 1,
isLast,
};
};
const query = useInfiniteQuery(['popular_posts'], fetchPost, {
getNextPageParam: (lastPage) => {
if (!lastPage.isLast) return lastPage.nextPage;
return undefined;
},
select: null,
refetchOnWindowFocus: false,
refetchOnMount: true,
refetchOnReconnect: true,
retry: 1,
});
return query;
};
export default usePopularPostInfinite;
그러면, 해당 쿼리를 사용할 컴포넌트에서는 다음과 같이 사용하면 됩니다.
import { useCallback, useMemo } from 'react';
import PageLayout from '@/components/Common/PageLayout';
import SEOHelmet from '@/components/Common/SEOHelmet';
import HomeTab from '@/components/Common/HomeTab';
import usePopularPostInfinite from '@/hooks/queries/usePopularPostInfinite';
import PostList from '@/components/Post/PostList';
import useIntersection from '@/hooks/useIntersection';
const PopularPage = () => {
const { fetchNextPage, data } = usePopularPostInfinite();
const mergePosts = useMemo(() => data.pages.flatMap((page) => page.result), [data.pages]);
const onIntersect = useCallback(async (
entries: IntersectionObserverEntry[],
observer: IntersectionObserver,
) => {
const entry = entries[0];
if (entry.isIntersecting) {
observer.unobserve(entry.target);
await fetchNextPage();
observer.observe(entry.target);
}
}, [fetchNextPage]);
const target = useIntersection(onIntersect);
return (
<>
<SEOHelmet
title="HLOG | 인기 게시글"
/>
<PageLayout>
<HomeTab />
<PostList posts={mergePosts} />
<div ref={target}>Target</div>
</PageLayout>
</>
);
};
export default PopularPage;
이제 위의 코드가 어떤식으로 흘러가는지 이해가 가시죠.
여기서 봐야할 포인트는 mergePost입니다.
React Query에서는 data가 위와 같이 오게됩니다.
그래서 이를 배열 형태로 직렬화하는 작업이 필요합니다.
flatMap이라는 메서드에 집중하면 좋을 것 같습니다.
map과 flat이라는 메서드를 병합해놓은 구현체인데, map은 콜백함수에서 리턴된 값을 배열로 반환합니다.
flat은 중첩된 객체를 평탄화하는 작업을 하게 됩니다.
const arr1 = [1, 2, [3, 4]];
arr1.flat();
// [1, 2, 3, 4]
그래서 위 둘을 병합한 메서드가 flatMap이고 다음 코드를 보면 이해가 가실겁니다.
let arr1 = [1, 2, 3, 4];
arr1.map(x => [x * 2]);
// [[2], [4], [6], [8]]
arr1.flatMap(x => [x * 2]);
// [2, 4, 6, 8]
그러면
const mergePosts = useMemo(() => data.pages.flatMap((page) => page.result), [data.pages]);
위 코드를 다시한번 곱십어봅시다.
data.pages.map(page => page.result);
// [Array(5), Array(5), Array(5), ....]
map을 사용하게 되면 다음과 같이 동작하게 됩니다. 이를 flat해서 다음과 같이 동작하게 됩니다.
그러면 이제 이 데이터를 PostList에 그대로 넘겨줄 수 있는 형태가 되었습니다.
하지만 아직 남아있는 문제가 있습니다. intersection중일 때 계속 Render가 실행되는 문제입니다.
이 문제를 해결하려면 useInfiniteScroll에서 hasNextPage값을 가져와서 fetchNextPage요청에 조건부를 추가합니다.
const onIntersect = useCallback(async (
entries: IntersectionObserverEntry[],
observer: IntersectionObserver,
) => {
const entry = entries[0];
if (entry.isIntersecting) {
observer.unobserve(entry.target);
if (hasNextPage) {
await fetchNextPage();
}
observer.observe(entry.target);
}
}, [fetchNextPage, hasNextPage]);
이렇게 수정하면 다음 페이지가 없을 때 더이상 요청을 보내지 않습니다.
22.6.15 추가 내용
위의 코드에서 몇가지의 수정 사항이 있습니다. 사실 문제가 많습니다.
- 마지막 페이지면 더이상 요청을 보내지는 않지만, Callback은 계속 실행되는 문제
- 데이터를 Fetching 중일 때 데이터를 계속 가져오는 문제
우선 Fetching 중일 때 데이터를 가져오는 문제를 해결해보도록 하겠습니다. 그 전에 코드가 몇가지 변경이 되었는데요,
import { useRef, useEffect } from 'react';
const useIntersection = (
callback: () => void,
options?: IntersectionObserverInit,
) => {
const target = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((
entries,
) => {
entries.forEach((entry) => entry.isIntersecting && callback());
}, options);
observer.observe(target.current);
return () => observer.disconnect();
}, [callback, options]);
return target;
};
export default useIntersection;
이렇게 useIntersection Hook이 변경이 되었습니다.
그러면 사용하는 코드에선 감지되었을 때 실행할 Callback만 전달해주면 되는것이죠.
const PopularPage = () => {
const {
fetchNextPage, hasNextPage, data,
} = usePopularPostInfinite();
const onIntersect = useCallback(async () => {
if (hasNextPage) {
await fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isLoading]);
const target = useIntersection(onIntersect);
const mergePosts = useMemo(() => data.pages.flatMap((page) => page.result), [data.pages]);
return (
<>
<SEOHelmet
title="HLOG | 인기 게시글"
/>
<PageLayout>
<HomeTab />
<PostList posts={mergePosts} />
<div ref={target} />
</PageLayout>
</>
);
};
게속 진행해서 데이터를 Fetching 중일 때 데이터를 계속 가져오는 문제를 해결해보도록 하겠습니다.
간단하게 생각하면, loading 상태일 때 데이터를 불러오는 로직을 실행하지 않으면 됩니다.
const {
fetchNextPage, hasNextPage, data, isLoading,
} = usePopularPostInfinite();
const onIntersect = useCallback(async () => {
if (hasNextPage && !isLoading) {
await fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isLoading]);
그러면 문제가 해결되는 것으로 보이나... 한가지 문제가 더 발생하게 됩니다.
"마지막 페이지면 더이상 요청을 보내지는 않지만, Callback은 계속 실행되는 문제"
이것을 해결하려면 저는 두가지의 선택지가 떠오르네요.
- hasNextPage가 없을 때 Ref를 렌더링하지 않는다.
- useIntersection Hook에서 disable값을 전달받아서 disconnect한다.
하지만 첫번째 방법을 작성하고 서버를 실행시켜보면, 에러를 마주하게 됩니다.
해당 에러를 마주하는 이유는 참조하고있는 Ref가 DOM 상에서 없어졌기 때문입니다.
그래서 두번째 제안사항인 disable을 사용해서 구현해 보았습니다.
if (options?.disable) { observer.unobserve(target.current); }
먼저 옵션에 disable값을 받아서 참이라면 구독을 해제하는 방식입니다.
사용하는 입장에서는 다음과 같이 작성하면 됩니다.
const target = useIntersection(onIntersect, {
disable: !hasNextPage,
});