IntersectionObserver를 활용한 무한스크롤 구현하기

이정수·2024년 9월 25일
0
post-thumbnail

무한스크롤_작은용량

원티드 프리온보딩 챌린지의 사전과제로 intersection observer를 활용하여 무한스크롤 구현하기를 해보았다.
사실 전에 프로젝트에서 이미 react-query의 useInfiniteQuery를 활용하여 만들어본적이 있는데, 빡빡했던 일정탓에 비동기상태 라이브러리에 의존하다보니 아쉬움이 남아있었다.

이번 챌린지를 통해 다시 한번 도전해보려고 신청하게 되었다.

먼저 이번 챌린지의 기능 구현 요구사항은 다음과 같다.

하나의 SinglePage에 Intersection Observer를 이용해 무한스크롤을 구현하세요.
현재 가져온 상품 리스트들의 액수들의 합계를 화면에 보여주세요 (ex. 현재 20개의 상품을 가져온 상태라면 20개 물품의 가격 총 합을 보여주면 됨)

무한 스크롤의 조건
페이지를 현재 보여주는 페이지의 최하단으로 이동 시 다음 페이지 정보를 가져오게 합니다.
더이상 가져올 수 없는 상황이라면 더 이상 데이터를 가져오는 함수를 호출하지 않습니다.
로딩 시 로딩 UI가 보여아 합니다. (UI의 형식은 자유)

과제 유의 사항
React + 함수형 컴포넌트를 사용해서 개발해주세요
제공해드린 Mock 데이터는 수정 및 추가가 가능합니다.
무한스크롤 관련된 라이브러리 사용 절대 금지
비동기 상태 관리 라이브러리 사용 절대 금지 (ex. tanstack-query)
3, 4번 조건 외의 라이브러리는 자유롭게 사용하셔도 됩니다

intersection observer 알아보기

Intersection Observer API는 브라우저 뷰포트와 원하는 요소(element)의 교차점을 관찰하며 요소가 뷰포트에 포함되는지 아닌지를 구별하는 기능을 제공한다.

Intersection Observer는 비동기적으로 실행되어서 메인스레드에 영향을 주지 않으면서 요소들의 변경사항을 관찰 할 수 있다.

MDN에서 말하는 Intersection Observer를 활용가능한 상황은 아래와 같다.

  • 페이지가 스크롤 되는 도중 발생하는 이미지나 다른 컨텐츠의 지연 로딩(lazy loading)
  • 스크롤시 더 많은 컨텐츠가 로드되어 사용자의 페이지 이동을 막아주는 infinite-scroll
  • 광고 수익을 계산하기 위한 용도로의 광고 가시성 보고
  • 사용자에게 결과가 표시되는 여부에 따라 작업이나 애니메이션을 수행할 지의 여부를 결정

데이터 패칭

  const fetchData = async () => {
    console.log(` 👉${page}번째 페이지 데이터 불러오는 중... `)
    setIsLoading(true)
    try {
      const res: IResponse = await getMockData(page)
      if (!res) {
        console.log('오류가 발생했습니다.')
      }
      const newData = res.datas

      const newPriceSum = calPriceSum(newData) //새로운 데이터의 Price합 구함
      setTotalPrice(prev => prev + newPriceSum)
      setData([...data, ...newData])

      setIsMore(!res.isEnd)
    } catch (error) {
      console.error(error)
    } finally {
      setIsLoading(false)
    }
  }

data를 불러올 함수를 먼저 작성했다.

함수내부에서는 데이터를 불러와서 data상태에 넣고, 해당 데이터 내에 있는 price값의 합을 구해서 저장한다.

생성자

intersection observer는 new IntersectionObserver생성자를 통해 인스턴스를 만든다


// new IntersectionObserver(callback, options)

//1. obersver 초기화
  const observer = new IntersectionObserver(entries => { 
    if (entries[0].isIntersecting && !isLoading) {
      if (isMore) {
        setIsLoading(true)
        setPage(prev => prev + 1)
        fetchData()
      } else {
        alert('이제 더는 데이터가 없어요..😭')
      }
    }
  })

나는 options는 설정하지 않고 callback 함수만 작성해주었다.

callback함수는 두가지 인수(entries, observer)를 받을 수 있다.

entries 는 IntersectionObserverEntry의 배열을 뜻하며, 읽기전용의 여러가지 속성들이 있다.

조건문을 통해, 타겟 요소(여기서는 리스트가 랜더링되고, 가장 하단부의 div 요소)가 교차 상태인지 아닌지 확인한다. 또한 로딩상태가 아니고, 더 로딩할 페이지가 남아있는 경우에만 페이지를 더 가져오도록 한다.

등록하고 대상요소 관찰하기

  const target = document.querySelector('#target')

  useEffect(() => {
    if (target) {
      observer.observe(target)
    }

    return () => {
      if (target) {
        observer.unobserve(target)
      }
    }
  }, [isLoading, data])

querySelector를 통해 observer가 관찰할 요소를 target 변수로 불러와서 전달한다.
위에서 만들어둔 IntersectionObserver 인스턴스의 observer 메소드를 사용하여 감시관찰을 시작한다.

trouble shooting

다중 패칭

스크롤을 몇번 내리기 않았는데도, 하단에 붙여준 target이 나타났을 시 옵저버의 콜백함수가 여러번 실행되면서 불러오는 페이지수가 비정상적으로 증가하게 되었다.

  useEffect(() => {
    if (target) {
      observer.observe(target)
    }

    return () => {
      if (target) {
        observer.unobserve(target)
      }
    }
  }, [isLoading, data])

useEffect의 클린업 함수를 통해 관찰자의 연결을 끊어줘서 과도한 호출과 메모리 누수를 방지해줘야 한다.

로딩상태 처리하기

기존 로딩방식을 처리하던 상태는 isLoading값에 따라 특정 컴포넌트를 랜더링 하는 것 이였다.

      <section>
        {isLoading ? (
          <>로딩중</>
        ) : (
          <div>
            {data && data?.map((item, index) => <Item key={index} {...item} />)}
          </div>
        )}
        <div id="target"></div>
      </section>

하지만 무한스크롤을 통해 게시글을 불러오는 경우, 이런방식을 사용하면 추가 데이터를 불러오는 동안 기존 게시글까지 몽땅 로딩 UI로 바뀌게 된다.
뿐만 아니라, 3페이지 -> 4페이지로 넘어갈때 로딩이 걸리게 되면서 4페이지의 데이터가 모두 불러와졌을 때는 다시 화면의 상단부로 이동하게 되는 문제가 발생했다.

아래처럼 기존 데이터는 보여준 채로, 로딩상태에만 로딩중...을 보여주도록 바꾼다

        <div>
          {data && data?.map((item, index) => <Item key={index} {...item} />)}
        </div>
        {isLoading && <>로딩중</>}

이렇게 되면 훨씬 더 낫긴 하다..!

그런데 뭔가 부족하다..
사용자에게 기다리는 동안 skeletonUI가 대신 보여 질 수 있도록 변경하자

export default function ItemSkeleton() {
  return (
    <div className="flex flex-col items-center justify-center border-2 my-3 rounded-lg py-4 h-28 bg-slate-200 gap-2 animate-pulse w-96">
      <div className="bg-slate-300 w-[150px] h-5 rounded-lg "></div>
      <div className="bg-slate-300 w-[80px] h-4 rounded-lg"> </div>
      <div className="bg-slate-300 w-[200px] h-4 rounded-lg"></div>
    </div>
  )
}

위처럼 스켈레톤 UI를 구현하고 ItemListSkeleton 컴포넌트에서 count갯수를 받아 이를 랜더링 랜더링 해주도록 하자

export default function ItemListSkeleton({ count }: { count: number }) {
  return new Array(count).fill(0).map((_, idx) => <ItemSkeleton key={idx} />)
}

이렇게 하면 로딩중이라는 글씨 대신 예쁜 skeleton이 로딩중에 표시된다.

최종코드

function App() {
  const [data, setData] = useState<MockData[]>([]) //불러온 모든 데이터
  const [isMore, setIsMore] = useState<boolean>(false) // 불러올 데이터가 더 있는지 여부
  const [page, setPage] = useState(1) // 불러올 페이지
  const [totlaPrice, setTotalPrice] = useState(0) // 데이터 내부의 총 가격 합
  const [isLoading, setIsLoading] = useState(true) // 데이터 로딩중인지 여부 확인
  const target = document.querySelector('#target')

  const fetchData = async () => {
    console.log(` 👉${page}번째 페이지 데이터 불러오는 중... `)
    setIsLoading(true)
    try {
      const res: IResponse = await getMockData(page)
      if (!res) {
        console.log('오류가 발생했습니다.')
      }
      const newData = res.datas

      const newPriceSum = calPriceSum(newData) //새로운 데이터의 Price합 구함
      setTotalPrice(prev => prev + newPriceSum)
      setData([...data, ...newData])

      setIsMore(!res.isEnd)
    } catch (error) {
      console.error(error)
    } finally {
      setIsLoading(false)
      console.log('데이터 불러오기 완료!')
    }
  }

  useEffect(() => {
    fetchData()
  }, [])

  useEffect(() => {
    if (target) {
      observer.observe(target)
    }

    return () => {
      if (target) {
        observer.unobserve(target)
      }
    }
  }, [isLoading, data])

  const observer = new IntersectionObserver(entries => {
    if (entries[0].isIntersecting && !isLoading) {
      if (isMore) {
        setIsLoading(true)
        setPage(prev => prev + 1)
        fetchData()
      } else {
        alert('이제 더는 데이터가 없어요..😭')
      }
    }
  })

  return (
    <div>
      <h1 className="text-green-800"> ✅ 무한 스크롤 구현 </h1>
      <section>
        현재 보여진 모든 Price 값의 합 :{totlaPrice}
        <br />
        현재 가져온 모든 Data 의 갯수 : {data.length}
      </section>

      <section className="flex flex-col items-center justify-center ">
        <div>
          {data && data?.map((item, index) => <Item key={index} {...item} />)}
        </div>
        <div id="target"></div>
        {isLoading && <ItemListSkeleton count={3} />}
      </section>
    </div>
  )
}

| 참고했던 블로그

profile
keep on pushing

0개의 댓글