호진방 블로그
기술

글, 댓글 조회시 무한스크롤을 구현하겠습니다, 근데 이제 에러핸들링까지 곁들인

# Offispace 커뮤니티 페이지에서 무한스크롤을 이용하여 글 조회와 댓글 조회를 구현해봤습니다.

2024년 05월 27일

도입배경

프로젝트의 커뮤니티 페이지에서는 게시글 전체 조회, 게시글 댓글 전체 조회시 Get 요청을 통해 모든 글과 댓글을 가져와 화면에 표시한다. 하지만 게시글과 댓글의 수가 많아지면 불러오는 데 시간이 오래 걸리고 성능 저하가 발생하기 때문에 글 같은 경우는 size 10, 댓글은 size 5로 가져오게 된다. 이 때 페이지네이션무한 스크롤(Infinite scroll) 두 가지 선택지를 활용하여 페이징을 구현 할 수 있다.

페이지네이션

무한스크롤

페이지네이션이 아닌 무한스크롤을 사용하는 이유

페이지네이션과 무한스크롤 중 어떤 쪽을 사용할지는 웹사이트의 목적, 콘텐츠의 유형 그리고 의도된 UX에 따라 달라진다. 페이지네이션은 사용자가 특정한 콘텐츠를 찾고 있는 웹사이트에 가장 적합하고, 무한 스크롤은 사용자가 무언가 흥미로운 콘텐츠를 보기 위해 목적 없이 검색하는 상황에서 더 적절하다. 또한, 모바일 기기에도 매우 효과적이다.

❗ 이번 프로젝트에서는 모바일 기기 환경을 최우선으로 염두해두고 기획된 프로젝트이고, 커뮤니티 페이지 특성 상 사용자가 일일이 페이지1, 페이지2로 버튼을 누르는 행위 자체가 답답한 작업으로 취급 될 수 있기 때문에 버튼을 누른다는 행위를 최소화하면서 커뮤니티 글을 조회 할 수 있는 사용자 편의성을 위해 무한스크롤을 도입하기로 결정 했다.


useInfiniteQuery

무한 스크롤을 구현하기 위해, 필요한 준비물이 2가지가 있다. useInfiniteQueryIntersection Observer이다. 먼저 useInfiniteQuery에 대해 알아보자.

useInfiniteQuery | TanStack Query React Docs

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey, //고유한 쿼리 키
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam), // 데이터를 가져오는 비동기 함수
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor, // 다음페이지 파라미터 추출
});

공식문서에서 제공하는 useInfiniteQuery의 기본 사용법이다. react-query에서는 무한스크롤을 편하게 구현할 수 있도록 돕는 useInfiniteQuery라는 훅을 제공하고 있다.

React Query가 설치된 프로젝트에서, 위 코드와 같이 React Query를 사용하여 Infinite Query를 생성한다.

data: 서버에 요청해서 받아온 데이터. pages 배열로 저장됨.

fetchNextPage : 다음페이지를 불러온다.

hasNextPage: 가져올 다음페이지가 있는지 boolean 값을 통해 그 여부를 나타낸다. getNextPageParam 옵션을 통해 알 수 있다.

fetchPage : 페이지별로 데이터를 가져오는 역할

getNextPageParam : 콜백함수를 사용해서 다음 페이지를 정의

queryKey: 쿼리를 구별하여 캐시를 관리하기위한 이름

queryFn: 쿼리가 데이터를 요청하는 데 사용할 함수

❗ 단, 주의 사항으로 가져온 데이터는 useQuery 형식과 다르게 pages 배열안에 담겨져 있으므로, 해당 데이터를 이용하려면 pages 배열안에 아이템을 빼오는 작업이 필요하다.


const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = useInfiniteQuery(
  ['AllPosts', newCategory],
  ({ pageParam }) => getAllPosts({ pageParam, category: newCategory }),
  {
    getNextPageParam: (lastPage) => {
      return lastPage.hasNext ? lastPage.lastVisible : undefined;
    },
    enabled: !!newCategory,
  }
);
 
const allPosts = data?.pages?.map(({ content }) => content).flat();

때문에 pages에 감싸져 있는 데이터를 꺼내주고, 이를 flat() 함수를 사용해 펼쳐줘서 pages 배열 형태가 아닌 하나에 배열 안에 모든 아이템들이 감싸져 있는 형태로 바꿔 사용했다.


return (
  <div className="mx-4 mt-8 mb-16">
    {allPosts?.map((post: postDataType, i: number) => (
      <Fragment key={post?.postId}>
        <PostItem post={post} />
        {i < allPosts?.length - 1 && <div className="w-full h-[2px] bg-gray-50" />}
      </Fragment>
    ))}
    {(isFetching || isFetchingNextPage || hasNextPage) && <Loader />}
    <div className="w-full touch-none" ref={ref} />
  </div>
);

이를 통해 별다른 작업 없이 useQuery 사용 형식처럼 해당 배열의 item을 map을 돌려 글과 댓글을 화면에 표시했다.


Intersection Observer

그렇다면 사용자가 최하단에 스크롤을 위치한것을 어떻게 알 수 있을까?

아쉽지만 useInfiniteQuery에서는 자체적으로 스크롤이 최하단에 위치한것을 감지 하지 못한다. 때문에 Intersection Observer 라는 훅을 사용하여 사용자의 스크롤 위치를 감지 해야 한다. Scroll Event 또한 사용 가능하지만, 스크롤 이벤트는 스크롤에 움직임에 따라 이벤트를 감지한다. 이는 스크롤이 조금만 움직여도 이벤트가 빈번하게 발생하여 성능상으로 좋지 않은 영향을 끼칠 위험이 크다.

Intersection Observer란?

import { RefObject, useState, useEffect } from 'react';
 
function useIntersectionObserver(
  elementRef: RefObject<Element>,
  { threshold = 0.1, root = null, rootMargin = '0%' }
) {
  const [entry, setEntry] = useState<IntersectionObserverEntry>();
 
  const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
    setEntry(entry);
  };
 
  useEffect(() => {
    const node = elementRef?.current;
    const hasIOSupport = !!window.IntersectionObserver;
 
    if (!node || !hasIOSupport) return;
 
    const observerParams = { threshold, root, rootMargin };
    const observer = new IntersectionObserver(updateEntry, observerParams);
 
    observer.observe(node);
 
    return () => observer.disconnect();
 
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [elementRef?.current, root, rootMargin, JSON.stringify(threshold)]);
 
  return entry;
}
 
export default useIntersectionObserver;

elementRef: RefObject<Element>: 요소의 참조를 나타내는 React Ref 객체. 이 Ref는 Intersection Observer가 관찰할 요소를 가리킨다.

{ threshold = 0.1, root = null, rootMargin = '0%' }: 옵션 객체로, Intersection Observer의 설정을 지정한다. 기본값으로는 요소가 10%나 노출될 때 콜백이 호출되며, root는 뷰포트를 나타낸다. rootMargin은 뷰포트와의 여백을 지정

const [entry, setEntry] = useState<IntersectionObserverEntry>();: entry라는 상태와 해당 상태를 업데이트하는 setEntry 함수를 useState 훅을 사용하여 선언. entry는 Intersection Observer에서 반환한 가시성 정보를 나타낸다. 간단히 말해, 이 부분은 React 컴포넌트에서 Intersection Observer의 가시성 정보를 저장하고 업데이트하는 데 사용되는 상태와 함수를 정의하는 부분이다

const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {...}: 새로운 Intersection Observer 엔트리를 받아와서 상태를 업데이트하는 함수. Intersection Observer의 콜백으로 사용된다. 즉 실제로 실행되는 부분

observerParams 객체에는 Intersection Observer의 파라미터들을 정의

Intersection Observer + useInfiniteQuery

const ref = useRef<HTMLDivElement | null>(null);
const pageRef = useIntersectionObserver(ref, {});
const isPageEnd = !!pageRef?.isIntersecting;
 
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = useInfiniteQuery(
  ['AllPosts', newCategory],
  ({ pageParam }) => getAllPosts({ pageParam, category: newCategory }),
  {
    getNextPageParam: (lastPage) => {
      return lastPage.hasNext ? lastPage.lastVisible : undefined;
    },
    enabled: !!newCategory,
  }
);
 
const fetchNext = useCallback(async () => {
  const res = await fetchNextPage();
  if (res.isError) {
    console.log(res.error);
  }
}, [fetchNextPage]);
 
useEffect(() => {
  let timerId: NodeJS.Timeout | undefined;
 
  if (isPageEnd && hasNextPage) {
    timerId = setTimeout(() => {
      fetchNext();
    }, 1000);
  }
 
  return () => clearTimeout(timerId);
}, [fetchNext, isPageEnd, hasNextPage]);
 
const allPosts = data?.pages?.map(({ content }) => content).flat();

#1 useRef 및 Intersection Observer 설정:

const ref = useRef<HTMLDivElement | null>(null);
const pageRef = useIntersectionObserver(ref, {});
const isPageEnd = !!pageRef?.isIntersecting;

#2 useInfiniteQuery 설정:

const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = useInfiniteQuery(
  ['AllPosts', newCategory],
  ({ pageParam }) => getAllPosts({ pageParam, category: newCategory }),
  {
    getNextPageParam: (lastPage) => {
      return lastPage.hasNext ? lastPage.lastVisible : undefined;
    },
    enabled: !!newCategory,
  }
);

#3 fetchNext 함수

const fetchNext = useCallback(async () => {
  const res = await fetchNextPage();
  if (res.isError) {
    console.log(res.error);
  }
}, [fetchNextPage]);

#4 useEffect를 사용한 무한 스크롤 로직

useEffect(() => {
  let timerId: NodeJS.Timeout | undefined;
 
  if (isPageEnd && hasNextPage) {
    timerId = setTimeout(() => {
      fetchNext();
    }, 1000);
  }
 
  return () => clearTimeout(timerId);
}, [fetchNext, isPageEnd, hasNextPage]);

예상치 못한 문제 발생

❗ 무한 스크롤 사용 시 유저가 글 상세 페이지를 이동했다가 뒤로가기를 누르면 클릭 이전 위치로 돌아올 수 없다는 문제가 발생했다. React Query 덕분에 데이터는 전부 유지되고 있지만, 스크롤이 최상단으로 복구되어 이전까지 탐색하던 위치를 유지하지 못하는것이다. 사용자는 해당 글이 있던 자리까지 다시 스크롤 해야 하는 불편함이 발생됐다.


해결 아이디어

생각한 해결 아이디어는 다음과 같다.

#1 글이 클릭 될 때 해당 클릭 된 글의 스크롤 위치와, 로드한 데이터를 저장하기

const PostItem = ({ post, allPosts }: { post: postDataType; allPosts: postDataType[] }) => {
  const router = useRouter();
  const tag = useEnumToTag(post?.tag);
 
  const handlePostClick = () => {
    sessionStorage.setItem('scrollPosition', window.scrollY.toString());
    sessionStorage.setItem('savedData', JSON.stringify(allPosts));
    router.push(`/community/${post.postId}`);
  };
 
  return (
    <div onClick={handlePostClick} className="my-6 cursor-pointer">
      ...
    </div>
  );
};

map으로 뿌려준 PostItem에 클릭 이벤트를 걸어서, 해당 아이템이 클릭되면 위치와, 로드된 전체 데이터를 저장하고, 해당 글 상세 페이지로 이동한다.


useEffect(() => {
  const handleScroll = () => {
    sessionStorage.setItem(SCROLL_POSITION_KEY, window.scrollY.toString());
  };
 
  window.addEventListener('scroll', handleScroll);
 
  const savedScrollPosition = sessionStorage.getItem(SCROLL_POSITION_KEY);
  if (savedScrollPosition) {
    window.scrollTo(0, parseFloat(savedScrollPosition));
  }
 
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

처음에는 위 코드처럼 postLayout에 useEffect 으로 윈도우에 스크롤 이벤트를 처리하는 방식으로 구현했는데, 콘솔에 찍히는 이벤트 처리량이 매우 많았기 때문에 비용적, 성능적 측면에서 매우 좋지 않다고 판단하여, PostItem에서 session storage에 저장했다.


#2-1 커뮤니티 메인페이지로 복귀 했을 때 session storage에 저장된 글 데이터 있다면 해당 값들을 사용해 데이터를 복원

const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = useInfiniteQuery(
  ['AllPosts', newCategory],
  ({ pageParam }) => getAllPosts({ pageParam, category: newCategory }),
  {
    getNextPageParam: (lastPage) => {
      return lastPage.hasNext ? lastPage.lastVisible : undefined;
    },
    enabled: !!newCategory,
    initialData: () => {
      const savedData = sessionStorage.getItem('savedData');
      return savedData ? JSON.parse(savedData) : undefined;
    },
  }
);

session storage에 저장된 데이터가 있으면 쿼리를 실행하지 않고 해당 데이터를 사용한다는걸 어떻게 구현할까 걱정이 많았는데, useInfiniteQuery에서는 initialData를 메서드를 제공한다. sessionStorage에 ‘savedData’이름으로 저장된 데이터들을 initialData값으로 사용하고, initialData값이 없다면 undefined가 return 되어 자동으로 쿼리가 실행되는 로직이다.


#2-2

const queryClient = useQueryClient();
const queryData = queryClient.getQueryData(['AllPosts', 'ITDEV']);
 
...
initialData: () => {
          return queryData ? queryData : undefined
        }
...

데이터가 캐싱되어 있기 때문에 queryClient에서 데이터를 가져와 initialData에 넣어줄 수도 있다. 다만 pages[]의 배열 형태로 저장되어 있기 때문에 다시 flat으로 펴주는 작업이 필요하다. 때문에 이미 펴진 상태의 #2-1 allPosts을 사용하도록 했다.


#3 커뮤니티 메인페이지로 복귀 했을 때 session storage에 저장된 위치 데이터가 있다면 해당 값을 사용해 위치를 복원하기

useEffect(() => {
  const savedScrollPosition = sessionStorage.getItem('scrollPosition');
  if (savedScrollPosition) {
    window.scrollTo(0, parseFloat(savedScrollPosition));
  }
}, []);

useEffect로 처음 마운트될 때 scrollPosition 값을 get하고 window.scrollTo을 사용해 해당 위치로 이동하면 될 수 있도록 구현했다.


또 문제 발생..

❗ PostItem을 클릭하고 session storage에 데이터가 저장된 상태에서, footer 메뉴바를 클릭해 다른 페이지로 이동했다가, 다시 커뮤니티 페이지로 이동했을 때 세션 스토리지에는 글 전체 데이터와 스크롤 Y값이 남아 있게 된다. 즉 뒤로가기 동작이 아니여도 세션 스토리지에 저장된 값의 영향을 받게 되어, 해당 위치로 이동되는것이다.


해결 아이디어2

💡 커뮤니티 메인 페이지로 이동 되는 경우가 언제인지 알아야, 우리가 원하는 동작인 글 상세페이지에서 뒤로 가기했을 때만 스크롤이 이동될 수 있도록 할 수 있을것이다.

해당 프로젝트에서 커뮤니티 메인페이지로 이동 될 수 있는 경우는 다음과 같다.

이 중 1번 3번은 스크롤 복원이 되면 안되므로, 커뮤니티 이동 로직에서 session storage 값을 제거해주었다.

const ToBackWithRemoveSession = () => {
  const router = useRouter();
  const onClickBackIcon = () => {
    sessionStorage.removeItem('scrollPosition'); //session data 제거
    sessionStorage.removeItem('savedData'); //session data 제거
    router.back();
  };
  return (
    <div onClick={onClickBackIcon} className="cursor-pointer py-3 max-w-max">
      <img src="/community/back.svg" alt="" />
    </div>
  );
};
me
@banhogu
안녕하세요 배움을 나누며 함께 전진하는 1년차 주니어 개발자 방호진입니다.