커뮤니티 글 목록 데이터 관리를 state에서 apollo cache로 바꾼 이유와 그 과정

이승훈·2023년 9월 2일
1

시행착오

목록 보기
14/23
post-thumbnail

리액트 네이티브로 어플리케이션 한창 개발중 입니다.
커뮤니티 글 목록을 보는 스크린은 스크롤을 아래로 내리면서 트리거가 동작되면 다음 글 목록을 불러와서 보여주는 방식의 무한스크롤형식으로 개발되었습니다.
기능적으로는 완성을 하였으나 뭔가 아쉬웠고 아쉬움은 바로 성능이었지

기존

기존의 방식은 게시글 목록을 state로 관리하였음.
즉 새로운 글 목록을 불러오면 기존의 글목록 state배열에 추가해주는 방식

이 방식의 장점은 코드를 유지보수하기 편했다는 점.
그냥 state는 너무 익숙하고 편하니까.
뭐 그냥 게시글 목록 불러와서 새로운 배열로 추가해주면 되니까.

그리고 게시글 들어갔다 나왔을 때도 새로 다 불러와서 그냥 새로 불러온 게시글들 다 state에 갈아끼우면 되니까.

또한 커뮤니티 글 목록 주제가 바뀌면 바뀐주제에 따른 글 목록을 그냥 state에 막 넣어버리면 되니까.

아래는 기존의 게시글목록을 관리하는 커스텀훅과 그 내부 함수들이고 나에게 있어서 코드 유지보수와 가독성에선 아직도 이 코드가 더 유리하다고 생각하는중입니다.

// 새로 고침의 상태와 작업을 처리합니다.
const useRefresh = (callback: () => void) => {
  const [refreshing, setRefreshing] = useState(false);

  const onRefresh = useCallback(() => {
    setRefreshing(true);

    setTimeout(() => {
      callback();
      setRefreshing(false);
    }, 1500);
  }, [callback]);

  return {
    refreshing,
    onRefresh,
  };
};

// 데이터를 가져오는 로직을 별도의 훅으로 분리합니다.
const useFetchFeedPostList = () => {
  const { showBasicModal } = useBasicAlert();
  const [getTagSearchFeedPostList, { loading }] =
    useLazyQuery<GetTagSearchFeedPostListType>(TagSearchFeedPostListQuery);

  const fetchFeedPostList = async ({ variables, isCacheUse }: fetchProps) => {
    try {
      console.log('게시글 목록 가져오는 함수 호출!!!!!!!');
      const { data } = await getTagSearchFeedPostList({
        variables,
        fetchPolicy: isCacheUse ? 'no-cache' : 'cache-and-network',
      });
      return data?.tagSearchFeedPostList;
    } catch (error) {
      console.log('🔴 ~ getTagSearchFeedPostList Error', error);
      showBasicModal({ title: '네트워크 에러' });
      return null;
    }
  };

  return { fetchFeedPostList, loading };
};

//데이터를 관리하는 로직을 별도의 훅으로 분리합니다.
const useManageFeedPostList = () => {
  const [feedPostListData, setFeedPostListData] = useState<
    FeedPostListType | undefined
  >();

  const addDataToFeedPostList = (newData: FeedPostListType) => {
    try {
      if (newData.totalCount === 0) {
        setFeedPostListData(newData);
        return;
      }

      if (feedPostListData) {
        // newData.feedPosts.unshift(...feedPostListData.feedPosts);
        const newfeedPosts = {
          feedPosts: [...feedPostListData.feedPosts, ...newData.feedPosts],
        };
        const newnewnew = { ...newData, ...newfeedPosts };
        setFeedPostListData(newnewnew);
      }
    } catch (error) {
      console.log('🔴 ~ addDataToFeedPostList Error', error);
    }
  };

  const replaceFeedPostListData = (newData: FeedPostListType) => {
    setFeedPostListData(newData);
  };

  return {
    feedPostListData,
    addDataToFeedPostList,
    replaceFeedPostListData,
  };
};

useFetchFeedPostList 훅과 useManageFeedPostList 훅을 조합하여, 데이터를 가져와서 상태를 업데이트하는 전체적인 과정을 관리하는 책임을 갖습니다.
const useTagSearchFeedPostList = ({ postCount }: Props) => {
  const [currentFeedTag, setCurrentFeedTag] = useState('전체');
  const { fetchFeedPostList, loading } = useFetchFeedPostList();
  const { feedPostListData, addDataToFeedPostList, replaceFeedPostListData } =
    useManageFeedPostList();

  const getFeedList = async ({
    isRefresh,
    updatedPostCount,
    isCacheUse,
  }: getFeedListProps) => {
    const variables = {
      orderInput: {
        orderBy: 'CREATEDAT',
        orderDirection: 'DESC',
      },
      tagNames: currentFeedTag === '전체' ? null : currentFeedTag,
      paginationInput: {
        take: updatedPostCount ? updatedPostCount : postCount ?? 3,
        after: isRefresh || updatedPostCount ? null : feedPostListData?.cursor,
      },
    };

    const newFeedPostListData = await fetchFeedPostList({
      variables,
      isCacheUse,
    });

    if (!newFeedPostListData) return;

    if (isRefresh) {
      replaceFeedPostListData(newFeedPostListData);
    } else {
      addDataToFeedPostList(newFeedPostListData);
    }
  };

  const { refreshing, onRefresh } = useRefresh(getFeedList.bind(null, true));

  useFocusEffect(
    useCallback(() => {
      getFeedList({
        isRefresh: true,
        isCacheUse: true,
        updatedPostCount: feedPostListData?.feedPosts.length,
      });
    }, [feedPostListData?.feedPosts.length]),
  );

  useEffect(() => {
    getFeedList({ isRefresh: true, isCacheUse: false });
  }, [currentFeedTag]);

  return {
    feedPostListData,
    currentFeedTag,
    setCurrentFeedTag,
    refreshing,
    onRefresh,
    getFeedList,
    loading,
  };
};

하지만 state로 관리되는 게시글은 내가 원한것보다 더 많은 리렌더링을 초래하였고
무한 스크롤 과정에서 다음 목록을 불러와서 후다닥 밑에 붙여주는속도가 마음에 들지 않았음.

고래서 알아보다보니 Apollo에서 페이지네이션 최적화를 위해 제공해주는 fetchmore라는 메소드가 있었고 캐시로 게시글 목록을 관리하면 훨씬 빠르고 리렌더링도 줄일 수 있을거라 판단 했습니다.

조사한 결과 fetchmore를 사용했을 때의 이점은 아래와 같습니다.

  • 부분적인 데이터 요청: fetchMore 메소드를 사용하면 한 번에 모든 데이터를 로드하는 대신, 필요한 만큼의 데이터만 서버에서 가져올 수 있습니다. 이는 불필요한 대량의 데이터 전송을 줄이며 서버와 클라이언트 사이의 부하를 감소시킵니다.
  • 사용자 경험 향상: 사용자는 페이지를 스크롤하면서 추가적인 데이터를 기다리지 않고 바로 보게 됩니다. 이는 사용자에게 더 빠른 반응성을 제공하며, 더 나은 사용자 경험을 제공합니다.
  • 서버 부하 감소: 한 번에 모든 데이터를 요청하는 대신, 필요한 만큼의 데이터만 요청하면 서버의 부하가 줄어들게 됩니다. 이는 서버의 처리 속도를 향상시키고, 전체 시스템의 성능을 개선시킵니다.
  • 캐시 최적화: 현재 코드는 Apollo Client의 캐시 기능을 사용하고 있습니다. fetchMore를 사용하면, 이미 로드된 데이터는 캐시에 저장되고, 추가적으로 필요한 데이터만 서버에서 가져옵니다. 이는 네트워크 사용을 줄이고, 데이터의 일관성을 유지할 수 있습니다.

싹다 갈아 치워야겠다..라고 판단 후 실행에 옮김

개선

Core pagination API
Fetching and caching paginated results (Apollo 공식문서)
https://www.apollographql.com/docs/react/pagination/core-api/

네 뭐 fetchmore를 사용하기로 결정했고 실행에 옮겼습니다.

 const { data, loading, fetchMore, refetch } =
    useQuery<GetTagSearchFeedPostListType>(TagSearchFeedPostListQuery, {
      fetchPolicy: 'cache-and-network',
      variables: {
        orderInput: {
          orderBy: 'CREATEDAT',
          orderDirection: 'DESC',
        },
        tagNames: currentFeedTag === '전체' ? null : currentFeedTag,
        paginationInput: {
          take: postCount,
          after: null,
        },
      },
      onError: (error) =>
        console.log('🔴 ~ TagSearchFeedPostListQuery Error', error),
    });

일단 뭐 기본적으로 초기에 게시글 목록을 불러옵니다.

useQuery를 사용했을때 기본적으로 반환해주는 fetchmore 메소드를 사용해 추가적인 게시글 목록을 불러오는데 사용할 함수를 만들 수 있습니다.

  const fetchMoreFeedPostList = useCallback(async () => {
    if (!data?.tagSearchFeedPostList.hasNextPage || loading) {
      return;
    }
    setFetchMoreLoading(true);

    const fetchMoreVariables = {
      orderInput: { orderBy: 'CREATEDAT', orderDirection: 'DESC' },
      tagNames: currentFeedTag === '전체' ? null : currentFeedTag,
      paginationInput: {
        take: postCount,
        after: data.tagSearchFeedPostList.cursor,
      },
    };

    await fetchMore({
      variables: fetchMoreVariables,
    });

    console.log('fetchMore completed');
    setFetchMoreLoading(false);
  }, [data, loading, fetchMore]);

  // 게시글 새로고침 하는 함수
  const onRefresh = useCallback(() => {
    setRefreshing(true);

    setTimeout(async () => {
      await refetch();
      setRefreshing(false);
    }, 1500);
  }, [refetch]);

위의 fetchMoreFeedPostList 함수를 실행하면 fetchMore 메소드를 원하는 variable을 입력하여 실행해줍니다.

그런데 여기서 문제가 하나 있습니다.

캐시는 fetchMore 메소드로 불러온 쿼리 결과를 원래 쿼리 결과 와 병합해야 한다는 사실을 아직 모른다는 점 입니다. 캐시는 두 개의 결과를 완전히 별도의 목록으로 저장합니다.

이를 위해선 cache의 typePolicy 정책을 수정해줘야합니다.

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feedPosts: {
          // Don't cache separate results based on
          // any of this field's arguments.
          keyArgs: false,

          // Concatenate the incoming list items with
          // the existing list items.
          merge(existing = [], incoming) {
            return [...existing, ...incoming];
          },
        }
      }
    }
  }
})

위의 코드처럼 캐시정책을 수정해줌으로서 fetchMore로 불러온 새로운 쿼리 결과에 대해서 기존의 쿼리 결과배열에 추가해줄 수 있습니다.

추가 개선

위의 캐시정책 코드에서 Apollo Client는 새로운 쿼리 결과(incoming)를 기존의 쿼리 결과(Existing)배열에 추가해주기전에 incoming의 결과중 Existing에 존재하는 것이 있는지 검사하여 기존재하는 데이터의 경우는 중복을 제거해주는 기본적인 기능이 있습니다.

이 연산의 시간 복잡도는 O(n)입니다.
왜냐하면 배열에서 원소의 존재여부를 확인하기 위해선 모든 배열의 원소를 순회해야하기 때문입니다.

허나 아래의 코드처럼 Set자료형으로 바꿔주게 되면 Set자료형은 유일한 값들을 가지는 자료형이기에 특정 원소의 존재여부를 확인하는 시간 복잡도는 O(1)이 됩니다.

즉, fetchMore로 데이터를 불러왔을 때 기존의 목록에 데이터가 추가되더라도
기존에 존재하는 데이터였는지 확인하는 절차의 시간을 줄여주는 코드입니다.

const apolloCache = useRef(
    new InMemoryCache({
      typePolicies: {
        PaginationFeedPostOutput: {
          fields: {
            feedPosts: {
              merge(
                existing: Reference[] | undefined,
                incoming: Reference[],
              ): Reference[] {
                const existingSet = new Set(
                  existing ? existing.map((item) => item.__ref) : [],
                );

                const merged = existing ? [...existing] : [];
                for (const inc of incoming) {
                  if (inc.__ref && !existingSet.has(inc.__ref)) {
                    merged.push(inc);
                  }
                }

                return merged;
              },
            },
          },
        },
      },
      dataIdFromObject(responseObject) {
        return `${responseObject.__typename}: ${responseObject.code}`;
      },
    }),
  );

후기

흠 확실히 캐시로 관리해주고 몇가지 양념을 좀 쳐줬더니
체감상 무한스크롤이 훨씬 빠르고 자연스럽게 이루어지는것으로 보인다.

아주... 아주...뿌듯해...
짜릿....

profile
Beyond the wall

0개의 댓글