본문 바로가기
SeSAC

[코딩온]Image grid 형태로 Infinite scroll 기능 구현하기

by 새파란레몬 2024. 1. 3.

먼저 전체 코드이다.

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import Loading from '../Loading';

export default function Film({ year, filter }) {
  const [data, setData] = useState({ results: [] });
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [page, setPage] = useState(1);
  const loader = useRef(null);
  
  // 이미지 data값이 없을 경우 default로 나타내주는 이미지 변수
  const defaultMovieImg =
    'https://cdn.pixabay.com/photo/2017/11/24/10/43/ticket-2974645_1280.jpg';

  // 초기 화면 세팅 & year filter change handling
  useEffect(() => {
    setLoading(true);
    setPage(1); // Reset page number on year/filter change
    fetchData(1); // Fetch first page of data
  }, [year, filter]);

  // Infinite scrolling 로직, dependency array에 page 변수
  useEffect(() => {
    if (page > 1) {
      fetchData(page); // 다음 페이지의 data를 fetch 해 옴.
    }
  }, [page]);

  // 데이터 가져오는 함수
  const fetchData = async (pageNum) => {
    try {
      const response = await axios.get(
        `https://api.themoviedb.org/3/discover/movie?include_adult=false&include_video=false&language=en-US&page=${pageNum}&primary_release_year=${year}&region=US&sort_by=${filter}`,
        {
          headers: {
            accept: 'application/json',
            Authorization: `Bearer ${process.env.REACT_APP_TMDB_KEY}`,
          },
        }
      );
      setData((prevData) => ({
        ...prevData,
        results:
          pageNum === 1
            ? response.data.results
            : [...prevData.results, ...response.data.results],
      }));
    } catch (error) {
      setError(error);
    } finally {
      setLoading(false);
    }
  };
  
  // 콜백 함수
  const handleObserver = useCallback((entries) => {
    const target = entries[0];
    if (target.isIntersecting) {
      setPage((prevPage) => prevPage + 1);
    }
  }, []);
  
  // IntersectionObserver 활용 부분
  useEffect(() => {
    const observer = new IntersectionObserver(handleObserver, {
      root: null,
      rootMargin: '20px',
      threshold: 1.0,
    });
    if (loader.current) observer.observe(loader.current);

    return () => {
      if (loader.current) observer.unobserve(loader.current);
    };
  }, [handleObserver]);

 
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <div className="films-grids">
        {data &&
          data.results.map((movie, index) => (
            <div key={index} className="film-grid">
              <div className="img-wrapper image-hover-effect">
                <Link to={`/films/detail/${movie.id}`}>
                  <img
                    src={
                      movie.backdrop_path || movie.poster_path
                        ? `https://image.tmdb.org/t/p/original${
                            movie.backdrop_path || movie.poster_path
                          }`
                        : defaultMovieImg
                    }
                    alt={movie.title}
                  />
                </Link>
              </div>
              <div className="movie-info movie-info-flex">
                <Link to={`/films/detail/${movie.id}`}>
                  <p className="movie-title-p">{movie.title}</p>
                </Link>
                <div>
                  <Link to={`/films/detail/${movie.id}`}>
                    <span className="more-span">more</span>
                  </Link>
                </div>
              </div>
            </div>
          ))}
      </div>
      {loading && (
        <div>
          <Loading />
        </div>
      )}
      {error && <p>Error: {error.message}</p>}
      <div ref={loader} />
    </div>
  );
}


기능이 구현된 화면입니다.
(디자인 참고 : https://www.behance.net/gallery/186855431/Golden-Age-of-Hollywood-Film-Festival-Web-Design?tracking_source=search_projects&l=9 )



고전 영화인 만큼 데이터가 없는 영화도 꽤 많아서, request 결과 data에 Backdrop_path와 poster_path 둘 다 없는 경우를 위해 default 이미지 변수를 넣어주었다.

 

1. 초기화면 세팅


상위 컴포넌트의 select tag에서 받아온 parameter 'year'과 'filter'로 연도에 따라, 인기순 혹은 최신순으로 초기 화면을 세팅한다. 이때 불러오는 page는 항상 1이다. 


- useState를 통해 영화 데이터, 로딩 상태, 오류 상태, 페이지 번호를 관리한다.

- useEffect를 통해 year, filter 에 따른 초기 데이터를 불러오고, 페이지 번호가 변경될 때 추가 데이터를 불러온다.

- fetchData 함수는 API 호출을 통해 영화 데이터를 가져오고, 이전 데이터와 불러온 데이터를 더해준다.

- IntersectionObserverhandleObserver는 페이지 하단 도달 시 다음 데이터 로드에 사용된다. 

 

 

2. 무한 스크롤 구현

 

(출처 : https://velog.io/@suyeonme/react-Infinite-Scroll-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0 )

이런 무한 스크롤을 구현하는 방법에는 3가지가 존재하는데, Scroll Event, IntersectionObserver, useRef가 그 세 가지이다. 이 구분에 대해서는 위의 블로그에 자세히 나와있다. 

그 중 Intersection Observer APIuseRef를 사용하였다.

 

(주요 작동 원리 참고 : https://medium.com/suyeonme/react-how-to-implement-an-infinite-scroll-749003e9896a )
IntersectionObserver는 대상 요소가 특정 요소에 교차하는지를 감시하는 API이다. DOM 요소를 직접 참조하기 위해서 useRef를 사용했다. 위에서 <div ref={loader}/>에서 threshold 1.0( 대상 요소가 100% 보이면)이 되면 감지가 되는 시스템이다.

해당 div가 100%화면에 노출되면 IntersectionObserver는 handleObserver를 호출한다. 이에 교차됨을 확인하고 target.isIntersecting이 true이기에 추가 데이터 로드가 된다. 이 함수에서는 page가 증가하는 로직을 넣어주었다. 여기서 부터 page는 2이상이 된다. 

 

이렇게 page가 변화하면 dependency array에 page가 있는 useEffect에 따라 fetchData에 변화된 page 변수를 반영해 추가 데이터를 가져오게 된다!