React Query - useInfiniteQuery & 이미지 최적화 적용하기 🎉

seul_velog·2023년 9월 20일
0
post-thumbnail

이제 앞에서 살펴본 👉 이미지최적화 + 👉 React Query로 최적화를 해보자 ✨


useInfiniteQuery 사용하기

✍️ useInfiniteQuery로 무한 스크롤 구현하기


스크롤 함수 구현하기

const handleScroll = () => {
    const scrolled = window.scrollY;
    const viewportHeight = window.innerHeight;
    const fullHeight = document.documentElement.scrollHeight;

    if (scrolled + viewportHeight >= fullHeight * 0.9 && !isFetchingNextPage && hasNextPage) {
      fetchNextPage();
    }
  };

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

이때 deps 값 hasNextPage, isFetchingNextPage, fetchNextPage 에 대해 알아보자.

  • hasNextPage: 이 값이 false에서 true로 변경될 경우, 사용자는 더 많은 데이터를 스크롤하여 로드할 수 있다. 따라서 이 값을 의존성 배열에 포함시켜 handleScroll 이벤트 리스너를 다시 설정하면 좋다.

  • isFetchingNextPage: 이 값은 데이터를 가져오는 중인지를 나타낸다. 이 값이 변경되면 handleScroll 함수의 동작에 영향을 준다. 만약 현재 데이터를 가져오는 중이라면 (isFetchingNextPage가 true일 경우), 추가 요청을 방지하기 위해 handleScroll에서 fetchNextPage를 호출하지 않는다.

  • fetchNextPage: 이 함수는 스크롤 시 다음 페이지의 데이터를 가져온다. 일반적으로 함수는 상태나 속성이 아니므로 종속성 배열에 포함할 필요가 없지만, React Query나 다른 훅 라이브러리를 사용할 때, 함수가 재생성될 수 있다. 이 경우, 함수를 의존성 배열에 포함시켜주는 것이 안전하다고 한다.🤔




useInfiniteQuery hook

export interface useGetProductsProps {
  queryCategory?: string;
  IsAuthRequired?: boolean;
  perPage?: number;
}
  
export const useGetProducts = ({ queryCategory, IsAuthRequired, perPage }: useGetProductsProps) => {
  const fetchProducts = async ({ pageParam = 1 }) => {
    const res = await v1.getProducts({ queryCategory, IsAuthRequired, perPage, page: pageParam });
    const data = res.data.mainData;

    return {
      ...res.data,
      products: excludeAskProducts(data),
      askProducts: onlyAskProducts(data)
    };
  };

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
    ['products', queryCategory, IsAuthRequired, perPage],
    fetchProducts,
    {
      getNextPageParam: (res) => {
        const { currentPage, totalPage } = res;
        if (currentPage && currentPage < totalPage) {
          return currentPage + 1;
        }

        return undefined;
      },
      staleTime: 1000 * 60 * 5
    }
  );

  return {
    products: data?.pages.flatMap((page) => page.products),
    askProducts: data?.pages.flatMap((page) => page.askProducts),
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  };
};

1. fetchProducts()

  • 서버 요청을 통해 상품 데이터를 가져온다.
  • 서버 요청 시 queryCategory , IsAuthRequired , perPage , pageParam 등의 파라미터를 사용하여 필요한 상품 데이터를 요청한다.
  • 응답에서 받은 데이터 중 excludeAskProductsonlyAskProducts 유틸 함수를 사용해 필터링된 상품 리스트를 생성한다.
  • 최종적으로 필터링된 상품 리스트와 기타 응답 데이터를 함께 반환한다.
    ✍️ 기타 응답 데이터는 페이지네이션을 위한currentPagetotalPage 를 위해 넘겨주었다.

2. useInfiniteQuery

  • React Query의 useInfiniteQuery 를 사용하여 서버에서 여러 페이지의 데이터를 무한 스크롤 방식으로 가져올 수 있다.
  • 첫 번째 인자는 쿼리 키이다. 이 키는 해당 쿼리의 고유한 식별자로 사용된다.
  • 두 번째 인자는 데이터를 가져오는 함수이다. 여기서는 fetchProducts 를 사용한다.
  • 세 번째 인자는 쿼리의 옵션을 설정하는 객체이다.
    여기서는 다음 페이지의 번호를 반환하는 getNextPageParam 함수와 쿼리 데이터의 유효 기간을 나타내는 staleTime 을 설정한다.

3. return

  • data.pages 를 사용하여 각 페이지의 상품 데이터를 가져와 모두 합친다.
  • fetchNextPage , hasNextPage , isFetchingNextPage 는 무한 스크롤 기능을 지원하기 위한 변수와 함수이다.
  • 최종적으로 모든 데이터와 함수를 객체로 묶어 반환한다.

✍️ data.pages 는?
: useInfiniteQuery 에서 반환되는 data 객체 내의 속성이다. useInfiniteQuery 를 사용할 때, 각 페이지의 데이터는 pages 배열에 순서대로 저장된다.
예를들어 첫 번째 페이지의 데이터는 data.pages[0] 에, 두 번째 페이지의 데이터는 data.pages[1] 에 저장된다.


✍️ 데이터는 어떻게 합치나?
직접 페이지네이션을 구현했을 때와 유사하게, useInfiniteQuery 를 사용하여 여러 페이지의 데이터를 불러올 때 각 페이지의 데이터를 하나의 배열로 합치기 위해 flatMap 메서드를 사용한다.

// 각 페이지의 products 데이터를 하나의 배열로 합친다.
products: data?.pages.flatMap((page) => page.products)

flatMap 은 각 페이지의 products 배열을 순회하면서, 각 배열의 요소를 하나의 새 배열로 "펼친" 결과를 반환한다. 이로 인해 여러 페이지의 상품 데이터가 하나의 연속된 배열로 합쳐지는 것!

data.pages 각 페이지의 데이터를 저장하는 2차원 배열 구조를 가진다. 예를들어 각 페이지에서 반환되는 데이터가 배열 형태라면:

첫 번째 페이지의 데이터: ['a', 'b']
두 번째 페이지의 데이터: ['c', 'd']
세 번째 페이지의 데이터: ['e', 'f']

data.pages 의 구조는 다음과 같이 된다.

[
  ['a', 'b'],  // 첫 번째 페이지의 데이터
  ['c', 'd'],  // 두 번째 페이지의 데이터
  ['e', 'f']   // 세 번째 페이지의 데이터
]

이러한 2차원 배열 구조는 flatMap 과 같은 메서드를 사용하여 쉽게 1차원 배열로 변환할 수 있다.

MDN_flatMap




staleTime


만약 5분이 지나기 전에 서버로 부터 받아오는 데이터가 바뀌면 리액트쿼리는 이것을 변경할까?

✍️
staleTime 옵션을 사용할 때, 설정된 시간 동안 React Query는 데이터를 "신선하다"고 간주하고 해당 쿼리에 대한 추가 요청을 피한다. 그러나 만약 데이터가 서버에서 변경되더라도 React Query는 이를 자동으로 알 수 없다.

5분이 지나기 전에 서버의 데이터가 변경되면, 그 변경 사항은 React Query에 의해 자동으로 반영되지 않는다. 그렇기 때문에 데이터의 실시간 업데이트가 중요한 경우 staleTime을 너무 길게 설정하지 않는 것이 좋다.

만약 데이터의 변경을 감지하고 자동으로 업데이트하고 싶다면, 다음과 같은 방법을 고려할 수 있다고 한다.

(1) WebSockets 또는 Server Sent Events (SSE): 서버와 클라이언트 간의 실시간 통신을 통해 데이터 변경을 감지하고 React Query의 queryClient.invalidateQueries 메서드를 사용하여 특정 쿼리를 무효화시킬 수 있다. 무효화된 쿼리는 다시 호출될 때 데이터를 서버로부터 다시 가져온다.

(2) Polling: 일정 시간 간격으로 데이터를 주기적으로 가져오는 방법이다. React Query의 refetchInterval 옵션을 사용하여 쿼리를 주기적으로 다시 실행시킬 수 있다.

이러한 방법들을 사용하면 서버의 데이터 변경을 클라이언트에서 반영할 수 있다. 하지만 각 방법에는 장단점이 있으므로 상황에 맞게 적절한 방법을 선택하는 것이 중요하다.



만약 5분이되기전에 데이터가 바뀌었다고 가정하고, 이때 데이터가 바뀌자마자 다른 페이지에있다가 돌아와서 앱 포커스를 맞추면 패치를 할까? 아니면 여전히 5분이 지나지않았으므로 패치하지 않을까?

✍️
React Query는 refetchOnWindowFocus라는 설정이 있다. 기본적으로 이 옵션은 true로 설정되어 있어서 앱/페이지에 포커스가 다시 돌아올 때 자동으로 데이터를 다시 가져오게 된다.

하지만 이 refetchOnWindowFocus 동작은 staleTime 설정과 함께 동작하기때문에 staleTime 동안 데이터가 신선하다고 판단되면, 포커스가 다시 돌아왔을 때에도 데이터는 다시 가져오지 않는다.

예를 들어, staleTime을 5분으로 설정했다면, 5분이 지나기 전에 다른 페이지에 있었다가 돌아와도 데이터는 다시 가져오지 않는다.
5분이 지난 후에 다른 페이지에 있었다가 돌아오면, 그 때는 데이터를 다시 가져온다.

만약 refetchOnWindowFocus 동작을 원치 않는다면, 해당 옵션을 false로 설정할 수도 있다!




Next/Image

✍️ Next.js에서 next/image 사용하기

이미지를 반응형으로 렌더링하기

현재 내가 작성중인 코드는 반응형으로 ImageWrapper 의 사이즈가 동작한다.
이때, 이미지의 사이즈를 반응형으로 작성하기 위해서 layout="responsive" 를 추가했다.

<ImageWrapper onClick={() => handleMoveProductDetail(data.id)}>
    <Image src={data.img_url} alt={data.name} width={400} height={400} layout="responsive" />
</ImageWrapper>

이때 신기했던 것은, img 태그를 사용하는 것보다 Image 컴포넌트를 사용했을때 이미지 최적화가 이루어지고, 사이즈가 훨씬 작아진 다는 것은 알았지만, 반응형을 위해 layout="responsive" 를 적용하니 적용하기 전보다 사이즈가 조금 더 커졌다.

layout="responsive"를 사용할 때와 사용하지 않을 때 이미지 최적화 동작이 달라질 수 있다고 하는데.. 예를 들어, 특정 화면 크기에서 더 높은 해상도의 이미지가 필요할 경우 해당 이미지의 크기가 증가할 수 있다고 한다. 🧐
(이 부분은 정확하게 알진 못하지만)
layout="responsive" 옵션을 사용하면 브라우저가 다양한 환경에 맞게 최적화된 이미지를 선택하여 로드하게 되어서 데이터의 크기가 약간 변경될 수 있는 원리 같다.✍️




Intersection Observer

const loaderRef = useRef(null);

useEffect(() => {
    const handleObserver = (entries: any) => {
      const target = entries[0];

      if (target.isIntersecting && hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    };

    const options = {
      root: null,
      rootMargin: '0px 0px 1000px',
      threshold: 0
    };

    const io = new IntersectionObserver(handleObserver, options);
    const currentRef = loaderRef.current;

    if (currentRef) {
      io.observe(currentRef);
    }

    return () => {
      if (currentRef) {
        io.unobserve(currentRef);
      }
    };
  }, [hasNextPage]);

return (
  ... // UI 렌더링 로직
<div ref={loaderRef}></div>
  ...
)
  1. 초기화 하기
  • loaderRef 라는 이름의 ref를 생성한다. 이 ref는 후에 DOM 요소에 직접 연결되어 해당 요소의 참조를 가져올 수 있게 된다.

  1. useEffect
  • hasNextPage 값이 변경될 때마다 재실행된다.

  • handleObserver 함수를 정의한다. 이 함수는 인터섹션 옵저버가 관찰 대상 요소와 상호 작용할 때마다 호출된다.

  • 옵션 객체 options 를 정의한다. 이 객체는 인터섹션 옵저버가 동작하는 방식을 결정한다.
    - root : null이므로 뷰포트를 기준으로 관찰한다.
    - rootMargin : 1000px의 마진을 아래쪽에 추가한다. 이는 관찰 대상 요소가 뷰포트에서 1000px 떨어진 위치에 도달하면 콜백을 트리거한다는 것을 의미한다! 😀
    - threshold : 0으로 설정되어 있으므로 관찰 대상 요소가 뷰포트에 약간이라도 들어오면 콜백이 트리거된다.

  • 새로운 IntersectionObserver 인스턴스를 생성하고 handleObserver 함수와 options 객체를 전달한다.
    loaderRef.current 가 존재하면 (즉, 참조하려는 DOM 요소가 실제로 존재하면) 해당 요소를 관찰한다.

  • 컴포넌트가 언마운트되거나 useEffect가 재실행될 때 실행되는 클린업 함수를 정의한다. 이 함수에서는 관찰 대상 요소의 관찰을 중지한다.


  1. handleObserver 함수
  • 이 함수는 관찰 대상 요소가 뷰포트와 상호 작용할 때마다 호출된다.
  • isIntersecting 속성은 대상 요소가 뷰포트와 교차하는지 여부를 나타낸다.
  • 만약 대상 요소가 뷰포트와 교차하고 isIntersecting
    다음 페이지를 불러올 수 있으며 hasNextPage
    현재 다음 페이지를 불러오고 있지 않다면 !isFetchingNextPage
    👉 fetchNextPage 함수를 호출하여 다음 페이지의 데이터를 불러온다.

  1. 렌더링
  • 주어진 컴포넌트의 UI를 렌더링하고, 맨 아래에는 loaderRef 를 참조하는 빈 div 요소를 추가한다. 이 div 요소는 실제로 눈에 보이지 않지만, 사용자가 페이지의 하단 근처에 도달했을 때 데이터를 더 불러오기 위한 "센티널"(감시자, 지표, 표식, 지시자) 역할을 한다.😤




무한스크롤과 정렬 문제에 대해

정렬과 관련된 드롭다운 메뉴(최신순, 가격 높은순, 가격낮은순, 인기순 등)를 넣는다면?

상품들의 목록을 보여주는 이커머스의 상품 목록 페이지를 상상해보자.
여기서 만약 무한스크롤 페이지네이션을 적용한다면 어떻게 될까? 🤔

가정 1)
백엔드에서 최신순으로 모든 상품데이터를 한 번에 불러왔다고 가정한다. 그리고 별도의 드롭다운 메뉴를 통해 상품을 정렬한다.
가정 2)
상품 목록 페이지의 상품은 sold out된 제품들과 그렇지 않은 제품들이있다.
가정 3)
sold out된 제품은 가장 하단으로 정렬되는 것이 기본이다.

?per_page=20&page=1, ?per_page=20&page=2 ... 에 따라서 상품을 약 50개씩 가져온다면, 첫 화면에서의 데이터 50개중, 솔드아웃 제품은 하단에, 그렇지 않은 제품은 상단에 뜰 것이다.

이어서 스크롤을 하여 page=2 목록도 불러온다고 가정하면, 총100개의 상품중 soldout된 제품은 하단으로 보내질 것이다.

이때 사용자는 스크롤을 했을때 전체 상품목록이 바뀌는 듯한 혼란을 느끼게될 것이다. 왜냐하면 서버로부터 애초에 정렬된 데이터를 가져와서 차례대로 렌더링 하는 것이 아니라 서버로부터 정렬되지 않은 목록을 불러오고 프론트엔드에서 다시 재정렬하기 때문이다.

이것의 한계를 극복하기위해서는 백엔드에서 이미 soldout 된 제품을 하단에 정렬한 상태로 전체 목록을 보내주거나, 특정 쿼리값(예를들어 최신순, 인기순, 가격높은순, 가격낮은순)에 따라서 정렬된 아이템을 주어야 해결될 것이라고 생각한다. 그리고 보통 다른사이트에서도 이렇게 하고 있을 것이다. 🧐


👉 무한 스크롤을 사용할 때 페이지별로 데이터를 가져올 경우, 전체 데이터 셋에 대한 정렬을 유지하기 위해서는 서버 측에서 데이터를 미리 정렬해 전달하는 것이 효과적!

  1. 서버에서의 정렬:
    • 무한 스크롤의 특성 상 사용자가 스크롤을 내릴 때마다 새로운 페이지의 데이터를 가져오게 된다. 이때, 각 페이지마다 개별적으로 정렬을 하게 되면 전체 데이터의 연속성이 깨지게 된다.
    • 서버에서 데이터를 미리 정렬해 주면, 클라이언트는 단순히 가져온 데이터를 순서대로 렌더링만 하면 된다.
    • 따라서 사용자는 스크롤을 내리면서도 데이터의 정렬 순서가 유지되는 것을 확인할 수 있습다.
  2. 쿼리 기반 정렬:
    • 사용자가 선택한 정렬 방식 (최신순, 가격 높은순, 가격 낮은순 등)에 따라 쿼리 파라미터를 변경하여 서버에 요청한다.
    • 서버는 이 쿼리 파라미터를 기반으로 데이터를 정렬하여 응답할 것이다.
  3. 다른 사이트의 접근 방식:
    • 많은 웹사이트들, 특히 대형 쇼핑몰이나 검색 엔진 등, 무한 스크롤과 같은 페이징 기법을 사용할 때 서버에서 데이터를 미리 정렬하여 전달하는 방식을 사용한다.

이러한 서버에서의 정렬 방식은 사용자는 전체 데이터에 대한 일관된 경험을 할 수있게 되고, 즉 연속적인 데이터를 볼 수 있게 될 것이다. 따라서 사용자 경험을 최적화 할 수 있게 될 것이라고 생각했다! 😀



최적화 전 🤔

최적화 후 ✨

그때그때 속도 차이가 있긴 하지만 위의 결과만 본다면 약 70배 단축 효과를 얻었다 😄🎉 (동작은 동일, 시간을 약 98% 줄였다!)

profile
기억보단 기록을 ✨

0개의 댓글