이런 무한 스크롤을 만들기 위한 여정을 기록으로 남긴다.
더보기
등의 버튼을 통해 불러오는 방식을 선택할 수 있다. (프로젝트에서는 자동으로 불러오는 방식을 사용했다.)당시에는 당연하게 "무한 스크롤을 사용해야지" 라고 생각했는데, 되돌아보면 나름 무의식속으로 무한 스크롤의 장점을 생각했던 것 같다.
우리 프로젝트에서는 전역 상태관리 툴로 Zustand, 서버 상태관리 툴로 TanStack Query(=React Query)를 사용했다. 왜냐하면
TanStack Query 라이브러리를 활용하여 무한 스크롤 요청을 보내고 받아서 표시하기
작성일 기준 관리하고 있는 소스코드이다.
// usePostInfiniteScroll.ts
const { data, hasNextPage, isFetching, fetchNextPage } = useInfiniteQuery(
[QUERY_KEYS.POSTS, searchType, filter],
async ({ pageParam = -1, queryKey }: QueryFunctionContext) =>
await getQueryFn(pageParam, queryKey[1] as SEARCH_FILTER), // lastId, 검색 타입
{
getNextPageParam: (lastPost) =>
lastPost.isLast ? undefined : lastPost.lastId,
}
);
searchType, filter
를 입력하는데 이는 검색 기능을 무한 스크롤에 병합하며 생긴 부분이다. (개인적으로 Best-Practice는 아니라고 생각중이다.)queryKey
와 getQueryFn의 queryKey[1]
부분도 검색을 위한 부분이다.가장 기본적인 소스코드 버전은 아래와 같이 작성할 수 있었다.
const fetchPost = async (pageParam: string): Promise<PostPages> => {
const filter = useSearchStore.getState().filter as SearchFilter;
const { data } = await axiosInstance.get(`/posts?lastId=${pageParam})}`);
return data;
};
const { data, hasNextPage, isFetching, fetchNextPage } = useInfiniteQuery(
[QUERY_KEYS.POSTS],
async ({ pageParam = -1 }: QueryFunctionContext) =>
await fetchPost(pageParam), // lastId
{
getNextPageParam: (lastPost) =>
lastPost.isLast ? undefined : lastPost.lastId,
}
);
/posts?lastId=:pageParam
형식으로 보내고, posts, isLast, lastId
를 받아오도록 명세했다.pageParam
값은 마지막으로 받은 페이지의 id
로 명세했다.lastId
값을 pageParam
값으로 함께 전달한다.이제 받아온 data를 화면에 렌더링해보자.
// PostScroll.tsx
const PostScroll = (): JSX.Element => {
// ...useInfiniteQuery API로 data를 받아온다.
// 포스트 바에 표시할 포스트 정보 목록
const postInfos = useMemo(
(): PostInfo[] =>
data?.pages.flatMap((postScroll: PostPages) => postScroll.posts) ?? [],
[data]
);
return (
<div className="post-scroll">
{postInfos.map((postInfo) => (
<Post key={postInfo.id} postInfo={postInfo} />
))}
</div>
);
};
export default PostScroll;
postInfos
는 useInfiniteQuery
의 data
값의 타입인 InfiniteData<T>
에서 실제 표시할 데이터를 가공하는 과정이다.data
는 기본적으로 { pageParams, pages }
속성을 가지고 있다.pageParams
는 요청으로 보낸 pageParam(=lastId)
를 순서대로 배열로 반환한다.pages
는 응답 데이터인데, 무한 스크롤을 유지하기 위해 실제 데이터인 posts
뿐만 아니라 isLast
, lastId
등을 함께 포함한다.Array.flatMap
API를 활용하여 posts
부분만 순서대로 하나의 배열로 담을 수 있다.[첫번째 포스트 정보, 두번째 포스트 정보, ...]
data
가 변경되지 않으면 다시 계산하지 않도록 useMemo
로 최적화했다.이제 Post 컴포넌트는 받아온 데이터를 표시하기만 하면 된다.
interface PostProps {
postInfo: PostInfo;
}
export const PostContext: React.Context<PostInfo> =
createContext<PostInfo>(defaultPostInfo);
const Post = ({ postInfo }: PostProps): JSX.Element => {
return (
<PostContext.Provider value={postInfo}>
<div className="post">
<PostTitle />
<PostImageSlider />
<PostBody />
<PostFooter />
</div>
</PostContext.Provider>
);
};
export default Post;
스크롤의 끝을 감지하고 다음 페이지 로딩을 요청하기
여러가지 방법이 있지만, Intersection Observer API를 사용하기로 했다. 왜냐하면
mdn 문서에 Intersection Observer API의 필요성으로 이런 내용을 소개했다.
Implementing "infinite scrolling" web sites, where more and more content is loaded and rendered as you scroll, so that the user doesn't have to flip through pages.
즉, 무한 스크롤을 구현하기 좋은 API라는 의미!!
Intersection Observer의 작동 원리는 간단하지만 한번 살펴보고 가지 않으면 헷갈릴 수 있다.
출처 : https://blog.arnellebalane.com/the-intersection-observer-api-d441be0b088d
target
이 뷰포트(기본값, 보고 있는 화면) 또는 특정 DOM 요소(옵션으로 지정할 경우)에 교차(Intersect) 하는 경우를 감지해서 콜백 함수를 실행한다.let options = {
root: document.querySelector('#scrollArea'), // target이 비교할 대상, 기본값=뷰포트
rootMargin: '0px', // root의 상,하,좌,우 마진을 줄 수 있음.
threshold: 1.0 // 겹치는 정도 (1.0 = 100%)
}
let observer = new IntersectionObserver(callback, options);
let callback = (entries, observer) => {
entries.forEach((entry) => {
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
};
let target = document.querySelector('#listItem');
observer.observe(target);
// hooks/useIntersect.ts
/**
* root 원소와 target 원소가 교차 상태인지를 판단하여 조건에 만족할 경우
* callback 함수를 실행하는 target 원소의 Ref 를 반환합니다.
*/
const useIntersect = (
onIntersect: IntersectHandler,
options?: IntersectionObserverInit
): RefObject<HTMLDivElement> => {
const ref = useRef<HTMLDivElement>(null);
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting) {
console.log("2. intersect");
onIntersect(entry, observer);
}
});
},
[onIntersect]
);
useEffect(() => {
if (ref.current === null) {
return;
}
console.log("1. new element");
const observer = new IntersectionObserver(callback, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options, callback]);
return ref;
};
export default useIntersect;
코드를 하나하나 뜯어보자.
ref
: HTMLDivElement를 가리킬 ref
오브젝트이다. 구현할 때 스크롤의 바닥에 div element
를 두고 해당 요소가 뷰포트에 감지되면 콜백 함수를 실행하도록 구현 할 예정이다.callback
: 간단하게 옵저버가 무언가 감지했을 경우 감지된 대상(entry.isIntersecting을 만족하는 대상)에 대해서 onIntersect
콜백을 호출한다.onIntersect
: 훅을 호출할 때 입력하며, 감지 되었을 때 실행할 로직을 입력한다.useEffect
: 생성한 ref
오브젝트가 변경될 경우나, 새로 호출되어서 옵션/콜백 등이 변할 때 옵저버를 새로 생성해주는 부분이다.이제 훅으로 반환한 ref
를 스크롤 바닥의 div element
에 연결하고, 교차 시 실행할 콜백 함수만 전달하면 된다.
// hooks/usePostInfiniteScroll
const usePostInfiniteScroll = (): PostInfiniteScrollResults => {
const { data, hasNextPage, isFetching, fetchNextPage } = useInfiniteQuery(...);
// ...중략
/**
* Intersection Observer 를 위한 콜백 함수
* 다음 페이지를 로딩해야 하는 상황을 감지했을 때 fetchPost 요청을 보내서 가져옵니다.
*/
const onIntersect = useCallback(
(
entry: IntersectionObserverEntry,
observer: IntersectionObserver
): void => {
observer.unobserve(entry.target);
if (hasNextPage === true && !isFetching) {
console.log("3. 다음 페이지를 불러옵니다.");
fetchNextPage();
}
},
[hasNextPage, isFetching, fetchNextPage]
);
return { data, hasNextPage, isFetching, fetchNextPage, onIntersect };
};
export default usePostInfiniteScroll;
useIntersect
훅에 전달할 onIntersect
함수를 구현했다.entry.target
= 현재 감지된 대상 = div element
이다. unobserve
를 해주는 이유는 로직을 중복해서 실행을 방지하기 위해서이다. (앞선 이미지에서 excute callback function! 이 두번 발생하는 부분 확인)hasNextPage, isFetching
등을 쉽게 사용할 수 있다.// PostScroll.tsx
const PostScroll = (): JSX.Element => {
const { data, onIntersect, hasNextPage } = usePostInfiniteScroll();
// ...중략
return (
<div className="post-scroll">
{postInfos.map((postInfo) => (
<Post key={postInfo.id} postInfo={postInfo} />
))}
<ScrollLoader onIntersect={onIntersect} onLoad={hasNextPage} />
</div>
);
};
// ScrollLoader.tsx
const ScrollLoader = ({
onIntersect,
spinner = true,
onLoad = true,
}: PostLoaderProps): JSX.Element => {
const intersectRef = useIntersect(onIntersect);
return (
<div className="scroll-loader">
{spinner && onLoad && (
<LoadingSpinner className="scroll-loader__spinner" />
)}
<div className="scroll-loader__target" ref={intersectRef}></div>
</div>
);
};
export default ScrollLoader;
PostScroll
컴포넌트에서 usePostInfiniteScroll
훅을 실행해서 위쪽에 포스트들을 표시한다.ScrollLoader
컴포넌트를 두고, 해당 컴포넌트가 감지되면 다음 페이지 데이터를 요청하도록 한다.ScrollLoader
컴포넌트에서는 PostScroll
에게서 받은 onIntersect
콜백 함수로 useIntersect
훅을 호출하여 ref
를 반환받는다.LoadingSpinner
는 로딩중을 표시하는 컴포넌트이다. (기능에는 영향이 없으므로 넘어가자.)target
이 될 div element
에 해당 ref
를 연결한다.여기까지 되면 무한 스크롤 아래에 ScrollLoader
가 위치하고 있고, 스크롤을 끝까지 내리면 다음 페이지 데이터를 가져올 수 있다면 자동으로 로딩한다.
(1) 새로운 옵저버 등록 - (2) 스크롤 바닥 감지 - (3) 다음 페이지가 있으면서, 현재 로딩중이 아니라면 다음 페이지 로딩
이라는 로직에 맞춰서 동작한다.이번 무한 스크롤 구현은 나에게 있어서 굉장히 큰 도전이었다.
사용한 기술 모두 처음 사용해봤고, 심지어 Intersection Observer는 이번에 처음 알게 된 기술이였다.
짧은 시간 내에 내가 맡은 역할을 끝내기 위해 열심히 구글링을 했는데, 이번 구현에 있어서 문서화의 중요성을 다시 한번 느꼈다.
참고한 수많은 래퍼런스, 블로그 포스트 등의 이런 문서들이 앞이 보이지 않던 나에게 방향을 잡아주었던 것 같다. 많은 분들께 감사하다고 인사드리고 싶다.
이번 프로젝트를 하면서 느낀점이나 배운 부분이 굉장히 많은데, 그런 내용들을 꾸준히 블로그에 업로드 해야겠다고 느꼈다.
그리고 코드가 좀 지저분하긴 하지만 이정도 기능으로 구현했다니, 대성공이라고 느꼈다. (팀원분들 그렇죠?)
아마 다음 포스트는 무한 스크롤에 검색 기능이나, useMutation을 이용해 낙관적 업데이트를 구현한 부분을 포스팅 할 것 같다.