무한 스크롤 구현하기
무한 스크롤(Infinite scroll)은 무엇인가요? 🤔
- 사용자가 글 목록을 클릭하지 않고 자동으로 다음 페이지로 넘어가는 것
페이지 네이션과 무엇이 다른가요?
- 페이지 네이션
- 장점
- 사용자에게 많은 정보를 제공할 수 있다
- 몇 페이지가 남아있는지 확인할 수 있고 페이지를 건너뛸 수 있다
- 단점
- 추가적인 데이터를 보기 위해 사용자의 개입이 필요하다
- 코어 사용자가 아닌 단순 흥미위주의 사용자에게는 피로감이 될 수 있다.
- 장점
- 무한 스크롤
- 장점
- 자연스럽다. 상 하 스크롤링 만으로 다음 페이지로 넘어갈 수 있다
- 모바일 환경에서 사용하기 적합하다
- 단점
- 원하는 데이터의 위치를 기억하기 어렵다
- 장점
무한 스크롤은 왜 사용하나요?
- 모바일 환경에서 적합합니다.
- 계속해서 내리면서 정보를 볼 수 있는 뉴스 같은 서비스에 적합합니다
무한 스크롤의 작동 원리는 어떻게 되나요?
- 데이터를 불러옵니다
- 화면의 최 하단부에 내려갑니다
- 다음 데이터를 불러옵니다
구현!
필요 State & Ref
- State
- 데이터를 저장할 State
- 데이터 로딩을 체크할 수 있는 State
- api 페이지 처리를 할 수 있는 State
- Ref
- 옵저버를 체크할 Ref
- 중복 생성을 방지하는 Ref
- 마지막 페이지를 체크할 수 있는 Ref
// 받아온 데이터를 저장할 위치
const [data, setData] = useState([]);
// 로딩중인지 체크
const [load, setLoad] = useState(null);
// 페이지 체크 => useEffect 실행을 위함
const [page, setPage] = useState(0);
// 옵저버 엘리먼트
const observerRef = useRef(true);
// 옵저버 중복생성 방지
const preventObserverRef = useRef(true);
// 마지막 페이지 체크
const endRef = useRef(false);
필요 함수
1. 데이터를 불러올 수 있는 함수
const getMovieData = useCallback(async () => {
setLoad(true);
try {
const MovieDatas = await axios({
url: `${URL}?api_key=${API_KEY}&page=${page}`,
});
// console.log(MovieDatas.data.results);
const movies = MovieDatas.data.results.map((movie) => {
return {
id: movie.id,
title: movie.title,
poster: movie.poster_path,
};
});
setData((prev) => [...prev, ...movies]);
} catch (error) {
console.log(console.error());
}
setLoad(true);
}, [page]);
2. 옵저버 콜백 함수
Intersection Observer API - Web API | MDN
MDN 에서 인터렉션 옵저버 확인하면 다음과 같이 구성되어 있습니다.
let observer = new IntersectionObserver(callback, options);
observer 라는 이름으로 새로운 Intersection 옵저버를 생성해 줍니다.
인터섹션 옵저버에는 옵저버를 체크할 수 있는 콜백함수가 필수입니다.
const observerHandler = (entries) => {
const target = entries[0];
if (
// !endRef.current &&
target.isIntersecting &&
preventObserverRef.current
) {
console.log(preventObserverRef);
preventObserverRef.current = false;
setPage((prev) => prev + 1);
}
};
entires 배열에는 다음과 같은 정보가 담깁니다
바로 사용할 수 없기 때문에 entires [0]
을 타겟으로 만들어 두고 사용합니다.
3. 옵저버 생성
useEffect(() => {
// getMovieData();
const observer = new IntersectionObserver(observerHandler);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => {
// 옵저버 중복을 방지하기 위해서 연결을 끊어줌
observer.disconnect();
};
}, []);
4. JSX 코드 작성
<div className="App">
<p>무한스크롤 테스트하기</p>
<div>
{data.map((d) => (
<div key={d.id}>
<S.CatImg
src={`https://image.tmdb.org/t/p/w500${d.poster}`}
alt=""
/>
<p>{d.title}</p>
</div>
))}
</div>
{load ? <div>로딩중</div> : <div></div>}
<li ref={observerRef}>옵저버 체크</li>
</div>
최 하단 li 태그와 스크롤이 만나면 옵저버가 체크됩니다.
전체 코드
import React, { useState, useEffect, useRef, useCallback } from "react";
import Test from "./Test";
import axios from "axios";
import * as S from "./AppStyle";
import "./App.css";
const API_KEY = '당신의 API 키'
const URL = "https://api.themoviedb.org/3/movie/popular";
function App() {
// 받아온 데이터를 저장할 위치
const [data, setData] = useState([]);
// 로딩중인지 체크
const [load, setLoad] = useState(null);
// 페이지 체크 => useEffect 실행을 위함
const [page, setPage] = useState(1);
// 옵저버 엘리먼트
const observerRef = useRef(null);
// 옵저버 중복생성 방지
const preventObserverRef = useRef(true);
// 종료
// const endRef = useRef(false);
// 옵저버 생성하기
// https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API
// let observer = new IntersectionObserver(callback, options);
useEffect(() => {
// getMovieData();
const observer = new IntersectionObserver(observerHandler);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => {
// 옵저버 중복을 방지하기 위해서 연결을 끊어줌
observer.disconnect();
};
}, []);
// 처음에 사진 가져오게 하기 위함
useEffect(() => {
getMovieData();
}, [page]);
// 옵저버 콜백함수 생성
// entries(배열) => 감지한 DOM 요소들의 인터섹션 상태 정보가 담긴다
// entries = IntersectionObserverEntry
const observerHandler = (entries) => {
// console.log(entries);
const target = entries[0];
// console.log(target);
if (
// !endRef.current &&
target.isIntersecting &&
preventObserverRef.current
) {
// console.log(preventObserverRef);
preventObserverRef.current = false;
setPage((prev) => prev + 1);
}
};
// console.log(page);
// 사진 받아오기 => Effect 로 재 랜더링 됐을때를 대비해서 Callback 사용
const getMovieData = useCallback(async () => {
setLoad(true);
try {
const MovieDatas = await axios({
url: `${URL}?api_key=${API_KEY}&page=${page}`,
});
// console.log(MovieDatas.data.results);
const movies = MovieDatas.data.results.map((movie) => {
return {
id: movie.id,
title: movie.title,
poster: movie.poster_path,
};
});
setData((prev) => [...prev, ...movies]);
} catch (error) {
console.log(console.error());
}
setLoad(true);
}, [page]);
return (
<div className="App">
<p>무한스크롤 테스트하기</p>
<div>
{data.map((d) => (
<div key={d.id}>
<S.CatImg
src={`https://image.tmdb.org/t/p/w500${d.poster}`}
alt=""
/>
<p>{d.title}</p>
</div>
))}
</div>
{load ? <div>로딩중</div> : <div></div>}
<li ref={observerRef}>옵저버 체크</li>
</div>
);
}
export default App;
어려웠던 점
제일 처음 렌더링 할 때 2 페이지가 한번에 불러와지는 문제
이를 해결하기 위해서 여러가지 방법을 시도해 보았지만 무한 스크롤이 불가능 해지는 오류가발생하거나 되려 중복된 key 를 불러온다는 이해 못할 상황이 벌어지기도 했습니다.아직 데이터를 불러오지 못한 상황에서 옵저버를 체크해주는 ref 가 연속으로 체크 되었습니다.
해결
Mui 스켈레톤을 이용해서 야매로 해결 했습니다
로딩을 위한 State 가 존재했기 때문에 데이터를 불러오는 동안 스켈레톤으로 자리를 채우고
다음 데이터가 도착하기 전까지 시간을 ref 를 체크하지 못하게 했습니다.
검색 후 다음 페이지로 넘어가지 않는 문제
포스팅에는 없지만 프로젝트에서는 검색 기능 또한 구현해야 했습니다.
검색한 키워드가 있으면 페이지를 0 으로 초기화 하는 조건을 만들어 두었고
이때문에 검색한 내용이 존재하면 계속해서 페이지를 0으로 초기화 했습니다.
따라서 다음 페이지로 이동하지 않고 데이터 또한 계속해서 초기화 되었습니다.
해결
검색 컴포넌트에서 props 로 데이터를 초기화 하는 함수를 호출할 수 있게 함
느낀점
무한 스크롤, 슬라이더 등 구현하기 어려운 기능들을 만들어야 할 때면 늘 라이브러리를 사용하고는 했습니다.
라이브러리는 편안하지만 커스텀이 어렵고 라이브러리 사용을 위한 또 다른 학습이 수반되어야 합니다.
이러면 라이브러리를 쓰는 의미가 없지 않나?? 하는 생각에 직접 구현하였습니다.
생각보다 구현할 만 했습니다.
물론 중간중간 어려움도 많았지만 야매든 정답이든 해결했을 땐 굉장한 뿌듯함을 느꼈습니다.
참고 자료
Intersection Observer API - Web API | MDN