Supabase + TanStack Query + Intersection Observer :: infinite scroll 구현
개발 환경
- 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 함수가 실행된다.
- inView는 불리언 값이고, ref를 걸어 둔 요소가 뷰포트 내에 있는지 여부를 나타낸다.
- {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 걸어 활용)
'Programing > React' 카테고리의 다른 글
Restful API Axios로 호출하는 기본 패턴 (useState, useEffect 사용) + strict mode (0) | 2024.07.27 |
---|---|
함수형 컴포넌트 자동완성 VSCode 플러그인 (0) | 2024.07.27 |
React-router-dom을 활용한 페이지 라우팅(디테일 페이지) (0) | 2024.06.27 |
React 에서 자주 쓰이는 if문 패턴 (0) | 2024.06.24 |
TanStack Query 기본 사용법 (0) | 2024.06.24 |
댓글