기능 구현 - 무한 스크롤

치맨·2023년 6월 28일
2

기능구현

목록 보기
4/9
post-thumbnail

목차


디바운스와 쓰로틀

  • 무한 스크롤을 구현할 때 처음에는 scroll event를 사용하여 구현하면 되겠지? 라는 생각으로 접근했었습니다.

  • 그러나 Scroll이 발생할 때마다 스크롤 이벤트에 대한 콜백 함수가 여러 번 실행되는 것을 확인할 수 있습니다. 현재는 console.log를 사용하고 있지만, 만약 API 요청과 같이 많은 데이터를 다루는 상황이라면 엄청난 성능 저하가 발생할 수 있습니다.

  • 스크롤 이벤트를 포함한 다양한 이벤트를 최적화하기 위한 기법으로 디바운스와 쓰로틀이 있습니다.

디바운스(Debounce)

  • 디바운스는 연속적으로 발생하는 이벤트 중에서 마지막 이벤트가 발생한 후 일정 시간 동안 추가적인 이벤트가 없을 때에만 해당 이벤트를 처리하는 기법입니다.

  • 예를들어 사용자 입력에 따라 검색 요청을 보내는 경우에 디바운스를 적용할 수 있습니다. 사용자가 입력을 시작하면 타이머를 초기화하고, 일정 시간(예: 300ms)이 경과한 후에 검색 요청을 보내는 방식으로 구현할 수 있습니다. 이를 통해 사용자의 연속 입력에 따라 검색 요청이 제어되고 성능이 개선됩니다.

  • 만약 디바운스를 사용하지 않았다면 디바운스라는 단어를 검색하기 위해 ㄷ, 디, 딥, 디바, ... 등 11번의 단어를 API 요청해야 했을 것이며, 이는 엄청난 성능 문제를 발생시킬 수 있습니다.

  • 주로 자동완성, 버튼 중복 클릭 방지 처리 등에 유용하게 사용됩니다.

  • 디바운스 구현 코드. (아직 typescript에 대해 많이 부족하여 무분별한 any를 사용한점 반성합니다.)

    
    import styled from 'styled-components';
    import MainPage from '../components/pages/MainPage';
    
    const StyleContainer = styled.div`
      height: 100vh;
    `;
    
    const debounce = (callback: (...args: any) => void, delay: number) => {
      let timerId: number;
      return (...args: any) => {
        clearTimeout(timerId);
        timerId = setTimeout(() => callback(...args), delay);
      };
    };
    
    function InfinityScroll() {
      const onChange = debounce((e) => {
        console.log(e.target.value);
      }, 300);
    
      return (
        <MainPage>
          <StyleContainer>
            <input onChange={onChange} />
          </StyleContainer>
        </MainPage>
      );
    }
    
    export default InfinityScroll;
    

쓰로틀(Throttle)

  • 쓰로틀은 처음 이벤트가 발생하고, 일정한 주기로 반복적으로 발생하는 이벤트의 실행 빈도를 제어하는 기법입니다.

  • 디바운스가 마지막 이벤트 호출 이후 일정 시간이 지난 후에 함수를 실행하는 것과 달리, 쓰로틀은 일정한 간격으로 이벤트를 실행합니다.

  • 주로 Scroll관련, 무한 스크롤에 사용 됩니다.

  • 이번엔 lodash에서 제공하는 throttle을 통해 구현해봤습니다.


    import styled from 'styled-components';
    import MainPage from '../components/pages/MainPage';
    import { throttle } from 'lodash';

    const StyleContainer = styled.div`
      height: 100vh;
    `;

    function InfinityScroll() {

      const onChange = throttle((e) => {
        console.log(e.target.value);
      }, 2000);


      return (
        <MainPage>
          <StyleContainer>
            <input onChange={onChange} />
          </StyleContainer>
        </MainPage>
      );
    }

	export default InfinityScroll;

  • 쓰로틀링 사용


Intersection Observer API

  • 무한 스크롤 구현을 쓰로틀기법이 있는데도 불구하고 많은 분들이 Intersection Observer API를 사용 하는 걸 찾아볼 수 있습니다.

  • 왜 그럴까? 찾아보던 중 성능을 비교하는 글을 찾아볼 수 있었습니다.
    Aggelos Arvanitakis ← 글 참조

  • 아래와 같은 성능 차이가 있음을 알 수 있었습니다.

  • 또한 chat GPT에게 물어봤습니다.

  • 성능 차이뿐만 아니라 자동 감지, 유연한 관찰 옵션, 효율적인 리소스 사용 등 많은 이점들이 있는 것을 알 수 있었습니다.


Unsplash 이미지 API 받아오기

  • 무한 스크롤을 구현하기 전 Unsplash에서 사진을 받아오도록 구현 해보겠습니다.
  • Unsplash 개발자 페이지를 참조. unsplash 바로가기
  • API 요청하기
      1. 개발자 페이지에서 로그인 후 앱을 등록하여 API Key를 발급 받습니다.
      1. API 주소로 요청을 보내 데이터를 받아옵니다.
      1. Data를 useState를 통해 생성한 list에 담아줍니다.
      • 3-1 list의 type을 지정해줍니다.
	import { useState } from 'react';
	
	interface IImgList {
        id: string;
        urls: {
          thumb: string;
        };

        alt_description: string;
      }

	const API_KEY = '나의 API Key';
	
    const [keyword, setKeyword] = useState('');
    const [list, setList] = useState<IImgList[]>([]);

    const fetchRequest = async (keyword: string) => {
    const UNSPLASH_URL = `https://api.unsplash.com/search/photos?page=1&query=${keyword}&client_id=${API_KEY}&per_page=10`;
    
    const response = await fetch(UNSPLASH_URL);
    const json = await response.json();
    const result = json.results;
    setList(result);
  };
    1. list의 id, src를 받아와 map을 통해 뿌려줍니다.
     {list.map((item, i) => (
                  <div key={`${item.id}-${i}`}>
                    <img src={item.urls.thumb} alt={item.alt_description} />
                  </div>
                ))}


React로 무한스크롤 구현하기

  • IntersectionObserver를 사용하여 구현 해보겠습니다. IntersectionObserver MDN 참조

  • 사진을 뿌려주고, 화면 가장 아래에 로딩박스를 만들어 useRef를 통해 target을 지정해줍니다.

{loading && <StyleLoading ref={pageEnd}>로딩중...</StyleLoading>}
  • 화면 가장 아래에 있는 로딩박스가 사용자가 보고 있는 화면에 100% 나타났을 때 새로운 사진들을 나타낼 수 있도록 IntersectionObserver를 사용해줍니다.
  useEffect(() => {
      if (loading) {
        const observer = new IntersectionObserver(
          (entries) => {
            // 로딩박스가 나타나면 page값을 1 증가 시켜줍니다.
            if (entries[0].isIntersecting) nextPage();
          },
          // 0~1까지 있으며 1은 100%가 다 화면에 나타날때를 의미합니다.
          { threshold: 1 }
        );

        // 타겟으로 지정해준 로딩박스를 관찰해줍니다.
        if (pageEnd.current) observer.observe(pageEnd.current);
      }
    }, [loading]);
  • page를 증가시켜 주는 함수를 만들어줍니다.

     const nextPage = () => {
        setPage((prev) => prev + 1);
      };
  • page가 변경 될 때마다 page를 증가시켜 API 요청을 보내 추가적인 사진을 받아옵니다.


 	const fetchNextRequest = async (keyword: string, page: number) => {
    const UNSPLASH_URL = `https://api.unsplash.com/search/photos?page=${page}&query=${keyword}&client_id=${API_KEY}&per_page=10`;

    const response = await fetch(UNSPLASH_URL);
    const json = await response.json();
    const result = json.results;
    setList((prev) => [...prev, ...result]);
    // 새로 받아오는 result가 없다면 loading state를 true로 만들어줍니다. 
	result.length === 0 ? setLoading(true) : setLoading(false);
  };

 useEffect(() => {
    if (page !== 1) fetchNextRequest(keyword, page);
  }, [page]);
  • 무한스크롤 구현 영상
    업로드중..

코드 리팩토링

  • 코드 리팩토링이 필요한 부분을 찾아보고 수정해보겠습니다.

API 요청 코드 분리

  • 테스트에도 용이하고, 유지보수를 높이기 위해 fetch 관련 함수를 분리해줍니다.

  • API 요청하는 코드를 따로 utils폴더 아래에 api.ts 파일을 만들어 분리를 해보겠습니다.

// api.ts
const API_KEY = '나의 API Key';

const fetchData = async (apiUrl: string) => {
  try {
    const response = await fetch(apiUrl);
    const json = await response.json();
    const result = json.results;
    return result;
  } catch (err) {
    console.log(err);
  }
};

export const fetchImgList = async (keyword: string) => {
  const UNSPLASH_URL = `https://api.unsplash.com/search/photos?page=1&query=${keyword}&client_id=${API_KEY}&per_page=10`;
  const result = await fetchData(UNSPLASH_URL);
  return result;
};

export const fetchNextImgList = async (keyword: string, page: number) => {
  const UNSPLASH_URL = `https://api.unsplash.com/search/photos?page=${page}&query=${keyword}&client_id=${API_KEY}&per_page=10`;
  const result = await fetchData(UNSPLASH_URL);
  return result;
};
  • 기존의 코드를 수정해줍니다.
// InfinityScroll.tsx
 const updateImgList = async (keyword: string) => {
    const result = await fetchImgList(keyword);
    setList(result);
    setLoading(true);
  };

  const updateNextImg = async (keyword: string, page: number) => {
    const result = await fetchNextImgList(keyword, page);
    setList((prev) => [...prev, ...result]);
    result.length === 0 ? setLoading(false) : setLoading(true);
  };
  
  useEffect(() => {
    if (keyword !== '') updateImgList(keyword);
  }, [keyword]);

  useEffect(() => {
    if (page !== 1) updateNextImg(keyword, page);
  }, [page]);

observer의 첫번째 인자 콜백함수 분리

  • IntersectionObserver의 첫번째 인자인 콜백함수를 따로 handleObsever 함수로 분리시켜줍니다.

  • 기존코드


const nextPage = () => {
      setPage((prev) => prev + 1);
    };


 useEffect(() => {
      if (loading) {
        const observer = new IntersectionObserver(
          (entries) => {
            // 로딩박스가 나타나면 page값을 1 증가 시켜줍니다.
            if (entries[0].isIntersecting) nextPage();
          },
          // 0~1까지 있으며 1은 100%가 다 화면에 나타날때를 의미합니다.
          { threshold: 1 }
        );

        // 타겟으로 지정해준 로딩박스를 관찰해줍니다.
        if (pageEnd.current) observer.observe(pageEnd.current);
      }
    }, [loading]);
  • 수정코드
    const handleObserver = (entries: any) => {
        const target = entries[0];
        if (target.isIntersecting) {
          setPage((prevPage) => prevPage + 1);
        }
      };

    useEffect(() => {
      if (!loading) return;
      const observer = new IntersectionObserver(handleObserver, { threshold: 1 });
      if (pageEnd.current) observer.observe(pageEnd.current);
    }, [loading]);
profile
기본기가 탄탄한 개발자가 되자!

0개의 댓글