-
[Portfolly] react-query로 뒤로 가기 가능한 무한 스크롤 재구현Studying/Proj 과정 2024. 1. 9. 17:39
문제 상황
기존 Portfolly 홈페이지에서 무한스크롤 기능이 제대로 구현되지 않은 것 같아 리팩터링합니다.
react-query로 마이그레이션 하는 김에 useInfiniteQuery 훅을 사용해 구현할 예정입니다.
더하여 무한 스크롤에서 포트폴리오 한 개를 클릭한 뒤, 다시 뒤로가기 했을 때 스크롤 위치를 기억할 수 있게 만들 계획입니다.
구현 과정
useInfiniteQuery로 데이터를 페이징 해서 가져오기
useSuspenseInfiniteQuery 훅을 반환하는 usePortfoliosQuery 훅 입니다.
메인 페이지의 PortfolioList 데이터를 GET 요청하는 게 주요 목적입니다.
저는 PortfolioList를 지연 로딩하고 Suspense에 스켈레톤 UI를 적용해서,
그냥 useInfiniteQuery가 아니라 useSuspenseInfiniteQuery를 사용했습니다.
이렇게 react-query 훅에 Suspense를 사용함을 명시해줘야 서버 요청에 대한 대기 상태를 자동으로 Suspense가 처리하게 됩니다.
(react 버전 4이하는 suspense: boolean 옵션을 사용했으나, 5 버전 이상부터 useSuspenseQuery가 별도 등장했습니다)
export const usePortfoliosQuery = (section: Section, filter: {[key in string]: string}) => { ... return useSuspenseInfiniteQuery({ queryKey: portfolioKeys.list(section, filter), queryFn: getPortfolios, select: data => data.pages.flat(), initialPageParam: 1, getNextPageParam: (lastPage: any, allPages: any) => { const nextPageNum = allPages.length + 1; return lastPage?.length < PAGE_PER_DATA ? null : nextPageNum; }, staleTime: Infinity, gcTime: Infinity, }); };
저는 페이지 당 불러오는 데이터 수를 100개로 상수화 했습니다. (PAGE_PER_DATA)
⦁ select: 서버 응답 데이터를 받을 때, select 메서드를 통해 데이터 형태를 변환해 반환할 수 있습니다. 저의 경우 useInfiniteQuery 응답 데이터 형태가 {pages, pageParams} 형태인 걸 pages만 뽑아와, 1차원 배열로 펼친 값을 컴포넌트에 전달합니다. (그래야 map으로 바로 순회할 수 있어 편합니다)
⦁ initialPageParam: 첫 페이지 기본 값입니다. 저는 1페이지를 첫 번째 페이지로 설정했습니다.
⦁ getNextPageParam: 다음 페이지를 계산하는 메서드입니다. 다음 페이지(nextPageNum) 번호는 현재 출력한 모든 페이지(allPages) + 1 값이 됩니다. 만약 마지막 페이지의 데이터 갯수가 100개 미만이라면 더이상 출력할 데이터가 없다는 뜻이므로 null 을 반환해 무한스크롤 페이징을 끝내고, 추가 데이터가 있으면 nextPageNum을 반환합니다.페이지 끊어서 보여주기
뒤로가기 시 스크롤 위치를 기억하는 건 올리브영 테크 블로그를 참고해 구현했습니다.
방법은 다음과 같습니다.
1. 포트폴리오 아이템을 클릭했을 때 sessionStorage에 스크롤 위치, 포트폴리오 index를 저장한다.
2. 뒤로가기를 클릭했을 때 저장된 스크롤 위치로 이동한다.
보기엔 아주 단순해 보입니다!
그런데 저의 경우, 1페이지 당 100개의 데이터를 가져온다고 했는데, 이를 한 번에 다 보여주는 게 아닙니다.
그 안에서 또 포트폴리오를 6개씩(왜 6개냐면 더미 데이터 만들기 너무 힘들어요) 끊어서 보여줍니다.
그래서 다음과 같이 끊어서 보여주는 개수를 showsNum 이라는 상태값으로 관리했습니다.
const [showsNum, setShowsNum] = useState<number>(ITEMS_PER_SHOW);
그리고 다음과 같이 showsNum 개수만큼 PortfolioCard를 렌더링했습니다.
return ( <S.Wrapper> <S.GridBox $section={currentSection}> { portfolios?.length > 0 ? portfolios.map((portfolio: Portfolio, index: number)=>{ if(index >= showsNum) return; return( <PortfolioCard key={portfolio.id} portfolio={portfolio} onClick={() => saveScrollAndPage(++index)} /> ) }) : <S.Notification> <Text size='bodyLarge' color='lightgray'> 해당하는 아이템이 없습니다. </Text> </S.Notification> } </S.GridBox> { hasNextPerShows && <S.ObserveDiv ref={setObservationTarget}></S.ObserveDiv> } </S.Wrapper> )
여기서 주목할 점은 바로 맨 하단에 있는 ObserveDiv 입니다.
저는 intersectionObserver를 사용해 무한스크롤을 구현했습니다. intersectionObserver란 Observer 객체로 특정 컴포넌트가 뷰포트 내부에 보이는 걸 감지하고 콜백 함수를 실행시키는 기능을 제공합니다.
동기적으로 작동하는 window.scroll 이벤트는 연속적으로 콜백 함수를 실행할 시 메인 스레드에 부하를 가져다주지만,
비동기적으로 동작하는 Observer 콜백 함수는 메인 스레드 부하를 줄여줍니다.
저는 다음과 같이 Observer 콜백 함수를 정의했습니다.
const loadNextPage = () => { // 만약 남은 포트폴리오 개수가 showsNum 개수보다 작을 경우 boolean 값 const isLastPortfoliosLessThanPerShow = (portfolios.length - showsNum) < ITEMS_PER_SHOW; // 지금까지 한 페이지의 포트폴리오를 다 보여줬고, 다음 페이지가 또 있다면 -> fetch if(showsNum === portfolios.length && hasNextPage) { fetchNextPage(); return; } // 남은 포트폴리오 개수가 showsNum보다 작을 경우 -> 개수 차이가 생기지 않게 차이를 빼서 더해준다 if(isLastPortfoliosLessThanPerShow) { setShowsNum(prev => prev + (portfolios.length - showsNum)); return; } // 그 외 showsNum 만큼 더해서 포트폴리오를 더 보여준다. setShowsNum(prev => prev + ITEMS_PER_SHOW); }; // 콜백 함수 전달 const setObservationTarget = useIntersectionObserver(loadNextPage);
돌발 문제 1번째
그런데 여기서 이해가 안 가는 에러가 발생했습니다.
아래 깃헙 버그 이슈에 등록해놓은 건데, IntersectionObsrever가 감지되고 콜백 함수가 수행되면 아무리 setShowsNum을 해줘도 showsNum이 해당 콜백 함수 내에선 초기값 그대로 변동이 없는 문제였습니다.
+ 분명 그랬는데... 다른날 또 다시 해보니까 상태값 업데이트가 잘 됩니다^^;
제 조건문 로직이 틀렸던 걸 수도 있습니다.
다만 아직까지 궁금한 건, useIntersectionObserver 훅에서 관리하는 상태값 count를(현재는 사용 안 함) 콜백 함수에서 setCount했을 때 count 값이 업데이트 되지 않던 이유를 못 찾고 코드를 수정해버렸습니다.
뒤로가기 시 이전 스크롤 위치 기억하기
다음은 스크롤 위치를 기억하는 법 입니다.
방법은 간단합니다. 다음과 같이 포트폴리오를 클릭했을 때 sessionStorage에 포트폴리오 index와 스크롤 위치를 저장합니다.
const saveScrollAndPage = (index: number) => { sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ anchorPosition: window.pageYOffset, clickedPortfolioIndex: index, })); };
그리고 뒤로가기 버튼을 눌러 페이지가 새롭게 렌더링 되었을 때 다음과 같은 useEffect() 훅을 동작시킵니다.
useEffect(() => { // 세션 스토리지 값을 가져온다. const getStorage = sessionStorage.getItem(SESSION_STORAGE_KEY); if(!getStorage) return; // 객체로 변환한다. const { anchorPosition, clickedPortfolioIndex } = JSON.parse(getStorage); // 클릭한 포트폴리오 인덱스 만큼 showsNum을 업데이트 해 렌더링한다. setShowsNum(clickedPortfolioIndex); // 스크롤 이동하기 전 시간을 500ms 정도 주고 스크롤을 이동시킨다. setTimeout(() => { window.scrollTo({ top: anchorPosition, }); }, 500); // 언마운트 되면 세션 값을 제거한다. return () => sessionStorage.removeItem(SESSION_STORAGE_KEY); }, []);
돌발 문제 2번째
그런데 여기서 돌발 문제가 또 발생했습니다.
만약 3번째 포트폴리오를 클릭하고 뒤로갈 시 showsNum이 3이 되어 그 뒤로 이어지는 공간은 그냥 빈 여백이 되었습니다. 다시 스크롤을 내리면 포트폴리오가 더 렌더링되지만 당장 그 순간의 UI가 너무 별로였습니다.
따라서 다음과 같이 이전에 클릭한 포트폴리오 인덱스가 ITEMS_PER_SHOW 보다 작을 경우 여유분으로 5개 더 렌더링해주었습니다.
if(clickedPortfolioIndex < ITEMS_PER_SHOW) { setShowsNum(clickedPortfolioIndex + 5); } else setShowsNum(clickedPortfolioIndex);
구현 결과
실행 결과 무한 스크롤이 정상 동작되며, 뒤로가기 클릭 시 이전 스크롤로 이동하는 걸 확인할 수 있습니다. ^_^
참고 자료
'Studying > Proj 과정' 카테고리의 다른 글
[Portfolly] ErrorBoundary로 컴포넌트 단위 에러 화면 띄우기 (0) 2024.01.11 [Portfolly] 북마크, 좋아요 낙관적 업데이트로 개선하기 (0) 2024.01.10 [Portfolly] 포트폴리오 CRUD를 react-query로 마이그레이션 (0) 2023.12.25 [Portfolly] 직접 반응형 카테고리 Slider 만들기 (0) 2023.12.24 [Portfolly] 메인 페이지 UI/UX를 개선해보자(feat. mobbin) (0) 2023.12.24