1. 무한 스크롤 이란?
흔히 저희가 웹 서핑을 하다 보면 수많은 글 들이 세로로 끝이 없이
나열되어 있는 것을 보실 수 있습니다.
그렇다면 이들은 어떻게 구성되는 걸까요?
1. 한 번에 모든 게시물을 불러온다.
2. 10 ~ 15개씩 끊어서 부른다.
단순하게 생각해 봐도 게시물의 개수가 500개 이상이 된다면
한 번에 모든 데이터를 단일 통신으로 받아오는 데는 latency와 서버에 부하가 될 수 있겠죠?
요번에는 FE 관점에서 무한 스크롤을 적용할 수 있는 방법에 대해서 알아보려고 합니다.
2. 어떤 식으로 구성할까?
무한 스크롤을 적용하는 방법은 생각보다? 간단합니다.
다음과 같은 방식으로 스크롤을 쭉 내리다가 초록색이 보였을 때 다음 아이템들을 불러올 수 있는 API를 call 하면 됩니다.
물론 이와 같은 API를 이용하기 위해서는 Server단에서 이에 대한 준비가 되어있어야겠죠?
그렇다면 아래에 도달했다는 것은 어떻게 알 수 있을까요?
https://ww8007-learn.tistory.com/6
이전 포스트에서 알아보았던 Intersection Observer를 이용하면 됩니다.
뷰 포트에 초록색의 Element가 보이느냐 마느냐에 따라 다음 API를 호출할 것인지에 대한 여부를 결정하게 되는 거죠.
그럼 이를 코드를 통해서 알아볼까요?
3. 코드로 구현하기
무한 스크롤에 대한 구현은
1. intersection observer
2. useInfinityQuery
3. axios
를 사용할 예정입니다. 순수 JS를 이용할 수도 있지만 데이터에 대해서 캐싱을 지원하는 것과
서버 데이터와 비즈니스 로직의 관심사를 분리할 수 있다는 장점으로 React Query를 이번 포스트에서는 사용해 볼까 합니다 😃
3-1. useIntersection 커스텀 훅 만들기
일단 첫 번째로 intersection에 대해서 사용해 줄 커스텀 훅을 만들어야 합니다.
물론 사용하려는 UI 컴포넌트 내부에 만들어도 되겠지만 무한 스크롤이 한 페이지 이상에서 사용이 된다면
이와 같은 방식이 좀 더 좋겠죠?
구현은 다음과 같습니다.
intersect에 대한 설명은 저번 포스트에서 해두었기 때문에 이에 대한 설명은 생략하도록 하겠습니다.
import React, { useEffect } from 'react';
interface Props {
target: React.RefObject<HTMLElement>;
rootElement?: HTMLElement | null;
rootMargin?: string;
threshold?: number;
onIntersect: (
entries: IntersectionObserverEntry[],
observer: IntersectionObserver,
) => void;
}
const useIntersection = ({
target,
rootElement = null,
rootMargin = '0px',
threshold = 0,
onIntersect,
}: Props) => {
useEffect(() => {
if (!target.current) return;
const io = new IntersectionObserver(onIntersect, {
root: rootElement,
threshold,
rootMargin,
});
io.observe(target.current);
return () => io.disconnect();
}, [onIntersect, target, threshold]);
};
export default useIntersection;
3-2. useBottomIntersction 커스텀 훅 만들기
위에서 만든 useIntersection 커스텀 훅에 대해서 추상화를 한 단계 더 진행하는 코드입니다.
결국에 저희가 사용할 UI 컴포넌트에는 필요한 것은
아래 선언된 커스텀 훅처럼
1. 다음 API를 call 할 수 있는 fetchNextPage 함수
2. 끝을 판단할 수 있는 botoomRef
이니 이를 한 단계 더 추상화시켜 주었습니다.
- Props
interface IntersectionObserverProps {
fetchNextPage: () => void;
}
- Return
interface Return {
bottom: React.RefObject<HTMLDivElement>;
}
import { useCallback, useRef } from 'react';
import useIntersection from './useIntersection';
interface IntersectionObserverProps {
fetchNextPage: () => void;
}
interface Return {
bottom: React.RefObject<HTMLDivElement>;
}
const useBottomIntersection = ({
fetchNextPage,
}: IntersectionObserverProps): Return => {
const bottom = useRef(null);
const onIntersect = useCallback(
([entry]: IntersectionObserverEntry[]) => {
if (entry.isIntersecting) {
fetchNextPage();
}
},
[fetchNextPage],
);
useIntersection({
onIntersect,
target: bottom,
});
return { bottom };
};
export default useBottomIntersection;
3-3. API 선언하기
이제 api를 만들 차례입니다.
무한 스크롤에 사용될 API의 경우 일반 API와는 다르게 특별한 정보가 필요한데
- 몇 개씩 불러올 것인가?
- 내가 지금 불러올 페이지의 정보는 무엇인가?
에 대한 정보가 필요합니다.
이 정보가 없이는 무한한 데이터의 끝이 어디고, 어디까지 불렀는지에 대해서 서로 알기 힘드니
이를 통해서 어디까지 부를 것인가에 대한 서로 약속을 한다고 보시면 됩니다.
일반적인 예제로 api에 대해서 알아보고 싶다면 다음의 포켓몬 API 도감을 참고하시면 됩니다.
API에 대한 코드는 다음과 같습니다.
export interface ServerLikeBookmarkList {
hasNext: boolean; // 다음에 부를 페이지가 있는지
contents: LikeBookmarkItem[]; // 데이터 정보
}
export interface LikeBookmarkItem {
bookmarkId: number;
title: string;
url: string;
isUserLike: boolean;
}
interface GETLikeBookmarkListRequest {
memberId: number;
pageRequest: {
cursorId?: number | null; // 서버와 약속된 내가 부를 정보
pageSize: number; // 몇 개씩 부를 것인지
};
}
const GETLikeBookmarkList = async (params: GETLikeBookmarkListRequest) => {
const { data } = await client.get<ServerLikeBookmarkList>(
`/members/${params.memberId}/bookmarks/likes`,
{
params: {
memberId: params.memberId,
cursorId: params.pageRequest.cursorId,
pageSize: params.pageRequest.pageSize,
},
},
);
return data;
};
3-4. useInfiniteQuery 구현하기
이제 대망의 무한 스크롤 API를 구현할 차례입니다.
React Query에서는 무한 스크롤을 좀 더 쉽게 사용하기 위한 useInfiniteQuery를 지원합니다.
공식문서의 내용은 다음과 같습니다.
https://tanstack.com/query/v4/docs/react/reference/useInfiniteQuery
특징적인 부분을 몇 개 살펴본다면
일반적인 useQuery와는 다르게 pageParam과 api에 대해서 return 하는 부분이 다르다는 것을 알 수 있습니다.
return useInfiniteQuery(
GET_LIKE_BOOKMARK_LIST(params.memberId), // query key
async ({ pageParam = null }) => {
const { contents, hasNext } = await GETLikeBookmarkList({
...params,
pageRequest: {
cursorId: pageParam,
pageSize: 10,
},
});
return {
contents,
hasNext,
};
},
{
getNextPageParam: (lastPage) => { // 중요한 부분 다음 fetch를 결정
if (lastPage.hasNext) {
return lastPage.contents[lastPage.contents.length - 1].bookmarkId;
}
return undefined;
},
suspense: true,
},
);
이는 내가 부를 다음 데이터에 대한 정보를 위의 로직을 통해서
몇 번째 페이지를 부를지, 그리고 부를 데이터가 존재하는지에 대해서 결정해 준다고 보시면 됩니다.
전체 코드는 다음과 같습니다.
export const useGETLikeBookmarkListQuery = (
params: GETLikeBookmarkListRequest,
) => {
return useInfiniteQuery(
GET_LIKE_BOOKMARK_LIST(params.memberId),
async ({ pageParam = null }) => {
const { contents, hasNext } = await GETLikeBookmarkList({
...params,
pageRequest: {
cursorId: pageParam,
pageSize: 10,
},
});
return {
contents,
hasNext,
};
},
{
getNextPageParam: (lastPage) => {
if (lastPage.hasNext) {
return lastPage.contents[lastPage.contents.length - 1].bookmarkId;
}
return undefined;
},
suspense: true,
},
);
};
3-5. 데이터 렌더링 하기
이제 API는 모두 완성이 되었고 이제 데이터를 사용해 볼 차례입니다.
여기서 중요한 부분이 있는데 아까 만들었던 useBottomIntersection의 커스텀 훅에
useInifiniteQuery에서 제공하는 fetchNext를 인자로 넘겨주고, bottomRef를 아이템의 맨 마지막에 선언해줘야 하는 점입니다.
이렇게 해야 저희가 원하는 대로
- 리스트의 끝에 도달
- 다음 API Call
- 리스트 아이템에 추가
와 같은 플로우로 이어질 수 있겠죠?
또한 useInifiniteQuery에서 제공하는 데이터의 경우 특징적인 부분이 하나 더 있는데
기존 return 해주는 데이터의 형식이 일반 데이터와 다르다는 점입니다.
이는 페이지네이션된 게시판을 생각하면 이해하기 좋은데
페이지 별로 1, 2 페이지 같이 데이터가 존재하고
이들을 API가 불린 cursorId와 같이 매칭을 하여 데이터를 사용하게 됩니다.
하지만 이들을 그냥 사용하게 되면 컴포넌트의 모양새가 좀 이쁘지 않은 형태로 나타나게 되는데요
{isEditMode &&
bookMarkList.pages[0].contents[0].bookmarkId &&
bookMarkList?.pages.map((page) => (
<BookmarkList
key={page.contents[0]?.bookmarkId}
bookmarkList={page.contents?.filter(
(item) => item.readByUser === isReadMode,
)}
renderItem={(bookMarkList) => (
<BookmarkEditItem
key={bookMarkList.bookmarkId}
{...bookMarkList}
onClickItem={onClickBookmarkItemInEdit}
/>
)}
/>
))}
페이지의 정보 대해서 배열 형식으로 데이터를 가지고 있고
데이터에 대해서 또 배열 형식으로 데이터를 가지게 되니
이를 flatMap을 이용해서 조금 예쁘게 만들어주도록 하겠습니다.
flatMap의 경우 배열의 깊이와 길이에 영향을 받아 성능 저하가 일어날 수 있는데
이 부분에 대해서는 추후에 다른 포스트를 이용해서 다뤄보도록 하겠습니다.
전체 코드는 다음과 같습니다.
const BookmarkLikeList = () => {
const USER_ID = 1;
const { data: bookmarkList, fetchNextPage } = useGETLikeBookmarkListQuery({
memberId: USER_ID,
pageRequest: {
cursorId: null,
pageSize: 15,
},
});
const bookmarkItems = bookmarkList?.pages.flatMap((page) => page.data);
// flatMap을 이용한 평탄화
const { bottom } = useBottomIntersection({ fetchNextPage }); // 다음 페이지 불러올 수 있게
return (
<>
{bookmarkItems &&
bookmarkItems.map((bookmark) => (
<BookmarkLikeItem key={bookmark.bookmarkId} {...bookmark} />
))}
<div ref={bottom} />
</>
);
};
여기에 이제 로딩 fetchNext에 대한 로딩 상태를 추가해 준다면 다음과 같습니다.
const BookmarkLikeList = () => {
const USER_ID = 1;
const {
data: bookmarkList,
fetchNextPage,
isFetchingNextPage,
} = useGETLikeBookmarkListQuery({
memberId: USER_ID,
pageRequest: {
cursorId: null,
pageSize: 10,
},
});
const bookmarkItems = bookmarkList?.pages.flatMap((page) => page.contents);
const { bottom } = useBottomIntersection({ fetchNextPage });
return (
<>
{bookmarkItems &&
bookmarkItems.map((bookmark) => (
<BookmarkLikeItem key={bookmark.bookmarkId} {...bookmark} />
))}
{isFetchingNextPage && <SkeletonBookmarkLikeList count={5} />}
<div ref={bottom} />
</>
);
};
export default BookmarkLikeList;
4. 완성된 모양
완성된 모양은 다음과 같습니다.
끝내면서
지금까지 무한 스크롤에 대해서 알아보았습니다.
하지만 이게 끝이 아닌 데이터가 1000개 이상일 경우
브라우저에 대한 렌더링에 대한 부하에 대한 최적화와
threshold 임계값에 대한 경우 어떻게 설정해야 할 것인가에 대한 관문이 남아있습니다.
이를 비즈니스 요구사항에 맞도록 개발해 나가는 것이 FE 개발자가 가져야 할 소양 이겠죠 ㅎㅎ
다음 포스트에서는 가상화 컴포넌트를 이용한 많은 컴포넌트 렌더링 최적화에 대해 알아보도록 하겠습니다 👍
'React' 카테고리의 다른 글
내 상태를 관리해주는 useState 직접 구현해보기 (0) | 2023.10.13 |
---|---|
왜 내 컴포넌트는 reRendering이 될까? (0) | 2023.10.06 |
스토리북에서 UI 상태 변경하기 with useArgs (1) | 2023.04.16 |
스켈레톤 UI 그리고 Suspense (1) | 2023.03.31 |
[React] 최적화 관련 Hook에 대해서 알아보자(1) (0) | 2021.08.11 |