돌멩이 하나/에러는 미래의 연봉

[기능 구현 챌린지] 무한 스크롤 UI

미래에서 온 개발자 2023. 5. 30. 20:16

TMDB API를 통해 영화 데이터를 받아와 무한 스크롤을 구현해 보았다. 먼저 고전적인 스크롤 이벤트를 사용하는 방식으로 구현해 본 후, Intersection Observer API를 사용하는 방식으로 리팩토링했다. 
 
- GitHub 레포: https://github.com/Ah-ae/infinite-scroll
- 배포 링크: https://infinite-scroll-practice.vercel.app
 

구현 화면 영상

 

1. 스크롤 감지 방식

핵심은 다음과 같다. 

1) window.scrollY로 스크롤 위치를 기억한 다음, window.scrollTo() 메소드를 사용해 해당 위치로 보내주기
2) 스크롤이 바닥에 닿았는지를 판별해 바닥에 닿았다면 다음 데이터를 fetch하기 
3) scroll 이벤트 리스너를 달기

 
하나씩 차례대로 살펴보자. 
 
 
1) 스크롤 위치 기억하기

  const [movies, setMovies] = useState([]);
  const [page, setPage] = useState(1);
  const [hasNoMoreResult, setHasNoMoreResult] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      const scrollY = window.scrollY;

      const { results } = await (
        await fetch(
          `https://api.themoviedb.org/3/movie/popular?api_key=${process.env.REACT_APP_API_KEY}&page=${page}`
        )
      ).json();
      setMovies([...movies, ...results]);

      if (results.length < 20) setHasNoMoreResult(true);
      setIsLoading(false);
      window.scrollTo(0, scrollY);
    };
    fetchData();
  }, [page]);

 
if (results.length < 20) setHasNoMoreResult(true); 이 들어간 이유는 한 페이지당 받아오는 영화 데이터의 개수가 20개였기 때문에 데이터 개수가 20개 미만이라면 더이상 받아올 데이터가 없는 마지막 데이터라는 뜻이다. 
 
 
2) 다음 데이터 fetch하기 

  const loadMoreData = () => {
    if (!hasNoMoreResult) {
      setPage((page) => page + 1);
      setIsLoading(true);
    }
  };
  
    const handleScroll = () => {
    const { scrollHeight, scrollTop, clientHeight } = document.documentElement;
    const isScrollEnd = scrollTop + clientHeight >= scrollHeight;

    // 화면 하단까지 스크롤되었고, !isLoading 이어서 현재 요청 중이 아니라면 다음 페이지 정보 요청
    if (isScrollEnd && !isLoading) loadMoreData();
  };

const isScrollEnd = scrollTop + clientHeight >= scrollHeight 의 의미

 
scroll의 위치와 클라이언트 뷰포트의 높이를 더한 값이 전체 콘텐츠의 높이보다 크거나 같다면 스크롤이 바닥에 닿았다고 판단할 수 있다. 스크롤이 바닥에 닿으면 데이터를 추가로 불러오는 loadMoreData 함수가 호출되고, loadMoreData 함수 내에서 page가 +1씩 증가하기 때문에 page가 변할 때마다 useEffect 내부의 fetchData 함수가 호출되는 원리이다.
 
 
3) scroll 이벤트 리스너를 달기

  useEffect(() => {
    window.addEventListener("scroll", handleScroll);

    // 컴포넌트 언마운트시, scroll 이벤트에 달았던 handleScroll 함수 제거하는 clean up 함수
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, [loadMoreData]);

 
스크롤 이벤트는 브라우저 렌더링 시 reflow가 발생하여 연속 이벤트 실행시 성능이 떨어지기 때문에 이벤트를 일정한 주기(설정한 ms)마다 발생하게 하는 throttling 기법을 적용하여 과다한 이벤트 실행을 방지해 웹 페이지의 성능을 향상시키는 것이 좋다. 
 

  const throttle = (callback, waits) => {
    let lastCallback; // timer id of last invocation
    let lastRan; // time stamp of last invocation

    return (...args) => {
      const context = this;
      if (!lastRan) {
        callback.apply(context, args);
        lastRan = Date.now();
      } else {
        clearTimeout(lastCallback);
        lastCallback = setTimeout(() => {
          if (Date.now() - lastRan >= waits) {
            callback.apply(context, args);
            lastRan = Date.now();
          }
        }, waits - (Date.now() - lastRan));
      }
    };
  };

  useEffect(() => {
    // 컴포넌트 마운트시, throttle로 0.5초에 한 번씩만 동작하도록 제한
    window.addEventListener("scroll", throttle(handleScroll, 500));

    return () => {
      window.removeEventListener("scroll", throttle(handleScroll, 500));
    };
  }, [loadMoreData]);

 
📍 문제 발생 및 해결
이렇게 영화 목록을 간단한 카드 형태로 보여주는 무한 스크롤 ui를 구현한 후, 해당 카드를 클릭하면 영화 상세 정보를 보여주는 페이지로 이동하게 했다. 이때 상세 페이지로 이동하면 화면의 최상단으로 이동해야 영화의 상세 정보가 보여지는데, 영화 목록 페이지에서 스크롤 위치가 그대로 상세 페이지에 적용되어서 사용자는 아무 정보도 보이지 않는 화면을 보고 있는 문제가 발생했다. 스크롤을 최상단으로 올리면 컨텐츠를 볼 수 있지만 사용자 입장에서는 아무 것도 안 보이는 페이지가 뜬다고 생각할 수 밖에 없었다. 
 
react-router-dom 라이브러리 때문에 생기는 이슈로, react-dom에서는 다른 경로로 이동할 때 스크롤 위치가 맨 위로 이동하는 것이 아닌 현재 스크롤 위치를 유지하는 것이 디폴트 동작이다. Scroll Restoration이라는 방식으로 해결할 수 있다고 안내하는 공홈의 전용 페이지가 따로 있는데, 여기서 알려주는 방식으로 해봐도 상세 페이지로 이동할 때 스크롤 위치가 한 번에 최상단으로 이동하지 않고 현재 위치에서 최상단으로 끌려서 이동하는 것처럼 동작했다.
 
크롬 브라우저에서 이런 현상이 발생했는데, 사파리와 웨일 브라우저에서는 이런 현상이 없이 최상단으로 바로 이동했다. 브라우저의 스크롤 디폴트 동작이 달라서 그런 것 같았다. 한참 삽질(?)을 하다가 scrollTo 메소드의 mdn을 보고 해결책을 찾아냈다. scrollTo 메소드를 쓸 때 보통 scrollTo(0,0)과 같이 x, y 좌표를 인자로 넣는다. 하지만 scrollTo 메소드에 다음과 같이 옵션 객체를 넣을 수도 있다.

window.scrollTo({
  top: 0,
  left: 0,
  behavior: "instant",
});

 
behavior의 default value가 "smooth"이기 때문에 이를 "instant"로 바꾸어서 MovieDetail 페이지의 useEffect 안에서 호출하니 브라우저의 종류에 상관없이 상세 페이지로 이동할 때 스크롤이 자동으로 최상단으로 이동했다. 

useEffect(() => {
    window.scrollTo({ top: 0, behavior: "instant" });
}, []);

 

2. Intersection Observer API

타겟 요소(특정 DOM 요소)와 뷰포트가 교차하는 부분의 변화를 비동기적으로 관찰하는 API로, observe() 메소드로 타겟을 관측하도록 하면 해당 타겟이 뷰포트에 들어왔는지 여부를 판단할 수 있다. 이때, observer는 여러 개의 DOM을 관측할 수 있기 때문에 스크롤을 올렸다가 내리면 추가 데이터 fetching이 일어날 수 있기 때문에 기존 타겟은 unobserve()나 disconnect() 메소드로 관측을 해제해야 한다. 
 
IntersectionObserver 인스턴스 생성시 콜백 함수가 첫번째 인자로 들어가고, 두번째 인자로 옵션 객체를 받을 수 있는데, 이 중 threshold 값은 default가 0으로, 0일 경우 조금만 뷰포트에 들어와도 감지되지만, 1일 경우에는 타겟이 완전히 다 들어와야 감지된다. 
 

function MovieList({ movies, loadMoreData, loading }) {
  const target = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) loadMoreData();
    });

    if (target.current) observer.observe(target.current);

    return () => observer.unobserve(target.current);
  }, [movies]);

  return (
    <div className={styles.container}>
      <div className={styles.movieListGrid}>
        {movies &&
          movies.map((movie) => (
            <Link to={`/${movie.id}`}>
              <MovieCard
                id={movie.id}
                title={movie.title}
                posterPath={movie.poster_path}
              />
            </Link>
          ))}
        <div ref={target} />
      </div>
      {loading && (
        <div className={styles.loadingIndicatorContainer}>
          <LoadingIndicator />
        </div>
      )}
    </div>
  );
}

 
이 때 두 가지 에러가 있었다. 
 
먼저 unobserve 메소드 때문에 다음과 같은 타입 에러가 떴다. 

TypeError: Failed to execute 'unobserve' on 'IntersectionObserver': parameter 1 is not of type 'Element'. 

 

runtime error 캡처 이미지

 
에러 메시지만 읽고는 무슨 뜻인지 바로 파악이 안 되었는데, stack overflow의 답변에서 힌트를 얻었다. 요지는 타겟 요소가 아직 존재하지 않아서 발생하는 에러라는 얘기였다. 
 
해결책은 두 가지가 있었다. 1) 타겟 요소가 있을 때에만 unobserve 하도록 clean up 함수를 수정하거나 2) 모든 가시성 변화를 중단하는 disconnect 메소드를 사용하면 에러가 발생하지 않았다.

// 해결책 1
return () => {
      if (target.current) observer.unobserve(target.current);
};

// 해결책 2
return () => observer.disconnect();

 
두 번째는 첫 렌더링 때 데이터 패칭이 2번 발생해서 page가 2로 넘어가고, 영화 데이터를 담는 상태 변수인 movies 배열에 총 40개의 데이터가 담기고 시작하는 문제가 있었다. 사용자 입장에서는 처음부터 40페이지가 들어와있고, 하단으로 스크롤을 내릴 때마다 20개씩 추가 데이터를 더 받아오는 게 그렇게까지 큰 문제는 아니긴 했다. react 특성상 데이터가 화면에 나타나기 전에 observer가 관측 중인 target이 화면에 먼저 렌더되어서 교차가 발생하기 때문에 useEffect 안의 loadMoreData 함수가 호출되어 page가 2로 넘어가기 때문에 생기는 문제였다. 다음과 같은 조건부 렌더링을 통해 이 문제를 해결했다.
 

  return (
    // 생략
        {movies.length > 0 && <div ref={target} />}
  );

 
 
📚 참고자료

 

실전 Infinite Scroll with React

시작하며 안녕하세요. 카카오엔터프라이즈 워크코어개발셀에서 프론트엔드 개발을 담당하고 있는 Denis(배형진) 입니다. 약 1년 전, 저는 프레임워크의 선택, React vs Angular 이라는 포스팅을 통해

tech.kakaoenterprise.com

 

React 무한 스크롤 적용 - TIL #7

2022년 2월 마지막 주, 부트캠프 프로젝트 마무리로 React 무한 스크롤 적용을 해보았습니다.

velog.io

 

[React]Intersection Observer 사용하여 무한스크롤 구현하기

무한 스크롤...? 어렵지 않아요😅

velog.io

 

IntersectionObserverAPI로 무한스크롤 구현 - 공부방

자, 이렇게 관찰자, 관찰 대상, 조건, 콜백함수 를 다 만들었으니 끝난걸까? 아니다. 지금 이대로 실행하면 절대 원하는 결과를 얻지 못한다. 왜? 관찰 대상 은 새로운 데이터를 가져올 때 마다 변

simian114.gitbook.io