지금까지 구현한 무한스크롤 되짚어보기! interSectionObserver / Vanilla JS, React로 3번 다르게 구현한 과정

GY·2022년 3월 4일
0

리액트

목록 보기
38/54
post-thumbnail

지금까지 intersectionObserver를 활용해 무한스크롤을 총 3번 만들어보았다.

1.Vanilla JS2.React3.React

왜 3번이나 만들었을까?

댓글 삭제 및 추가 등의 기능을 연습하기 위해 처음에 인스타그램을 흉내내어 프로젝트를 만들어보았다.
그러다 조금 더 UX를 고려한 프론트엔드 개발자로서 많이 구현하게 되는 사항들을 추가로 구현해보고 싶어졌다. 흔히 서비스를 사용하면서 볼 수 있는 스켈레톤 UI와, 무한 스크롤 기능을 만들어보고, 어떻게 사용자 경험을 개선하기 위해 많이 쓰이는 기능들을 구현할 수 있는지 알고 싶었다.

그러나 그 때마다 나름의 고민과 궁금한 점들이 생겨 같은 기능도 다른 방식으로 조금씩 만들면서 공부해보았다.

왜 모두 intersectionObserver를 사용했을까?

효율성

addEventListener로 스크롤 이벤트를 구현하게 되면, 연속해서 함수가 호출되어 불필요한 이벤트를 컨트롤 해야 한다. 이럴 경우 debounce혹은 throttle을 사용하게 되는데, intersectionObserver를 사용하면 별도로 이에 대해 일일히 구현하지 않아도 된다.

reflow를 하지 않음

스크롤 이벤트에서는 현재의 높이 값을 알기 위해 offsetTop을 사용하는데, 정확한 값을 가져오기 위해 매번 layout을 새로 그리게되고, 이는 렌더트리를 재생성하는 것이기 때문에 브라우저의 성능을 저하시킨다. 하지만 intersectionObserver는 이 부분을 걱정하지 않아도 될 분더러 타겟요소를 구독할 필요가 없어질 경우 관찰을 중단할 수 있다.


1️⃣ Vanilla JS로 무한스크롤 구현하기


data를 fetch한 뒤 skeletonFeed를 생성해 표시한다.
그리고 생성한 intersectionObserver로 관찰을 시작할 수 있도록 startObserver()함수를 호출한다.

async function fetchData() {
    feedData = await(await fetch('data/feed.json')).json();
    addSkeletonFeed();
    startObserve();
}

💀 스켈레톤 피드 UI

새로운 요소를 생성한 뒤 innerHTML로 skeleton UI를 위해 작성해놓은 html코드를 넣어준다.
setTimeout으로 인위적으로 로딩 상태를 만들었다. 2초 후에는 displayFeed()함수가 호출되어 피드를 생성한다.

function addSkeletonFeed() {
    const $newFeed = document.createElement('article');
    $newFeed.classList.add('skeleton');
    $newFeed.innerHTML = skeletonHtml;
    $feeds.appendChild($newFeed);
    setTimeout(() => {
        displayFeed($newFeed);
    }, 2000)
}

displayFeed는 skeleton에서 feed로 클래스 이름을 바꾸어주었다.
여러 긴 html코드들은 html이라는 이름으로 클래스를 만들어 사용했다.
displayFeed함수가 호출될 때마다 index는 증가하고, 이에 데이터를 호출한다.

function displayFeed(feedEl) {
    const index = displayCount++;
    const data = feedData[index];
    feedEl.classList.remove('skeleton');
    feedEl.addEventListener('click', handleEvent);

    feedEl.classList.add('feed');
    feedEl.innerHTML = html.addFeed(data, index);
}

🔗 무한 스크롤

불러온 data만큼만 skeletonFeed를 표시한 뒤 2초 후에 실제 피드를 표시한다.
그 길이를 넘어가면 더 이상 데이터가 없다는 메시지를 표시한다.
intersectionRation는 1로 설정하여 관찰하는 요소가 모두 화면 안에 들어왔을 때 callback 함수를 호출하도록 했다.

const callback = (entry, observer) => {
    if(entry[0].isIntersecting && entry[0].intersectionRatio === 1) {
        nextFeedCheckIndex++;
        if(nextFeedCheckIndex < feedData.length) {
            addSkeletonFeed();
        } else {
            displayNoFeedMessage();
            observer.unobserve($feedEnd);
        }
    }
}

let options = {
    root: null,
    rootMargin: '0px',
    threshold: 1,
}

const observer = new IntersectionObserver(callback, options)

function startObserve() {
    observer.observe($feedEnd);
}

❗️ 고민했던 점

  • 목업데이터의 수도 적고, 무한 스크롤 기능 구현 자체에 초점을 두었기 때문에 스크롤을 내릴 때마다 한 개의 피드가 로드되도록 했다. 그리고 그 때마다 일부러 지연시킨 로드시간 동안 스켈레톤 UI가 보인다. 실제 좋은 사용자 경험을 고려해 만든 것은 아니었기 때문에 실제 서비스의 사례를 참고하여 좋은 UX를 제공할 수 있도록 만들어보고 싶었다.
  • 프론트엔드에서 목업 데이터를 활용하여 구현했지만, 실제로는 백엔드에서 추가적인 데이터를 요청해오는 것이기 때문에 실제로 백엔드와 함께 협업하여 구현해보고 싶었다.

2️⃣ React로 무한스크롤 구현하기

깃허브 링크

👉 진행한 이유

  • 이전에 구현해보았을 때부터 궁금했던 부분이었기 때문에, 백엔드 팀원과 함께 다시 만들어보게 되었다.
  • 어떤 프로젝트를 진행하고 싶은지 발표할 때부터 무한스크롤 기능에 대해 어필했었는데, 그리고 정말 실력있고 좋은 백엔드 팀원분들을 만나서 함께 만들어 볼 수 있었다. :)

💀 스켈레톤 피드 UI

Feeds.js


function Feeds() {
  const [page, setPage] = useState(0);
  const [feedList, setFeedList] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const SKELETON_FEED_COUNT = 5;

화면 로드 초기에만 보여줄 스켈레톤 피드를 생성하는 코드이다.
스켈레톤 피드 UI를 몇 개 생성할 것인지 인자로 전달 받고,
그 수에 해당하는 길이의 배열을 생성하여 미리 만들어두었던 스켈레톤 피드 컴포넌트를 mapping 해 생성했다.

  function createSkeletonFeed(count) {
    let skeletonFeedCount = Array.from({ length: count }, (v, i) => i);
    return skeletonFeedCount.map(num => {
      return <Skeleton key={num} />;
    });
  }

백엔드에 요청해 데이터를 받아오는 것은 아니기 때문에, 로딩중이라는 상태값을 만들었고 임의로 로딩 시간을 지연시켰다.

  useEffect(async () => {
    setIsLoading(true);
    await fetchTimeDelay(1000);
    await fetchFeedData(page);
    setIsLoading(false);
  }, [page]);
  const fetchTimeDelay = time =>
    new Promise(resolve => setTimeout(resolve, time));

🛠 목업데이터로 페이지네이션 구현하기

여기서 페이지네이션 개념을 적용했는데, 백엔드 요청을 하지는 않지만 쿼리파라미터로 데이터 요청을 하는 형태를 가상으로 만들었다.
미리 feed1, feed2, feed3....의 넘버링된 이름을 가진 json파일을 만들어두고,
인자로 전달되는 page에 따라 각각의 파일을 불러와 기존 데이터에 추가하는 방식이었다.

  async function fetchFeedData(page) {
    const newFeeds = await (await fetch(`data/feed${page}.json`)).json();
    setFeedList(prevFeeds => [...prevFeeds, ...newFeeds]);
  }

가장 처음에만 스켈레톤 피드 UI를 보여줄 것이었기 때문에 page가 0이고 로딩 중일 때만 스켈레톤 피드를 생성하여 보여주었다.
이외에는 feedList에 들어있는 복수의 피드 데이터들을 mapping하여 개별 피드들을 보여주었다.

컴포넌트는 intersectionObserver 관련 로직이 들어있는 곳이다. 이것은 맨 처음에 피드가 로딩 중이고 스켈레톤 피드 UI가 노출될 때를 제외하면 가장 아래의 요소에 위치한다.

  return (
    <div className="feeds">
      <div className="feed-container">
        {page === 0 && isLoading
          ? createSkeletonFeed(SKELETON_FEED_COUNT)
          : feedList.map((feed, idx) => <Feed key={idx} feed={feed} />)}
      </div>
      <LoadMoreFeed isLoading={page !== 0 && isLoading} setPage={setPage} />
    </div>
  );
}

export default Feeds;

🔗 무한스크롤

LoadMoreFeed.js

데이터가 아직 로드되지 않아 로딩 중이라면 css로 구현한 로딩화면을 보여주고, 로딩이 완료되었다면 데이터를 불러와 새로운 피드를 보여준다.


import React, { useRef } from 'react';
import './LoadMoreFeed.scss';

function LoadMoreFeed({ isLoading, setPage }) {
  const feedEndRef = useRef();
  const observer = new IntersectionObserver(entry => {
    if (entry[0].isIntersecting && entry[0].intersectionRatio > 0.1) {
      setPage(page => page + 1);
    }
  });

  useEffect(() => {
    observer.observe(feedEndRef.current);
    return () => {
      observer.unobserve(feedEndRef.current);
    };
  }, []);

  return (
    <div className="feed-end" ref={feedEndRef}>
      {isLoading ? <div className="loading" /> : ''}
    </div>
  );
}

export default LoadMoreFeed;

🛠 로딩 써클 애니메이션

  • 스켈레톤은 화면 안에서 많은 내용들이 로드될 때 전체적인 레이아웃을 보여주는 역할이 크다고 생각한다. 반대로 인스타그램에 보다 적합할 수 있는 로딩 써클을 구현해보았다. 자바스크립트 로직 대신 css로 간결하게 구현해보았다.

css로 로딩 써클 구현

css로 로딩중임을 보여줄 수 있도록 loading circle animation을 만들었다.
원하는 곳에서 사용할 수 있도록 믹스인으로 변수로 만들었다.

@mixin loading {
  width: 60px;
  height: 60px;
  border: 4px solid rgba($color: $loadingColor, $alpha: 0.4);
  border-radius: 50%;
  border-top-color: $loadingColor;
  animation: spin 1s ease-in-out infinite;

@keyframes spin {
  to { transform: rotate(360deg); }
}
}

필요한 곳에서 이렇게 사용했다.

    .loading {
      @include loading;
    }

❗️ 성장 포인트 / 기존과 달라진 점

  • 어떻게 백엔드와 협업하여 이 부분을 구현하는지 궁금했다. 그리고 페이지네이션이라는 개념에 대해 알게되었는데, 아쉬운대로 프론트엔드에서 이 것을 흉내내어 구현해보기로 했다. 이전과 달리 한번에 보여주는 갯수를 5개 정도로 늘렸다. 그리고 5개는 각각의 파일로 나누어 fetch해왔다.

  • 이전과 달리 많은 피드를 한번에 슥슥 내려 스크롤하는 인스타그램의 특성을 고려해 특정 갯수 (목업데이터를 일일히 만들기 힘들어 5개로 제한했지만, 실제로는 더 많은 피드 갯수로 가정)만큼을 한 번에 로드하되, 각 피드가 아닌 하단의 로딩스피너를 추가하여 로드 중임을 표시했다. 기존 피드의 스켈레톤 UI는 초기 렌더링 시 콘텐츠가 로드되기 전에만 사용했다.


❗️ 아쉬운점

  • 이 코드가 리액트 내에서 잘 작동하는 코드인지 확실하지 않았다. 가이드가 있으면 좋겠어서 다른 사람들의 코드를 찾아봤는데, 조금 더 공부가 필요하다.

3️⃣ React에서 무한스크롤 구현하기2

무한스크롤 기능 관련 코드 뜯어보기

깃허브 링크


🔗 무한스크롤

Main.js

  return (
    <>
      <div className="main">
		//생략
      </div>
      {page === 5 && (
        <FilterProduct addCart={addCart} cartList={cartList} showMore={false} />
      )}
      {productsList && page < 5 && <LoadMoreProducts setPage={setPage} />}
    </>
  );

LoadMoreProducts.js

import React, { useRef } from 'react';
import { useEffect } from 'react';
import './LoadMoreProducts.scss';

function LoadMoreProducts({ setPage }) {
  const feedEndRef = useRef();
  useEffect(() => {
    const callback = entry => {
      if (entry[0].isIntersecting) {
        setPage(page => page + 1);
      }
    };

    const observer = new IntersectionObserver(callback);

    let observerRefValue = null;
    if (feedEndRef.current) {
      observer.observe(feedEndRef.current);
      observerRefValue = feedEndRef.current;
    }
    return () => {
      if (observerRefValue) {
        observer.unobserve(observerRefValue);
      }
    };
  }, [setPage]);

  return <section className="loadMoreProducts" ref={feedEndRef} />;
}

export default LoadMoreProducts;

❗️ 성장포인트

  • 드디어 API요청을 통해 무한 스크롤 기능을 구현해보게 되었다! api 쿼리 파라미터와 페이지네이션 개념을 실제로 활용해볼 수 있었다.
  • 실제 무한스크롤 기능을 통한 UX개선에 대해서 다시 한번 생각해보게 되었다. 마켓컬리를 참고하여 스크롤을 내리면 끝에 도달하기 전에 미리 데이터가 로드되도록 만들었다. 정말 많은 양의 데이터를 요청해 로드 속도가 느리다면 로딩 UI가 별도로 필요하겠지만, 해당 메인 페이지에서 다루는 상품의 각 데이터는 라인 별로 20개 내외로 정해져있으므로 별도로 구현하지 않고 고객이 스크롤 끝에 도달하기전 자연스럽게 콘텐츠를 이어 볼 수 있도록 구현했다.
  • eslint를 사용하면서 보다 적절한 코드에 대해 더 고민해보게 되었다. 나름대로 코드를 뜯어보고 정리해보았다.

🛠 공식문서 코드 살펴보기

나름대로 eslint rule을 적용하면서 조금 더 좋은 코드를 작성하기 위해 뜯어보고 정리하고, 코드에 반영했던 내용을 정리해보았다.

마주한 에러

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글