무한스크롤 구현기

윤뿔소·2023년 2월 7일
0

팀 프로젝트: 맛피

목록 보기
7/8
post-thumbnail

프론트에서 게시글을 보여주는 방법은 다양하다. 아예 전체글을 보여주든가 근본의 페이지네이션을 설정하든가, 요즘 핫한 무한스크롤을 쓰는 것이다.

사용 이유

무한스크롤은 양날의 검이다. 왜냐면 내릴 수록 끝도 없다는 게 사람들에게 호불호를 안겨다줄 수 있고, 특정 컨텐츠를 찾기 힘들 수도 있고(물론 키워드 검색과 주변 장소 검색 등이 있다.), 데이터가 많아지면 많아지는 특성상 성능이 저하될 수 있다.

그러면 나는 왜 무한스크롤을 쓰려고 했을까?
맛피는 인스타 피드처럼 쭉 나오는 메인 글이 가장 핵심적인 기능이자 자주 즐기는 기능이다.
이 기능을 기획하며 중요한 포인트가 있었는데,

  1. 글이 많아지고 다양해진다면 웹 서핑을 하듯이 땡기는 음식을 최대한 아무 방해 없이 보여주고 싶었다. 방해가 1이라도 생긴다면 이탈이라든지, 흐름에 영향이 생기니까.
  2. 위 사진에서 보다싶이 컨텐츠를 담는 양이 3분의 1정도만 있다. 그래서 무한스크롤로 버튼을 줄여 한꺼번에 좀 더 많은 컨텐츠를 담고 싶었다.

이러한 이유로 무한스크롤을 구현하기로 했다!

무한스크롤

Axios, TypeScript, React로 무한스크롤 하기! 서버에서 모든 게시글을 불러온다음 프론트에서 뚝 잘라 렌더링하는 방법도 있지만
우리 팀은 서버에서 페이징을 설정하고 그 다음 페이지를 불러오고 싶다면 프론트에서 요청을 보내는 식으로 진행했다.

  1. In your React component, create a state variable to keep track of the current page number, and another state variable to keep track of the data that you are loading.

데이터를 담아줄 상태와 페이지를 설정할 상태를 만들어 줌

const [page, setPage] = useState(1);
const [data, setData] = useState([]);
  1. Create a function that uses Axios to load data from your API, and update the state variables accordingly. This function can be triggered when the user scrolls to the bottom of the page.

Axios를 사용하여 API에서 데이터를 로드하는 함수를 생성하고 그에 따라 상태 변수를 업데이트한다. 이 기능은 사용자가 페이지 하단으로 스크롤할 때 트리거된다.

const loadData = async () => {
  const response = await axios.get(`api-url?page=${page}`);
  setData([...data, ...response.data]);
  setPage(page + 1);
}
  1. Use the useEffect hook to detect when the user has scrolled to the bottom of the page, and call the loadData function.

useEffect를 사용해 사용자가 페이지 하단으로 스크롤한 시점을 감지하고 loadData 함수를 호출한다.

useEffect(() => {
  const handleScroll = () => {
    const scrollHeight = document.body.scrollHeight;
    const innerHeight = window.innerHeight;
    const scrollTop = window.scrollY;
    if (scrollHeight - innerHeight === scrollTop) {
      loadData();
    }
  };
  window.addEventListener("scroll", handleScroll);
  return () => {
    window.removeEventListener("scroll", handleScroll);
  };
}, [page]);
  1. Use the data that you loaded to render the content on the page.

로드한 데이터를 가져와 페이지의 콘텐츠를 렌더링한다.

return (
  <div>
    {data.map((item) => (
      <div key={item.id}>{item.name}</div>
    ))}
  </div>
);

참고로 기본적인 예고, 이거를 본인의 개발 환경에 맞게 변환하여 옮겨야 한다.

실패점

useEffect(() => {
  const handleScroll = () => {
    const scrollHeight = document.body.scrollHeight;
    const innerHeight = window.innerHeight;
    const scrollTop = window.scrollY;
    if (scrollHeight - innerHeight === scrollTop) {
      loadData();
    }
  };
  window.addEventListener("scroll", handleScroll);
  return () => {
    window.removeEventListener("scroll", handleScroll);
  };
}, [page]);

document.body.scrollHeight라든지 window.innerHeight는 뷰포트 전체 윈도우 기준이라 하나의 컴포넌트에서의 scroll을 기준 잡고 싶다면 안된다. 그래서 scroll 이벤트를 따로 작성하여 scroll하고 싶은 기준이 되는 div에 넣어서 계산해야한다.

해결

이렇게! 도메인 페이지와 같이 보자

// 모듈 중략

// css 중략...

const Domain: React.FC = () => {
  // 계정 정보 중략...

  const [hasMore, setHasMore] = useState<boolean>(true);
  const [postsReload, setPostsReload] = useState<boolean>(false);
  const [page, setPage] = useState(1);
  const [limit] = useState(24);
  const [postData, setPostData] = useState([]);
  const { responseData: posts } = useAxios(getPosts, [postsReload], false);
  const { axiosData: getPageAxios, responseData: pagePosts } = useAxios(
    () => getPagePosts(page, limit),
    [page],
    false
  );

  // 이벤트를 타겟해 scroll 관련 이벤트를 잡고, 그 이벤트가 발생하면 페이지 +1, useAxios의 get 이벤트 발생
  const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
    const target = event.target as HTMLDivElement;
    const { scrollTop, clientHeight, scrollHeight } = target;
    // 시작 조건
    if (scrollTop + clientHeight >= scrollHeight && hasMore) {
      setPage(page + 1);
      loadData();
      // 종료 조건
      if (pagePosts.length < limit) {
        setHasMore(false);
      }
    }
  };

  const loadData = () => {
    getPageAxios();
    setPostData([...postData, ...pagePosts]);
  };

  const getAllPostsReload = () => {
    setPostsReload(!postsReload);
  };

  return (
    <StyledFeed>
      <HeaderContainer>
        <h1>오늘의 맛 Post</h1>
      </HeaderContainer>
      <StyledPosts onScroll={handleScroll}>
        {posts &&
          posts.map((post: IPosts) => (
            <PostRead
              key={post.id}
              post={post}
              getAllPostsReload={getAllPostsReload}
            />
          ))}
        {postData &&
          postData.map((post: IPosts) => (
            <PostRead
              key={post.id}
              post={post}
              getAllPostsReload={getAllPostsReload}
            />
          ))}
      </StyledPosts>
    </StyledFeed>
  );
};

export default Domain;
export const getPagePosts = async (page: number, limit: number) => {
  const response = await axios.get(`${url}/places/posts?page=${page}&size=${limit}`);
  return response.data;
};
  1. posts를 불러와 렌더링
  2. 이벤트를 활용해 타겟한 컴포넌트의 스크롤바 기준으로 잡아 스크롤바가 맨아래로 내려가면 setPage(page + 1)이 됨
  3. 추가 컨텐츠를 불러오고 posts이후 postData를 렌더링
  4. 추가 컨텐츠보다 limit(한번에 불러오는 데이터 양)이 더 크다면 그 다음 컨텐츠는 없으므로 hasMorefalse가 되어 시작 조건에 맞지 않아 더이상 불러오지 않음

그런 구조를 갖고 있다.

결론

이거 은근 빡셌다. 그래도 마지막 구현하고 나니 진짜 뿌듯했다. 카카오맵이랑 동급이랄까 시간이 생각보다 오래 걸렸다.

div를 기준 삼아 '관찰되는 즉시' 불러오는 Intersection Observer API, 캐싱 고수 리액트 쿼리 등등으로 구현할 수 있는 거를 알았다.

어떤 원리인지 알았으니 다음 무한스크롤이 있으면 자신있게 구현할 수 있을 거 같다. 무엇이든간에 구현한다면 적어도 1개는 배운다는 걸 알았다 ㅎㅎㅎ

profile
코뿔소처럼 저돌적으로

5개의 댓글

comment-user-thumbnail
2023년 2월 22일

스크롤 이벤트로 구현하는거 어려웠을 텐데 구현하셨다니 정말 대단합니다 !!! 하나 또배워갑니다!

답글 달기
comment-user-thumbnail
2023년 2월 23일

무한 스크롤 자료는 많지만 내게 맞게 설정해서 구현하기는 참 힘든 거 같아요. 멋지게 완성시키셨다니 대단합니다!

답글 달기
comment-user-thumbnail
2023년 2월 25일

매 번 수정 과정 디테일하게 다 기록하셨다니 놀랍습니다 ㅠㅠ

답글 달기
comment-user-thumbnail
2023년 2월 26일

무한스크롤 적용할 때 참고하러 다시 오겠슴돠 !!

답글 달기
comment-user-thumbnail
2023년 2월 26일

무한 스크롤 구현 저도 너무 궁금했어요!! 진짜 역시 대단하십니다 잘 보고 가요 ㅎㅎ

답글 달기