Intersection Observer를 활용한 무한 스크롤 만들어보기

박채윤·2024년 4월 4일
2

🤷‍들어가면서...

이번주에는 그동안 직접 구현해보고싶었던 intersection-observer를 활용해서 무한스크롤을 구현해봤다.

무한 스크롤을 구현하는 과정에서 겪었던 어려움과 해결 과정을 공유하고자 이 글을 작성해본다. 많은 사람들이 그러하듯 구현을 위해서 많은 검색을 통해 해결을 하게되는데 이번 무한 스크롤을 검색했을땐 doucument.querySelector와 같이 직접 DOM태그를 조작하는 방식에대한 예시가 많이 보였다. 하지만 지금까지 React로 작업했을땐 그런 방식을 사용하지 않았엇는데? 라는 의문이 들었다. 아래 글을 같이보며 어떤 방식으로 의문을 해결했고 무한스크롤을 구현했는지 알아보자.

무한스크롤을 구현하기위해 RadImage라는 간단한 프로젝트를 만들었다.
RadImage의 컨셉은 Random-Image라는 컨셉의 서비스인데 상단에 category-bar를 두고 해당 category에 맞는 random image를 보여주는 프로젝트이다.

무한스크롤의 동작을 이해하기 위해 RadImage Project를 보며 알아보자.

🔧설계

우선 RadImage의 설계를 간단하게 알아보자.

1. Landing Page

간단하게 랜덤이미지 들을 보기전에 거쳐가는 페이지가 존재한다.
image
위와같이 접속하면 Landing Page가 등장하고 전시관으로 버튼을 누르면 이제 부터 Random-Image들이 전시된 페이지를 볼 수 있다.

Gallery Page에서 보여질 것

  • 어떤 주제의 사진을 볼 것인지 선택할 수 있는 category-bar
  • 해당 주제에 맞는 Infinity-Scroll을 지원하는 이미지들

👓미리보기

우선 intersection-observer를 활용하기 전에 어떻게 동작하는지 알아보자.
(글자 수를 줄이기 위해 io = intersection-observer로 사용하겠습니다.)

1. io 인스턴스 생성하기

io를 사용하기 위해서는 다음 과 같이 instance를 생성해야한다.

const intersectionObserver = new InterSectionObserver((callback,option))

그 후 생성한 io를 이용해서 관찰하고싶은 DOM요소, 예를들어<img/>태그 와 같은 요소들을 관찰하는 것이다.

//example
const images = document.querySelectorAll("img")
images.forEach((img)=>io.observe(img))

위의 예시코드 처럼 document.querySelectorAll() 를 사용해 DOM요소를 특정하고

io에서는 여러가지 메서드를 제공하는데 그 중 사용할 몇가지만 살펴보자.

    1. observe()
      io.observe(관찰대상)를 통해 원하는 요소를 관찰 시작
    1. unobserve()
      io.unobserve(관찰대상)를 통해 관찰대상 해제
    1. disconnect()
      만일 여러개의 관찰대상이 있었다면 한번에 해제하기 위해 io.disconnect()를 사용해 모든 관찰대상을 해제 할 수 있다.

2. io parameter 사용하기

InterSectionObserver((entries,observer)=>{},options)

1. options

options는 객체 형태로 사용해야한다. 그 안에 특정 옵션들을 사용할 수 있는데

  1. root => 관찰대상과의 교차점의 기준
    따로 선언해주지 않으면 기본값은 null이고 이 때 뷰포트를 기준으로 삼는다.
  2. rootMargin => root의 margin을 설정
    css margin값과 같이 문자열안에 rootMargin: "상px 우px 하% 좌%"의 형태로
    px이나 %단위로 선언해주면 된다 .
  3. threshold => 관찰대상과 root의 교차되는 영역의 넓이에따라 observer를 실행
    0~1의 값으로 선언해주면된다.
// example
const options = {
	root: null // viewport를 기준으로 삼는다
  	rootMargin: "0px 0px 15px 0px" // root의 marginbottom을 15px로 설정
  	threshold : 0.3 // 관찰대상이 root와 30% 겹쳐질때 관찰 시작
}

new InterSectionObserver((callback,options))

2. callback

callback에는 두가지 parameter를 가질 수 있는데 관찰할 대상(Target)이 등록되거나 가시성(Visibility, 보이는지 보이지 않는지)에 변화가 생기면 관찰자는 콜백(Callback)을 실행한다.

이번 작업에서는 메서드중 isintersecting만 사용해볼 것인데

//example

const io = new IntersetionObserver((entries)=>{
	entries.forEach((entry)=>
	if(entry.isintersecting){ // 요소와 root가 option에만족하게 겹쳐졋을때 true
   // 관찰대상이 root영역과 겹쳣을때 동작할 logic 작성
})},{})

위와같이 io에 관찰중일때의 동작과 option을 정의한 후 사용할 컴포넌트 에서 io instance를 생성함으로써 동작시킬 수 있다.

여기까지 프로젝트의 설계와 간단하게 interstion-observer에대해 간단하게 알아 봤으니 실제로 코드를 작성해서 동작시켜보자.

(추가로 더 많은 메서드 들이 존재하는데 더 많은 내용은 아래 참고자료 에서 확인하시면 됩니다.)

✨querySelector로 관찰(Observe)하기

1.설계

무한스크롤을 구현하기 위해서 검색해본 결과 많은 글에서 document.qeurySelectorAll("")을 사용해서 DOM요소를 특정하고 그 후에 DOM요소와 root가 교차했을때 다음 data를 불러오는 로직을 확인할 수 있었다.

이때까지만 해도 무한스크롤의 동작을 잘 이해하지못해서 동작의 이해를 위해 같은 방식으로 구현해보기로 했다. 설계는 다음과 같다.

api로 데이터를 불러오는 것이아니라서 임의로 한번에 5개씩 이미지를 불러오도록 했다.
root는 viewport로 설정하고 last-image와 viewport가 30%겹쳣을때 다음5개의 image를 불러오는 동작을 시켜보자.

2.구현

1.loadImages()

우선 loadImages 함수를 만들어서 n개의 image를 불러오는 함수를 만들었다.

// loadiImages.jsx

// params  imgLength :number - 전체 image데이터의 길이값 
// params  loadNum :number - 함수실행시 추가로 불러올 데이터 숫자 number

const loadimages = (imgLength = 0, loadAmount) => {
  return Array.from({ length: loadAmount }, (_, idx) => {
    if ((imgLength + idx) % loadAmount === loadAmount - 1) {
      return (
        <img
          width={800}
          height={800}
          src="https://loremflickr.com/320/240/"
          key={imgLength + idx + 1}
          className="last-image"
        />
      )
    } else {
      return (
        <img
          width={500}
          height={800}
          src="https://loremflickr.com/320/240/"
          key={imgLength + idx + 1}
        />
      )
    }
  })
}

위와 같이 loadImages() 를 실행하면 loadAmount만큼의 image component를 생성하고
마지막 ImageComponent에 className ="last-image"라는 것을 추가해 마지막 요소를 관찰대상으로 삼을 준비를 마쳤다.

이제 io를 정의하고 io를 통해서 무한스크롤을 구현해보자.

2.useInfinityScrollQuery()

// use-infinty-scroll-query

export const useInfinityScrollQuery = () => {
  const [imageArr, setImageArr] = useState(loadimages(0, 5))

  const io = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) { // 마지막 이미지가 root와 30%교차했을때
          
          //요소중 className === last-image인 요소라면 className을 삭제
          if (entry.target.className === 'last-image') {
            entry.target.classList.remove('last-image')
          }
          const newArray = loadimages(imageArr.length, 5)
          setImageArr((prev) => [...prev, ...newArray])
        }
      })
    },
    { threshold: 0.3 },
  )

  return { imageArr, io }
}

customhook을 생성해서 img data를 담을 상태와 io를 반환하는 useInfinityScrollQuery() 를 생성했다.
last-image라는 calssName을 가진 태그가있다면 기존 last-image라는 calssName을 지우고 loadImage()를 실행해 기존 imageArr뒤에 추가해준다.

loadImage()함수와 useInfinityScrollQuery()를 생성해서 모든 준비를 마쳤으니
페이지 컴포넌트에서 불러와보자.

3.실행

// pagecomponent
 
     ...페이지구성 코드
     
  useEffect(() => {
    const lastImage = document.querySelector('.last-image')
    io.observe(lastImage)

    return () => {
      io.disconnect(lastImage)
    }
  }, [imageArr])

	...페이지구성 코드

페이지 컴포넌트에서 다음과 같이 useEffect를 사용해서 io를 생성하고 DOM요소를 관찰하도록 설정해줬다.

3.결과물

눈으로 좀더 쉽게 확인하기 위해 마지막 이미지의 크기를 더 크게 두었다.

위의 영상과 같이 교차점에 도착하면 className을삭제하면서 새로운 list를 가져오는 것을 확인 할 수 있었다.

😱문제점

검색을 통해 알아본 무한스크롤은 위의 코드와 같이 동작하는 방법을 많이 다루고있엇는데 React에서 직접 DOM 요소를 조작하는 것은 보편적으로 권장되지 않는다고 한다. 이는 React가 가상 DOM을 사용하여 실제 DOM 조작을 추상화하고, 직접적인 DOM 조작은 React의 상태 및 렌더링과 충돌할 수 있기 때문이다. React에서는 상태를 변경하여 UI를 업데이트하고, 이를 통해 React가 자동으로 가상 DOM을 조작하고 실제 DOM을 업데이트하도록 하는 것이 일반적이라고 한다.

직접 DOM 조작이 권장되지 않는 이유는 다음과 같은데

  • 1.React의 상태와 동기화 문제: 직접적인 DOM 조작은 React의 가상 DOM과 동기화되지 않을 수 있다. 이는 예기치 않은 결과를 초래할 수 있으며, React의 상태 변경에 대한 업데이트를 놓칠 수 있다.
  • 2.성능 문제: React는 가상 DOM을 사용하여 필요한 최적화를 수행한다. 직접적인 DOM 조작은 React의 최적화 메커니즘을 우회하고 성능을 저하시킬 수 있다.

같이 작업하는 고수 개발자님이 Ref를 사용하면 더 좋은 코드를 작성 할 수 있다고하셨는데 그래서 지금 작성한 코드로 프로젝트를 마무리 할 수는 없을것 같았다.
그래서 다른 방법을 찾아봤는데

☝바로 ref를 이용하는 방법이다. 다음 내용에서 더 알아보자.

✨ref로 관찰(Observe)하기

1.설계

useRef를 사용해 DOM요소를 관리하고 해당 DOM요소를 만나면 loadImages를 실행하도록하면 좋지않을까?

마지막DOM요소를 하나더 두고 해당 요소를 만나면 그 위에 상태로 관리되는 ImageArr를 추가하는 방식으로 구현하도록 설계했다.
(추후에 마지막DOM요소는 loading-spinner가된다.)

2.구현

1.getImgInfoArray()

 // getImgInfoArray.js


// params  keyword :string - 프로젝트의 요구사항 : 어떤 이미지를 불러올지 
// params  arrayLength :number - 몇개의 이미지를 불러올지

const getImgInfoArray = (keyword, arrayLength) => {
  const baseUrl = import.meta.env.VITE_APP_IMG_BASE_URL
  const timestamp = getTimeStamp()
  const result = []
  for (let i = 0; i < arrayLength; i++) {
    const imgId = timestamp + i
    const imgSrc = baseUrl + `${keyword}/?random=${imgId}`
    result.push({
      id: imgId,
      src: imgSrc,
    })
  }
  return result
}

getImgInfoArray() 라는 함수를 생성해 arrayLength만큼의 이미지와 프로젝트의 요구사항에 맞는 id값을 담은 result를 return하는 함수를 생성한다.

2.useInfinityScroll()

// useInfinityScroll.js

// params  searchKeyword :strign - 어떤 주제를 가진 이미지를 가져올지
// params  perPage :number - 한번에 몇개의 이미지를 불러올지

const useInfinityScroll = (searchKeyword, perPage) => {
  /**
   * @info 배열에는 아래와 같은 타입의 객체가 담깁니다.
   * {
   *  id: number
   *  src: string
   * }
   */
  const [imgInfoArray, setImgInfoArray] = useState([])

  const bottomElementRef = useRef(null)

  const intersectionObserver = useMemo(() => {
    return new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const newArray = getImgInfoArray(searchKeyword, perPage)
            setImgInfoArray((prev) => [...prev, ...newArray])
            return
          }
        })
      },
      {
        rootMargin: '60px',
        threshold: 1,
      },
    )
  }, [searchKeyword, perPage])

  useEffect(() => {
    if (bottomElementRef === null || !bottomElementRef.current) return
    const target = bottomElementRef.current
    intersectionObserver.observe(target)

    return () => {
      intersectionObserver.disconnect(bottomElementRef)
    }
  }, [bottomElementRef, intersectionObserver])

  return { imgInfoArray, bottomElementRef }
}

다음과 같이 useInfinityScroll()을 생성하고 imageData와 Ref를 return하는 customhook을 생성했다.
useInfinityScroll()을 생성한후 페이지컴포넌트에서 비구조화할당을 통해 간단하게 사용해 볼 수 있을거 같다.

<SpinningCrirle ref={bottomElementRef}/>

useInfinityScroll()을 페이지 컴포넌트에서 불러온후 imageArr로 화면에 image들을 보여주고 가장 아래 위와같이 spinning circle에 ref를 연결해주면 목표했던 동작을 완료 할 수 있엇다.

🎉결과물(시연영상)

😘마치며...

이 글은 단순히 무한스크롤을 구현하는 과정을 보여주기 위해 작성한 글은 아니다.
구현과정에서 어떤 문제점이 있엇는지, 또 어떻게 그것을 해결했는지 까지의 과정을 공유하고 싶었다.
새로운 것을 공부할땐 항상 검색을 통해 구현하게 되고 그 방법이 좋은지 안좋은지에 대한 판단은 항상 동작을 다 이해한 후에 내릴 수 있었다.
비록 첫번째 코드는 앞으로 사용하지 않겠지만 과연 저 과정이 없었다면 왜 DOM을 직접 조작하면 안되는지에 대한 내용을 알 수 있었을까? 또한 무한스크롤의 동작을 쉽게 이해할 수 있었을까? 하는 생각이든다.

안좋은 코드는 있어도 안좋은 경험은 없다고 생각하기 때문에 이 글을 읽은 모두가 더 좋은 방법이 있는지 어떤 방식으로 기능이 구현되는지에 대해 계속해서 의문을 품고 공부했으면 좋겟다.

👇참고자료

profile
왕이될 상인가

4개의 댓글

comment-user-thumbnail
2024년 4월 4일

RandomImage 인데 왜 RadImage로 하신거죠? 거슬립니다 ;; 보다 직관적인 이름으로 변경해주세요.

1개의 답글