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

useInfiniteQuery로 무한스크롤 구현하기

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

2023.10.04

올하~! 안녕하세요. 오랜만에 돌아온 새싹 프론트엔드 개발자 홍시홍입니다. 🌱

그동안 올리브영에 많은 변화가 있었는데요, 매거진관, 셔터 같은 새로운 서비스들이 오픈되고, 기존에 있던 페이지들이 신규 아키텍쳐로 탈바꿈하고 있답니다.
지난 6월에 오픈한 메인 홈 영역에 이어서 드.디.어. 검색 결과 페이지도, 신규 아키텍쳐로 전환되었습니다 👏👏

searchResult

그래서 이번 글에서는 위 검색 결과 페이지를 개발하며 겪었던 경험 중 useInfiniteQuery로 무한스크롤을 구현하는 방법뒤로가기 시 이전 상품목록과 스크롤을 유지하는 방법에 대해서 공유해 보려고 합니다.

Infinite scroll (무한스크롤)

내용 공유에 앞서 저희가 사용하고 있는 프론트엔드 기술 스펙 및 버전은 다음과 같습니다.

  • Next.js 12
  • react-query 3.39.0
  • Typescript 4.7.4

검색 결과 페이지는 상단 필터 아래에 상품 리스트가 위치하고, 스크롤을 내리면 새로운 상품리스트가 하단에 그려지는 구조로 이뤄져 있습니다. 저희는 이러한 구조에 맞춰 무한스크롤을 구현하기 위해 react-query에서 제공하는 useInfiniteQuery 훅을 사용했습니다.

/* useInfiniteQuery 기본 구조 */
const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})

반환값

useInfiniteQuery의 반환 값 중, 자주 쓰이는 세 가지는 다음과 같습니다.
fetchNextPage
다음 페이지를 요청할 때 사용되는 메서드
hasNextPage
다음 페이지가 있는지 판별하는 boolean 값
isFetchingNextPage
다음 페이지를 불러오는 중인지 판별하는 boolean 값

옵션

getNextPageParam
lastPage와 allPages는 콜백함수에서 리턴된 값으로 lastPage는 직전에 반환된 리턴값, allPages는 이제까지 받아온 전체 페이지를 뜻합니다. 흔히 마지막 페이지일 경우 undefined를 리턴하여 hasNextPage값을 false로 설정합니다.

컴포넌트에 기능 적용하기

structure

검색 결과 페이지 예시로 상품 리스트 영역을 감싸고 있는 부모 컴포넌트(ProductList.tsx) 아래 상품 카드 컴포넌트(ProductCard.tsx), 스켈레톤 컴포넌트(Skeleton.tsx) 이렇게 자식 컴포넌트가 있다고 가정해보겠습니다. 부모 컴포넌트에서는 첫 로딩 시에 스켈레톤 컴포넌트를 노출하고, 로딩이 끝나면 첫 번째 페이지(40개)의 상품 카드를 노출하게 됩니다. 그 후, 첫번째 페이지의 마지막 상품 카드에 스크롤이 닿으면 두 번째 페이지 데이터를 불러옵니다. 데이터를 불러오는 동안 스켈레톤을 노출하고, 로딩이 끝나면 상품 카드를 반복해서 노출합니다.

productList

상품 카드 데이터를 불러오는 커스텀 훅(useSearchProductQuery.tsx) 에서는 useInfiniteQuery 을 사용하여 products, isLoading, isError, fetchNextPage, isFetchingNextPage 을 반환합니다. 부모 컴포넌트(ProductList.tsx)에서는 데이터 패칭이 실패한 경우(isError)에는 빈 컴포넌트를, 첫 페이지 로딩 (isLoading)시에는 스켈레톤 컴포넌트를 노출합니다.
저희는 스크롤을 내리게 될 때 viewPort에 마지막 요소가 보여지는지 체크하기 위해 react-intersection-observer 의 useInview 훅을 이용했습니다.
useInview 훅은 리액트 컴포넌트의 "inView" 상태를 모니터링해주는 훅으로, 요소가 뷰포트에 진입/제외되는 시점을 파악할 수 있습니다. viewPort에 보일 때를 체크할 element에 ref 속성을 걸어주고( <div ref={ref} />), 이 요소가 뷰포트 안에 보였을 때 (inView === true) fetchNextPage를 실행하여 다음 페이지를 가져옵니다. 다음 페이지 데이터를 가져오는 동안에는(isFetchingNextPage)에는 <div ref={ref} /> 요소 대신 스켈레톤 컴포넌트를 노출하고 로딩이 끝나면 다시 해당 요소를 노출하게 됩니다.


ProductList.tsx

import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { useSearchProductQuery } from "../../useSearchProductQuery";
import { getSearchProduct } from "../../api/search";
import { ProductCard } from "../../ProductCard";
import { Skeleton } from "../../SearchProductSkeleton";
import styles from "@../ProductList.module.scss";

const cx = classNames.bind(styles);

const ROWS_PER_PAGE = 40; // 한 페이지당 불러올 상품개수

const ProductList = () => {
   const { products, isLoading, isError, fetchNextPage, isFetchingNextPage } = useSearchProductQuery({
    rowsPerPage: ROWS_PER_PAGE,
    // startCount: 몇번째 상품부터 불러올건지 시작인덱스 / row: 받아 올 상품 개수
    queryFn: (pageParam = 1) => getSearchProduct({startCount: ROWS_PER_PAGE * (pageParam - 1), row: ROWS_PER_PAGE}),
  });

  const { ref, inView } = useInView();

  useEffect(() => {
    if (inView) {
      fetchNextPage();
    }
  }, [inView]);

  if (isLoading) {
    return (
      <div className={cx("productList")}>
        <Skeleton />
      </div>
    );
  }

  if (isError) {
    return (
      <></>
    );
  }

  return (
    <div className={cx("productList")}>
      {products.map((product, index) => {
        <ProductCard key={index} product={product} />
       })}

      {isFetchingNextPage ? (<Skeleton />) : (<div ref={ref} />)}
    </div>
  );
};
export default ProductList;

UseSearchProductQuery.tsx

import { useMemo } from "react";
import { QueryFunctionContext, useInfiniteQuery } from "react-query";
import { TSearchProduct } from "../../types/search";

interface useSearchProductQueryProps {
  rowsPerPage: number; // 한 페이지당 불러올 상품개수
  queryFn: (context?: QueryFunctionContext) => Promise<TSearchProduct>;
}

const queryKey = "searchProducts";

const useSearchProductQuery = ({ rowsPerPage, queryFn }: useSearchProductQueryProps) => {
  const { data, isLoading, isError, fetchNextPage, isFetchingNextPage } = useInfiniteQuery<TSearchProduct>(
    queryKey,
    queryFn,
    {
      getNextPageParam: (lastPage, allPages) => {
      const nextPage = allPages.length + 1;

      //상품이 0개이거나 rowsPerPage보다 작을 경우 마지막 페이지로 인식한다.
      return lastPage?.data.count === 0 || lastPage?.data.count < rowsPerPage ? undefined : nextPage;
    },
      retry: 0,
      refetchOnMount: false,
      refetchOnReconnect: false,
      refetchOnWindowFocus: false,
    }
  );

  const products = useMemo(() => {
    // 상품 컴포넌트(ProductCard.tsx)의 props에 맞춰 데이터 가공처리
    // ...

    return productList;
  }, [data]);

  return { products, isLoading, isError, fetchNextPage, isFetchingNextPage };
};

export default useSearchProductQuery;

🥲 : 레거시 API(getSearchProduct) response 의 pagination 부재로 getNextPageParam 에서 마지막 페이지 여부를 직접 판단함

이전 상품목록 및 스크롤 유지

이제 무한스크롤을 구현했으니 뒤로가기 시 이전 상품목록과 스크롤을 어떻게 유지했는지 알아보겠습니다.

개발 배경

올리브영 온라인몰의 상품 상세페이지는 구 몰(레거시) 페이지, 검색 결과 페이지는 신규 아키텍쳐 페이지로 이루어져 있습니다. 따라서 프로젝트가 분리되어있다 보니 브라우저 자체 기능을 사용하여 스크롤 유지를 하는 것이 의미가 없었습니다. 특히나 검색 결과 페이지에서 스크롤을 내려 2페이지 이상의 상품을 클릭한 뒤 다시 돌아왔을 경우 해당 페이지까지 데이터를 다 불러오지 않았기 때문에 단순히 스크롤 위치를 이동시키는 것으로는 해결되지 않았습니다. 그래서 상품 상세에서 돌아왔을 때, 클릭했던 상품의 인덱스 값까지의 상품 데이터와 스크롤을 유지하기 위한 이전 스크롤 포지션 위치를 기억해야 했습니다.

해결 방법

저희는 우선 상품 상세페이지와 검색 결과 페이지간의 이동에도 영향받지 않고 정보를 저장하기 위해, 세션 스토리지를 활용했습니다. 상품 클릭 시 세션 스토리지에 클릭 된 상품의 인덱스 값과 스크롤 Y값을 저장하고, 다시 검색 결과 페이지에 돌아왔을 때 useQueryClient를 활용하여 저장된 상품의 인덱스 값까지의 상품 데이터를 불러온 뒤 스크롤 위치를 이동시켜주었습니다. 이때, useQueryClient를 어떻게 활용했는지는 아래에서 자세히 설명하겠습니다.

QueryClient 활용

import { useQueryClient } from "react-query";

const queryClient = useQueryClient();

prefetchInfiniteQuery
무한 쿼리를 미리 가져오고 캐싱할 때 사용되는 훅으로 흔히 useInfiniteQuery와 함께 사용됩니다.

await queryClient.prefetchInfiniteQuery({ queryKey, queryFn })

getQueryData
기존 쿼리의 캐시 된 데이터를 가져오는 데 사용할 수 있는 동기 함수, 쿼리가 존재하지 않으면 undefined가 반환됩니다.

const data = queryClient.getQueryData(queryKey)

setQueryData
쿼리의 캐시 된 데이터를 즉시 업데이트하는 데 사용할 수 있는 동기 기능입니다.

queryClient.setQueryData(queryKey, updater)

저는 위 세 가지를 활용하여 원하는 상품 인덱스만큼 상품을 1page에 저장한 뒤, 세션 스토리지에 저장된 앵커 위치에 포커스를 주고 세션 스토리지를 제거하는 방법을 사용했습니다. 자세한 방법은 아래 예시 코드로 설명하겠습니다.

ProductList.tsx

import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { useSearchProductQuery } from "../../useSearchProductQuery";
import { getSearchProduct } from "../../api/search";
import { ProductCard } from "../../ProductCard";
import { Skeleton } from "../../SearchProductSkeleton";
import sessionStorage from "@utils/sessionStorage";
import styles from "@../ProductList.module.scss";

const cx = classNames.bind(styles);

const ROWS_PER_PAGE = 40;   // 한 페이지당 불러올 상품개수
const SESSIONSTORAGE_KEY = "clickedSearchProduct";

const ProductList = () => {
   const [isPrefetchData, setIsPrefetchData] = useState(false);

   const { products, isLoading, isError, fetchNextPage, isFetchingNextPage } = useSearchProductQuery({
    rowsPerPage: ROWS_PER_PAGE,
    // startCount: 몇번째 상품부터 불러올건지 시작인덱스 / row: 받아 올 상품 개수
    // meta 속성 존재여부에 따라 startCount, row 변경
    queryFn: (pageParam = 1, meta) =>{
      const row = meta?.rowsPerPage ? Number(meta.rowsPerPage) : ROWS_PER_PAGE;
      const startCount = pageParam === 2 && isPrefetchData ? products.length : ROWS_PER_PAGE * (pageParam - 1);

      return getSearchProduct({startCount, row});
    },
    sessionStorageKey: SESSIONSTORAGE_KEY,
  });

  const { ref, inView } = useInView();

  const saveScroll = (index: number) => {
    sessionStorage.setItem(SESSIONSTORAGE_KEY, {
      anchorPosition: window.pageYOffset,
      clickedGoodsIndex: startIndex + index,
    });
  };

  useEffect(() => {
    if (inView) {
      fetchNextPage();
    }
  }, [inView]);

  useEffect(() => {
    const getStorage = sessionStorage.getItem(SESSIONSTORAGE_KEY);
    if (!getStorage) {
      return;
    }

    setTimeout(() => {
      window.scrollTo({
        top: getStorage.anchorPosition,
      });

      sessionStorage.removeItem(SESSIONSTORAGE_KEY);
    }, 1000);

    setIsPrefetchData(true);
  }, []);

  if (isLoading) {
    return (
      <div className={cx("productList")}>
        <Skeleton />
      </div>
    );
  }

  if (isError) {
    return (
      <></>
    );
  }

  return (
    <div className="productList">
      {products.map((product, index) => {
        <ProductCard key={index} product={product}
        onClick={() => saveScroll(index + 1)}/>
       })}

      {isFetchingNextPage ? (<Skeleton />) : (<div ref={ref}></div>)}
    </div>
  );
};
export default ProductList;

useSearchProductQuery.tsx

import { useMemo } from "react";
import { InfiniteData, QueryFunctionContext, useInfiniteQuery, useQueryClient } from "react-query";
import { TSearchProduct } from "../../types/search";
import sessionStorage from "@utils/sessionStorage";

interface useSearchProductQueryProps {
  rowsPerPage: number; // 한 페이지당 불러올 상품개수
  queryFn: (context?: QueryFunctionContext) => Promise<TSearchProduct>;
  sessionStorageKey: string;
}

const queryKey = "searchProducts";

const useSearchProductQuery = ({ rowsPerPage, queryFn, sessionStorageKey }: useSearchProductQueryProps) => {
  const queryClient = useQueryClient();

  useEffect(() => {
    (async () => {
      const getStorage = sessionStorage.getItem(sessionStorageKey);
      if (!getStorage) return;

      const { clickedGoodsIndex } = getStorage;

      await queryClient.prefetchInfiniteQuery(queryKey, (data) => {
        return queryFn({
          ...data,
          pageParam: 1,
          // prefetch 여부를 알 수 있도록 meta 속성 사용
          meta: {
            rowsPerPage: Number(clickedGoodsIndex) < rowsPerPage ? rowsPerPage : Number(clickedGoodsIndex),
          },
        });
      });

      const getData = queryClient.getQueryData<InfiniteData<TSearchProduct>>(queryKey)?.pages[0];
      if (!getData) return;

      queryClient.setQueryData(queryKey, {
        pages: [{ ...getData }],
        pageParams: [1],
      });
    })();
  }, []);

  const { data, isLoading, isError, fetchNextPage, isFetchingNextPage } = useInfiniteQuery<TSearchProduct>(
    queryKey,
    queryFn,
    {
      getNextPageParam: (lastPage, allPages) => {
      const nextPage = allPages.length + 1;

      //상품이 0개이거나 rowsPerPage보다 작을 경우 마지막 페이지로 인식한다.
      return lastPage?.data.count === 0 || lastPage?.data.count < rowsPerPage ? undefined : nextPage;
    },
      retry: 0,
      refetchOnMount: false,
      refetchOnReconnect: false,
      refetchOnWindowFocus: false,
    }
  );

  const products = useMemo(() => {
    // data 상품타입으로 변환
    // ...

    return productList;
  }, [data]);

  return { products, isLoading, isError, fetchNextPage, isFetchingNextPage };
};

export default useSearchProductQuery;

결과 화면


💡 상품 상세페이지에서 뒤로가기를 통해 검색 결과 페이지에 재진입했는지는 어떻게 판단했을까?

예를 들어 사용자가 "틴트"를 검색한 후 특정 상품을 클릭하여 상품 상세페이지에 진입했다고 가정해보겠습니다. 사용자는 상품 상세페이지에서 상단 헤더의 홈버튼이나 장바구니 아이콘을 클릭하는 등 다양한 액션을 통해 다른 페이지에 접근할 수 있습니다. 이때, 세션 스토리지에는 검색 결과 페이지에서 저장한 상품 인덱스값과 스크롤 Y값이 남아있게 됩니다. 이 상태로 다른 검색어를 검색하여 검색 결과 페이지에 진입하게 되면 세션 스토리지에 저장된 값의 영향을 받게 됩니다.

searchInput

올리브영의 검색 결과 페이지는 위 검색입력창 페이지를 통해서만 진입이 가능한 구조라는 점을 활용하여, 검색입력창 페이지 첫 진입 시 해당 세션 스토리지 값을 제거해 주었습니다.

마무리

지금까지 무한스크롤 적용 방법 및 이전 상품목록 유지하는 방법에 대해 알아보았습니다. 이번 프로젝트를 진행하며 리액트쿼리에 대해 많이 알게 되어 참 좋았었는데요, 제 글도 누군가에게 도움이 될 수 있었으면 좋겠습니다. 😊
그럼 저는 앞으로도 올리브영 고객님들께 더 원활한 검색 환경을 제공할 수 있도록 열심히 달려보겠습니다! 🏃🏻‍♀️💦 긴 글 읽어주셔서 감사합니다.

FrontEnd
올리브영 테크 블로그 작성 useInfiniteQuery로 무한스크롤 구현하기
😻
홍시홍 |
Front-end Engineer
치즈냥이 홍시님의 집사를 맡고있는 신입 프론트엔드 개발자입니다.🐣