본문 바로가기
🐣React

무한 스크롤(Infinite scroll) 구현하기(1) - IntersectionObserver API

by egg.silver 2024. 9. 10.

 

대량의 컨텐츠를 이용자에게 보여줄 수 있는 가장 대표적인 방법에는 무한 스크롤과 페이지네이션이 있습니다.

그 중 오늘은 무한 스크롤에 대해서 알아보고 내가 실제로 IntersectionObserver를 이용하여 무한 스크롤을 어떻게 구현했는지 정리해보겠습니다.

 

🧐 무한 스크롤(Infinite scroll)이란?

무한 스크롤은 사용자가 페이지 하단에 도달했을 때, 콘텐츠가 계속 로드되는 사용자 경험(UX) 방식입니다.

한 페이지 아래로 스크롤 하면 끝없이 새로운 화면을 보여주게 되고 이로 인해 많은 양의 콘텐츠를 스크롤 해서 볼 수 있습니다.

핀터레스트나 크롬에서 이미지를 볼 때 무한 스크롤 방식이 사용된 것으로 이해하면 됩니다.

 

🧐 왜 페이지네이션이 아니라 무한 스크롤을 구현했나?

우선 제가 무한 스크롤을 적용한 부분은 사용자가 자신이 읽고 있는 책이나 읽고 싶은 책, 다 읽은 책 목록을 보러 페이지 이동을 했을 때입니다. 다양한 페이징 기능들 중 제가 무한 스크롤 기능을 택한 이유는 페이지네이션은 매번 버튼을 눌러 다음 페이지로 이동해야 하지만, 무한 스크롤은 사용자가 따로 클릭을 하지 않고 단순히 스크롤만 하더라도 자연스럽게 더 많은 양의 데이터를 볼 수 있게 해주기 때문입니다. 따라서 사용자의 클릭을 최소화 하면서 한번에 많은 양의 데이터를 보여줄 수 있는 무한스크롤로 구현하기로 결정했습니다.

 

🧐 무한 스크롤을 구현하는 방법은 어떤 것들이 있는가?

무한 스크롤을 구현하는 방법에는 다양한 방법이 있지만 오늘은 Scroll Event, IntersectionObserver API 이렇게 두 가지에 대해서 알아보고 제가 프로젝트에서 실제로 구현한 IntersectionObserver API에 대해 자세히 알아보겠습니다.

 


 

1. Scroll Event

스크롤 이벤트는 사용자가 페이지를 스크롤할 때마다 발생하는 이벤트입니다. 이를 사용하면 페이지의 스크롤 위치를 감지하고, 특정 위치에 도달했을 때 추가 작업을 수행할 수 있습니다. 가장 전통적인 방식으로, 사용자가 페이지를 스크롤할 때마다 이벤트가 트리거됩니다.

 

- scrollTop: 현재 스크롤 위치에서 가장 윗부분의 위치

- scrollHeight: 수직 스크롤 없이 콘텐츠를 전부 나타냈을 때 그 길이
- clientHeight: 현재 스크린 상에 보이는 화면의 높이 (css높이  + css padding - 수평스크롤바 높이)


➔  때문에 스크롤이 끝까지 내려가면 scrollTop + clientHeight >= scrollHeight 이라는 식이 성립됩니다.

장점
- 스크롤 위치를 직접 감지하는 방식이기 때문에 쉽게 구현할 수 있습니다.
- 모든 브라우저에서 지원되며 오래된 브라우저에서도 작동합니다.


단점
- 스크롤 이벤트는 사용자가 스크롤할 때마다 실행되므로, 페이지가 복잡하거나 스크롤이 자주 발생할 경우 성능 저하를 유발할 수 있습니다. 
- 스크롤 이벤트는 페이지 전체가 아닌 특정 요소의 가시성을 감지하는 데 적합하지 않습니다.
- 이벤트가 너무 자주 발생하는 것을 방지하기 위해, 보통 디바운싱(debouncing) 또는 스로틀링(throttling) 기법을 추가하여 이벤트 호출 빈도를 제한해야 합니다.

 

디바운싱* : 이벤트가 연속해서 발생할 때, 이벤트가 끝난 후 일정 시간이 지나면 하나의 작업만 수행되도록 하는 기법입니다. 즉, 이벤트가 계속 발생하는 동안에는 대기 상태에 있고, 마지막 이벤트가 발생하고 나서 지정된 시간이 지나야 함수가 실행됩니다.

 

스로틀링* : 자주 발생하는 이벤트를 일정한 시간 간격으로 제한하여 특정 시간 간격마다 한 번씩 함수가 실행되도록 하는 기법입니다. 스로틀링은 이벤트가 지속적으로 발생하더라도 지정된 시간 동안 한 번만 이벤트를 처리하게 만듭니다.

 


 

2. IntersectionObserver API

Intersection Observer API특정 DOM 요소가 뷰포트(Viewport)에 들어오거나 나가는 것을 감지하는 API입니다. 이를 통해 페이지 하단에 도달했는지 감지하거나, 특정 요소가 화면에 보이는지 여부를 쉽게 알 수 있습니다.

 

- root: 대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소, 기본값은 브라우저 뷰포트이며 root값이 null이거나 지정되지 않을 때 기본값으로 설정됨
- rootMargin: root가 가진 여백, 교차성을 계산하기 전에 적용됨
- threshold: target의 가시성 퍼센트

 

➔  예를들어 target이 root에서 30%만큼의 요소가 보여졌을때를 탐지하고 싶다면 0.3으로 설정하면 됩니다. 기본값은 0으로, 요소가 1px이라도 보이면 콜백이 실행됩니다. 반면 1.0으로 설정할 시 target의 전체가 화면에 노출되기 전엔 콜백이 실행되지 않습니다.

 

 

장점
- 스크롤 이벤트와 달리 요소의 가시성 변화가 있을 때만 콜백 함수가 호출되므로, 성능에 더 유리합니다. 다시 말해서, 스크롤 이벤트처럼 지속적으로 호출되는 것이 아니라 필요한 시점에만 호출됩니다.
- 요소가 화면에 보이는지 여부를 정확하게 감지할 수 있습니다. 페이지의 특정 위치나 요소의 가시성을 트리거로 활용할 때 유용합니다.
- 불필요한 이벤트 호출을 줄여서 더욱 부드럽고 자연스러운 사용자 경험을 제공합니다.


단점
- 대부분의 브라우저에서 지원되지만, 일부 구형 브라우저(특히 IE)에서는 지원되지 않으며 폴리필이 필요할 수 있습니다.

 

 

폴리필(Polyfill)* : 특정 기능이나 API가 구형 브라우저 또는 기능을 지원하지 않는 브라우저에서 동작하도록 해주는 코드 또는 스크립트를 의미합니다. 


 

📚 프로젝트에서 사용한 방법 - IntersectionObserver API

 

1. 커스텀 훅 분리: useInfiniteScroll

import { useEffect, useCallback, useState } from 'react'

interface UseInfiniteScrollProps {
  hasMore: boolean
  onLoadMore: () => Promise<void>
  threshold?: number
}

const useInfiniteScroll = ({ hasMore, onLoadMore, threshold = 0.1 }: UseInfiniteScrollProps) => {
  const [target, setTarget] = useState<Element | null>(null)
  const [isLoading, setIsLoading] = useState<boolean>(false)

  const handleIntersect = useCallback(
    async ([entry]: IntersectionObserverEntry[]) => {
      if (entry.isIntersecting && hasMore && !isLoading) {
        setIsLoading(true)
        await onLoadMore()
        setIsLoading(false)
      }
    },
    [hasMore, isLoading, onLoadMore],
  )

  useEffect(() => {
    const observer = new IntersectionObserver(handleIntersect, { threshold })

    if (target) observer.observe(target)

    return () => {
      if (target) observer.unobserve(target)
    }
  }, [target, handleIntersect, threshold])

  return setTarget
}

export default useInfiniteScroll

 

 

[Props 설명]
- hasMore: 로드할 추가 데이터가 남아 있는지 여부를 판단합니다. 데이터를 모두 로드했으면 false로 설정하여 더 이상 호출하지 않도록 합니다.
- onLoadMore: 더 많은 데이터를 로드할 때 호출하는 비동기 함수입니다. 페이지나 데이터를 불러오는 작업을 수행합니다.
- threshold: Intersection Observer가 요소를 감지할 때 사용할 페센트 값입니다.


[상태 관리 설명]
- target: Intersection Observer로 관찰할 DOM 요소를 가리킵니다. 이 요소가 뷰포트에 들어오면 다음 데이터를 로드합니다.
- isLoading: 추가 데이터를 로드 중인지 여부를 관리합니다. 중복 호출을 방지하기 위해 사용합니다.
- handleIntersect: IntersectionObserverEntry[] 배열에서 첫 번째 항목을 가져와 해당 요소가 뷰포트에 들어왔는지 확인합니다(isIntersecting).
만약 요소가 뷰포트에 들어오고, 추가 데이터가 남아 있으며(hasMore), 로드 중이 아니면(!isLoading), onLoadMore 함수를 호출하여 데이터를 불러옵니다.

➔  useEffect를 통해서 컴포넌트가 마운트되면 IntersectionObserver를 생성하고, target이 설정되면 관찰을 시작합니다. target이 변할 때마다 해당 요소를 관찰하거나 관찰을 중지하는 로직을 수행합니다.


 

2. ReadBook 컴포넌트: 실제 데이터 로딩과 타겟 설정

이제 커스텀 훅을 실제로 사용하는 컴포넌트 ReadBook을 살펴보겠습니다.

이 컴포넌트는 읽은 책 목록을 무한 스크롤 방식으로 불러옵니다.

import BookBox from '../../shelf/BookBox'
import StarRate from '../../shelf/StarRate'
import { useMyContext } from '../../Context/MyContext'
import { baseInstance } from '../../../api/config'
import { useEffect, useState } from 'react'
import useInfiniteScroll from '../../../hooks/useInfiniteScroll'

type BookType = {
  cover_image_url: string
  title: string
  author: string
  grade: number
}

const ReadBook = () => {
  const [books, setBooks] = useState<BookType[]>([])
  const [page, setPage] = useState<number>(1)
  const [hasMore, setHasMore] = useState<boolean>(true)
  const [isLoading, setIsLoading] = useState<boolean>(true)
  const { isModalOpen, setIsModalOpen, setSelectedBook } = useMyContext()

  const handleBookClick = (book: any) => {
    setSelectedBook({ ...book, status: 'Read' })
    setIsModalOpen(true)
  }

  const getReadBooks = async () => {
    try {
      setIsLoading(true)
      const access = localStorage.getItem('accessToken')

      const response = await baseInstance.get(
        `/readings?status=READ&page=${page}`,
        {
          headers: { Authorization: `Bearer ${access}` },
        },
      )
      const readBooks = response.data.bookInfos.content

      if (response.data.bookInfos.empty) {
        setHasMore(false)
      } else {
        setBooks((prevBooks) => [...prevBooks, ...readBooks])
      }
    } catch (error) {
      console.log(error)
    } finally {
      setIsLoading(false)
    }
  }

  useEffect(() => {
    getReadBooks()
  }, [isModalOpen])

  const loadMore = async () => {
    setPage((prevPage) => prevPage + 1)
    await getReadBooks()
  }

  const setTarget = useInfiniteScroll({ hasMore, onLoadMore: loadMore })

  return (
    <div>
      <div className="grid grid-cols-4 gap-y-14 gap-x-8 mt-16">
        {isLoading && books.length === 0
          ? Array.from({ length: 12 }).map((_, index) => (
              <div
                key={index}
                className="skeleton bg-base-300 w-[19.1875rem] h-[10.25rem] rounded-[2.0625rem] animate-pulse"
              />
            ))
          : books.map((book, index) => (
              <BookBox
                key={index}
                img={book.cover_image_url}
                title={book.title}
                writer={book.author}
                onClick={() => handleBookClick(book)}>
                {book.grade === 0 ? (
                  <button className="text-gray-600 pl-6 pt-2">책 리뷰 남기기 {' >'}</button>
                ) : (
                  <StarRate grade={book.grade} />
                )}
              </BookBox>
            ))}
        {isLoading &&
          books.length > 0 &&
          Array.from({ length: 6 }).map((_, index) => (
            <div
              key={index}
              className="skeleton bg-base-300 w-[19.1875rem] h-[10.25rem] rounded-[2.0625rem] animate-pulse"
            />
          ))}
      </div>
      <div ref={setTarget} className="w-full h-3 bg-transparent"></div>
    </div>
  )
}

export default ReadBook

 

[상태 관리 설명]

- books: 현재 로드된 책 목록을 관리하는 상태입니다.
- page: 페이지 번호를 관리하는 상태로, 새로운 페이지가 로드될 때마다 증가합니다.
- hasMore: 더 이상 로드할 책 목록이 없는 경우, 무한 스크롤을 멈추게 하기 위해 사용됩니다.
- isLoading: 데이터 로딩 중인지 여부를 확인하여 로딩 상태를 보여줍니다.


[getReadBooks 함수]
: 현재 페이지에 해당하는 책 목록을 서버에서 가져옵니다.
서버에서 데이터를 성공적으로 받아오면 books 상태에 추가하고, 추가 데이터가 없는 경우 hasMore를 false로 설정하여 더 이상 데이터를 불러오지 않도록 설정합니다.


[loadMore 함수]

:새로운 페이지를 로드하기 위한 함수입니다. 페이지 번호를 증가시키고, getReadBooks를 호출하여 데이터를 추가로 불러옵니다.


[useInfiniteScroll 호출]

:hasMore와 loadMore을 인자로 useInfiniteScroll 훅을 호출하고, 이 훅에서 반환된 setTarget을 특정 DOM 요소에 연결합니다. 이 요소가 뷰포트에 들어오면 다음 페이지의 데이터를 자동으로 로드합니다.
ref={setTarget}를 통해 마지막 요소를 감지할 수 있게 해, Intersection Observer가 해당 요소가 뷰포트에 나타났을 때 데이터를 불러오게 합니다.

 

📚 최종 결과물