기존 더보기 버튼으로 구현되어있던 페이지네이션 pagenation 을
useInfiniteQuery, Intersection Observer API를 이용하여 무한 스크롤 리팩토링 하였다.
기존 더보기 구현 방식
먼저 서버에서 사용하는 데이터 형식을 확인하면,
공연 전체조회 시 쿼리로 page 를 필수로 받는다.
다른 codename(장르), title(검색), date(공연날짜)는 필터링이나 상세공연정보 detail 에 접속할때 사용한다.
즉, 이러한 형식으로 요청을 전송하면 데이터는 json 배열로 8개의 각 공연 객체가 전송된다.
데이터를 불러오는 react-query 코드
하나의 api url -> 검색, 카테고리, 페이지네이션, 날짜를 이용한 공연상세
위 기능을 모두 사용하기 때문에 useQuery 를 사용하여 파라미터 인자값을 옵셔널로 전송하기 위해 도입하였다.
멘토님께서 코드의 활용도를 높이기 위해 사용하는 것을 추천하셨다.
이 프로젝트에서 처음 도입하여 조금 미흡하였지만, 이후 3개의 프로젝트에서 또 사용하며 훅, refetch, invalidate 등 다양한 기술을 활용해 실력을 디벨롭했다~
//Performances.api.ts
export const fetchPerformances = async (
codename?: string,
title?: string,
page?: number,
date?: string,
): Promise<IPerformancePayload[]> => {
try {
// const processedCodename = codename ? codename.split('/')[0] : undefined;
const response = await httpClient.get<IPerformancePayload[]>(`/events`, {
params: {
codename,
title,
page,
date,
},
});
if (Array.isArray(response.data)) {
return response.data as IPerformancePayload[];
} else {
throw new Error('API 응답 데이터 형식 오류: 배열이 아닙니다', response.data);
}
} catch (error) {
throw new Error('API 요청 오류' +error + '');
return [];
}
};
//usePerfromances.ts
export interface IPerformancePayload {
title: string;
image: string;
codename: string;
date: string;
[key: string]: any; // 추가 속성들을 허용
}
export const usePerformances = ({ codename, title, page = 1 }: { codename?: string; title?: string; page: number }) => {
const res = useQuery<IPerformancePayload[]>({
queryKey: [
'performances',
{
codename,
title,
page,
},
],
queryFn: () => fetchPerformances(codename, title, page),
placeholderData: keepPreviousData,
});
return {
...res,
data: res.data || [],
};
};
//Home.tsx
const {
data: performances,
isLoading,
isError,
error,
} = usePerformances({
page,
codename: selectedCategory.includes(`/`) ? selectedCategory.split(`/`)[0] : selectedCategory || undefined,
title: searchTerm || undefined,
});
페이지네이션의 더보기로 전체 데이터를 나열하려면
기존 데이터 prev 에 다음페이지 불러오는 데이터를 합쳐서 allPerformances 로 관리했다.
const [page, setPage] = useState<number>(1);
const [allPerformances, setAllPerformances] = useState<IPerformancePayload[]>([]);
// Performances 데이터 업데이트
useEffect(() => {
if (performances && Array.isArray(performances)) {
if (page === 1) {
setAllPerformances(performances);
} else if (performances.length > 0) {
setAllPerformances((prev) => [...prev, ...performances]);
}
}
}, [performances]);
// 페이지네이션 로직을 포함하여 공연 데이터 로드
const loadMorePerformances = useCallback(() => {
if (!isLoading && performances.length > 0) {
setPage((prevPage) => prevPage + 1);
// setAllPerformances([]);
}
}, [isLoading, performances]);
// 로딩 및 에러 상태 처리
if (isLoading && allPerformances.length === 0) return <div>로딩 중...</div>;
if (isError) {
// 에러 메시지 처리
if (error instanceof Error) {
return (
<div>
데이터를 불러오는 데 문제가 발생했습니다.
<p>{error.message}</p>
</div>
);
}
}
이때 발생한 트러블 슈팅이 코드의 복잡성을 높였다
page=1 의 performance가 allPerformances 에 prev 에 들어가고, page=2 의 performance 가 새로 받아오는 ...performances 값으로 set이 새롭게 되어야 했는데
1. 비동기의 문제로 setPage가 늦게 실행되어 page=1의 공연값이 두번 중복되어 나오는 문제가 발생하거나
2. setPerformances가 원하는대로 동작하지 않았다. - 히스토리 관리의 문제점
3. 페이지네이션된 데이터에서(allPerformances) 검색이나 카테고리를 적용하는 문제점이 발생하였다. api 를 refetch 해야하는데!!
모바일 형식으로 디자인하였기 때문에 페이지 형식이 아닌 스크롤 형식으로 설계하였는데,
스크롤 형식은 더보기 버튼을 클릭하는 것보다 무한스크롤이 더 적절하다고 판단하여 리팩토링을 진행하였다.
useInfiniteQuery
구글에 `useInfinitequery 무한스크롤` 검색하면 엄청나게 많은 글이 나온다.
올리브영, 카카오엔터프라이즈 등 다양한 기업에서도 많이 도입하고 있는것을 알 수 있다.
<공식문서, 사용 개념>
무한 스크롤을 편하게 구현할 수 있도록 한다. 기존 버튼클릭방식도 데이터를 계속 쌓아주기 때문에 사용하기 오히려 좋다
기존 useQuery 사용(return, option 속성 동일)
추가된 특히 다른 리턴값은 returns fetchNextPage, hasNextPage이고,
옵션 option 은 getNextPageParam이다.
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
promise,
...result
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 1,
...options,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) =>
firstPage.prevCursor,
})
위 공식 문서에서 페이지네이션 구현 시 꼭 사용했던 것만 소개해보겠다.
return
- data : 요청을 통해 받아온 데이터
- fetchNextPage : 다음 페이지의 데이터 불러온다
- hasNextPage : 가져돌 다음 페이지의 데이터가 존재하는지 확인한다. (boolean)
- refetch, isLoading, isError, error 리턴 추가로 사용함
option
- `getNextPageParam:(lastPage, allPages)` : 새 데이터를 수신하면, 마지막 페이지와 모든페이지의 전체배열, pageParam 정보를 수신한다. 다음 페이지가 없을 경우 undefined 또는 null 을 반환한다. 반환값이 다음 api 호출의 pageParam 으로 들어간다.
- select : 반환되는 데이터를 필터링, 계층화 등 변환할 경우 사용하는 것이 성능에 좋다. useInfiniteQuery의 경우 페이지가 쌓이는 형태이기 때문에 구조가 복잡해져서 가공하는 편이 코드에 좋다. (상세, 전체의 차이점은 place, org_link 두개)
- initialPageParam : 페이지네이션 값 초기값 선언
프로젝트 활용코드 - 옵션, 로직
import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchPerformances } from '@/apis/Performances.api';
export interface IPerformancePayload {
title: string;
image: string;
codename: string;
date: string;
}
export const getPerformances = ({ codename, title }: IPerformancePayload) => {
const { data, fetchNextPage, hasNextPage, refetch, isLoading, isError, error } = useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam = 1 }) => fetchPerformances(codename, title, pageParam as number),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length === 0) {
return undefined;
}
return allPages.length + 1;
},
initialPageParam: 1,
select: (data) => ({
pages: data.pages.map((page) =>
page.map((item) => ({
title: item.title,
image: item.image,
codename: item.codename,
date: item.date,
})),
),
pageParams: data.pageParams,
}),
});
return {
performance: data?.pages.flat() || [],
fetchNextPage,
hasNextPage,
refetch,
isLoading,
isError,
error,
};
};
기존에는 page 까지 매개변수로 받았는데, useInfiniteQuery 를 도입하면서 제거했다.
queryFn 는 api 호출하는 옵션인데, 기본적으로 `pageParam` 이라는 옵션으로 제공한다. 첫 페이지는 1로 지정한다.
getNextPageParam 으로 다음페이지가 있는지 확인하고, lastPage의 데이터가 비었으면 끝! undefined 를 리턴한다.
끝이 아닐경우에는 `allPages.length+1` 을 한다.
왜 length 인지? 데이터를 확인해보면 다음처럼 배열로 묶인 객체 형태로 데이터가 전송되기 때문이다.
initialPageParam을 1로 초기화 한다.
전체 페이지 로드에서 사용하는 데이터 항목 (4개)만 select 필터링해준다.
org_link 와 place 데이터가 제외된걸 확인할 수 있다.
카테고리, 검색(title) 이 변경될 경우, refetch 를 통해 데이터를 다시 가져온다. 항상 최신의 데이터를 제공한다.
// 카테고리 변경 핸들러
const handleCategoryChange = useCallback(
(category: string) => {
startTransition(() => {
setSelectedCategory(category === '전체' ? '' : category);
setSearchTerm('');
});
refetch();
},
[refetch],
);
// 검색어 변경 핸들러
const handleSearchChange = useCallback(
(term: string) => {
startTransition(() => {
setSearchTerm(term);
});
refetch();
},
[refetch],
);
이렇게 하면 페이지네이션 요청을 위한 react-query 작성 완료
Intersection Observer API 위치 감지
무한 스크롤에서 스크롤을 감지하는 두가지 방법
1. onScroll 방식
콘텐츠의 전체 길이와 현재 스크롤한 길이를 비교하여 스크롤 바닥을 감지하는 방법
scrollHeight : 콘텐츠(HTML) 전체의 길이
clientHeight : 스크린 상의 높이
scrollTop : clientHeight 윗부분. 스크롤을 맨밑으로 내리면 scrollHeight - UI 높이값 = scrollTop
무한 스크롤 감지할 스크롤 위치에서는 (최하단)
scrollHeight - scrollTop 값은 clientHeight 가 된다.
최하단 이전에는 scrollHeight - scrollTop 값 > clientHeight 상태이다. 같아질때 스크롤이 바닥에 닿는다고 감지할 수 있다
단점 : 스크롤이 움직일때마다 이벤트 핸들러가 호출되어 성능문제가 존재한다.
단점 해결 : Throttle, Debounce 최적화 - 이벤트를 한번만 호출시키도록 해야한다.
이 복잡한 과정을 간편하게 해결한게 Intersection Observer!! (polyfill 방식도 존재)
2. Intersection Observer API
특정 요소 element 가 viewport에 교차하는 여부를 비동기적으로 감지할 수 있다.
스크롤을 내려서 페이지의 마지막 요소에 닿았을때 감지하는 역할을 한다.
그리고 다음 페이지의 데이터를 자동으로 가져오도록 메소드를 전달한다.
비동기적으로 실행되어 스레드에 영향을 주지 않고, 요소의 변경사항을 관찰할 수 있다. 리플로우 현상도 방지할 수 있다.
라이브러리가 존재하지만, 간단히 훅을 작성하면 쉽게 사용할 수 있다고 하여 직접 작성해보았다.
import { InfiniteQueryObserverResult } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';
type IntersectionObserverProps = {
hasNextPage: boolean | false;
fetchNextPage: () => Promise<InfiniteQueryObserverResult>;
};
function useIntersect({ hasNextPage, fetchNextPage }: IntersectionObserverProps) {
const ref = useRef<HTMLDivElement>(null);
const handleIntersect: IntersectionObserverCallback = useCallback(
([entry]: IntersectionObserverEntry[]) => {
if (entry?.isIntersecting && hasNextPage) {
fetchNextPage();
}
},
[fetchNextPage, hasNextPage],
);
useEffect(() => {
let observer: IntersectionObserver;
if (ref.current) {
observer = new IntersectionObserver(handleIntersect, { threshold: 0.1 });
observer.observe(ref.current);
}
return () => observer && observer.disconnect();
}, [ref, handleIntersect]);
return ref;
}
export default useIntersect;
- hasNextPage : 다음 페이지가 있는지를 나타내는 불리언
- fetchNextPage : 다음 페이지 데이터를 가져오는 비동기 함수
- useRef 로 감지할 요소를 지정한다.
- Intersection Observer 콜백 : handleIntersect 함수로 element 가 뷰포트에 들어오는지 판단한다.
- `entry.isIntersecting` : 요소가 뷰포트에 들어왔는지를 나타내는 불리언 값
- hasNextPage : 다음 페이지가 있는지 확인
- 두 조건이 참일 경우 : fetchNextPage()를 호출해 다음 페이지의 데이터를 가져온다.
- useEffect로 컴포넌트가 마운트 될때 Intersection Observer를 설정하고, 언마운트 될때 해제한다.
- `observer = new IntersectionObserver(handleIntersect, { threshold: 0.1 });`
- observer 객체를 생성
- handleIntersect를 콜백으로 전달한다.
- threshold 를 0.1 로 설정하여, 요소의 10%가 뷰포트에 들어올때 호출된다.
- `observer.observe(ref.current)`를 통해 지정된 DOM 요소를 관찰
- 컴포넌트가 언마운트될 때, observer.disconnect()를 호출하여 메모리 누수를 방지
- `observer = new IntersectionObserver(handleIntersect, { threshold: 0.1 });`
🔥 문제점 - ref 렌더링 높이에 따른 IntersectionObserver 감지 불가능
ref 에서 요소가 존재하지 않으면 데이터가 더이상 받아와지지 않았다. (page 4까지만 동작함) 기존 많은 블로그 코드는 아래처럼 작성됨..
{hasNextPage ? (
<div ref={target} />
) : (
<div className="loadMoreBtn" style={{ backgroundColor: 'lightgray' }}>
더이상 데이터가 없습니다.
</div>
)}
Intersection Observer 는 뷰포트와 관찰하는 요소 사이의 교차 상태를 감지하는 API 이다. 판단을 위해 bounding box 요소의 경계를 사용한다.위처럼 지정할 경우 div 에 아무 내용도 없기 때문에 렌더링된 높이가 0일수 있다. 이러한 경우 요소를 감지할 수 없어 Intersection Observer가 호출되지 않는다.
ref div를 아래처럼 영역이 존재한다면 무한스크롤이 정상동작하였다. -> test code
<div ref={target} className="loadMoreBtn">
더보기 로딩중...
</div>
-> develop
기존 형식과 비슷한 div 모양으로 만드니
직관적으로 UI 가 로딩중이라는 티가 전혀 나지 않아
loading spinner 를 적용했다. (이외의 로딩에도 전체 적용)
.lds-ellipsis,
.lds-ellipsis div {
box-sizing: border-box;
}
.lds-ellipsis {
display: inline-block;
position: relative;
left: 45%;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 30px;
width: 13.33333px;
height: 14px;
border-radius: 50%;
background: #eb6b83;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 0px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 0px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 24px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 48px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
이로인해 추가된 사항은 스크롤 표시와 Image lazy Loading 이 있다.
기존에는 모바일 타겟이기에 스크롤을 display:none 했는데, 무한스크롤하면 스크롤이 어디있는지 확인하는게 좋다고 판단해 표시했다.
이미지 lazy Loading 은 Intersection Observer API 를 사용하는 방법이 있어 적용중이다. (완료되면 추가!)
💡마무리
2주간의 짧은 개발과정에서 useInfiniteQuery 없이 페이지네이션을 구현하기위해 마주했던 수많은 오류를 생각하면
진작에 이 존재를 알고 도입했다면 좋았겠다는 생각이 들었다.
프로그래머스와 동아리 프로젝트 활동에서 배운것은 '왜' 이 기술을 도입해야하는지 먼저 생각하고 여러 방법 중 최선의 선택을 하는것이 중요하다.
더보기 페이지네이션을 무한스크롤로 개선하며 useInfiniteQuery를 도입한 경험은 사용자 측면을 깊게 고려하고, 코드(기술)의 복잡성을 줄이는 긍정적인 경험을 주었다.
더 나아가 로딩 상태, 이미지 lazy loading 등 다양한 생각으로 넓혀갈 수 있어 좋았다.
- 깃허브