본문 바로가기

Supabase + TanStack Query + Intersection Observer :: infinite scroll 구현

codeConnection 2024. 6. 29.

개발 환경

  • React
  • Vite
  • TypeScript

사용 라이브러리

  • TanStack Query
  • Supabase
  • React-Intersction-Observer

패키지 설치

yarn add @tanstack/react-query @supabase/supabase-js react-intersection-observer

 

단, supabase 셋업은 완료된 상태로 가정함.

데이터 fetch 함수 작성

supabase에서 어떤 데이터를 가져올 것이고, 데이터를 스크롤 한 번당 얼마나 끊어서 가져올 것인지 설정하는 함수이다.

별도 컴포넌트로 작성해도 되고, useInfiniteQuery를 작성한 커스텀 훅 바로 위에서 작성해도 좋다.

아니면 fetch만 모아 놓은 api.ts 같은 파일에 다른 fetch 함수들과 모아서 작성해도 좋다.

import supabase from '../api/supabase';

export interface Sponsor {
  uuid: string;
  serielnumbers: string;
  name: string;
  datetime: string;
  amounts: number;
}

const ITEMS_PER_PAGE = 10;

const fetchSponsorData = async (page: number): Promise<Sponsor[]> => {
  const start = page * ITEMS_PER_PAGE;
  const end = start + ITEMS_PER_PAGE - 1;

const { data, error } = await supabase
    .from('bankstatement')
    .select('uuid, serielnumbers, name, datetime, amounts')
    .eq('transactiontype', '후원')
    .order('datetime', { ascending: false })
    .range(start, end);

  if (error) {
    console.error('Error fetching data:', error);
    throw error;
  }

  return data ?? [];
};
  • export interface Sponsor ...
    • supabase에서 가져오는 컬럼의 타입을 지정한다.
  • const ITEMS_PER_PAGE = 10;
    • 한 번 스크롤이 당겨질 때 fetch로 가져올 데이터의 개수이다.
    • 첫 화면에서 부터 데이터 10개를 가져온다는 것은 아니다. 첫 화면에서는 스크롤이 끝나는 곳까지 데이터를 꽉 채워서 가져온다. 즉 가져오는 데이터가 렌더링 되어서 화면을 얼마나 채우는 지에 따라 첫 데이터의 양은 달라진다.
  • const fetchSponsorData = async (page: number): Promise<Sponsor[]> => { ... }
    • page라는 페이지 번호 매개 변수를 받아서 그 페이지 번호의 데이터들을 fetch 하는 함수이다.
    • 데이터를 fetch 하는 함수는 Promise를 반환하고, 제네릭으로 위에서 지정한 데이터 반환 값의 타입을 배열로 지정한다.
  • const start = page * ITEMS_PER_PAGE; 
    • 매개 변수로 받은 page, 즉 페이지 번호를 이용해서 가져올 데이터의 시작 인덱스를 계산한다.
    • 예를 들어 page에 0이 전달되면 start는 0이 되고, page가 1이면 start는 10이 된다. 그리고 page가 2가 되면 start는 20이 된다.
    • ITEMS_PER_PAGE는 맨 위에서 10으로 설정했다. (스크롤 한 번 당겨질 때 가져올 데이터 수)
  • const end = start + ITEMS_PER_PAGE - 1;
    • 데이터의 마지막 인덱스를 계산한다. end는 start에서 ITEMS_PER_PAGE를 더하고 여기서 1을 뺀 값이다.
    • page가 1로 전달되어서 start가 위에서 계산을 끝내서 10이 되면 (10 + 10 - 1 = 19) 19가 된다. 즉 start 10 ~ end 19면 데이터를 10개 가져오는 게 된다.
  • 즉 위 start, end는 스크롤이 한 번 당겨질 때 가져올 데이터의 개수를 계산하는 로직이다. page라는 매개 변수는 useInfiniteQuery를 정의한 부분에서 queryFn에서 전달된다. 초기값은 0으로 설정했다. 일반적으로는 0이 아닌 다른 숫자로 건너 뛸 이유가 없다.
// useInfiniteQuery

queryFn: ({ pageParam = 0 }) => fetchSponsorData(pageParam)
  • const { data, error } awiat supabase ... : supabase에서 데이터를 가져오는 supabase 내장 메서드이다.
    • .from('bankstatement') : bankstatement 데이터 테이블을 지목한다.
    • .select('uuid, serielnumbers, name, datetime, amounts') : 그 테이블에서 이 컬럼들만 꺼내온다.
    • .eq('transactiontype', '후원') : 그런데 조건이 있다. transactiontype 컬럼의 값이 '후원'인 것들만 위 컬럼들을 꺼내온다.
    • .order('datetime', { ascending: false }) : datetime이라는 컬럼은 날짜가 입력된 컬럼인데, 최신 데이터가 먼저 fetch 되도록 내림차순으로 데이터를 꺼내온다.
    • .range(start, end) : 특정 인덱스만 꺼내오는 메서드이다. start 에서 end 까지만 꺼내온다. 이 start와 end는 위에서 계산했다.
  • if (error) ... : API 통신 함수는 서버 오류든 어떤 오류든 항상 에러 처리를 해주어야 한다. 통신 중 에러가 발생하면 에러를 throw 던진다.
  • return data ?? []; : 가져온 데이터가 null일 수 있으니 이런 경우 [] 빈 배열을 반환한다. 이 에러 처리를 안 하면 null 이 반환되면서 코드에서 에러가 발생한다.

TanStack Query 무한 스크롤 구현 (useInfiniteQuery 훅)

위에서 작성한 데이터 fetch 함수를 이용해서 TanStack Query에서 제공하는 useInfiniteQuery 훅으로 무한 스크롤 기능을 구현하는 커스텀 훅을 작성한다.

import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchSponsorData, Sponsor } from '../api/supabase';

export const useSponsorList = () => {
  return useInfiniteQuery({
    queryKey: ['sponsorList'],
    queryFn: ({ pageParam = 0 }) => fetchSponsorData(pageParam),
    getNextPageParam: (lastPage, allPages) => {
      if (lastPage.length === 0) return null;
      return allPages.length;
    },
    select: (data) => data.pages.flat(),
    staleTime: Infinity,
  });
};
  • return useInfiniteQuery({ ...
    • useInfiniteQuery 훅을 호출하여 코드 블럭 내부에서 무한 스크롤 쿼리 로직을 작성하고 useSponsorList라는 커스텀 훅 이름을 작명하여 내보낸다. 이렇게 내보내면 다른 컴포넌트에서 사용할 수 있다.
  • queryKey: ['sponsorList']
    • 쿼리키는 이 쿼리를 식별하는 이름이다. 지금은 중요한 게 아니고, 이 커스텀 훅을 사용하는 다른 컴포넌트에서 CUD가 발생해서 데이터를 무효화시키거나, 캐싱하여야 할 때 이 이름이 필요하다.
  • queryFn : ({ pageParam = 0 }) => fetchSponsorData(pageParam)
    • queryFn은 데이터를 가져오는 함수, 아까 만들어 둔 함수를 지정한다.
    • 그런데 그냥 함수만 실행할 거면 함수 이름만 작성해도 되지만, 아까 만들어 둔 함수에서는 page라는 매개 변수를 받게끔 했었다. 따라서 화살표 함수로 표현식 작성을 해서 매개 변수를 설정해준다.
    • 여기서 이름이 pageParam으로 했지만 위에서 말한 page가 맞다.
    • 초기값은 0으로 한다. 그래야 데이터를 순서대로 잘 불러온다. 1이 아니라 0인 이유는, 사람이 숫자를 셀 때는 1부터 시작하지만 컴퓨터에서는 0이 1번이다.
  • getNextPageParam: (lastPage, allPages) => { ... }
    • 다음 페이지의 번호를 반환하는 함수이다. TanStack Query에서 제공하는 useInfiniteQuery 훅을 사용하려면 설정해줘야 하는 내용이다.
    • 매개 변수에서 lastPage는 마지막으로 가져온 페이지의 데이터이고, allPages는 지금까지 가져온 모든 페이지의 데이터 배열이다.
  • if (lastPage.length === 0) return null;
    • 마지막으로 가져온 페이지, 이 배열의 길이가 0인지 확인한다.
    • 이 배열의 길이가 0이라는 것은 더 이상 가져올 데이터가 없음을 의미한다. 이 때는 null을 return 해버리고 무한 스크롤(데이터 fetch)를 중단한다.
  • return allPages.length;
    • if문이 아닌 경우는 마지막으로 가져온 페이지가 있을 경우인데, 만약 지금까지 가져온 페이지의 개수가 3개면, allPages.length는 3이 되고, 이것을 return한다.
    • 즉 if문을 다시 살펴보면 마지막으로 가져온 페이지가 더이상 없으면, 더 가져올 데이터가 없는 것이니 getNextPageParam 즉, 다음 페이지 번호를 null로 할당해서 더이상 가져올 데이터가 없다고 전달하고 가져올 데이터가 있으면 지금까지 가져온 페이지의 개수인 allPages.length로 전달해서 getNextPageParam, 즉 다음 페이지 번호로 전달한다.
  • select: (data) => data.pages.flat(),
    • select 옵션은 TanStack Query에서 useInfiniteQuery 훅을 사용하는 경우 데이터를 원하는 형태로 변환할 수 있게 해주는 옵션이다.
    • data.pages는 useInfiniteQuery훅에서 제공하는 데이터 구조이다. 배열의 배열 형태로 되어 있다.
    • 그리고 flat() 메서드는 이러한 2차원 배열을 1차원 배열로 평평하게 펴 주는, 즉 평탄화 해주는 JS 배열 메서드이다.
data.pages = [
  [0번째 데이터, 1번째 데이터, 2, 3, 4, 5, 6, 7, 8, 9], // 첫 번째 페이지의 데이터 배열
  [10번째 데이터, 11번째 데이터, 12, 13, 14, 15, 16, 17, 18, 19], // 첫 번째 페이지의 데이터 배열
]
const 2차원배열 = [
  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], // 첫번째 페이지 데이터
  [10, 11, 12, 13, 14, 15, 16, 17, 18, 19], // 첫번째 페이지 데이터
];

const 1차원배열변환된배열 = 2차원배열.flat();
// [0, 1, 2, ... 18, 19]
  • staleTime: Infinity
    • TanStack Query에서 데이터를 쿼리(불러오기)할 때, 데이터를 얼마나의 시간 뒤에 stale하다고 볼 것이냐는 설정이다.
    • 무슨 말이냐면, TanStack Query에서는 데이터의 상태를 크게 Fresh, Stale 상태 두 개로 나눠서 구분하는데, Fresh는 단어 그대로 서버에서 사용자에게 전달된 데이터가 서버와 같은 상태, 즉 신선한 상태이기 때문에 데이터를 새로 받아와봤자 의미가 없으니 사용자가 fetch 요청을 하더라도 사용자에게 캐싱한 데이터를 꺼내올 뿐 서버로 요청을 날리지는 않겠다는 의미이다. 반대로 stale하다는 의미는 단어 그대로 서버에서 가져온 데이터가 사용자에게 전달되고 나서 썩었다는 이야기이다. 이 옵션을 넣지 않게 되면 staletime의 기본 값은 0이기 때문에 서버에서 데이터를 가져 오자마자 데이터가 썩었다고 보고, 사용자가 fetch 요청을 하면 하는 대로 서버에 데이터 요청을 하게 된다.
    • 잘 판단해서 사용하면 되겠고, 본인은 어차피 서버에서 자주 바뀌는 데이터가 아니기 때문에 CUD가 발생하지 않는 한 데이터를 항상 Fresh하다고 보고 사용자에게 캐싱 해버려서 서버 트래픽을 아끼고자 이런 설정을 하였다.

전체 로직 설명

로직이 다소 복잡해보인다. 따라서 예시를 들어서 설명하겠다.

 

  • 여기서 작성한 useInfiniteQuery 훅을 가져다 쓴 컴포넌트에서 사용자로부터 첫번째 데이터 fetch 요청이 발생한다.
  • pageParam의 초기값이 0이기 때문에 이 커스텀 훅이 동작하면서 queryFn의 fetch 함수인 pageParam이 0으로 전달된다.
  • getNextPageParam 함수가 동작하면서 lastPage와 allPages를 이용하여 다음 페이지 번호를 계산하여 전달한다.
  • fetchSponsorData(0)이 전달되면서 supabase에서 0번째 페이지의 데이터를 가져온다.
  • 이어서 start가 0으로 할당되고, end가 9로 할당되면서 이게 0번째 페이지를 구성한다.
  • 두번째 호출에서는 getNextPageParam에 의해 fetchSponsorData(1)이 전달되면서 위 로직을 반복한다.

무한 스크롤을 적용할 컴포넌트 작성

위에서 작성한 무한스크롤 커스텀 훅을 그냥 import 해서 사용하면 아름다운 결말이겠지만, 약간의 설정을 해주어야 한다.

react-intersection-observer를 사용해서 스크롤이 어디까지 도달했는지 측정해야 그 시점에서 커스텀 훅을 실행할 수 있다.

만약 라이브러리를 사용하기 싫으면 자바스크립트로 intersection-observer를 구현하는 MDN 문서를 참고해서 직접 제작해도 좋다.

import { useSponsorList } from '../hooks/useSponsorList';
import { Link } from 'react-router-dom';
import { useCommaFormat } from '../hooks/useCommaFormat';
import LoadingSpinner from '../components/Loading';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';

const SponsorList = () => {
  const { ref, inView } = useInView({ threshold: 0 });
  const { data: sponsors, isFetching, fetchNextPage, hasNextPage, error } = useSponsorList();

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

  if (error) return <div>Error loading data: {error.message}</div>;

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8 text-center">후원자 목록</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {sponsors && sponsors.map((sponsor) => (
          <Link to={`/sponsorlist/detail/${sponsor.uuid}`} key={sponsor.uuid}>
            <div className="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 ease-in-out transform hover:scale-105">
              <h2 className="text-xl font-semibold mb-2">{sponsor.name}</h2>
              <h2 className="text-xl font-semibold mb-2 text-pastelRed">{useCommaFormat(sponsor.amounts)}원</h2>
              <p className="text-gray-700">{sponsor.serielnumbers}</p>
              <p className="text-gray-700">{new Date(sponsor.datetime).toLocaleDateString()}</p>
            </div>
          </Link>
        ))}
        {isFetching && <LoadingSpinner />}
        <div className='h-5' ref={ref}></div>
      </div>
    </div>
  );
};

export default SponsorList;
  • const { ref, inView } = useInView({ threshold: 0 });
    • react-intersection-observer에서 제공하는 훅이다.
    • 사용자가 스크롤 할 때 요소가 보이는지 감지하는 훅이다.
    • InView 훅에서 ref를 하나 더 꺼내 왔는데, 밑에 JSX를 렌더링 하는 부분에서 빈 div에 ref를 일정 크기(여기서는 tailwind h-5 높이)의 영역에 참조를 걸어서 그 요소가 보이는지 체크한다.
    • 즉 화면 맨 아래에 스크롤이 도달했는지 체크하는 메서드이다.
  • const { data: sponsors, isFetching, fetchNextPage, hasNextPage, error } = useSponsorList();
    • 아까 위에서 만든 useInfiniteQuery 훅이다. 여기서 제공하는 값들 중에서 저것들이 다 필요하다.
    • data: sponsors : API 요청 결과 반환되는 값이다. data라는 이름은 헷갈리니 sponsors 바꿔서 배열을 사용하겠다.
    • isFetching: 데이터가 로딩 중인지 확인시켜준다.
    • fetchNextPage: 다음 페이지의 데이터를 가져오는 함수이다.
    • hasNextPage: 더 가져올 페이지가 있는지 여부를 나타낸다.
    • error: API 요청 중 에러가 발생하면 그 에러의 내용을 반환해주는 것이다.
  • if (inView && hasNextPage) {fetchNextPage();}
    • inView && hasNextPage는, ref를 걸어 둔 요소가 화면에 보이고, 더 가져올 페이지가 있는 경우에만 fetchNextPage() 함수를 실행하라는 의미이다.
    • 그런데 이 로직은 useEffect 훅으로 감싸져 있고, 의존성 배열에는 inView, hasNextPage, fetchNextPage가 있는데, 이것들의 값이 변경될 때마다 다시 실행시킨다.
      • inView는 불리언 값이고, ref를 걸어 둔 요소가 뷰포트 내에 있는지 여부를 나타낸다.
        • 사용자가 스크롤을 내려서 ref를 걸어 둔 빈 div가 화면에 들어오면 true로 바뀌고, 그러면 fetchNextPage 함수가 발동한다. 그러면서 다음 페이지 번호를 넘기면서 다시 10개 데이터를 불러와서 이 빈 div가 화면 밖으로 밀려난다. 그러면서 다시 값이 false로 바뀌고, 또 스크롤을 내리면 true로 바뀌고, 이런 과정을 반복하면서 이 함수는 계속 실행된다.
      • hasNextPage는 불리언 값이고, 다음 페이지가 더 있는지 여부를 나타낸다.
        • 이 불리언 값이 의존성 배열에 들어가면 다음 페이지, 즉 더 불러올 데이터가 있을 때만 fetch 함수가 실행된다.
  • {isFetching && <LoadingSpinner />}
    • 이제부터 return문의 내용이다. &&은 표현식에서 if문과 같은 의미임. 좌항이 true면 우항을 반환하는 것.
    • 즉, isFetching, 데이터를 가져오는 중이면 미리 제작 해 놓은 로딩 스피너를 띄우라는 의미임.
    • 이건 옵셔널한 설정임.
  • <div className='h-5' ref={ref}></div>
    • return문에서는 이 부분이 핵심이다. inView에 이용되는 화면 최하단의 빈 div이다. h-5가 이 div의 크기인데 tailwind라서 그렇고, 필요에 따라서 크기를 설정한다. intersection observer로 ref를 거는 것이 핵심이다.
    • 그러면 이 ref가 사용자의 뷰포트에 진입하면 불리언 값을 바꾸면서 위에서 작성한 로직이 유기적으로 동작한다.

무한스크롤 구현 순서 의사코드 정리

  • 패키지 설치
    • TanStack Query, react-inersection-observerm supabase(본인의 경우)
  • 데이터 가져오는 함수 작성
    • 서버의 데이터 반환 값 타입 정의(본인의 경우 interface)
    • 데이터 fetch 함수 작성
      • 페이지 번호를 받아서 해당 페이지의 데이터를 가져오는 함수
  • 무한스크롤 useInfiniteQuery 커스텀 훅 작성
    • 굳이 커스텀 훅은 아니어도 됨. 그냥 바로 사용할 컴포넌트에서 작성해도 됨. 하지만 본인의 경우 컴포넌트가 지저분해지는 것이 싫어서 커스텀 훅.
    • queryKey, queryFn, getNextPageParam, select, flat 작성
  • 무한스크롤 구현 할 컴포넌트 작성
    • 커스텀 훅으로 데이터 불러오기.
    • intersection observer 설정. useInView훅 (빈 div 화면 맨 아래에서 ref 걸어 활용)

 

댓글