Intersection observer와 useSWRInfinite를 이용해 무한스크롤 구현하기 | 스데브 개발일지6

dana·2022년 1월 31일
6

velog clone coding

목록 보기
8/11
post-thumbnail

✨ 무한 스크롤 구현

마이 페이지의 데이터들을 useSWR로 받아 무한스크롤로 연결하는 작업을 진행했다. 목업데이터로 무한 스크롤을 팀원들이 Intersection observer API을 이용해 구현해주셨길래, Intersection Observer API를 공부하다가 뷰포트에 대한 이해가 부족해 뷰포트에 대한 공부를 먼저 하게 됐다.

브라우저 뷰포트

브라우저에서 뷰포트 == 화면 크기
디바이스 크기와 브라우저마다 계산되는 영역이 달라 같은 웹페이지라도 환경에 따라 다른 결과값을 얻는다.
visual viewport vs Layout Viewport
기기의 환경에 넓이값을 맞추기 위해 <meta> 태그를 사용해야 한다.

<meta name="viewport" content="width=device-width, initial-scale=1.0">
  • width=device-width : 기기의 넓이에 따라 넓이 설정
  • initial-scale=1.0 : 페이지가 처음 로드될 때의 초기 줌 레벨
  • minimum-scale : 줄일 수 있는 최소 크기를 지정
  • maximum-scale : 늘릴 수 있는 최대 크기를 지정
  • user-scalable : 사용자가 화면을 축소 및 확대 가능하도록 지정 (yes / no)

뷰포트보다 웹 문서가 큰 경우, 스크롤이 생성되며 스크롤을 이용해 뷰포트를 이동시킬 수 있다.

viewport 계산

document.documentElement.clientWidth / clientHeight : 문서의 viewport 크기
window.innerWidth / innerHeight : 브라우저 viewport 의 스크롤 포함 크기 📌 미디어쿼리에서 사용되는 넓이
window.outerWidth / outerHeight : 브라우저 창 크기
document.documentElement.offsetWidth / offsetHeight : 문서의 크기

Layout viewport / visual viewport

Layout viewport : 사용자의 액션에 영향을 받지 않는 고정된 화면 - position 값의 기준이 됨.
Visual viewport : 사용자의 액션에 영향을 받는 유동적인 화면

참고 : 브라우저 뷰포트 (layout 와 visual viewport) 간단 정리하기

Intersection Observer API

intersection observer api는 타겟 요소와 함께 타겟 요소의 조상 요소나 최상위 document의 뷰포트가 상호작용을 일으키면 비동기적으로 변화를 관찰하는 기능이다.

  • 페이지 스크롤 시 lazy-loading으로 이미지나 콘텐츠를 불러올 때
  • 무한 스크롤 구현 시 (사용자가 스크롤 할 때마다 더욱 많은 데이터들을 불러올 때)
  • 광고 수익을 계산하기 위해 광고의 가시성을 보고해야 할 때
  • 유저가 결과 확인 여부에 따라 애니메이션의 실행이나 퍼포먼스가 결정될 때

호환성


기존에는 사파리에서 지원하지 않았지만, 최근 버전에서는 지원하고 있는 것을 확인할 수 있다.

scroll event

기존 scroll event를 사용하는 경우
1. 단시간에 호출이 너무 많이 발생
2. 동기적으로 실행되어 main thread에 영향
3. 한 페이지 내에 여러 scroll 이벤트가 우르르 발생할 수 있음
- 디바운싱(Debouncing)과 쓰로틀링(Throttling)을 통해 개선 가능
4. 특정 지점 관찰을 위해 getBoundingClientRect() 함수 사용 -> reflow를 발생

다음과 같은 단점들을 보완하기 위해 intersetionObserver api 사용을 권장한다.

사용 방법

const io = new IntersectionObserver(callback, [options])
callback : 타겟 엘리먼트가 교차되었을 때 실행할 함수
- entries : IntersectionObserverEntry 객체의 리스트. 배열 형식으로 리턴
- observer : 콜백함수가 호출
options
- root : default = null(브라우저의 viewport)
교차영역의 기준이 될 element. observe의 대상이 될 element는 반드시 root의 하위 element여야 함
- rootMargin : default = 0px 0px 0px 0px
root element의 margin값
- threshold : default = 0
0 ~ 1 사이의 숫자 입력값으로, 타겟 엘리먼트에 대한 교차 영역 비율.
타겟 엘리먼트가 어느 비율 이상 교차영역에 진입했을 때 observer을 실행할 것인지를 결정

IntersectionObserverEntry의 속성반환값
.boundingClientRect타겟 엘리먼트의 정보
.rootBoundsroot 엘리먼트의 정보 반환
.intersectionRect교차된 영역의 정보 반환
.intersectionRatio교차영역에 타겟 엘리먼트가 얼마나 교차했는지 비율(0~ 1.0) 으로 반환)
.isIntersecting타겟 엘리먼트가 교차 영역에 있는지 boolean 반환
.target타겟 엘리먼트 반환
.time교차된 시간 반환

위의 성질들을 모두 알 필요는 없다. 제일 중요한 건 target에 도달했을 때, 정해진 함수가 실행된다 는 점이다. 자바스크립트에서는 돔 객체를 직접 observer에 다음과 같이 등록한다. 등록된 객체가 뷰포트에 threshold에 설정한 비율 이상 등장하게 되면, 지정했던 함수가 실행된다고 이해하면 된다. 리액트에서 사용하는 방법은 아래 실행착오와 함께 설명하도록 하겠다.

// IntersectionObserver 를 등록한다.
const io = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    // 관찰 대상이 viewport 안에 들어온 경우 
    if (entry.intersectionRatio > 0) {
      console.log("in")
    }
    else {
      console.log("out")
    }
  })
})

// 관찰할 대상을 선언하고, 해당 속성을 관찰시킨다.
const boxElList = document.querySelectorAll('.box');
boxElList.forEach((el) => {
  io.observe(el);
})

useSWRInfinite

useSWR을 이용해 무한 스크롤을 구현하기 위해 검색하던 중, SWR공식 사이트에서 무한 스크롤과 페이지네이션을 위한 함수를 만들어 제공한다는 사실을 알게되었다.
공식 문서 읽어보기

syntax

import useSWRInfinite from 'swr/infinite'

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

SWR 0.x버전을 이용중이라면 import { useSWRInfinite } from 'swr'로 불러와야한다고 한다.

useSWRInfinite의 리턴값을 하나하나 살펴보면
data : 데이터를 리턴해준다.
error : 에러 발생시 에러를 리턴해준다.
isValidating : 데이터를 다운받는 중인지 확인해준다. true면 로딩중 false면 완료
size : 페이지 인덱스를 나타낸다
setSize : 페이지 인덱스를 변경해주는 함수이다.

sizesetSize는 useState를 생각하면 쉽다. 페이지 인덱스 값을 이용하기 위해선 size를 이용하고, 페이지 인덱스를 변경하고 변경된 값을 data로 받기 위해선 setSize를 이용하면 된다.

getKey는 api의 주소를 입력해주는 부분이고, fetcher는 request를 보내는 fetch 함수를 의미한다.

const getKey = (pageIndex, previousPageData) => {
  if (previousPageData && !previousPageData.length) return null // reached the end
  return `/users?page=${pageIndex}&limit=10`                    // SWR key
}

api별로 페이지 리밋과 페이지를 나누는 기준이 다르니 양식을 꼭 확인해보는 것이 좋다.
strapi의 경우, 페이지 인덱스를 pagination[page] 으로 페이지 별 데이터 수는 pagination[pageSize] 를 이용해서 받아오게 된다.
${API_ENDPOINT}/${path}?pagination[page]=${pageIndex}&pagination[pageSize]=5

실행 착오

시도 11

  const { theme } = useContext(ThemeContext)

  const [isLoaded, setIsLoaded] = useState(false)
  const [pageIndex, setPageIndex] = useState(1)
  const [loadedData, setLoadedData] = useState([])

  const { data, error, isValidating } = useSWR(
    `${API_ENDPOINT}/posts?pagination[page]=1&pagination[pageSize]=${pageIndex * PAGE_SIZE}`,
    fetcher
  )

  const onIntersect: IntersectionObserverCallback = async (
    [entry],
    observer
  ) => {
    if (entry.isIntersecting && !isValidating) {
      if (pageIndex < 7) {
        observer.unobserve(entry.target)
        console.log(data.data)
        setPageIndex(pageIndex + 1)
        setLoadedData(loadedData.concat(data.data))
      }
    }

    console.log("end function")
  }

  const { setTarget } = useIntersectionObserver({
    root: null,
    rootMargin: "0px",
    threshold: 0.5,
    onIntersect,
  })
  

기존에 만들어놓은 무한 스크롤에서 useSWR을 이용해 만들었던 무한스크롤.

무한 스크롤처럼 데이터를 받아오나, 항상 스크롤이 맨 위로 올라가 다시 내려가야하는 문제 발생.
게다가 재로딩되는 화면이 떠서 현재 코드에 잘못된 부분이 있다고 느꼈다.

게다가 useSWRInfinite를 사용하지 않아 일일히 데이터를 더해줘야하는 불편함도 있었다.

시도 22

const { theme } = useContext(ThemeContext)

const [target, setTarget] = useState<HTMLElement | null | undefined>(null)
const isLoaded = useRef(false)

const getKey = (pageIndex: number, previousPageData: any) => {
  if (previousPageData && !previousPageData.data) return null
  return `${API_ENDPOINT}/posts?pagination[page]=${pageIndex}&pagination[pageSize]=${PAGE_SIZE}`
}

const { data, size, setSize, error, isValidating } = useSWRInfinite(
  getKey,
  fetcher
)

const isLoadingData = !data && !error
const isLoadingMore = isLoadingData || (size > 0 && data && !data[size - 1])

const onIntersect: IntersectionObserverCallback = ([entry], observer) => {
  if (entry.isIntersecting) {
    if (size < 7) {
      observer.unobserve(entry.target)
      isLoaded.current = true
      console.log(isLoaded.current)
      setSize((prev) => prev + 1)
      console.log(size)
      isLoaded.current = false
      console.log(isLoaded.current)
    }
  }
}

useEffect(() => {
  if (!target) return
  const observer = new IntersectionObserver(onIntersect, {
    threshold: 0.4,
  })
  observer.observe(target)
  return () => observer && observer.disconnect()
}, [target])

useSWRInfinite를 사용했지만, 다음과 같이 Rerendering이 원하는대로 되지 않고, setSize에서 멈춰 원하는대로 렌더링이 값이 바뀌지 않고 강제로 리렌더링 시켜야 데이터가 변하는 오류가 발생했다.
(원래라면 true하고 값이 바뀌고 false가 되어야함..🥲)

엄청난 구글링 끝에 혹시 useSWR자체의 문제가 아닐까 싶어 이슈에 들어가보았다. 나와 비슷한 오류를 겪은 사람의 조언(?)이 있길래 들어가봤더니, 다음과 같이 작성해보라는 충고가 있었다.

const [shouldLoadMore, setShouldLoadMore] = useState(false)

  useEffect(() => {
    if (shouldLoadMore) setSize((size) => size + 1)
    isLoaded.current = false
  }, [shouldLoadMore])

  const onIntersect: IntersectionObserverCallback = async ([entry]) => {
    if (entry) {
      isLoaded.current = true
      setShouldLoadMore(entry.isIntersecting)
    }
  }

  useEffect(() => {
    if (!target) {
      isLoaded.current = false
      return
    }
    const observer = new IntersectionObserver(onIntersect, {
      threshold: 0.4,
    })
    observer.observe(target)
    return () => observer && observer.disconnect()
  }, [target])

이렇게 실행하니 제대로 돌아갔다!!

그래서 어떻게 사용했는데?

저렇게 답을 찾은게 새벽 3시.. 자려고 누웠는데 문득 생각이 났다..

왜 그동안 내가 실행했던 코드에서는 에러가 났으며, 왜 위의 코드는 돌아가는 걸까.. unobserve 코드가 위 코드에서는 작성되지 않았는데.. 그럼 설마..? 하는 생각과 함께 새벽 4시에 다시 컴퓨터를 켰고..

✨ useSWRInfinite + IntersectionObserver + strapi ✨

const [target, setTarget] = useState<HTMLElement | null | undefined>(null)

    const getKey = (pageIndex: number, previousPageData: any) => {
        if (previousPageData && !previousPageData.data) return null
        return `${API_ENDPOINT}/posts?pagination[page]=${pageIndex}&pagination[pageSize]=${PAGE_SIZE}`
    }

    const { data, size, setSize, error, isValidating } = useSWRInfinite(
        getKey,
        fetcher
    )

    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 onIntersect: IntersectionObserverCallback = ([entry]) => {
        if (entry.isIntersecting && !isReachingEnd) {
            setSize((prev) => prev + 1)
        }
    }

    useEffect(() => {
        if (!target) return
        const observer = new IntersectionObserver(onIntersect, {
            threshold: 0.4,
        })
        observer.observe(target)
        return () => observer && observer.disconnect()
    }, [target])

onIntersect부분에 그냥 원하는 함수만 넣어주면 되는 것이었다.. observer는 어처피 변하지 않으니 굳이 지웠다가 재선언할 필요가 없었다..

쏘 깔끔해진 코드..

    const onIntersect: IntersectionObserverCallback = ([entry]) => {
        if (entry.isIntersecting && !isReachingEnd) {
            setSize((prev) => prev + 1)
        }
    }

한 100번 수정했던 모든 코드들이 다 unobserve 때문에 생긴일이라는 것을 깨닫게 되었다.
observe는 지웠다 넣었다 했는데 왜 unobserve를 지울 생각을 못했을까..

✍️ 오늘의 교훈

오류가 났을 땐 한줄 한줄 이 코드가 하는 역할이 무엇인지 생각해보자.

내 사라진 일주일 돌려내..

부록

혹시 fetcher함수를 궁금해하실 분들이 있을까봐

import axios from "axios"

export const fetcher = (url: string) => axios.get(url).then((res) => res.data)

axios를 사용한 fetcher를 놓고갑니다,, 혹시 코드에 대해 어려운 점이나 질문이 있으시면 언제든 댓글 남겨주세요! :)

profile
PRE-FE에서 PRO-FE로🚀🪐!

4개의 댓글

comment-user-thumbnail
2022년 2월 3일

좋은 글 잘 읽었습니다. 항상 감사합니다!

1개의 답글
comment-user-thumbnail
2022년 7월 8일

callback 함수 표현 특히 깔끔하네요! 참고해서 잘 적용시켰습니다. 좋은 글 감사합니다!

1개의 답글