React 무한 스크롤 기능 구현해 보기

    반응형

    React 무한 스크롤

     

    React를 공부하면서 "코딩 알려주는 누나"라는 유튜브 채널에서 React 무한 스크롤 기능을 잘 설명해 준 내용이 있었다. 무한 스크롤이 어떠한 형식으로 구현되는지 공부하고 다시 한번 반복 학습하기 내용을 정리하며 복습하려고 한다.

     

    무한 스크롤


    그렇다면 무한 스크롤은 대체 어떤 기능일까? 처음 말만 들었을 때는 무한으로 스크롤을 한다? 라고만 이해되었다. 이게 무엇일까? 고민하던 찰나에 페이지 네이션 기능과 무한 스크롤의 차이를 알게 되었는데 너무 쉽게 이해되었다. 특히나 인스타그램, TicTok 등 다양한 애플리케이션에서 무한스크롤을 지원하면서 사용자 경험을 향상시킨다고 볼 수 있다.

     

    • 페이지네이션 : 사용자가 직접 페이지를 이동하면서 데이터를 볼 수 있다.
    • 무한 스크롤 : 페이지가 특정 하단에 도달했을 때 다른 페이지를 호출하면서 데이터를 볼 수 있다

     

    1. 상태 선언하기

    import { useState } from "react";
    
    function App() {
      const [movies, setMovies] = useState([]); 
      // 영화 데이터
      const [page, setPage] = useState(1); 
      // 페이지
      const [loading, setLoading] = useState(false); 
      // 다음페이지로 넘어가기 위한 로딩 혹은 데이터를 가져오기 위한 로딩
      const [hasNextPage, setHasNextPage] = useState(false); 
      // 다음페이지 여부
      const [pageParams, setPageParams] = useState([]); 
      // API를 통해 그동안 호출했던 페이지를 저장
     
      return <div></div>;
    }
    
    export default App;

     

    무한 스크롤을 구현하기 위해 영상처럼 TMDB API를 사용해서 연습했다. 우선 필요한 상태가 무엇인가를 생각해 보면 영화 데이터를 저장할 상태, 페이지를 나타내는 상태, 정말로 무한이 아니고 언젠가 끝이 존재하기 때문에 다음 페이지가 있는가의 여부, 데이터를 가져오는 로딩 상태, 여태 호출했던 페이지를 중복 호출을 방지하기 위한 히스토리를 저장하는 상태이다.

     

    2. 데이터 가져오기

    import { useEffect, useState } from "react";
    
    function App() {
      const [movies, setMovies] = useState([]);
      const [page, setPage] = useState(1);
      const [loading, setLoading] = useState(false);
      const [hasNextPage, setHasNextPage] = useState(false);
      const [pageParams, setPageParams] = useState([]);
    
      const fetchMovies = async (page) => {
        const url = `https://api.themoviedb.org/3/movie/top_rated?page=${page}`;
    
        const response = await fetch(url, {
          headers: {
            accept: "application/json",
            Authorization: "Bearer API_KEY",
          },
        });
    
        const data = await response.json();
    
        setMovies(data);
      };
    
      useEffect(() => {
        fetchMovies(page);
      }, []);
    
      console.log(movies); // 데이터 출력 결과 확인
    
      return <div>App</div>;
    }
    
    export default App;

     

     

    Movies 출력 결과

     

    데이터를 가져와 보면 데이터가 정상적으로 출력되는 것을 확인할 수 있다. 총 페이지는 483개 한 페이지당 20개의 결과를 보여주고 있다. 

     

    3. 기본적인 UI 만들기

    import { useEffect, useState } from "react";
    import "./App.css";
    
    function App() {
      const [movies, setMovies] = useState([]);
      const [page, setPage] = useState(1);
      const [loading, setLoading] = useState(false);
      const [hasNextPage, setHasNextPage] = useState(false);
      const [pageParams, setPageParams] = useState([]);
    
      const fetchMovies = async (page) => {
        const url = `https://api.themoviedb.org/3/movie/top_rated?page=${page}`;
    
        try {
          setLoading(true);
    
          const response = await fetch(url, {
            headers: {
              accept: "application/json",
              Authorization: "Bearer API_KEY",
            },
          });
    
          const data = await response.json();
          setMovies(data.results);
    
          setLoading(false);
        } catch (error) {
          console.log(error);
        }
      };
    
      useEffect(() => {
        fetchMovies(page);
      }, [page]);
    
      console.log(movies);
    
      return (
        <>
          {loading ? (
            <div>데이터를 가져오는 중</div>
          ) : (
            <div className="movie__grid">
              {movies.map((movie, index) => (
                <div className="movie__card" key={index}>
                  <img
                    src={`https://image.tmdb.org/t/p/w300/${movie.backdrop_path}`}
                  />
                  <h3>{movie.title}</h3>
                </div>
              ))}
            </div>
          )}
        </>
      );
    }
    
    export default App;

     

    UI 결과

     

    기본적인 UI 틀을 만들고 try/catch 문을 통해 데이터를 가져오는 함수인 fetchMovies를 업데이트했다. 하지만 아직 부족한데 우선 page 값, pageParams를 통한 페이지 히스토리 관리, 다음 페이지가 있는가를 업데이트해주어야 한다.

     

    4. fetch 업데이트

    import { useEffect, useState } from "react";
    import "./App.css";
    
    function App() {
      const [movies, setMovies] = useState([]);
      const [page, setPage] = useState(1);
      const [loading, setLoading] = useState(false);
      const [hasNextPage, setHasNextPage] = useState(false);
      const [pageParams, setPageParams] = useState([]);
    
      const fetchMovies = async (page) => {
        const url = `https://api.themoviedb.org/3/movie/top_rated?page=${page}`;
    
        if (pageParams.includes(page)) return;
    
        try {
          setLoading(true);
    
          const response = await fetch(url, {
            headers: {
              accept: "application/json",
              Authorization:
                "Bearer API_KEY",
            },
          });
    
          const data = await response.json();
    
          setMovies((prev) => [...prev, ...data.results]);
    
          setLoading(false);
    
          setPageParams((prev) => [...prev, page]);
    
          setHasNextPage(data.page < data.total_pages);
        } catch (error) {
          console.log(error);
        }
      };
    
      useEffect(() => {
        fetchMovies(page);
      }, [page]);
    
      console.log(movies);
      console.log(pageParams);
      console.log(hasNextPage);
    
      return (
        <>
          {loading ? (
            <div>데이터를 가져오는 중</div>
          ) : (
            <div className="movie__grid">
              {movies.map((movie, index) => (
                <div className="movie__card" key={index}>
                  <img
                    src={`https://image.tmdb.org/t/p/w300/${movie.backdrop_path}`}
                  />
                  <h3>{movie.title}</h3>
                </div>
              ))}
            </div>
          )}
        </>
      );
    }
    
    export default App;

     

    pageParams를 통해 이미 호출한 페이지가 있다면 그대로 함수를 return 하고, setPageParams와 setHasNextPage를 통해 다음 페이지가 존재하는지 확인하는 로직을 추가한다.

     

    5. Intersection Observer API 를 활용한 특정 하단 부분 관찰

    Intersection Observer는 관찰할 요소가 웹페이지의 뷰포트와 교차하는지 즉 뷰포트에 관찰할 요소가 보이는지를 비동기적으로 관찰하는 방법을 제공한다. 상세한 내용은 따로 정리하는 것으로 하고 여기서는 사용해서 특정 요소가 관찰될 때마다 새로운 데이터를 호출하여 무한 스크롤을 구현하려고 한다.

     

    import { useEffect, useRef, useState } from "react";
    import "./App.css";
    
    function App() {
      const [movies, setMovies] = useState([]);
      const [page, setPage] = useState(1);
      const [loading, setLoading] = useState(false);
      const [hasNextPage, setHasNextPage] = useState(false);
      const [pageParams, setPageParams] = useState([]);
    
      const ref = useRef();
    
      const fetchMovies = async (page) => {
        const url = `https://api.themoviedb.org/3/movie/top_rated?page=${page}`;
    
        if (pageParams.includes(page)) return;
    
        setLoading(true);
    
        try {
          const response = await fetch(url, {
            headers: {
              accept: "application/json",
              Authorization:
                "Bearer API_KEY",
            },
          });
    
          const data = await response.json();
    
          setMovies((prev) => [...prev, ...data.results]);
    
          setLoading(false);
    
          setPageParams((prev) => [...prev, page]);
    
          setHasNextPage(data.page < data.total_pages);
        } catch (error) {
          console.log(error);
          setLoading(false);
        } finally {
          setLoading(false);
        }
      };
    
      useEffect(() => {
        const observer = new IntersectionObserver(
          (entries) => {
            const firstEntry = entries[0];
    
            if (firstEntry.isIntersecting && hasNextPage && !loading) {
              console.log("화면 노출");
              setPage((prev) => prev + 1);
            } else {
              // console.log("화면에 노출x");
              return;
            }
          },
          {
            threshold: 0.3,
          }
        );
    
        if (ref.current) {
          observer.observe(ref.current);
          console.log("관찰 시작");
        }
    
        return () => {
          if (ref.current) {
            observer.unobserve(ref.current);
            console.log("관찰 해제");
          }
        };
      }, [hasNextPage, loading]);
    
      useEffect(() => {
        fetchMovies(page);
      }, [page]);
    
      return (
        <div className="movie__grid">
          {movies.map((movie, index) => (
            <div className="movie__card" key={index}>
              <img src={`https://image.tmdb.org/t/p/w300/${movie.backdrop_path}`} />
              <h3>{movie.title}</h3>
            </div>
          ))}
          <div ref={ref}>Read More</div>
        </div>
      );
    }
    
    export default App;

     

    Intersection Observer를 사용해서 ref를 가진 요소가 화면 뷰포트에 교차할 때 즉 화면에 보일 때마다 page를 업데이트해주고, page가 업데이트될 때마다 API를 호출하는 로직으로 무한 스크롤을 구현한다.

     

     

    무한 스크롤

     

    Intersection Observer 생성자를 만들고 콜백 함수를 넣어주는 useEffect 내부에서 의존성 배열을 입력해 주지 않으니 제대로 동작하지 않았다. 초기 hasNextPage 값을 true로 변경했더니 그제서야 제대로 동작했다. 그런데 생각한 의도와 달라서 다시 false 값으로 변경하고 의존성 배열에 hasNextPage, loading 값을 넣어주니 정상적으로 동작을 했다. 다만 두 값의 상태가 변할 때마다 계속 호출이 되니 이 부분은 다시 고민해 봐야 할 것 같다.

     

     

     

     

    반응형

    댓글