SWR과 Intersection Observer API로 무한 스크롤 구현하기

정민·2022년 12월 9일
1

😵이번 글은 무한 스크롤을 구현하기 위한 일대기를 담은 포스팅입니다…

프로젝트 Github : https://github.com/boostcampwm-2022/Web02-XOXO

우리는 SWR 을 통해 무한 스크롤을 구현해 볼 것이다. 그러기 위해 꼭 알아야하는 useSWRInfinite 에 대해 우선 정리해보자.

useSWRInfinite

공식문서

기본 문법

import useSWRInfinite from 'swr/infinite'

// ...
const { data, error, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite(
  getKey, fetcher?, options?
)

반환 값

data

각 페이지의 응답 값의 배열

error

useSWR과 동일

isLoading

useSWR과 동일

isValidating

useSWR과 동일

mutate

useSWR과 동일

size

가져올 페이지, 반환될 페이지의 수

setSize

가져와야 하는 페이지의 수를 설정

파라미터

getKey

인덱스와 이전 페이지 데이터를 받고, 페이지 키를 반환하는 함수

만약 API 호출의 response가 아래의 형태라면

GET /users?cursor=123&limit=10
{
  data: [
    { name: 'Alice' },
    { name: 'Bob' },
    { name: 'Cathy' },
    ...
  ],
  nextCursor: 456
}

getKey함수는 이렇게 정의할 수 있다.

const getKey = (pageIndex, previousPageData) => {
  // 끝에 도달
  if (previousPageData && !previousPageData.data) return null

  // 첫 페이지, `previousPageData`가 없음
  if (pageIndex === 0) return `/users?limit=10`

  // API의 엔드포인트에 커서를 추가
  return `/users?cursor=${previousPageData.nextCursor}&limit=10`
}

fetcher

useSWR과 동일

options

useSWR과 동일

사용 방법

흐름은 다음과 같다.

setSizegetKeyfetcher

즉,

내가 추가로 데이터를 받아왔으면 하는 곳에 이벤트로 () => setSize(size+1) 를 걸어주자.

그러면 size가 바뀌면서 pageIndex가 +1 된 채로 getKey 함수가 호출 된다. 그러면 새로운 pageIndex 로 데이터가 갱신되게 되는 것이다.

swr 이 보여주고 있는 예제 코드를 통해 자세히 살펴보자!

import React, { useState } from "react";
import useSWRInfinite from "swr/infinite";

const fetcher = (url) => fetch(url).then((res) => res.json());
const PAGE_SIZE = 6;

export default function App() {
  const [repo, setRepo] = useState("reactjs/react-a11y");
  const [val, setVal] = useState(repo);

  const { data, error, mutate, size, setSize, isValidating } = useSWRInfinite(
    (index) =>
      `https://api.github.com/repos/${repo}/issues?per_page=${PAGE_SIZE}&page=${
        index + 1
      }`,
    fetcher
  );

  const issues = data ? [].concat(...data) : [];
  const isLoadingInitialData = !data && !error;
  const isLoadingMore =
    isLoadingInitialData ||
    (size > 0 && data && typeof data[size - 1] === "undefined");
  const isEmpty = data?.[0]?.length === 0;
  const isReachingEnd =
    isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE);
  const isRefreshing = isValidating && data && data.length === size;

  return (
    <div style={{ fontFamily: "sans-serif" }}>
      <input
        value={val}
        onChange={(e) => setVal(e.target.value)}
        placeholder="reactjs/react-a11y"
      />
      <button
        onClick={() => {
          setRepo(val);
          setSize(1);
        }}
      >
        load issues
      </button>
      <p>
        showing {size} page(s) of {isLoadingMore ? "..." : issues.length}{" "}
        issue(s){" "}
        <button
          disabled={isLoadingMore || isReachingEnd}
          onClick={() => setSize(size + 1)}
        >
          {isLoadingMore
            ? "loading..."
            : isReachingEnd
            ? "no more issues"
            : "load more"}
        </button>
        <button disabled={isRefreshing} onClick={() => mutate()}>
          {isRefreshing ? "refreshing..." : "refresh"}
        </button>
        <button disabled={!size} onClick={() => setSize(0)}>
          clear
        </button>
      </p>
      {isEmpty ? <p>Yay, no issues found.</p> : null}
      {issues.map((issue) => {
        return (
          <p key={issue.id} style={{ margin: "6px 0" }}>
            - {issue.title}
          </p>
        );
      })}
    </div>
  );
}

getKey

여기서는 따로 변수로 선언해주지 않고, useSWRInfinite 의 파라미터에 넣어놓았다.

(index) => `https://api.github.com/repos/${repo}/issues?per_page=${PAGE_SIZE}&page=${index + 1}`

여기서 indexsize 와 같다.

만약 어디선가 setSize 를 통해 size 가 변경되면 해당 함수가 호출이 돼서, 변경된 index에 맞는 페이지의 데이터를 요청하게 되는 것이다!

그렇다면 setSize 는 어디에서 하는 것일까…?

setSize

<button disabled={isLoadingMore || isReachingEnd} onClick={() => setSize(size + 1)}>
          {isLoadingMore
            ? "loading..."
            : isReachingEnd
            ? "no more issues"
            : "load more"}
 </button>

load more 버튼을 누르면, size 를 +1 시키고, 그에 따라 getKey 가 호출되는 형식이다.

여기서 보이는 isLoadingMore , isReachingEnd 는 추가로 데이터를 불러오고 있는지(로딩중), 데이터를 끝까지 불러왔는지를 확인하는 변수이다.

데이터 상태 변수들

  const issues = data ? [].concat(...data) : [];
  const isLoadingInitialData = !data && !error;
  const isLoadingMore =
    isLoadingInitialData ||
    (size > 0 && data && typeof data[size - 1] === "undefined");
  const isEmpty = data?.[0]?.length === 0;
  const isReachingEnd =
    isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE);
  const isRefreshing = isValidating && data && data.length === size;
  • issue data와 같음
  • isLoadingInitialData 첫번째 페이지의 데이터를 로딩중인지
  • isLoadingMore 첫번째 페이지를 제외한 데이터를 로딩중인지
  • isEmpty 데이터가 비어있는지
  • isReachingEnd 데이터를 끝까지 다 불러왔는지
  • isRefreshing 새로고침 중인지

Intersection Observer… 니가 뭔데

처음에는, 오로지 scroll 이벤트를 통해 무한 스크롤을 다루고자 했었다.

window.addEventListener('scroll', handleScroll)

를 통해 스크롤 이벤트를 걸어놓고, 해당 변수를 통해 확인해보는 것이다.

그러나 실제 이런 방식으로 구현했을 때, 사용자가 스크롤을 할때마다 계속 이벤트가 호출 되는 현상을 확인할 수 있었고, (물론 이는 디바운스나, 쓰로틀링을 통해 해결할 수 있기는 하다…) 다른 방식으로 구현할 수 있을까 찾아보게 되었다. 그래서 찾게된게 Intersection Observer 이다!

Intersection Observer

공식문서

Intersection Observer 는, target Element가 화면에 노출되었는지 여부를 간단하게 구독할 수 있는 api이다.

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

new 키워드를 통해 인스턴스를 생성하고, callback , options 2개의 파라미터를 받는다.

callback 은 가시성의 변화가 생겼을 때 호출되는 콜백 로직이고, options 는 만들어질 인스턴스에서 콜백이 호출되는 상황을 정의한다.

Intersection Observer options

  • root 타겟 요소의 가시성을 확인할 때 사용되는 루트 요소. 이것은 타겟 요소보다 상위 요소, 즉 요소의 조상 요소이어야 한다. 설정하지 않거나 root 값을 null 로 주었을 때 기본 값으로 브라우저 뷰포트가 설정된다.
  • rootMargin margin을 줘서 루트 요소의 범위를 확장할 수 있다.
  • threshold 타겟 요소의 가시성 퍼센티지를 나타낸다. 즉, 어느정도 타겟 요소가 보여졌는지에 따라서 콜백을 호출할 수 있다. 만약 50%만큼 보여졌을 때 탐지하고 싶다면 0.5를 설정하면 된다!

entries

IntersectionObserverEntry의 인스턴스를 담은 배열이다. IntersectionObserverEntry는 루트 요소와 타겟 요소의 교차의 상황을 묘사한다.

entry는 여러가지 프로퍼티를 가지고 있으며 모두 읽기 전용이다. 여기서 우리가 주로 봐야할 것은

IntersectionObserverEntry.isIntersecting

이 변수이다.

해당 entry 에 타겟 요소가 루트 요소와 교차하는 지 여부를 Boolean 값으로 반환한다.

우리는 해당 변수를 통해 Intersection observerCustom Hook 으로 만들어 재사용해보고자 한다!

Intersection Observer 커스텀 훅 만들기

import { useEffect, useRef } from 'react'

const useInfiniteScroll = (postings: any, callback: () => void) => {
  const bottomElement = useRef(null)
  useEffect(() => {
    if (bottomElement?.current) {
      const io = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            if (entry.isIntersecting) { 
              callback()
            }
          })
        }, {
          threshold: [1.0]
        }
      )
      io.observe(bottomElement.current)
      return () => io.disconnect()
    }
  }, [postings, bottomElement])
  return bottomElement
}

export default useInfiniteScroll

useInfiniteScroll 커스텀 훅을 생성해, posting 이라는 deps array와 callback 을 넘겨준다.

postingdeps 로 넘겨주는 이유는, 새로운 이미지를 받아왔을 때 bottom Element의 위치가 달라지게 되고, 그에 따른 observer를 다시 달아줘야하기 때문에 deps 로 넘겨주었다.

이후 앞서 말했던 isIntersecting 을 확인해, 교차했을 시 파라미터로 받은 callback 을 실행하는 식으로 구현하였다.

실제 사용 코드

const SCROLL_SIZE = 15

const FeedPostingList = ({ isOwner, dueDate, isGroupFeed }: IProps) => {
  const navigate = useNavigate()
  const { feedId } = useParams<{ feedId: string }>()

  const getKey = (pageIndex: number, previousPageData: Iposting[]) => {
    if (previousPageData && !previousPageData.length) return null
    if (pageIndex === 0) return `/feed/scroll/${feedId}?size=${isGroupFeed ? SCROLL_SIZE - 1 : (isOwner ? SCROLL_SIZE : SCROLL_SIZE - 1)}&index=${pageIndex}`
    return `/feed/scroll/${feedId}?size=${SCROLL_SIZE}&index=${previousPageData[previousPageData.length - 1].id}`
  }
  const { data: postings, error, size, setSize } = useSWRInfinite(getKey, fetcher, { initialSize: 1 })

  // 리스트 로딩중일 때
  const isLoading = (!postings && !error) || (size > 0 && postings && typeof postings[size - 1] === 'undefined')
  // 리스트를 정상적으로 받아왔지만 비어있을 경우 (게시글이 작성되지 않았을 경우)
  const isEmpty = postings?.[0]?.length === 0
  // 쓰기 버튼이 존재할때 리스트의 끝에 도달했는지 판단
  const isExistWriteButton = (postings != null) && ((postings.length === 1 && postings[0].length < SCROLL_SIZE - 1) || (postings.length > 1 && postings[postings.length - 1].length < SCROLL_SIZE))
  // 쓰기 버튼이 존재하지 않을 때 리스트의 끝에 도달했는지 판단
  const isNotExistWriteButton = (postings != null) && (postings[postings.length - 1].length < SCROLL_SIZE)
  // 그룹피드, 개인 피드의 주인, 개인 피드의 주인이 아닐 때 리스트의 끝에 도달했는지 판단
  const isReachingEnd = (isGroupFeed && isExistWriteButton) || (!isGroupFeed && !isOwner && isExistWriteButton) || (!isGroupFeed && isOwner && isNotExistWriteButton)

  const bottomElement = useInfiniteScroll(postings, () => {
    !isReachingEnd && setSize(size => size + 1)
  })

  useEffect(() => {
    if (isEmpty) toast('아직 작성된 포스팅이 없습니다')
  }, [isEmpty])

  const postingList = postings?.flat().map((posting: Iposting) => {
    return (
    <button key={posting.id} className="posting-container" onClick={() => handleClickPosting(posting.id)} >
      <img key={posting.id}
        className="posting"
        src={getFeedThumbUrl(posting.thumbanil)}
      />
    </button>
    )
  })

  const writePostingButton =
  <Link className="write-posting-container" to={`/write/${feedId}`}>
    <div className='write-posting-button'>
      <PlusIcon width={'5vw'}/>
    </div>
  </Link>

  return (
    <div className='posting-list-wrapper'>
    <div>
      <div className="posting-grid">
        {isWritabble && writePostingButton}
        {!isEmpty && postingList}
      </div>
    </div>
      {isLoading
        ? <Loading />
        : !isReachingEnd && <div className="bottom-element" ref={bottomElement}>
            <ObserverElement />
          </div>
        }
      <Toast />
    </div>
  )
}

export default FeedPostingList

더 해봐야 할 것

  • lazy loading 과 infinite scroll…
    • 그때 당시 나는 그냥 infinite scroll에 꽂혀있어서, 우선 이것을 구현하는 것에 급급해있었다. 이후 어찌저찌 구현을 완료하고 난 뒤, lazy loading을 시도해보려고 자세히 정보를 찾아보기 시작했을 때, 굳이 무한 스크롤이 적용되어 있는데 lazy loading 까지 도입을 해야하는 것에 대한 의문…? 이 들었다.

      (물론 lazy loading 을 시도해보는 것이 나에게 있어서 엄청나게 좋은 경험이 될 것 같고, 해보고 싶었지만!!, lazy-loading은 모든 이미지를 저화질?(혹은 blur)로 이미 불러와 놓고, 사용자에게 보여질때 제대로 로딩하는 기법이라면, infinite scroll은 일부의 이미지만 불러와 놓고, 사용자에게 보여질때 또다시 요청을 하는 기법이다보니까, 둘이 상충된다는 느낌을 받았었던 것 같다.)

    • 여튼 위의 이유로 접어뒀었는데, 아니나 다를까 발표시간에 해당 사항과 관련된 질문을 듣게 되었다. (infinite scroll만 적용했나요? lazy loading은요?)

    • 이후 내가 잘못 생각한걸까? 두개 다 하는게 맞는걸까..? 아니면 애초부터 그냥 lazy loading 만을 도입했어야 했나…? 하는 생각이 들게 됐고, 다시 한번 자세히 살펴본 뒤 두개를 동시에 진행해보는 방식으로 로직을 조금 수정해보고 싶은 생각이 든다.

참고

https://swimfm.tistory.com/entry/무한-스크롤-Infinite-Scroll-페이징-구현해보기-예제

https://dmdwn3979.tistory.com/9

https://dev.rase.blog/21-12-07-intersection-observer/

https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

https://moon-ga.github.io/react/infinite-scroll-with-intersectionobserver/

https://velog.io/@wlsuddldi/Intersection-Observer와-useSWRInfinite를-이용해-무한스크롤-구현해보기-feat.-Rsuite-Picker

https://velog.io/@minseok_yun/IntersectionObserver로-무한-스크롤-만들기

https://heropy.blog/2019/10/27/intersection-observer/

https://web.dev/rendering-performance/

https://velog.io/@code-bebop/SWR-심층탐구-useSWRInfinite

https://swr.vercel.app/ko/docs/pagination

profile
괴발개발~

0개의 댓글