개발공부/Next.js

[Next.js] 커서 기반 무한 스크롤 구현 (useSWRInfinite)

hani:) 2024. 5. 18. 21:32

본 글은 Next.js에서 useSWRInfinite를 사용해 커서 기반 무한 스크롤을 구현한 내용입니다.

 

목차

페이지네이션: 커서 기반 VS 페이지 기반

무한 스크롤 구현 (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