이전글에서 무한스크롤에 필요한 두가지 조건을 정리했다.
- 사용자가 페이지 하단에 도달했을때
- 콘텐츠가 계속 로드
첫번째 조건은 이전글에서 완료했으니, 2번째 미션을 해결할 차례다.
어떻게 콘텐츠가 계속 로드되게 만들 수 있을까?
데이터를 페이지네이션 형태로 제공하는 방법은 크게 cursor 기반과 offset 기반, 두가지 방법이 존재한다.
하지만 그것은 백엔드 개발자의 일이구요... 라고 하면 큰일나니 간단하게 알아보자.
- offset 기반
- 페이지단위로 구분해서 구현이 간단하나 데이터 중복문제(중간에 글이 써지는경우)가 발생한다.
- offset값이 커지면 앞의 데이터를 모두 읽어야해서 DB상 성능문제가 발생할 수 있다.
- cursor 기반
- cursor라는 사용자에게 마지막으로 응답해준 마지막 데이터 식별자 값을 기억하여 페이지네이션을 구현한다.
- offset에서 1억 +10번달라고 하면 1억 10개의 데이터를 읽지만, 커서기반에서는 1억번부터(cursor 데이터에 기록) 10개의 데이터만 읽게된다.
성능상의 이점때문에 우리 프로젝트는 cursor기반을 택했다.
useState + useEffect로 간단하게 구현
기존에 데이터 패칭을 하는 형태로 간단하게 구현해보자.
next가 true면 다음 cursor를 set하고 요소가 보일때 리패칭 해줄 것이다.
interface FetchData {
posts: { id: number; title: string; content: string }[];
cursor: string;
next: boolean;
}
받아온 데이터, 커서, refetch유무, next정보를 state로 구성해주자.
각각 다른 state로 구성했다.
export default function FetchingComponent() {
const [data, setData] = useState<FetchData['posts']>();
const [cursor, setCursor] = useState('');
const [refetch, setRefetch] = useState(true);
const [next, setNext] = useState(true);
...
}
그리고 내부적으로 fetch할 함수를 만들어주자.
useCallback으로 cursor정보가 바뀔때만 함수가 재선언되도록 만들어주었다.
const getFeed = useCallback(async () => {
const result = await axios.get<FetchData>(`/post${cursor && `?cursor=${cursor}`}`);
return result.data;
}, [cursor]);
그리고 useEffect를 통해 refetch가 true고 next가 있을때 데이터를 패칭되도록 했다.
useEffect(() => {
if (refetch && next)
getFeed().then((res: FetchData) => {
if (data) setData([...data, ...res.posts]); //데이터 이어붙이기
else setData(res.posts); //첫 데이터 받아오기
if (!res.next) setNext(false); //받아온 데이터가 next가 없으면 setNext를 false로
setCursor(res.cursor); //cursor정보 업데이트
setRefetch(false); //모든 작업이 끝나면 refetch를 false로 변경
});
}, [refetch]);
이제 요소가 화면에 들어오면 refetch를 true로 만들어 주면된다.
이전 게시글에서 만들어둔 useRefFocusEffect를 사용하자
export default function FetchingComponent() {
...
const [refetch, setRefetch] = useState(true);
const { elementRef } = useRefFocusEffect<HTMLDivElement>(() => setRefetch(true), []);
...
if (data)
return (
<div>
{data.map((article) => (
<div className="mt-10">
<div>제목: {article.title}</div>
<div>내용: {article.content}</div>
</div>
))}
<div ref={elementRef}>이게 보이면 리패칭</div>
</div>
);
}
이러면 잘 작동한다!
문제점
하지만 이 방식에는 문제점이 있다. 단일글에 대해서 라우팅처리를 따로 해주게되면 단일 게시글로 이동할때 컴포넌트가 언마운트되고, 뒤로가기로 무한 스크롤로 돌아오게되면 첫 cursor 요청부터 다시 시작해야한다는 점이다.
사용자 입장에서 매우 답답해지고, api요청수도 증가하게된다.
그렇지 않으려면, 전역상태로 서버데이터를 저장하고, 이를 관리해주어야한다.
- 전역상태가 있다면, fetch 하지 말것
- 특정 상황에 데이터를 다시 fetch해올 것
- 이 데이터를 cursor별로 관리할 수 있어야할 것
하지만, 전역상태로 서버데이터를 저장해주는 라이브러리가 이미 존재한다. 바로 리액트 쿼리다.
이에 나는 리액트 쿼리를 도입하여 피드데이터를 관리하고자 했다.
React-Query
리액트 쿼리는 서버로 부터 받아온 데이터를 전역 상태 형태로 관리할 수 있게 도와주는 라이브러리이다.
Query Key별로 데이터를 저장해둘 수 있다.
즉, 언마운트 될때도 상태정보가 유지가 되므로, api호출을 줄일 수 있게 된다.
자세한 원리는 이미 분석해둔 글이 있어서 가져왔다.
https://www.timegambit.com/blog/digging/react-query/01
useQuery사용해서 구성하기
useQuery를 간단하게 설명하자면, 아래와 같이 사용한다.
const result=useQuery({queryKey:쿼리키, queryFn:패칭함수});
여기서 queryKey에 패칭해온 데이터를 저장한다.
그럼 cursor 정보마다 queryKey를 구성해주면 된다...!
만 cursor정보는 런타임에 결정되므로 useQuery 훅을 동적으로 호출해야한다는 문제가 생긴다.
하지만 훅을 특정 상황에 동적으로 호출할 수는 없으므로 (React Fiber객체의 훅 연결리스트 순서보장 문제), 쿼리키에는 피드 전체 데이터를 저장하되, cursor별로 페이지를 분리하여 저장하면 될 것이다.
interface FeedPageData{
id: number;
title: string;
content: string
};
type Cursor=string;
interface FeedData{
pages:<Cursor,FeedPageData[]>[],
}
이런식으로 구성하고, useQuery에서 데이터를 패치하는 형태로 구성할 수 있을것이다.
하지만, React-Query에서는 이미 무한스크롤을 위한 Query를 제공한다. 바로 InfinityQuery다.
useInfinityQuery
useInfiityQuery는 지금까지 우리가 고민했던 모든것을 담아놓은 훅이다.
아래와 같은 기능을 제공한다.
- queryKey에 데이터 캐싱
- 데이터 페이지별로 관리
- 초기 cursor param
- 다음 데이터를 불러올 fetch함수를 만들기
아래와 같이 설정하여 호출하면 된다.
useInfiniteQuery({
queryKey: //쿼리키,
initialPageParam: //초기 param,
queryFn: ({ pageParam}) => getParamThread(pageParam), //param을 받아서 queryFn에게 넘겨주는 형태
getNextPageParam: (queryFn으로 받아온 데이터)=>다음 param을 return하도록 구성
});
return 값은 data, isFetchingNextPage, fetchNextPage등을 제공한다.
data는 page와 pageParams로 구성되며 실제 데이터는 page내부에 배열형태로 들어가있다.
fetchNextPage를 통해 다음 fetch함수를 실행시키고, page를 추가할 수 있다.
나같은 경우 전체 피드를 받아오는 useInfinityQuery를 훅으로 래핑해서 사용했다.
import { useInfiniteQuery } from '@tanstack/react-query';
import REACT_QUERY_KEYS from '@/constants/REACT_QUERY_KEYS';
import useAuthAxios from '@/hooks/useAuthAxios';
import { FeedData } from './type';
export default function useThreadsQuery() {
const authAxios = useAuthAxios();
const getParamThread = async (param?: string) => {
const result = await authAxios.get<FeedData>(`/apiURL${param && `?cursor=${param}`}`);
return result.data;
};
return useInfiniteQuery({
queryKey: [REACT_QUERY_KEYS.ENTIRE_THREADS],
initialPageParam: '',
queryFn: ({ pageParam = '' }) => getParamThread(pageParam),
getNextPageParam: (lastPage) => (lastPage.next ? lastPage.cursor : undefined),
});
}
이렇게 받아온 데이터를 아래와 같이 컴포넌트를 구성해 렌더링해주었다.
import { FeedArticle, Loading } from '@repo/components';
import useEntireThreadsQuery from '@/apis/useEntireThreadsQuery';
import useRefFocusEffect from '@/hooks/useRefFocusEffect';
import InfiniteLoading from './InfiniteLoading';
export default function EntireFeed() {
const { data, isFetchingNextPage, fetchNextPage } = useEntireThreadsQuery();
const { elementRef } = useRefFocusEffect<HTMLDivElement>(fetchNextPage, [data]);
if (!data) return <Loading />;
return (
<>
{data.pages.map((page) =>
page.posts.map((feedData, feedIndex) => (
<div className="border-b border-[#232636] py-2" key={feedIndex}>
<FeedArticle data={feedData} />
</div>
)),
)}
<div className="w-full justify-center flex items-center py-2">
{// 여기가 보이면 다음 cursor로 요청한다}
<InfiniteLoading ref={elementRef} isLoading={isFetchingNextPage} />
</div>
</>
);
}
이렇게 하면 잘 작동되는 것을 확인할 수 있다!
새로운 문제 - 피드 데이터 최신화
하지만 이게 끝이 아니다.
두가지 문제가 있다.
첫번째는 새롭게 글을 작성/수정/삭제하는경우이다.
두번째는 세부글에서 좋아요/댓글을 추가/삭제하는 경우이다.
피드에서 세부 게시글을 눌러서 세부 글을 볼때, 댓글이나 좋아요를 남긴후, 피드로 돌아가게되면 피드에는 전혀 반영이 되지않은 것을 볼 수 있다...!
어떻게 하면 이러한 문제들을 해결 할 수있을까?
다음 게시글에서 이 두가지 문제를 해결해 볼 것이다.