페블_
반짝이는 시냅스
페블_
전체 방문자
오늘
어제
  • 전체글 보기 (96)
    • QA (0)
    • 프로젝트 회고 (4)
    • 프로젝트 과정 기록 (12)
    • UI 구현 연구일지 (8)
    • Front-end (31)
      • Javascript (7)
      • CSS (10)
      • React (5)
      • Typescript (3)
      • Nextjs (3)
      • 스타일링 라이브러리 (3)
    • Back-end (0)
      • Express (0)
      • DB (0)
    • CS (0)
      • 자료구조 & 알고리즘 (0)
    • CI&CD (1)
    • 툴 사용법 (4)
      • Git (1)
      • Library&패키지 (2)
      • 기타 개발관련 (1)
    • 알고리즘 이론 & 풀이 (36)
      • 백준(BOJ) (14)
      • 프로그래머스 (22)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 파이썬
  • react
  • 선형대수학
  • chartjs
  • TypeScript
  • 개발블로그_시작
  • 시리즈_표지
  • Python
  • UI 컴포넌트
  • storybook
  • 생각
  • 토이프로젝트
  • JS
  • eslint
  • emotion
  • 캐러셀
  • 알고리즘
  • 백준

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
페블_

반짝이는 시냅스

[React] useInfiniteQuery와 Intersection Observer 이용한 무한스크롤 구현 👀 unsplash 이미지 무한으로 불러오기
UI 구현 연구일지

[React] useInfiniteQuery와 Intersection Observer 이용한 무한스크롤 구현 👀 unsplash 이미지 무한으로 불러오기

2023. 7. 26. 02:29

 

개요

  • 먼저 hook과 useInfiniteQuery를 사용하지 않고 순수하게 js와 react의 기능만을 사용해 무한스크롤을 구현한 예제를 소개할 것이다.
  • 그리고 Intersection Observer로 특정 element를 구독하는 과정을 간단하게 할 수 있는 useIntersect hook을 만들어보고, 그 hook의 callback으로 useInfiniteQuery의 fetchNextPage 함수를 줘서 무한스크롤로 API 호출하는 예제를 작성해 볼 것이다.

 

1. 순수하게 intersect observer 이용해 무한스크롤 구현하기

hook으로 로직 추상화를 하거나 별도의 라이브러리 없이 infinite observer로 target을 구독하는 코드를 직접 구현해봤다.

 

function InfiniteScrollPage() {
  const [images, setImages] = useState<any[]>([]);
  const [pageNum, setPageNum] = useState(1);
  const [isLoading, setIsLoading] = useState(false);	// loading 상태를 관리할 state

  const pageEnd = useRef<HTMLDivElement>(null);	// target에 부착할 ref
  
  // pageNum이 변하면 자동으로 이미지 fetch를 한다.
  useEffect(() => {
    fetchImages(pageNum);
  }, [pageNum]);

  useEffect(() => {
    const callback = (entries: IntersectionObserverEntry[]) => {
      if (!isLoading && entries[0].isIntersecting) {
        loadMore();
      }
    };
    const observer = new IntersectionObserver(callback, { threshold: 1 });
    let target = pageEnd.current;
    target && observer.observe(target);

    return () => {
      target && observer.unobserve(target);
    };
  }, [isLoading]);
  
  const loadMore = () => {
    setPageNum((prev) => prev + 1);
  };

loadMore라는 함수는 page += 1을 해주는 함수이다.

useEffect 부분을 살펴보면, observer로 target을 구독하도록 하고 있는데, 이 observer는 교차가 발생할 때마다 loadMore()를 실행해 page 수를 1 증가시킨다.

이렇게 pageNum이 증가하면, pageNum을 의존배열로 가진 또 다른 useEffect에 의해 fetchImages가 실행되는 구조이다.

 

로딩이 되어 isLoading이 true가 되면 deps 배열에 있는 isLoading이 변했기 때문에, useEffect의 return 부분이 실행되어 observer가 clean-up 된다. 그리고 로딩이 끝나 다시 isLoading이 false가 되면 새 observer가 생성되게 된다.

이렇게 isLoading과 clean-up을 이용하여 구독이 중복으로 일어나지 않게 했다.

 

  async function fetchImages(page: number) {
    try {
      setIsLoading(true);
      const res = await axios.get(
        `https://api.unsplash.com/photos?client_id=${process.env.REACT_APP_UNSPLASH_API_KEY}&page=${page}&per_page=15`
      );
      // await sleep(5000); // 5초의 시간동안 지연시켜서 loading하는 동안 위아래 스크롤로
      					// callback이 중복실행되지 않는지 검사하는 용도
      setImages([...images, ...res.data]);
    } catch (e) {
      alert('err: ' + e);
    } finally {
      setIsLoading(false);
    }
  }

  const sleep = (delay: number) => {
    return new Promise((resolve) => setTimeout(resolve, delay));
  };

  return (
    <Wrapper>
      {images && <CardList images={images} />}
      <Loading pageEnd={pageEnd} />
    </Wrapper>
  );
}

const Wrapper = styled.div`
  padding: 0 24px 0 24px;
  margin-right: auto;
  margin-left: auto;
`;

export default InfiniteScrollPage;

 

2. Intersection Observer hook과 useInfinite-Query 이용해 무한스크롤 구현

Intersection Observer hook - useIntersect 만들기

이 hook은 ref를 생성한 다음, useEffect 안에서 observer 객체를 생성해 ref를 구독하게 만들고,

최종적으로 그 ref를 반환하고 있다.

이렇게 observer가 구독하고 있는 ref를 target 요소에 붙이는 방식으로 특정 엘리멘트 구독을 편리하게 처리할 수 있다.

/* src/hooks/useIntersect.ts */
import { useEffect, useRef, useCallback } from 'react';

type IntersectHandler = (
  entry: IntersectionObserverEntry,
  observer: IntersectionObserver
) => void;

function useIntersect(
  onIntersect: IntersectHandler,
  options?: IntersectionObserverInit
) {
  const ref = useRef<HTMLDivElement>(null); // target 요소를 저장할 ref

  const callback = useCallback(
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
      // entries 배열인 이유는 한 observer가 여러 대상을 관찰할 수 있기 때문
      entries.forEach((entry) => {
        if (entry.isIntersecting) onIntersect(entry, observer);
      });
    },
    [onIntersect]
  );

  useEffect(() => {
    if (!ref.current) return;
    const observer = new IntersectionObserver(callback, options);
    observer.observe(ref.current);

    return () => observer.disconnect(); // unmount되면 구독 해제
  }, [ref, options, callback]);

  return ref;
}

export default useIntersect;

useIntersect의 인자로 받은 onIntersect 함수는 교차될 때 실행할 행동을 담은 콜백 함수이다.

교차가 발생할 때 observer의 callback 함수는 교차 상태를 묘사하는 entry라는 요소를 인자로 받게 되는데, 그를 통해 intersecting 여부를 검사해 onIntersect를 수행한다.

 

꼭 useEffect의 return 부분에 clean-up 하는 코드를 작성해서 중복 구독으로 인해 매 관찰마다 구독이 2, 4, 8번으로 늘어나는 일이 발생하지 않도록 한다.

 

useFetchImages hook - useInfiniteQuery로 이미지 fetch 하는 hook

이 hook은 이미지 fetch 로직과 useInfiniteQuery를 결합하여 이미지 무한 fetch를 쉽게 할 수 있게 하는 hook이다.

 

useInfiniteQuery의 인자로 이미지 fetch로직을 담고 있는 getPageImageData 함수를 넣어준다.

return 부분에서 useInfiniteQuery 함수를 실행해 결과적으로 { data, hasNextPage, isFetching, fetchNextPage }를 반환해준다.

import axios from 'axios';
import { useInfiniteQuery } from '@tanstack/react-query';

function useFetchImages(startPage: number) {
  // image fetch 로직
  const getPageImageData = async ({ pageParam = startPage }) => {
    const res = await axios.get(`https://api.unsplash.com/photos`, {
      params: {
        client_id: process.env.REACT_APP_UNSPLASH_API_KEY,
        page: pageParam,
        per_page: 15,
      },
    });

    return {
      current_page: pageParam,
      data: res.data,
      isLast: false,
    };
    // 이 반환 데이터 형식은 unsplash 데이터에 page와 isLast 페이지 여부가 없길래
    // 내가 임의로 가공한 것이다.
    // isLast 여부를 알려주는 API를 사용하고 있다면 내가 임의로 넣은 false를
    // API 반환값으로 대체하면 된다.
  };

  return useInfiniteQuery(['images'], getPageImageData, {
    getNextPageParam: (lastPage, totalPages) => {
      return lastPage.isLast ? undefined : lastPage.current_page + 1;
    },
    staleTime: 60000 * 20, // 1분 * n
  });
}

export default useFetchImages;

useInfiniteQuery 부분을 설명하자면,

  • 배열 ['images']에 담긴 images는 useInfiniteQuery로 불러온 데이터에 붙여줄 key 네임이고,
  • getNextPageParam은 lastPage와 totalPage를 인자로 받기 때문에, lastPage가 아니라면 +1 한 페이지를 가져오는 등의 로직을 작성할 수 있다.
  • staleTime은 이 데이터가 얼마동안 신선하다고 할 지 시간 설정을 하는 부분이다. 여태까지 불러온 데이터들이 배열에 쫙 들어있다면 그 데이터를 불러오기 위해 여태 사용한 query들을 staleTime이 지났을 때 다시 한번 실행한다.
    (따라서 경우에 따라 API 요청이 어마무시하게 많이 되는 모습을 볼 수 있는데, 그게 싫다면 새 데이터로 덜 자주 교체하도록 시간을 잡아도 된다)

 

useIntersect와 useInfiniteQuery를 적용한 최종 모습

앞에서 만든 useFetchImages hook은 useInfiniteQuery를 실행한 결과를 반환하고 있다.

useInfiniteQuery가 반환하는 객체와 함수 중에는 fetchNextPage라는 것이 있는데,

이 함수를 실행하면 페이지 parameter를 1 증가시킨다.

그리고 그 때 실행할 함수로 useInfiniteQuery에 넣은 getPageImageData 함수에 이 새로운 페이지 파라미터를 넣어 다음 데이터를 불러오는 구조인 것이다.

import React, { useMemo } from 'react';
import CardList from '../InfiniteScroll/components/CardList';
import styled from 'styled-components';
import Loading from '../InfiniteScroll/components/Loading';
import useFetchImages from '../InfiniteScroll/hooks/useFetchImages';
import useIntersect from '../InfiniteScroll/hooks/useIntersect';

function InfiniteScrollPage() {
  const { data, hasNextPage, isFetching, fetchNextPage } = useFetchImages(1);

  const images = useMemo(() => {
    return data ? data.pages.flatMap(({ data }) => data) : [];
  }, [data]);

  const pageEnd = useIntersect(async (entry, observer) => {
    observer.unobserve(entry.target);
    await sleep(700);	// api 데이터를 너무 빨리 전송받으면
    		// intersecting을 체감할 수 없기 때문에 의도적으로 sleep을 걸어줬다.
    if (hasNextPage && !isFetching) {
      fetchNextPage();
    }
  });

  // 로딩 시간 지연 테스트용
  const sleep = (delay: number) => {
    return new Promise((resolve) => setTimeout(resolve, delay));
  };

  return (
    <Wrapper>
      {images && <CardList images={images} />}
      {hasNextPage && <Loading pageEnd={pageEnd} />}
    </Wrapper>
  );
}

const Wrapper = styled.div`
  padding: 0 24px 0 24px;
  margin-right: auto;
  margin-left: auto;
`;

export default InfiniteScrollPage;

 

 

+) 기타 - 마크업 및 unsplash 키 관리

unsplash 개발자 센터에 가서 key 발급한 것을 프로젝트 최상단에 .env 파일을 생성해 넣어줬다.

REACT_APP_UNSPLASH_API_KEY=발급받은 key

 

이 프로젝트에 사용된 컴포넌트 마크업 부분

CardList 컴포넌트

import styled from 'styled-components';
import Card from './Card';

interface CardListProps {
  images: any[];
}

function CardList({ images }: CardListProps) {
  return (
    <CardListBlock>
      {images.map((image, i) => {
        const imgUrl = image.urls.small;
        return <Card key={i} imgUrl={imgUrl} />;
      })}
    </CardListBlock>
  );
}

const CardListBlock = styled.div`
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
  margin: 0 auto;
  max-width: 900px;
`;

export default CardList;

Card 컴포넌트

import styled from 'styled-components';

interface CardProps {
  imgUrl: string;
}

function Card({ imgUrl }: CardProps) {
  return (
    <CardBlock>
      <CardImage src={imgUrl} />
    </CardBlock>
  );
}

const CardBlock = styled.div``;

const CardImage = styled.img`
  width: 300px;
  height: 400px;
`;

export default Card;

PageEnd 컴포넌트 (target 역할. 이 부분에 ref를 부착해 intersect를 관찰한다)

interface LoadingProps {
  pageEnd: RefObject<HTMLDivElement>;
}

function Loading({ pageEnd }: LoadingProps) {
  return (
    <div
      ref={pageEnd}
      style={{ fontSize: '2rem', marginTop: '0px', textAlign: 'center' }}
    >
      ...Loading
    </div>
  );
}

export default Loading;

 

 

'UI 구현 연구일지' 카테고리의 다른 글

[React] Debounce(디바운스) 적용한 검색어 자동완성 구현기 (2) - 검색어 키보드로 선택하기  (0) 2023.06.14
[React] Debounce(디바운스) 적용한 검색어 자동완성 구현기 (1)  (0) 2023.06.14
[React 컴포넌트] 댓글 기능 구현하기  (0) 2022.07.12
[React 컴포넌트] Like, Dislike (좋아요, 싫어요) 기능 만드는 법  (0) 2022.07.11
[React 컴포넌트] Favorite(찜, 좋아요) 버튼 만드는 법  (0) 2022.07.11
    'UI 구현 연구일지' 카테고리의 다른 글
    • [React] Debounce(디바운스) 적용한 검색어 자동완성 구현기 (2) - 검색어 키보드로 선택하기
    • [React] Debounce(디바운스) 적용한 검색어 자동완성 구현기 (1)
    • [React 컴포넌트] 댓글 기능 구현하기
    • [React 컴포넌트] Like, Dislike (좋아요, 싫어요) 기능 만드는 법
    페블_
    페블_

    티스토리툴바