리액트에서 무한 스크롤 구현하기

otter·2022년 7월 14일
3
post-thumbnail

이번에 사전과제로 받았던 내용 중 무한스크롤이 있다. 무한스크롤은 항상 해봐야지 해봐야지 했는데 미뤄놨던 부분 중 하나였는데 😅, 이번기회에 정리도 한번 하고 과제도 한번 해보려고 한다. 그리고 원래라면 저번 레이지로드할때 사용했었던 IO를 그대로 이어서 사용하려고 했는데 또 다른 방법이 있길래 어떤 방법이 좋은지도 고민해보고 싶었다.

scroll 이벤트를 이용해서 구현하기.

자바스크립트에서 기본적으로 제공하는 이벤트 중 scroll를 활용해서 구현하는 방법이다.
이 이벤트를 구현하기 앞서, 기본적으로 화면단의 높이를 구하는 프로퍼티들을 알고 있어야 하므로 한번 짚고 넘어가야 한다.

  • clientHeight
    100vh를 생각하면 쉬운 속성인데, 구체적으로 구하는 방법은 조금 다르고 offsetHeight등과 차이점이 있다. 일단 mdn에 따르면 CSS상의 높이 + CSS상의 내부 여백 - 수평 스크롤바의 높이(존재하는 경우에만)로 계산된다고 한다.

  • scrollHeight
    이 속성은 스크롤을 포함한 전체 요소의 높이를 말한다. 그러니까, 스크롤이 생겨서 뷰포트에 보이지 않는 아래의 요소들의 높이까지 전부 포함한다. mdn에 따르면 Element.scrollHeight 읽기 전용 속성은 요소 콘텐츠의 총 높이를 나타내며, 바깥으로 넘쳐서 보이지 않는 콘텐츠도 포함한다고 한다.

  • scrollTop
    top속성이므로 세로의 스크롤 속성이 있을때만 작동하는데 현재 스크롤바의 위치라고 생각하면 편하다.

그림으로 보자면 이런 느낌이다.
이 세가지 속성을 이용해서, 현재 스크롤바의 위치를 구하고 화면단의 총 길이, 스크롤을 포함한 총 길이를 구해서 특정 스크롤의 위치에서 데이터를 새로 불러와서 렌더링한다면 무한스크롤을 구현할 수 있다.

공식을 세우자

  • clientHeight는 일단 100vh라고 생각하고 고정값이다. 현재 개발 과정에서 clientHieght821px이다.
  • scrollTop은 처음엔 0으로 시작해 스크롤바의 최상단의 높이를 말하는 값이다. 일단 개발단에서 초기페이지 렌더링을 위해 4가지 이미지만을 띄웠을때 맨 아랫단에서 256.5px이다.
  • scrollHeight는 이미지 4개를 띄웠을때 총길이 1077px 값을 가지고 있다.

진짜 간단히 계산해서, clientHeight + scrollTop >= scrollHeight 를 기준으로 새로운 데이터를 받아와야 하는 지점을 얻을 수 있다. 이렇게 하면 스크롤바가 맨 아래에 도달했을때 새로운 데이터를 불러올 수 있기 때문이다. 일단 이런식으로 진행을 하자.

간단한 코드를 작성하자.

const App = () => {
  const [data, setData] = useState([]);
  const [dataLen, setDataLen] = useState(0);
  // 초기에 페이지 렌더링에 필요한 데이터의 개수

  useEffect(() => {
    fetch('내 database url')
      .then((r) => r.json())
      .then((r) => r.map((v) => v.imgSrc))
      .then((r) => r.map((v) => v.replace('hotel,room', 'cat')))
    	// 이부분은 무시해도 된다. 
        // 단순히 고양이 사진을 보기위해서 교체한 부분이다.
      .then((r) => r.filter((v, i) => i >= dataLen && i < dataLen + 4))
    	// 이부분을 왜 이렇게 작성했는지의 여부는 아래에서 말하고자 한다.
      .then((r) => setData((prev) => [...prev, ...r]));

    console.log(data);
  }, [dataLen]);

  const handleScroll = () => {
    const scrollHeight = document.documentElement.scrollHeight;
    const scrollTop = document.documentElement.scrollTop;
    const clientHeight = document.documentElement.clientHeight;
    if (scrollTop + clientHeight >= scrollHeight) {
      // 페이지 끝에 도달하면 추가 데이터를 받아온다
      setDataLen((prev) => prev + 4);
    }
  };

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

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);
  return (
    <Container>
      <h1>INFINITE SCROLL</h1>
      {data && data.map((v) => <Card imgSrc={v} />)}
    </Container>
  );
};

사실 결과적으로 이 방법말고 다른 방법(IO를 사용하는 방법)으로 하기로 했기 때문에 디테일한 코드, fetch관련 부분이나 여타 부분은 고치지 않았다.

조금만 사용자 편의를 고려해보기

그런데, 지금 이 코드에는 두가지 문제점이 있다.

  • 첫번째 문제점은 무조건 스크롤바가 최하단에 있어야 새로운 데이터를 받아온다.
    스크롤바가 최하단 -> 새로운 데이터 받아옴 -> 이미지를 렌더링 하는 과정에서 잠시동안 멈춰지는 시간이 있다.
  • 스크롤 함수는 계속해서 실행된다.
    위처럼만 코드를 작성해도 스크롤바가 맨 하단에 있을 경우에만 이미지를 불러오므로 큰 문제가 없어 보이지만 사실 스크롤바 내부에서 공식으로 계산을 하는 부분은 계속해서 이루어진다.
    만약 스크롤바를 올렸다 내렸다만 하고, 최하단으로 도달하지 않는다면 이 계산은 계속해서 진행된다.
    결과적으로 성능상에 좋지 않을 것이다.

  • 브레이크 포인트에서 함수를 계속해서 실행하고 있다.
    그리고 이부분과 관련된 문제가 하나 더 있다. 브레이크포인트(새로운 데이터를 불러와야하는 지점에) 도달했다면, 상태값에 필요한 데이터의 수를 늘려주는 로직으로 작성하였는데 이렇게 작성하니 브레이크포인트에서 스크롤을 조금만 계속해도 중복적으로 계속 실행되었다.

스크롤바의 위치를 고려하기

첫번째 문제를 해결하기위해 공식을 조금만 고쳐보자.

  const handleScroll = () => {
    const scrollHeight = document.documentElement.scrollHeight;
    const scrollTop = document.documentElement.scrollTop;
    const clientHeight = document.documentElement.clientHeight;
    if (scrollTop + clientHeight >= scrollHeight - 100) {
      console.log(scrollHeight);
      console.log(scrollTop, 'top');
      // 페이지 끝에 도달하면 추가 데이터를 받아온다
      setDataLen((prev) => prev + 4);
    }
  };

위의 코드의 공식부분을 조금만 고쳐졌다. 이제 맨마지막에 도달하지 않고 맨마지막에서 100px정도 위일때 새로운 이미지, 데이터를 불러올 것이다. 이렇게 한다면 사용자 입장에서 좋지 않을까? 데이터 몇개정도면 불러와야 할 데이터의 크기도 크지 않을 것이고. (갑자기 프리렌더링 프리페치가 생각났다..!)

그런데, 이렇게 하니까 두번째 문제가 더 돋보인다. 하단지점에서 스크롤을 조금만 움직여도 계속해서 함수가 작동되고 상태값을 변하게 해서 새로 렌더링을 받아오고 있었다. 그러면 두번째 문제를 해결하자.

쓰로틀링 이용하기

디바운스를 사용해본적은 많았지만, 쓰로틀링을 이용한적은 처음이다. 하지만 무한스크롤은 쓰로틀링을 자주 사용한다고 해서 찾아보니 위의 문제를 해결해줄 수 있어 보였다.


const throttle = (callback, delay = 400) => {
  let timer = null;
  return () => {
    if (timer === null) {
      timer = setTimeout(() => {
        callback();
        timer = null;
      }, delay);
    }
  };
};
// 쓰로틀함수도 찾아왔지만 사용하진 않았다.

const App = () => {
  const [data, setData] = useState([]);
  const [dataLen, setDataLen] = useState(0);
  // 초기에 페이지 렌더링에 필요한 데이터의 개수
  const [throttle, setThrottle] = useState(false);
  // 쓰로틀을 사용하기 위한 상태
  // 정말 잘 사용할 거라면 커스텀 훅으로 만들어야겠다.

  useEffect(() => {
    fetch('내 데이터 베이스 url')
      .then((r) => r.json())
      .then((r) => r.map((v) => v.imgSrc))
      .then((r) => r.map((v) => v.replace('hotel,room', 'cat')))
      .then((r) => r.filter((v, i) => i >= dataLen && i < dataLen + 4))
      .then((r) => setData((prev) => [...prev, ...r]));

    console.log(data);
  }, [dataLen]);

  const handleScroll = () => {
    if (throttle) return;
    if (!throttle) {
      setThrottle(true);
      setTimeout(() => {
        console.log(1);
        const scrollHeight = document.documentElement.scrollHeight;
        const scrollTop = document.documentElement.scrollTop;
        const clientHeight = document.documentElement.clientHeight;
        if (scrollTop + clientHeight >= scrollHeight) {
          console.log(scrollHeight);
          console.log(scrollTop, 'top');
          // 페이지 끝에 도달하면 추가 데이터를 받아온다
          setDataLen((prev) => prev + 4);
        }
        setThrottle(false);
      }, 500);
    }
  };

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

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);
  return (
    <Container>
      <h1>INFINITE SCROLL</h1>
      {data && data.map((v) => <Card imgSrc={v} />)}
    </Container>
  );
};

코드를 이렇게 고쳐주고, 쓰로틀링 함수를 추가했다. 쓰로틀링을 이용해서 어느정도 문제점을 해결할 수 있었다.

이런 부분이 아쉬웠다.

  • 데이터를 불러와서 파싱을 해야만 하는 부분 (이부분은 데이터의 문제였다.)
    일단, 위 코드를 보면 데이터를 불러와서 index에 따라서 새로운 데이터를 추가적으로 넣어주는데 이건 내 데이터 베이스가 500여개의 이미지 데이터를 한꺼번에 가지고 있었기 때문이다. 만약, 이게 페이지로 나뉘어져 있었다면 페이지별로 불러올 수 있었겠지만 내가 사용하고 있는 데이터는 500개였고 매번 이를 불러와야했다.

  • 처음에는 scroll이벤트를 사용하는 부분이므로 코드가 굉장히 직관적일 것이라고 생각했다.
    그런데, 내가 intersection observer을 먼저 공부해봐서 그런가 오히려 io가 오히려 보기 편하다라는 생각이 들었다.

  • 대기시간-쓰로틀링의 300ms- 등이 유동적이지 않다.
    사용자의 행동을 예측하고 미리 시간을 설정해주어야 하는데, 이러한 설정을 임의로 고칠 수 없다. 어느정도의 시간이 적절한지에 대해 고민해볼 필요가 있다. (일반적으로 300ms라고는 하지만)

  • 결국 레이지로드를 적용할 부분인데 (다량의 이미지를 불러오므로) 그럴 것이라면 그냥 IO를 쓰는게 나을지도 라는 생각이 들었다.

이런 부분은 좋았다.

  • 특정 부분에 쓰로틀링을 걸어준다는 부분이 명확히 보일 수 있을 것 같았다.

  • 특정 API를 사용하는 코드가 아니라 직관적으로 누구나 알아보기 쉬울 것이라는 생각이 들었다.
    (IO쓰면 이걸 scroll한다는 느낌도 안드는 코드가 나왔던 것 같다)

  • 데이터의 페이지네이션이 잘 되어 있다면, 초기 렌더링 시간을 줄일 수 있을 거라는 생각이 들었다.
    나의 경우 500개를 불러왔고, IO도 결국 이미지만 안보일뿐 500개를 다 불러와야 하는데,
    페이지네이션이 잘 되어있는 데이터라면 초기페이지를 렌더링할때 2~30개의 데이터만 불러와도 된다.
    (그런데 IO써도 2-30개만 불러올 수 있을 것 같다 이부분은 내 생각이 틀린 것 같다.
    그냥 이건 데이터의 문제였다. 내가가진 데이터베이스가 그랬고, 페이지네이션이 잘 되어있는 데이터라면 큰 문제는 없을 것 같다.)

  • 그리고 모든 사용자가 무한스크롤로 아래로 내려가진 않을 거니까, 다른 페이지로 바로 이동할 수도 있다는 걸 생각해보면 이러한 방법이 더 효율적일 수 있다라는 생각이 들었다.

Intersection observer로 구현하기

이전에 레이지로드를 구현하면서, intersection observer을 사용해본적이 있었다. 오히려 그래서 조금 헷갈렸다. 사실 레이지로드를 구현했을때, 작성했던 글이
https://velog.io/@otterp/react%EC%97%90%EC%84%9C-image-Lazyload
이 글이었는데 이거 무한스크롤이랑 뭐가 다른거지 싶기도 했고 내가 사용했던 로직과 느낌이 조금 달랐다.

구현코드

intersection observer은 화면단의 클라이언트가 보고있는 뷰포트를 감지해주고, 이를 이용할 수 있는 기능을 제공해준다.

import React, { useEffect, useState, useRef } from 'react';
import styled from 'styled-components';
import Card from './Card';

const App = () => {
  const [data, setData] = useState([]);
  const [dataLen, setDataLen] = useState(0);
  // 초기에 페이지 렌더링에 필요한 데이터의 개수

  const observer = useRef<IntersectionObserver>();
  // intersectionObserver
  const lastItem = useRef<HTMLDivElement>(null);
  useEffect(() => {
    fetch('내 데이터베이스 url')
      .then((r) => r.json())
      .then((r) => r.map((v) => v.imgSrc))
      .then((r) => r.map((v) => v.replace('hotel,room', 'cat')))
      .then((r) => r.filter((v, i) => i >= dataLen && i < dataLen + 4))
      .then((r) => setData((prev) => [...prev, ...r]));

  }, [dataLen]);

  useEffect(() => {
    observer.current = new IntersectionObserver(intersectionObserver);
    lastItem.current && observer.current.observe(lastItem.current);
    
    return () => observer.current.disconnect();
  }, []);

  const intersectionObserver = (
    entries: IntersectionObserverEntry[],
    io: IntersectionObserver,
  ) => {
    console.log(entries);
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        console.log(entry.target);
        // entry.isIntersection 메서드를 통해
        // root와 targt이 교차되었는지 확인한다.
        // io.unobserve(entry.target);
        // 이부분이 헷갈렸다.
        // 레이지로드는 이미지가 이미 교차되면 더이상 관찰하지 않지만,
        // 인피니티 스크롤은 계속 관찰해야한다.!
        setDataLen((prev) => prev + 4);
        
        // 만약 새로운 데이터를 페치받아야 한다면, 이부분에 로직을 작성하면 된다.
        // 나는 통으로 받아야 하는 데이터였으므로, 데이터의 길이에 따라 새로운 데이터를
        // 받아오도록 만들었다.
      }
    });
  };

  return (
    <Container>
      <h1>INFINITE SCROLL</h1>
      {data && data.map((v, i) => <Card imgSrc={v} />)}
      <div ref={lastItem}>last item</div>
    </Container>
  );
};

export default App;

intersection observer을 사용하면 간단히 구현할 수 있다.
위에는 감지하는 target을 임시로 만들어둔 div로 했지만, 카드 중 마지막 요소에 ref를 달아서 하는 것도 가능할 것이다.

조금만 사용자 편의를 고려해보기

그런데, 이렇게 사용해도 문제점은 있다. 지금은 무조건 마지막요소가 IO와 교차되어야지만 새로운 요소를 불러온다. -저기 임의로 만들어둔 last item 을만나야만된다. - 이는 레이지로드를 구현했을때도 했던 똑같은 고민이었는데, 그때는 이미 많은 걸 구현한 상태여서 컴포넌트의 구조가 복잡해져 적용하지 못했는데 여기서는 작성할 수 있을 것 같다.

마지막요소가 교차되기 전에, 마지막요소로 도달하기 조금만 전에 새로운 요소를 불러들인다면 조금 더 좋은 사용자 경험을 만들 수 있을 것이다. 이를 위해 intersection observerroot옵션을 사용하던지, 교차가 되는 타겟 요소를 카드 아이템의 마지막에서 2번째정도 요소로 고쳐주면 될 것 같다.

일단, 커스텀훅을 먼저 만들어보려고 해서 이는 커스텀훅파트에서 같이 진행하려고 한다.
다음 포스트가 될 것 같다.

이런 부분은 아쉬웠다.

  • 일단 intersection observer을 사용하는 방식은 아무튼 새로운 API를 알아야한다.
    이전에 레이지로드를 작성할 때 한번 사용해보았으므로 큰 문제는 없었지만 필수적으로 알아야한다는건 구현에 걸림돌이 될 수도있다.
  • 이건 개인적인 생각 부분인데 결과적으로 intersection observer이 계속해서 화면단을 감시하고 있을 것이라는 가정 하게 비효율적인 부분이있다.
    특정 사용자는 사실 스크롤자체를 사용하지않고 다음 페이지로 넘어가기도 한다. intersection observer을 사용할 경우에는 트리거 포인트가 따로 존재하지않고 화면단이므로 모든 사용자에게 이 이벤트가 적용된다.
  • 초기페이지 렌더링이 이쁘지 않을 수도 있다.
    사용자의 편의성을 고려하면서 세번째카드정도에 새로운 페이지를 호출하려고 했다. 근데 그러면 초기페이지에도 스크롤이 생겨버린다. 이쁘지 않을 수도있다. (그런데 핸드폰 화면단에서는 또 큰 문제없을 것 같기도 하고 그렇다)

이런 부분은 좋았다.

  • 어차피 레이지로드도 할거라면 공부해보는 게 좋을 것 같다.
    얼마전 경험하고 구현했던 레이지로드는 신세계처럼 다가왔는데 꼭 필수적인 기능이라고 생각했다. 특히 이미지가 많은 사이트의 경우 이미지의 로딩이 레이지로드로 이루어지는게 일단 내가 편했다. 그래서 어차피 공부해야할 부분이라는 생각도 들었다.

  • 임의의 대기시간을 정해지지 않고 화면단으로 동작한다.
    스크롤바를 사용했다면 임의의 대기시간을 정해놨어야 하는데, 어떻게 보면 부담스럽다. 최적의 시간이 얼마인지 고민스럽기도 하고.

  • 쓰로틀링 등의 최적화 기법을 사용하지 않아도 된다.
    쓰로틀링 같은 부분없이 잘 작동하니까, 사용하지 않아도 된다.

Ref

https://helloinyong.tistory.com/297
https://tech.kakaoenterprise.com/149
https://kentakang.com/163

profile
http://otter-log.world 로 이사했어요!

1개의 댓글

comment-user-thumbnail
2022년 7월 26일

안녕하세요 오터, 글 잘읽었어요
오터의 생각 흐름을 따라갈 수 있었어서 술술 읽었네요~~

저도 이번에 인피니티스크롤 구현해보다가, scroll 이벤트를 사용해서 구현할 때의 단점을 찾아봤었는데, 작성하신 내용 제외하고선 reflow 관련해서 찾아봐도 좋을 것 같아요!
저는 getBoundingClientRect 를 사용했었는데, 오터는 scrollHeight 를 사용하신 것 보고 차이점도 알아볼 수 있었네요 ㅎㅎ

답글 달기