개발공부/Next.js
[Next.js] 커서 기반 무한 스크롤 구현 (useSWRInfinite)
hani:)
2024. 5. 18. 21:32
본 글은 Next.js에서 useSWRInfinite를 사용해 커서 기반 무한 스크롤을 구현한 내용입니다.
목차
페이지네이션: 커서 기반 VS 페이지 기반
우선 페이지 기반과 커서 기반의 차이점 및 장, 단점에 대해 정리했다.
페이지 기반 (Page-based Pagination)
- 페이지 번호와 오프셋(offset)을 사용해 특정 페이지의 데이터를 가져오는 방식
- 장점
- 구현이 간단 (페이지 번호와 오프셋을 사용해)
- 특정 페이지 번호로 이동하기 때문에 직관적으로 사용 가능
- 많은 라이브러리와 프레임워크에서 기본적으로 지원하는 페이지네이션 방식
- 단점
- 데이터가 많을 수록 비효율적
- 데이터가 빈번하게 변경되는 경우, 데이터가 중복되거나 누락될 수 있음
커서 기반 (Cursor-based Pagination)
- 데이터의 특정 위치를 가리키는 커서를 사용해 다음 페이지의 데이터를 가져오는 방식
- 장점
- 큰 데이터에서 효율적, 페이지 기반과 달리 페이지 중간에 데이터 추가, 삭제 하더라도 영향 X
- 데이터 중복 또는 누락되는 경우가 적음
- 스크롤을 하여 데이터를 연속적으로 불러오는 경우 유리
- 단점
- 구현이 상대적으로 복잡
- 데이터가 특정 순서로 정렬되어 있어야 함
나는 중복되는 데이터를 제거하기 위해 커서 기반 페이지네이션을 선택했다.
이전에 진행했던 프로젝트들은 규모가 크지 않았고, 데이터도 별로 없었고, 추가 및 삭제가 많지 않아 "페이지 기반"으로 무한 스크롤을 구현했었다.
하지만 지금 진행하고 있는 프로젝트의 경우, 데이터도 많고, 추가 및 삭제가 빈번하게 일어날 것으로 추정되어 "커서 기반"을 선택하게 되었다.
무한 스크롤 구현 (useSWRInfinite)
우선 내 기술 스택을 정리하면 아래와 같다.
Next.js (app router), TypeScript, useSwr
1. 설치하기
yarn add swr
2. 사용하기
기본적인 구조는 아래와 같다.
import useSWRInfinite from 'swr/infinite';
const { data, error, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite(
getKey, fetcher?, options?
)
(버전 별로 import 방식이 다르니 참고!)
먼저 파라미터에 대해 정리해봤다.
- getKey
- 페이지의 키를 반환하는 함수
- 인덱스와 이전 페이지 데이터를 받을 수 있음
- fetcher
- API 통신
- options
- useSWR이 지원하는 옵션
- initialSize: 초기에 로드해야 하는 페이지의 수
- revalidateAll (기본 값: false): 항상 모든 페이지의 갱신 시도
- revalidateFirstPage (기본 값: true): 항상 첫 번째 페이지 검증 시도
- persistSize (기본 값: false): 첫 페이지의 키가 변경될 때, 페이지 크기를 1로 초기화하지 않음 (initialSize가 설정된 경우 initialSize)
- parallel (기본 값: false): 여러 페이지를 병렬적으로 동시에 불러옴
반환 값은 아래와 같다.
- data: 각 페이지의 응답 값의 배열
- error: useSWR의 error와 동일
- isLoading: useSWR의 isLoading과 동일
- isValidating: useSWR의 isValidating과 동일
- mutate: useSWR의 바인딩 된 뮤테이트 함수와 동일하지만 데이터 배열을 다룸
- size: 가져올 페이지 및 반환될 페이지의 수
자세한 내용은 공식 문서를 참고하길 바란다.
위의 내용을 참고해 커서 기반 무한스크롤을 구현한 전체 코드는 아래와 같다.
'use client';
import useSWRInfinite from 'swr/infinite';
import { useEffect, useState } from 'react';
const Support = () => {
const [lastCursorId, setLastCursorId] = useState<string>(""); // 마지막 커서 ID
// 1. getKey 설정
// pageIndex: 현재 페이지 번호, previousPageData: 이전에 가져온 데이터
const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.hasNext) return null; // 끝에 도달한 경우 null 반환
// 첫 번쩨 요청에 보낼 url, 처음에 커서 정보를 모르기 때문에 구분 필요!
if (pageIndex === 0) return ''; // 서버 url 입력 필요
setLastCursorId(previousPageData.lastCursorId); // 마지막 커서 ID 저장
// 첫 번째 요청 이후부터 마지막 요청전까지는 아래 url이 return될 예정, 반드시 커서 ID를 실어 보내야 함.
if(Number(lastCursorId) <= Number(previousPageData.lastCursorId)) return ''; // 서버 url 입력 필요
else return null;
}
// 2. useSWRInfinite 사용해 데이터 요청
// fetchSupports는 axios를 사용 (axios.get(url))
const { data, setSize, mutate } = useSWRInfinite(getKey, fetchSupports, {
revalidateOnFocus: true,
shouldRetryOnError: false, // 에러 발생 시 재시도 비활성화
initialSize: 1 // 초기 로드 페이지 수
});
// 3. 불러온 데이터 관리
const [datas, setDatas] = useState<SupportListType[]>([]); // 불러온 데이터 state에 저장
useEffect(() => { // 불러온 데이터 저장
if(data) {
let temps: SupportListType[] = [...datas]; // 현재 데이터 복사
data.forEach((d) => {
d.data.forEach((item: SupportListType) => {
if (!temps.some(existingItem => existingItem.questionId === item.questionId)) {
temps.push(item); // 중복되지 않은 데이터만 추가
}
});
});
setDatas(temps);
}
}, [data]);
// 4. 스크롤 위치가 맨 아래에 도착 시 다음 데이터를 불러오기 위한 정보 셋팅
useEffect(() => {
const handleScroll = () => {
const { scrollTop, clientHeight, scrollHeight } = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 5) {
setSize((prevSize) => prevSize + 1); // size 값이 변경되면 getKey 함수 내 로직 실행
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [setSize]);
return (
<section>
{datas.map((d, index) => (
<Link href={``} key={index} className='h-[81px] p-[16px] border-b border-b-Neutral-L_Grey1 flex justify-between'>
<SupportContent title={d.questionTitle} createdOn={d.createdOn} isAnswer={d.isAnswer}/>
</Link>
))}
</section>);
};
마무리
커서 기반 페이지네이션 구현이 이번이 처음이라서 삽질을 많이 했었다..ㅎㅎ
다른 사람들은 내 코드를 참고해 불필요한 시간을 줄였으면 좋겠다! :)
728x90