본문 바로가기

카테고리 없음

[React] 무한스크롤 구현 및 최적화(react-intersection-observer => virtuoso)

기존에 react-intersection-obsever를 사용해 무한스크롤을 구현하였던 코드를 virtuoso를 사용하여 리팩토링 하기로 했다.

리팩토링을 하게 된 가장 큰 이유는 성능 최적화에 있다.

Virtuoso란? 

Virtuoso는 무한 스크롤 구현 및 성능 최적화를 위한 라이브러리이다.

 

특징

  • Virtual List(리스트 가상화)
    • Virtuoso는 데이터를 렌더링 함에 있어서 화면에 보여지는 데이터 리스트들만 DOM 요소에 추가한다. 즉, 스크롤을 통해 데이터를 가져올 때 보이지 않는 요소는 DOM에 추가되지 않기 때문에 메모리 사용을 최적화 할 수 있다.
  • Auto Resizing(자동 리사이징)
    • Virtuoso 구성 요소는 콘텐츠 크기 조정, 이미지 로딩 등으로 인해 항목의 높이가 변경될 경우 이를 자동으로 처리한다.
  • Endless Scrolling(무한 스크롤)
    • endReached라는 콜백함수를 통해 무한 스크롤을 지원한다.

 

기존 코드(react-intersection-observer 사용)

//MatchingBox.tsx

import { useEffect, useState, useCallback } from 'react';
import { useInfiniteScroll } from '../../../shared/utils/useInfiniteScroll';

export default function MatchingBox() {
  const [users, setUsers] = useState<MatchingUserCard[]>([]);
  const [hasNextData, setHasNextData] = useState<boolean>(true);
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const fetchUsers = useCallback(async () => {
   	//데이터 패칭 함수 users에 값 저장
    }
  }, [page, hasNextData, isLoading]);

  useEffect(() => {
    fetchUsers();
  }, []);

  const { ref } = useInfiniteScroll(fetchUsers,hasNextData,isLoading);


  return (
    <S.Container>
      {users.map((user) => (
        <S.CardContainer key={user.id}>
          <S.UpperContainer>
            <S.ProfileIconContainer>
              <S.ProfileIcon
                src={user.profileImage}
                alt={`${user.nickname}의 프로필`}
              />
            </S.ProfileIconContainer>
            <S.ProfileTextContainer>
              <S.UserName>{user.nickname}</S.UserName>
              <span>{user.matchCount}회 매칭됨</span>
              <span>{user.location}</span>
            </S.ProfileTextContainer>
            <S.UnderContainer>
              <S.HashtagContainer>
                {user.hashtags.map((tag, idx) => (
                  <Hashtag key={idx} text={tag} />
                ))}
              </S.HashtagContainer>
              <MediumButton
                text='1:1 채팅하기'
              />
            </S.UnderContainer>
          </S.UpperContainer>
        </S.CardContainer>
      ))}
      {isLoading && <p>로딩</p>}
      {error && <p>{error}</p>}
      {hasNextData && !isLoading && <div ref={ref} />}
    </S.Container>
  );
}


//useInfiniteScroll.ts
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';

export const useInfiniteScroll = (
  fetchData: () => Promise<void>,
  hasNextData: boolean,
  isLoading: boolean
) => {
  const { ref, inView } = useInView({
    threshold: 1,
    triggerOnce: false,
  });

  useEffect(() => {
    if (inView && !isLoading && hasNextData) {
      fetchData();
    }
  }, [inView, isLoading, hasNextData, fetchData]);

  return { ref };
};

React Intersection ObserveruseInView를 사용해서 하단 ref에 닿게 되면 fetchUser 함수를 통해 새로 데이터를 받아오는 로직을 작성하였다.

정상적으로 무한 스크롤은 작동하지만 이때 당시 생각하지 못한 것이 무한스크롤로 받아오는 데이터가 많아질수록 DOM에는 받아온 데이터들이 계속해서 추가되기 때문에 성능적으로 문제가 생긴다는 것이었다.

 

보다시피 스크롤을 내려 데이터를 패칭할 때 마다 새로운 데이터들이 DOM에 추가되며 화면에 보이지 않는 요소들 또한 여전히 남아있다.
이처럼 받아오는 데이터의 양이 많아질수록 성능 문제가 발생할 가능성이 높아진다.

 

그렇다면 위의 코드를 virtuoso를 사용하여 변경해보자

수정 후 코드(Virtuoso 사용)

 

//MatchingBox.tsx

import { useEffect, useState, useCallback } from 'react';
import { useInfiniteScroll } from '../../../shared/utils/useInfiniteScroll';

export default function MatchingBox() {
  const [users, setUsers] = useState<MatchingUserCard[]>([]);
  const [hasNextData, setHasNextData] = useState<boolean>(true);
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const fetchUsers = useCallback(async () => {
   	//데이터 패칭 함수 users에 값 저장
    }
  }, [page, hasNextData, isLoading]);

  useEffect(() => {
    fetchUsers();
  }, []);

  const { virtuosoRef } = useInfiniteScroll(fetchUsers, hasMore, isLoading);


  return (
   <S.Container>
      <Virtuoso
        useWindowScroll  //외부와 스크롤 통합
        ref={virtuosoRef} 
        data={users}
        endReached={fetchUsers}
        itemContent={(_, user: MatchingUserCard) => (
          <S.CardContainer key={user.id}>
            <S.UpperContainer>
              <S.ProfileIconContainer>
                <S.ProfileIcon
                  src={user.profileImage}
                  alt={`${user.nickname}의 프로필`}
                />
              </S.ProfileIconContainer>
              <S.ProfileTextContainer>
                <S.UserName>{user.nickname}</S.UserName>
                <span>{user.matchCount}회 매칭됨</span>
                <span>{user.location}</span>
              </S.ProfileTextContainer>
              <S.UnderContainer>
                <S.HashtagContainer>
                  {user.hashtags.map((tag, idx) => (
                    <Hashtag key={idx} text={tag} />
                  ))}
                </S.HashtagContainer>
                <MediumButton
                  text="1:1 채팅하기"
                />
              </S.UnderContainer>
            </S.UpperContainer>
          </S.CardContainer>
        )}
      />
      {isLoading && <p>로딩 중...</p>}
      {error && <p>{error}</p>}
    </S.Container>
  );
}


//useInfiniteScroll.ts
import { useEffect, useRef } from 'react';
import { VirtuosoHandle } from 'react-virtuoso';

export const useInfiniteScroll = (
  fetchData: () => Promise<void>,
  hasNextData: boolean,
  isLoading: boolean
) => {
  const virtuosoRef = useRef<VirtuosoHandle | null>(null);

  useEffect(() => {
    if (!isLoading && hasNextData) {
      fetchData();
    }
  }, [isLoading, hasNextData, fetchData]);

  return { virtuosoRef };
};

코드는 거의 기존과 크게 바뀌지 않는다. ref에서 virtuosoRef를 사용한다는 것과 Container 내부에서 <Virtuoso/>를 사용한다는 것이다.

 

react-interseciton-observer를 사용했을 때 DOM의 요소를 보면 화면에 렌더링되는 요소들만 추가되며 화면에서 사라지면 제거가 된다. 
따라서 데이터를 많이 가져와도 보이지 않는 요소들은 DOM에 남아있지 않으므로 성능 면에서 유리할 것이다.

 

이상으로 react-intersection-observer를 사용해 무한스크롤을 구현했던 코드를 virtuoso를 사용해 프로젝트에 변경 후 적용한 과정이다.