ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [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)

     

     

    InfiniteQuery 반환값 형태

    ⦁ 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 콜백 함수는 메인 스레드 부하를 줄여줍니다.

     

     

    [React] Intersection Observer API : 무한 스크롤 구현

    Intersection Observer API - Web API | MDN Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다.Intersection Observer API

    ddd120.tistory.com

     

     

    비동기와 멀티스레딩 - Jays blog

    1. 소개

    jayhyun-hwang.github.io

     

     

    저는 다음과 같이 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 초기 props로 상태값을 다루는 callback을 넘겨줬을 때, 상태가 업데이트 되지 않

    Bug Report 개요 PortfolioList.tsx에서 무한스크롤을 구현하는 중에 발생한 문제입니다. useIntersectionObserver 커스텀훅을 만들어서 초기값으로 가 observe 되었을 때 발생시킬 callback을 전달합니다. 그런데 c

    github.com

     

    + 분명 그랬는데... 다른날 또 다시 해보니까 상태값 업데이트가 잘 됩니다^^;

    제 조건문 로직이 틀렸던 걸 수도 있습니다.

     

    다만 아직까지 궁금한 건, 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);

     

     

     

    구현 결과

    실행 결과 무한 스크롤이 정상 동작되며, 뒤로가기 클릭 시 이전 스크롤로 이동하는 걸 확인할 수 있습니다. ^_^

     

     

     

    참고 자료

     

    useInfiniteQuery로 무한스크롤 구현하기 | 올리브영 테크블로그

    무한스크롤 구현 방법과 뒤로가기 시 스크롤 유지하는 방법을 소개합니다.

    oliveyoung.tech

     

Designed by Tistory.