무한스크롤 구현하기

JiHyun·2023년 4월 22일
1
post-thumbnail

EmoTrak 프로젝트에서 커뮤니티 게시판을 만들어 사람들의 게시글을 무한 스크롤 방법으로 구현해보기로 했다.

왜 무한스크롤일까?

  • 직접 페이지 버튼을 클릭해야하는 페이지네이션보다 더 나은 사용자 경험을 제공한다고 생각함.
  • 반응형 웹을 적용한다고 생각했을때 모바일 환경에서 콘텐츠를 보여주기 직관적이고 사용이 쉽다고 느낌.

무한스크롤 구현해보기

개발환경 : TypeScript, React, TanStack Query(React Query)

무한 스크롤 기능의 구현에는 다양한 방식이 존재하지만, 이번 프로젝트에서는 스크롤 이벤트를 활용한 구현 방식을 선택했다. (차후 IntersectionObserver를 사용한 구현도 계획 중입니다)

사용자가 현재 보고 있는 창의 높이와 사용자의 스크롤 위치를 합산한 값이 전체 페이지의 높이와 동일해지면, 페이지 번호를 1씩 증가시켜 서버에 데이터를 요청하는 방식으로 처리하였다.

const [page, setPage] = useState<number>(1);

const { data } = useQuery({
  queryKey: [keys.GET_BOARD, page],
  queryFn: async () => {
    const data = await user.get(`/boards`, { params: {page} });
    return data.data.data.contents;
  },
  keepPreviousData: true,
});

const [totalData, setTotalData] = useState< never[] | ImageType[]>([])

const onScroll = () => {
  const { scrollTop, offsetHeight } = document.documentElement;
  if (window.innerHeight + scrollTop >= offsetHeight) {
    setPage(pre => pre + 1);
  }
};

useEffect(() => {
  window.addEventListener("scroll", onScroll);
  return () => {
    window.removeEventListener("scroll", onScroll);
  };
}, [page]);

useEffect(() => {
  if (data) {
    setTotalData((prevPostData: never[] | ImageType[]): never[] | ImageType[] => [
      ...prevPostData,
      ...data,
    ]);
  }
}, [data]);

문제발생

❗️서버에 요청할 새로운 페이지 데이터가 없는 상황에서도 불필요한 요청이 계속 발생한다는 점이다. 따라서 마지막 페이지에 도달했을 때 요청을 중단시키는 기능이 필요했다.

마지막 페이지인지 아닌지를 알 수 있는 방법
👉 페이지 요청마다 들어오는 데이터의 갯수를 확인하여 현재 설정된 페이지 크기(20개)보다 적은 데이터가 반환되면 마지막 페이지로 판단하여 그 여부를 boolean값으로 처리하였다.

const onScroll = () => {
  if (!isLast && window.innerHeight + window.scrollY + 1 >= document.body.offsetHeight) {
    setPage(pre => pre + 1);
  }
};

UX적인 고민..🤔

무한 스크롤 기능은 원활하게 작동하지만, 사용자가 브라우저의 "뒤로 가기"를 통해 이전 페이지로 돌아갔을 때, 기존에 보고 있던 위치를 유지하는 것 더 좋을 것이라 판단하였다.

현재 문제는 마지막 페이지의 정보만 유지된채 이전 페이지의 정보는 사라지고 있었고 스크롤 위치도 최상단으로 이동하였다.

네트워크 창을 통해 문제의 원인을 분석해본 결과, 페이지 1의 정보를 불러오는 것과 거의 동시에 페이지 2의 정보가 로드되어, 페이지 1의 정보가 저장되기 전에 사라지는 것이였다.

(글의 가독성을 위해 다른 query string값은 빼두었습니다.)

이 문제를 해결하기 위해 이미 서버요청으로 불러온 데이터를 다음 페이지의 데이터와 별개로 캐싱해주는 방법을 찾고싶어 TanStack Query 공식문서를 찾아보았고 여기서 해답을 찾았다.

useInfinityQuery라는 훅을 제공해주는데 이 기능으로 데이터를 불러오면 그 값을 page단위로 캐시해서 제공해주었다.

const { data, isError, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: [keys.GET_BOARD],
  queryFn: async ({ pageParam = 1 }) => {
    const data = await user.get(`/boards?page=${pageParam}`);
    return { data: data.data.data.contents, pageParam };
  },
  getNextPageParam: (lastPage) => {
    const nextPage = lastPage.pageParam + 1;
    if (lastPage.data.length > 0) {
      return nextPage;
    }
  },
  keepPreviousData: true,
});

const onScroll = () => {
  const { scrollTop, offsetHeight } = document.documentElement;
  if (hasNextPage && window.innerHeight + scrollTop + 1 >= offsetHeight) {
    fetchNextPage();
  }
};


useEffect(() => {
  if (data) {
    const newData = data.pages.reduce(
      (arr: never[] | ImageType[], cur) => [...arr, ...cur.data],[]
    );
    setPostData(newData);
  }
}, [data]);

여전히 스크롤은 유지되지 않는다..

이전 구현에서 마지막 페이지인지 아닌지를 판단하기 위해 boolean 값을 사용하여 다음 페이지의 요청 여부를 결정하는 방식은 동일하지만 모든 페이지의 데이터는 캐싱되어 있어 데이터가 손실되지 않았다. 그러나, 스크롤이 맨 위로 이동하는 문제는 여전히 존재함.

스크롤의 위치를 저장하고 "뒤로가기"시 기존에 보고 있던 위치로 이동시켜주면 될것 같다고 생각하여 방법을 찾아보았다.

스크롤 위치를 유지하는 방법은 쿠키, 로컬 스토리지, 세션 스토리지 등 여러 가지 방법이 있다.
브라우저를 종료했을 때에도 스크롤 위치가 유지된다면, 사용자가 다시 페이지를 방문했을 때 마지막으로 저장된 위치로 강제로 이동되는 문제가 발생할 수 있어 UX상 좋지 않을 거라 판단하여, 브라우저가 닫힐 때 스크롤 위치 정보도 함께 지워지는 세션 스토리지를 사용하기로 결정하였다.

스크롤 위치를 저장하기 위해 페이지가 언마운트될 때 해당 값을 저장하는 방법을 선택하였습니다. 이를 위해 React의 useEffect 훅의 cleanup 함수를 활용함.

useEffect 훅의 cleanup 함수는 컴포넌트가 언마운트될 때 호출되므로, 이 시점에 스크롤 위치를 세션 스토리지에 저장하도록 구현하였고 페이지를 떠날 때마다 스크롤 위치가 자동으로 저장되고, 이전 페이지로 돌아갔을 때 해당 위치로 스크롤이 이동하게 될거라 판단하였음.

// 해당 스크롤 위치를 저장해주는 함수
const saveScrollPosition = () => {
  sessionStorage.setItem('scrollPosition', window.scrollY.toString());
}

// 해당 위치로 이동하게 만들어주는 함수
const restoreScrollPosition = () => {
  const scrollPosition = sessionStorage.getItem('scrollPosition');
  if (scrollPosition) {
    window.scrollTo(0, Number(scrollPosition));
    sessionStorage.removeItem('scrollPosition');
  }
}

useEffect(() => {
  window.addEventListener("scroll", onScroll);
  restoreScrollPosition(); 

  return () => {
	saveScrollPosition(); 
    window.removeEventListener("scroll", onScroll);
  };
}, [hasNextPage]);

❗️그러나 페이지가 언마운트될 때마다 스크롤 위치가 0으로 초기화되어 저장되었다.
이는 스크롤 위치를 저장하는 함수가 실행되는 도중 스크롤 이벤트가 중단되어, 계속해서 0으로 저장되는 것으로 추측함.
따라서 스크롤 이벤트가 발생하는 시점에 스크롤 위치를 저장하는 로직으로 변경.

const onScroll = () => {
  const { scrollTop, offsetHeight } = document.documentElement;
  if (hasNextPage && window.innerHeight + scrollTop + 1 >= offsetHeight) {
    fetchNextPage();
    saveScrollPosition();
  }
  saveScrollPosition();
};

useEffect(() => {
  window.addEventListener("scroll", onScroll);
  restoreScrollPosition();

  return () => {
    window.removeEventListener("scroll", onScroll);
  };
}, [hasNextPage]);

언마운트 시에 마지막 스크롤 위치가 올바르게 저장되는 것을 확인.

그런데, '뒤로 가기'를 클릭하면 해당 위치로 스크롤이 이동하는 로직이 실행된 후에 스크롤 위치가 다시 0으로 이동시키는 어떠한 로직이 있다는 것을 알게됨.
(이후 원인을 분석한 결과, 페이지가 새로 렌더링 될 때 data를 담고있는 배열이 초기값으로 변환되어 스크롤또한 0으로 초기화되었던 것이었다.)

이 문제를 해결하기 위해, 스크롤 위치를 이동시키는 함수에 setTimeOut을 적용하여 모든 함수 실행이 완료된 후에 실행되도록 수정하였다. 이로써 페이지 렌더링 이후에도 스크롤 위치를 올바르게 유지할 수 있게 됨.

function restoreScrollPosition() {
  const scrollPosition = sessionStorage.getItem("scrollPosition");
  console.log("scroll", scrollPosition);
  if (scrollPosition) {
    setTimeout(() => {
      window.scrollTo(0, Number(scrollPosition));
    }, 50);
    sessionStorage.removeItem("scrollPosition");
  }
}
profile
비전공자의 개발일기📝

0개의 댓글