ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Next.js] 커서 기반 무한 스크롤 구현 (useSWRInfinite)
    개발공부/Next.js 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
Designed by Tistory.