[React] 무한 스크롤 적용하기

sjoleee·2022년 10월 11일
36
post-thumbnail

왜 무한스크롤을 적용했나?

📝 구현 방법부터 읽으셔도 무방합니다.

배경

프로젝트에서 모든 아이돌 멤버 데이터를 가져와 카드 형태로 뿌려주는 기능을 구현했습니다.

처음 채택했던 방식은 페이지 진입과 동시에 모든 아이돌 멤버 데이터를 get한 후, 전부 다 렌더링하여 리스트를 생성하는 방식이었습니다.
그에 따라, 검색기능 역시 추가적인 api 호출 없이 searchInput에 입력된 값이 포함되어있는지, 처음 가져온 데이터에 filter메서드를 사용하는 방식으로 만들어졌습니다.

📝 아래 글에서 등장하는 것과 같은 프로젝트입니다.
https://velog.io/@sjoleee_/선택-취소-기능-1
https://velog.io/@sjoleee_/선택-취소-기능-2

그러나, 생각보다 아이돌 멤버의 숫자는 많았고(브랜드 평판지수 30위 안의 걸그룹만 해도 200명이 넘습니다.) 각 멤버마다 이미지를 포함하고 있기에, 보이그룹이나 솔로 아티스트까지 포함할 경우 통신에 걸리는 시간이나 렌더링에 걸리는 시간이 다소 길어져 유저 경험을 해치지 않을까 하는 우려가 생겼습니다.

따라서 아이돌 멤버 데이터를 나눠서 받을 수 있는 페이지네이션, 혹은 무한스크롤을 적용하기로 하였으며, 그에 따라 검색기능 역시 검색 api를 통해 받아온 데이터를 렌더링하는 방식으로 수정하기로 했습니다.

이번 글에서는 무한스크롤 관련 내용만 다루겠습니다.

페이지네이션 vs 무한스크롤

이제 페이지네이션과 무한스크롤 중 어느 방식을 도입할지에 대해 고민할 차례였습니다.

페이지네이션의 장/단점

먼저, 제가 생각하는 페이지네이션의 장점은 사용자에게 많은 정보를 제공한다는 점입니다.

페이지로 구분될 경우 사용자는 지금 몇 번째 페이지를 보고 있는지, 앞으로 대략 몇 페이지가 더 남아있는지 알 수 있으며, 원하는 페이지로 건너뛰어 이동할 수 있습니다.
이처럼 사용자에게 서비스가 가진 정보들을 조금 더 자세히 제공함으로써 사용자는 더욱 자유롭게 서비스를 이용할 수 있습니다.

반면, 제가 생각하는 페이지네이션의 단점은 추가적인 데이터를 보기 위해서 사용자가 행동을 취해야 한다는 점입니다.

이전페이지 1 2 3 4 5 다음페이지 와 같이 페이지 번호를 눌러 이동하거나, 버튼을 누르게 되는데, 이는 사용자에게 페이지 이동을 위해 클릭, 터치같은 행동을 취하도록 강요합니다.
특정 목적을 지니고 방문하여 서비스를 이용하려는 의지가 있는 사용자(검색이 해당될 것 같습니다.)가 아니고 단순히 흥미로 방문하여 이탈할 여지가 많은 사용자라면 취해야 할 액션이 줄고, 페이지 수가 줄어야 이탈이 감소할 것이라 생각합니다.
따라서, 페이지네이션은 사용자 입장에서 추가적인 데이터를 위한 허들로 느껴질 수 있다고 생각했습니다.

무한스크롤의 장/단점

제가 생각하는 무한스크롤의 장점은 자연스러움 입니다.
모바일 환경에서 가장 기본이 되는 상하 스크롤링으로만 데이터를 계속해서 접할 수 있다는 점을 말합니다.

모바일 환경의 사용자에게 있어 상하 스크롤링은 너무나 자연스러운 행위입니다. 대부분의 앱이 스크린사이즈를 초과할 경우 상하 스크롤을 통해 초과된 데이터를 제공하기 때문이죠.
이는 사용자에게 터치나 흔들기, 외부 버튼 클릭 등의 추가적인 행위로 인식될 수 있는 허들을 제거하여 자연스럽게 더욱 많은 데이터를 접하게 만듭니다.

반면, 제가 생각하는 무한스크롤의 단점은 원하는 데이터의 위치를 기억하기 어렵다는 점입니다.

만약 페이지네이션으로 구현된 상품 리스트에서 A라는 상품을 찾는다고 가정해봅시다.
저는 A5페이지 하단에서 찾을 수 있었고, 나중에 다시 A를 찾을 때는 5페이지(혹은 신규 상품이 추가되었을 경우 6페이지 또는 그 뒤의 페이지 어딘가)로 바로 방문하여 찾을 수 있겠습니다.

하지만 무한스크롤은 원하는 정보가 어디에 위치하는지를 기억할 수 없습니다.
대략적으로 이 정도 스크롤 내리면 나왔던 것 같은데? 와 같은 부정확한 직감에 의존하게 됩니다.
이와 같이, 무한스크롤은 특정 데이터를 찾고, 기억해야하는 서비스에서는 좋지 않을 수 있다고 생각합니다.

무한스크롤을 도입하게 된 이유

프로젝트에서는 수많은 아이돌 멤버를 보여주고 있습니다.

안타까운 현실이지만, 정말 많은 아이돌 그룹 중에는 우리가 들어보지 못한 그룹도 많습니다.
심지어는 꽤나 유명한 그룹임에도 특정 멤버만 유명한 경우도 존재하기에, 우리는 사용자로 하여금 가능한 많은 아이돌 멤버를 둘러볼 수 있도록 유도하고자 했습니다.
유명하고 유명하지 않고를 떠나서도, 수많은 아이돌을 다 기억하고 있기란 어려운 일입니다.

따라서 가능한 많은 데이터를 자연스럽게 접하며 여러 아이돌을 보고 흥미를 갖거나 기억을 떠올릴 수 있는 무한스크롤로 결정했습니다.


무한스크롤 구현 방법

무한스크롤을 구현하는 방법에는 크게 2가지가 있었습니다.

1️⃣ scroll event를 감지하는 방법(feat. throttle)

스크롤이 페이지 끝에 닿는 것을 감지하여 추가 데이터를 받아오는 방식입니다.

전체 페이지 높이 <= 지금까지 스크롤한 길이 + 현재 보이는 부분이면 끝에 닿았다고 할 수 있겠죠?
이것을 계산하기 위해 height에 관련된 프라퍼티들을 정리해보았습니다.

scrollTop: 맨 위 ~ 현재 보이는 부분까지
scrollHeight: 맨 위 ~ 맨 아래
clientHeight: 현재 보이는 부분(border 제외)
offsetHeight: 현재 보이는 부분(border 포함)

scroll 이벤트가 발생할 때마다, scrollHeightscrollTop + offsetHeight를 비교하면 페이지의 끝에 닿았는지를 알 수 있겠습니다.

하지만, "scroll 이벤트가 발생할 때마다"와 같은 과도한 이벤트는 성능 저하로 이어질 수 있습니다다.
성능 개선을 위해서는 throttle을 적용할 수 있습니다.

📝 throttle에 대해서는 https://velog.io/@sjoleee_/debounce-throttle-정리 를 참고

하지만, throttle이 성능을 개선해줄지언정 완벽하지는 않다고 생각했습니다.
과도한 이벤트를 줄여줄 뿐이지 근본적으로 불필요한 이벤트가 발생하는 것은 여전하기 때문입니다.
이벤트를 더욱 줄이기 위해서는 ms 간격을 더욱 늘릴 필요가 있고, 이는 화면의 끝에 닿아도 약간의 기다림 후 이벤트가 감지된다는 것을 의미합니다.

2️⃣ Intersection Observer API

Intersection Observer API
Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다.
출처 : MDN

스크롤을 내리다 뷰포트에 타겟 요소가 들어오게 되면 데이터를 불러온다.
불러온 데이터는 기존 데이터와 타겟 요소 사이에 추가한다.

다시 스크롤을 내리다 뷰포트에 타겟 요소가 들어오게 되면 데이터를 불러온다.
불러온 데이터는 기존 데이터와 타겟 요소 사이에 추가한다.

... 반복 ...

위 방법을 통해 불필요한 이벤트 발생 없이 무한 스크롤을 구현할 수 있습니다.

사용 방법

// ✅ 관측에 적용할 수 있는 옵션입니다.
let options = {
  root: null, // 타켓 요소가 "어디에" 들어왔을때 콜백함수를 실행할 것인지 결정합니다. null이면 viewport가 root로 지정됩니다.
  //root: document.querySelector('#scrollArea'), => 특정 요소를 선택할 수도 있습니다.
  rootMargin: '0px', // root에 마진값을 주어 범위를 확장 가능합니다.
  threshold: 1.0 // 타겟 요소가 얼마나 들어왔을때 백함수를 실행할 것인지 결정합니다. 1이면 타겟 요소 전체가 들어와야 합니다.
}

// ✅ 관측되었을 경우 실행할 콜백함수입니다.
let callback = () => {
  console.log('관측되었습니다.')
}

// ✅ observer를 선언합니다.
// 첫 번째 인자로 관측되었을 경우 실행할 콜백함수를 넣습니다.
// 두 번째 인자로 관측에 대한 옵션을 지정합니다.
let observer = new IntersectionObserver(callback, options);

// ✅ 타겟 요소를 지정합니다.
// React에서는 useRef를 활용하여 DOM을 선택합니다.
let target = useRef();

observer.observe(target); // ✅ 타겟 요소 관측 시작
observer.unobserve(target); // ✅ 타겟 요소 관측 종료

기본적인 사용법은 위와 같습니다.
다만, 실제로 적용하려면 아래와 같이 관측 시작을 위해 useEffect를 활용해야합니다.
아래 codepen에서 끝까지 스크롤을 내려보세요!

import { useEffect, useRef } from "react";

function App() {
  const target = useRef(null);

  useEffect(() => {
    observer.observe(target.current);
  }, []);

  const options = {
    threshold: 1.0,
  };

  const callback = () => {
    target.current.innerText += "관측되었습니다";
  };

  const observer = new IntersectionObserver(callback, options);

  return (
    <>
      <div style={{ height: "300vh", backgroundColor: "green" }} />
      <div style={{ height: "100px", backgroundColor: "red" }} ref={target}>
        target
      </div>
    </>
  );
}

export default App;

위 예시를 응용하여 callback에 데이터 요청 함수를 넣어주면 스크롤이 바닥에 닿았을 경우 데이터를 불러오는 무한스크롤이 완성됩니다.

다만, 실제로 프로젝트에 적용해보니 이런저런 고려사항이 정말 많았습니다 ㅠㅜ
예를 들자면,

1. 한 번에 여러 페이지가 로드되는 문제

해당 문제는 api 요청 응답이 오기 전에 또 다른 api 요청을 보내면서 발생했습니다.
기대한 사용자의 행동은 스크롤을 끝까지 내리고 -> 추가 목록이 불러와지고 -> 또 끝까지 내리고 -> ...(반복) 이었습니다.
하지만 스크롤을 끝까지 내리고 -> 추가 목록이 불러와지는 중에 스크롤을 올렸다가 다시 끝까지 내림 과 같은 행동을 취했을 경우, 서버에 부하가 생길 것을 우려했습니다.

따라서 isLoading이라는 상태를 만들어 api 호출과 함께 true로, 호출이 끝나면 false로 업데이트 해주었고, callback 내부에 조건문을 만들어 isLoadingfalse일 경우에만 동작하도록 해주었습니다.

이 방법으로 데이터를 받아오는 중에는 추가적인 api 요청을 보낼 수 없도록 수정했습니다.

2. 페이지 진입 시 중복 api 요청 문제

페이지 진입과 동시에 수행하는 첫 api 호출이 2번 발생되는 문제였습니다.
결론적으로, 이는 strictMode의 영향이었으므로 배포 환경에서는 발생하지 않았습니다.

3. api에 전달할 page 값

무한 스크롤 get 요청에서 사용하는 url은 queryString으로 pageNo를 전달해주어야 했습니다.
/page?pageNo=${query}
이를 위해 page라는 상태를 만들었고, callback 함수 내에서 setPage를 통해 관측될 경우 +1 해주도록 작성하였습니다.

const callback = (entries: IntersectionObserverEntry[]) => {
    const target = entries[0];
    if (!isLoading && target.isIntersecting && preventRef.current) {
      preventRef.current = false;
      setPage((prev) => prev + 1);
    }
  };

이어서, page가 업데이트 될 경우 api를 호출하도록 useEffect를 사용하였습니다.

  useEffect(() => {
    get();
  }, [page]);

지금 생각해보니 get함수의 인자로 page를 넘겨줘야 하는데... 외부 상태에 의존하게 만들었네요 ㅠㅠ

4. 이 외에도 사소한 문제들이 정말정말 많이 있었습니다.

대부분의 문제는 구글링과 MDN을 보며 해결할 수 있었습니다.
제가 참고한 글은 다음과 같습니다.
https://velog.io/@yunsungyang-omc/React-무한-스크롤-기능-구현하기-used-by-Intersection-Observer-2
https://tech.kakaoenterprise.com/149
https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

다만, 글쓰신 분들이 intersection observer을 다르게 사용하기도 하셨고, 사용한 환경도 각기 달랐습니다.
저는 next ts 환경에서 작업하였기에 동일한 환경에서 작성된 글을 찾기 어려웠습니다 ㅠㅠ
SSR로 인해 발생하는 문제가 하나 있었는데, window 객체가 없다는 것이었습니다... 이는 useEffect로 감싸서 클라이언트에서 처리하게 만들어 해결했습니다.

3️⃣ 라이브러리 사용

무한 스크롤을 구현할 수 있는 라이브러리가 존재합니다.
저는 사용하지 않았는데, intersection observer API보다 편한... 지는 잘 모르겠습니다.
둘러보시고 필요하시다면 라이브러리를 사용하시는 것도 좋겠습니다!
(저는 다 구현하고 나서 라이브러리가 있다는 것을 알았습니다!! 하하!!)


결과물

profile
상조의 개발일지

0개의 댓글