무한 스크롤 적용 일기 (TanStack Query, Intersection Observer)

Shyuuuuni·2022년 12월 11일
1

📚 Tech-Post

목록 보기
5/10

이런 무한 스크롤을 만들기 위한 여정을 기록으로 남긴다.

무한 스크롤, 왜 필요한데?

프로젝트 기획

  • 이번에 진행한 프로젝트는 SNS 형식의 코드 공유/리뷰 커뮤니티로 기획했다.
  • SNS 형식이다 보니 다른 유명한 SNS가 기본적으로 무한 스크롤을 사용한 것이 생각났다.
  • 그래서 자연스럽게 "무한 스크롤을 구현하자!" 라고 생각했고, 3주차 쯤 프론트엔드 무한 스크롤을 담당하여 구현했다.

무한 스크롤

  • 가지고 있는 콘텐츠의 하단에 도달하면 API 호출 등을 통해 다음 콘텐츠를 불러오는 방식을 의미한다.
  • 페이지 하단에 도달하면 자동으로 불러오거나, 더보기 등의 버튼을 통해 불러오는 방식을 선택할 수 있다. (프로젝트에서는 자동으로 불러오는 방식을 사용했다.)

vs 페이지네이션

출처 : https://brunch.co.kr/@hyoi0303/39

  • 페이지네이션 : 데이터를 특정 개수만큼 분리하여 웹사이트의 다른 페이지 단위로 분리하는 것
    • 예: 검색 결과 1페이지, 2페이지, ... 또는 게시판 1, 2, 3, ... 이런 버튼 등
  • 무한 스크롤 :
    • 예: 페이스북, 인스타그램 등 SNS 플랫폼 등 SNS 플랫폼

무한 스크롤을 선택한 이유

당시에는 당연하게 "무한 스크롤을 사용해야지" 라고 생각했는데, 되돌아보면 나름 무의식속으로 무한 스크롤의 장점을 생각했던 것 같다.

  • SNS : 기획 의도가 "코드 리뷰가 불편하지 않았으면" 이라는 생각에서 시작했기 때문에, 무한 스크롤이 사용자 경험에서 더 쉽고 가벼운 느낌이라고 생각했다.
  • 모바일 : 이번 스프린트에서는 PC 레이아웃까지만 구현할 것 같지만, SNS도 고려를 해야 했기 때문에 세로로 무한히 늘어나는 무한 스크롤이 페이지네이션보다 더 적합했다.

이제, 구현

서버 상태관리

  • 리액트에서 상태관리가 중요하다 라는 말은 많이 들었다. (Redux로 대표하는 전역 상태관리 등등..)
  • 상태는 프론트엔드에서 렌더링을 위해 저장하는 일종의 값이다.

우리 프로젝트에서는 전역 상태관리 툴로 Zustand, 서버 상태관리 툴로 TanStack Query(=React Query)를 사용했다. 왜냐하면

  • 포스트 정보, 검색 기록, 인기 태그 등 클라이언트가 아닌 서버에서 관리되고 있는 데이터가 많다.
  • 만약 서버 상태관리 툴을 사용하지 않는다면 상태 관리를 위한 API 호출부터, 로딩중, 에러 발생 등의 상태나 캐싱 등을 직접 처리해줘야 한다.
  • 그래서 서버 상태를 분리했고, SWR과 TanStack Query를 비교해서 최종적으로 TanStack Query를 사용했다.

useInfiniteQuery

TanStack Query 라이브러리를 활용하여 무한 스크롤 요청을 보내고 받아서 표시하기

작성일 기준 관리하고 있는 소스코드이다.

// usePostInfiniteScroll.ts
const { data, hasNextPage, isFetching, fetchNextPage } = useInfiniteQuery(
  [QUERY_KEYS.POSTS, searchType, filter],
  async ({ pageParam = -1, queryKey }: QueryFunctionContext) =>
    await getQueryFn(pageParam, queryKey[1] as SEARCH_FILTER), // lastId, 검색 타입
  {
    getNextPageParam: (lastPost) =>
      lastPost.isLast ? undefined : lastPost.lastId,
  }
);
  • 쿼리키로 포스트 이외에 searchType, filter를 입력하는데 이는 검색 기능을 무한 스크롤에 병합하며 생긴 부분이다. (개인적으로 Best-Practice는 아니라고 생각중이다.)
  • 마찬가지로 QueryFunctionContext 내의 queryKey와 getQueryFn의 queryKey[1] 부분도 검색을 위한 부분이다.

가장 기본적인 소스코드 버전은 아래와 같이 작성할 수 있었다.

const fetchPost = async (pageParam: string): Promise<PostPages> => {
  const filter = useSearchStore.getState().filter as SearchFilter;
  const { data } = await axiosInstance.get(`/posts?lastId=${pageParam})}`);
  return data;
};

const { data, hasNextPage, isFetching, fetchNextPage } = useInfiniteQuery(
  [QUERY_KEYS.POSTS],
  async ({ pageParam = -1 }: QueryFunctionContext) =>
    await fetchPost(pageParam), // lastId
  {
    getNextPageParam: (lastPost) =>
      lastPost.isLast ? undefined : lastPost.lastId,
  }
);
  • 서버 API를 GET /posts?lastId=:pageParam 형식으로 보내고, posts, isLast, lastId 를 받아오도록 명세했다.
  • pageParam 값은 마지막으로 받은 페이지의 id로 명세했다.
  • 다음 요청을 보낼 때는 응답으로 받은 lastId값을 pageParam값으로 함께 전달한다.
  • 만약 서버에서 3개씩 보내주기로 했다면 요청은 아래처럼 발생할 것이다.

요쳥

이제 받아온 data를 화면에 렌더링해보자.

// PostScroll.tsx
const PostScroll = (): JSX.Element => {
  // ...useInfiniteQuery API로 data를 받아온다.

  // 포스트 바에 표시할 포스트 정보 목록
  const postInfos = useMemo(
    (): PostInfo[] =>
      data?.pages.flatMap((postScroll: PostPages) => postScroll.posts) ?? [],
    [data]
  );

  return (
    <div className="post-scroll">
      {postInfos.map((postInfo) => (
        <Post key={postInfo.id} postInfo={postInfo} />
      ))}
    </div>
  );
};

export default PostScroll;
  • postInfosuseInfiniteQuerydata 값의 타입인 InfiniteData<T>에서 실제 표시할 데이터를 가공하는 과정이다.
  • 응답으로 받은 data는 기본적으로 { pageParams, pages } 속성을 가지고 있다.
    • pageParams는 요청으로 보낸 pageParam(=lastId)를 순서대로 배열로 반환한다.
    • pages는 응답 데이터인데, 무한 스크롤을 유지하기 위해 실제 데이터인 posts 뿐만 아니라 isLast, lastId 등을 함께 포함한다.
    • Array.flatMap API를 활용하여 posts 부분만 순서대로 하나의 배열로 담을 수 있다.
    • 결과 : [첫번째 포스트 정보, 두번째 포스트 정보, ...]
  • data 가 변경되지 않으면 다시 계산하지 않도록 useMemo로 최적화했다.
  • (참고) 아래 예시는 로컬 테스트 환경에서 로그를 찍어서 Mocking용 임시 데이터가 들어가 있는 상태다.

응답 예시

이제 Post 컴포넌트는 받아온 데이터를 표시하기만 하면 된다.

  • props driling을 해결하면서, 각 포스트별로 각자의 Context를 가지도록 Context API를 활용했다. (물론 성능 이슈가 있겠지만.. 가독성이 일단 좋고, 당장은 방법이 크게 생각나지 않았다.)
interface PostProps {
  postInfo: PostInfo;
}

export const PostContext: React.Context<PostInfo> =
  createContext<PostInfo>(defaultPostInfo);

const Post = ({ postInfo }: PostProps): JSX.Element => {
  return (
    <PostContext.Provider value={postInfo}>
      <div className="post">
        <PostTitle />
        <PostImageSlider />
        <PostBody />
        <PostFooter />
      </div>
    </PostContext.Provider>
  );
};

export default Post;

Intersection Observer API

스크롤의 끝을 감지하고 다음 페이지 로딩을 요청하기

  • 앞선 과정까지 진행하면 데이터를 받아오면 표시하는 기능까지 구현이 된다.
  • 하지만 실제로 실행해보면 첫번째 데이터만 받아오고 "무한" 스크롤은 되지 않는다. 왜 그럴까?
  • 언제 어떤 상황에서 새로 데이터를 불러올 지를 구현하지 않았기 때문이다.

여러가지 방법이 있지만, Intersection Observer API를 사용하기로 했다. 왜냐하면

  • 스크롤 이벤트를 감지하는 것 보다 성능적인 이점이 있다고 한다.
  • 지금 필요한 기능인 "스크롤의 바닥을 봤을 때 다음 데이터 로딩" 을 구현하기에 가장 적절하다.
  • 사파리, 파이어폭스에서 일부 기능이 동작하지 않지만, 짧은 스프린트 기간동안 타겟을 크롬 브라우저로 잡아서 크게 상관하지 않기로 했다.

Intersection Observer

mdn 문서에 Intersection Observer API의 필요성으로 이런 내용을 소개했다.

Implementing "infinite scrolling" web sites, where more and more content is loaded and rendered as you scroll, so that the user doesn't have to flip through pages.

즉, 무한 스크롤을 구현하기 좋은 API라는 의미!!

Intersection Observer의 작동 원리는 간단하지만 한번 살펴보고 가지 않으면 헷갈릴 수 있다.

io-image

출처 : https://blog.arnellebalane.com/the-intersection-observer-api-d441be0b088d

  • Intersection Observer라는 이름 그대로 교차하는 것을 감지하는 옵저버 역할을 수행한다.
  • 등록한 target이 뷰포트(기본값, 보고 있는 화면) 또는 특정 DOM 요소(옵션으로 지정할 경우)에 교차(Intersect) 하는 경우를 감지해서 콜백 함수를 실행한다.
let options = {
  root: document.querySelector('#scrollArea'), // target이 비교할 대상, 기본값=뷰포트
  rootMargin: '0px', // root의 상,하,좌,우 마진을 줄 수 있음.
  threshold: 1.0 // 겹치는 정도 (1.0 = 100%)
}

let observer = new IntersectionObserver(callback, options);
  • 이렇게 옵저버를 생성할 수 있고, 실행할 콜백 함수, 옵션을 등록할 수 있다.
let callback = (entries, observer) => {
  entries.forEach((entry) => {
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};
  • 콜백 함수는 위처럼 (entries, observer)를 인자로 하는 함수로 구현할 수 있다.
  • forEach문 안에 entry 속성을 활용하여 교차했을 때 실행할 기능을 작성할 수 있다.
let target = document.querySelector('#listItem');
observer.observe(target);
  • 마지막으로 관찰할 대상을 observe를 통해 등록할 수 있다.

적용해보기

  • Intersection Observer 로직을 무한 스크롤과 강결합 할 수 있었지만, 이미지를 Progressive Loading 할 때 재사용 하기 위해 useIntersect 라는 훅으로 분리했다.
  • 아래 코드는 구현할 때 가장 많이 참고했던 실전 Infinite Scroll with React 글을 많이 참고해서 구현한 코드이다. 원본 글이 정말정말 좋아서 많이 배울 수 있었다.
// hooks/useIntersect.ts
/**
 * root 원소와 target 원소가 교차 상태인지를 판단하여 조건에 만족할 경우
 * callback 함수를 실행하는 target 원소의 Ref 를 반환합니다.
 */
const useIntersect = (
  onIntersect: IntersectHandler,
  options?: IntersectionObserverInit
): RefObject<HTMLDivElement> => {
  const ref = useRef<HTMLDivElement>(null);
  const callback = useCallback(
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
      entries.forEach((entry: IntersectionObserverEntry) => {
        if (entry.isIntersecting) {
          console.log("2. intersect");
          onIntersect(entry, observer);
        }
      });
    },
    [onIntersect]
  );

  useEffect(() => {
    if (ref.current === null) {
      return;
    }
    console.log("1. new element");
    const observer = new IntersectionObserver(callback, options);
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref, options, callback]);

  return ref;
};

export default useIntersect;

코드를 하나하나 뜯어보자.

  • ref : HTMLDivElement를 가리킬 ref 오브젝트이다. 구현할 때 스크롤의 바닥에 div element를 두고 해당 요소가 뷰포트에 감지되면 콜백 함수를 실행하도록 구현 할 예정이다.
  • callback : 간단하게 옵저버가 무언가 감지했을 경우 감지된 대상(entry.isIntersecting을 만족하는 대상)에 대해서 onIntersect 콜백을 호출한다.
    • onIntersect : 훅을 호출할 때 입력하며, 감지 되었을 때 실행할 로직을 입력한다.
    • 우리가 구현하려는 기능인 다음 데이터를 요청하는 로직을 입력할 예정이다.
  • useEffect : 생성한 ref 오브젝트가 변경될 경우나, 새로 호출되어서 옵션/콜백 등이 변할 때 옵저버를 새로 생성해주는 부분이다.

이제 훅으로 반환한 ref를 스크롤 바닥의 div element에 연결하고, 교차 시 실행할 콜백 함수만 전달하면 된다.

// hooks/usePostInfiniteScroll
const usePostInfiniteScroll = (): PostInfiniteScrollResults => {
  const { data, hasNextPage, isFetching, fetchNextPage } = useInfiniteQuery(...);
  
  // ...중략

  /**
   * Intersection Observer 를 위한 콜백 함수
   * 다음 페이지를 로딩해야 하는 상황을 감지했을 때 fetchPost 요청을 보내서 가져옵니다.
   */
  const onIntersect = useCallback(
    (
      entry: IntersectionObserverEntry,
      observer: IntersectionObserver
    ): void => {
      observer.unobserve(entry.target);
      if (hasNextPage === true && !isFetching) {
        console.log("3. 다음 페이지를 불러옵니다.");
        fetchNextPage();
      }
    },
    [hasNextPage, isFetching, fetchNextPage]
  );

  return { data, hasNextPage, isFetching, fetchNextPage, onIntersect };
};

export default usePostInfiniteScroll;
  • 일단 무한 스크롤에 관한 로직도 위처럼 분리할 수 있다.
  • 앞서 구현한 useIntersect 훅에 전달할 onIntersect 함수를 구현했다.
    • 여기서 entry.target = 현재 감지된 대상 = div element 이다.
    • unobserve 를 해주는 이유는 로직을 중복해서 실행을 방지하기 위해서이다. (앞선 이미지에서 excute callback function! 이 두번 발생하는 부분 확인)
  • 그리고 TanStack Query가 편한 부분으로, useInfiniteQuery 반환값으로 hasNextPage, isFetching 등을 쉽게 사용할 수 있다.
    • 다음 페이지가 없으면 당연히 불러오지 않아도 되고, 마찬가지로 불러오는 중이라면 추가로 불러오지 않아도 된다.
    • 모든 조건을 만족하면 다음 페이지 데이터 요청을 호출하면 된다.
// PostScroll.tsx
const PostScroll = (): JSX.Element => {
  const { data, onIntersect, hasNextPage } = usePostInfiniteScroll();

  // ...중략
  
  return (
    <div className="post-scroll">
      {postInfos.map((postInfo) => (
        <Post key={postInfo.id} postInfo={postInfo} />
      ))}
      <ScrollLoader onIntersect={onIntersect} onLoad={hasNextPage} />
    </div>
  );
};

// ScrollLoader.tsx
const ScrollLoader = ({
  onIntersect,
  spinner = true,
  onLoad = true,
}: PostLoaderProps): JSX.Element => {
  const intersectRef = useIntersect(onIntersect);

  return (
    <div className="scroll-loader">
      {spinner && onLoad && (
        <LoadingSpinner className="scroll-loader__spinner" />
      )}
      <div className="scroll-loader__target" ref={intersectRef}></div>
    </div>
  );
};

export default ScrollLoader;
  • 이제 만들어놓은 레고 조각을 조립만 하면 된다.
  • PostScroll 컴포넌트에서 usePostInfiniteScroll 훅을 실행해서 위쪽에 포스트들을 표시한다.
  • 포스트 가장 아래에 ScrollLoader 컴포넌트를 두고, 해당 컴포넌트가 감지되면 다음 페이지 데이터를 요청하도록 한다.
  • ScrollLoader 컴포넌트에서는 PostScroll에게서 받은 onIntersect 콜백 함수로 useIntersect 훅을 호출하여 ref를 반환받는다.
    • LoadingSpinner는 로딩중을 표시하는 컴포넌트이다. (기능에는 영향이 없으므로 넘어가자.)
      loader
  • 마지막으로 target이 될 div element에 해당 ref를 연결한다.

여기까지 되면 무한 스크롤 아래에 ScrollLoader가 위치하고 있고, 스크롤을 끝까지 내리면 다음 페이지 데이터를 가져올 수 있다면 자동으로 로딩한다.

결과

  • 데이터가 로컬용 Mock Data여서 이쁘지는 않지만..
  • (1) 새로운 옵저버 등록 - (2) 스크롤 바닥 감지 - (3) 다음 페이지가 있으면서, 현재 로딩중이 아니라면 다음 페이지 로딩 이라는 로직에 맞춰서 동작한다.

결론

이번 무한 스크롤 구현은 나에게 있어서 굉장히 큰 도전이었다.

  • TanStack Query
  • Intersection Observer

사용한 기술 모두 처음 사용해봤고, 심지어 Intersection Observer는 이번에 처음 알게 된 기술이였다.

짧은 시간 내에 내가 맡은 역할을 끝내기 위해 열심히 구글링을 했는데, 이번 구현에 있어서 문서화의 중요성을 다시 한번 느꼈다.

참고한 수많은 래퍼런스, 블로그 포스트 등의 이런 문서들이 앞이 보이지 않던 나에게 방향을 잡아주었던 것 같다. 많은 분들께 감사하다고 인사드리고 싶다.

이번 프로젝트를 하면서 느낀점이나 배운 부분이 굉장히 많은데, 그런 내용들을 꾸준히 블로그에 업로드 해야겠다고 느꼈다.

그리고 코드가 좀 지저분하긴 하지만 이정도 기능으로 구현했다니, 대성공이라고 느꼈다. (팀원분들 그렇죠?)

아마 다음 포스트는 무한 스크롤에 검색 기능이나, useMutation을 이용해 낙관적 업데이트를 구현한 부분을 포스팅 할 것 같다.

래퍼런스

profile
배짱개미 개발자 김승현입니다 🖐

0개의 댓글