커뮤니티 기능 게시글 목록을 관리하는 커스텀훅을 SRP 원칙에 맞추어 리팩토링 한 기록

이승훈·2023년 8월 4일
0

시행착오

목록 보기
13/23

이전에 다루었던 선택한 태그에 따른 게시글을 불러오는 커스텀훅에 대해 추가적인 리팩토링을 진행하였다. (gpt선생님과 함께)

들어가며

이전에 다루었던 선택한 태그에 따른 게시글을 불러오는 커스텀훅에 대해 아주 불만이 많은 상태였다.
그저 돌아가기에 급급하게 짠 코드라는게 여실히 드러난다는점이 특히 마음에 들지 않았다.

리팩토링을 진행해보고자 여러 개발선배님들의 블로그 및 나의 시니어 gpt 선생님에게 조언을 구하였고
리액트 커스텀훅은 아래의 5가지 원칙을 지키며 작성하는것이 좋다는 교훈을 얻게 되었다.

  1. 단일 책임 원칙(Single Responsibility Principle): 각 커스텀 훅은 한 가지 작업만 수행해야 합니다. 예를 들어, API 호출과 상태 관리를 한 커스텀 훅에서 처리하지 말고 분리하는 것이 좋습니다.

  2. 재사용성(Reusability): 가능하면 범용적으로 사용할 수 있는 커스텀 훅을 만들려고 노력해야 합니다. 특정 컴포넌트 또는 특정 상황에 국한되지 않는 커스텀 훅을 만들면 다른 곳에서도 쉽게 재사용할 수 있습니다.

  3. 명확성(Clarity): 커스텀 훅의 이름은 그것이 무엇을 하는지 명확하게 표현해야 합니다. 또한, 커스텀 훅 내부의 로직은 읽기 쉽고 이해하기 쉬워야 합니다.

  4. 독립성(Independence): 각 커스텀 훅은 다른 훅에 의존하지 않고 독립적으로 동작할 수 있어야 합니다. 필요한 경우에만 다른 커스텀 훅을 사용하되, 훅 간의 의존성을 최소화해야 합니다.

  5. 테스트 가능성(Testability): 커스텀 훅은 테스트하기 쉬워야 합니다. 훅의 각 부분을 독립적으로 테스트할 수 있도록 설계하고, 필요한 경우 mock을 사용하여 테스트를 진행합니다.

이 원칙들 중 1번 SRP원칙을 최대한 지켜보자는 마음가짐으로 리팩토링을 진행해보았다.

문제

어려운 기능도 아닌데 코드가 복잡하고 어렵다.
내가 짯지만 보면서도 어느부분에서 어떠한 역할을 하는지 명확하게 구분하기가 어려웠고
나중에 특정부분에 대해 기능을 수정해야 한다면 어떻게 수정해야할지 매우 어려울것이라는 강한 예감이 들었다.

문제 코드

const useTagSearchFeedPostList = ({ postCount }: Props) => {
  const [currentFeedTag, setCurrentFeedTag] = useState('전체');
  const { showBasicModal } = useBasicAlert();
  const [cumulativeFeedPostList, setCumulativeFeedPostList] = useState<
    FeedPostListType | undefined
  >();

  const [getTagSearchFeedPostList] = useLazyQuery(TagSearchFeedPostListQuery);

  const getFeedList = async (isRefresh: boolean) => {
    const feedPostListData = await getTagSearchFeedPostList({
      variables: {
        orderInput: {
          orderBy: 'CREATEDAT',
          orderDirection: 'DESC',
        },
        tagNames: currentFeedTag === '전체' ? null : currentFeedTag,
        paginationInput: {
          take: postCount ?? 3,
          after: isRefresh ? null : cumulativeFeedPostList?.cursor,
        },
      },
      fetchPolicy: 'no-cache',
      onError: (error) => {
        console.log('🔴 ~ getTagSearchFeedPostList Error', error),
          showBasicModal({ title: '삭제된 게시물 입니다.' });
      },
    });

    const isEmptyFeedPosts =
      feedPostListData.data.tagSearchFeedPostList.totalCount === 0;

    if (isEmptyFeedPosts) {
      setCumulativeFeedPostList(feedPostListData.data.tagSearchFeedPostList);
      return;
    }

    if (isRefresh) {
      setCumulativeFeedPostList(feedPostListData.data.tagSearchFeedPostList);
      return;
    }

    if (cumulativeFeedPostList && !isRefresh) {
      feedPostListData.data.tagSearchFeedPostList.feedPosts.unshift(
        ...cumulativeFeedPostList.feedPosts,
      );
      setCumulativeFeedPostList(feedPostListData.data.tagSearchFeedPostList);
      return;
    }

    setCumulativeFeedPostList(feedPostListData.data.tagSearchFeedPostList);
  };

  const [refreshing, setRefreshing] = useState(false);

  useFocusEffect(
    useCallback(() => {
      getFeedList(true);
    }, [currentFeedTag]),
  );

  useEffect(() => {
    getFeedList(true);
  }, [currentFeedTag]);

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

    setTimeout(async () => {
      getFeedList(true);
      setRefreshing(false);
    }, 1500);
  }, [currentFeedTag]);

  const feedPostListData = cumulativeFeedPostList;

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

해결 과정

기존의 getFeedList 함수를 재사용가능하게 분리하고,분리한 각 함수의 단일 책임을 강조하며 코드의 명확성을 높이도록 하였다.

데이터를 가져오는 로직을 별도의 훅으로 분리

getTagSearchFeedPostList를 호출하여 데이터를 서버로부터 요청하여 받아오는 로직을 별도로 분리하였다.
더욱더 역할을 분리하기 위해선 에러 처리하는 로직을 별도로 분리해주어야 하겠지만 현재수준에선 에러처리 로직이 별도로 분리할 수준까진 아니라 판단하였다.

const useFetchFeedPostList = () => {
  const { showBasicModal } = useBasicAlert();
  const [getTagSearchFeedPostList] = useLazyQuery(TagSearchFeedPostListQuery);

  const fetchFeedPostList = async (
    variables: OperationVariables | undefined,
  ) => {
    try {
      const { data } = await getTagSearchFeedPostList({
        variables,
        fetchPolicy: 'no-cache',
      });
      return data.tagSearchFeedPostList;
    } catch (error) {
      console.log('🔴 ~ getTagSearchFeedPostList Error', error);
      showBasicModal({ title: '삭제된 게시물 입니다.' });
      return null;
    }
  };

  return { fetchFeedPostList };
};

새로 고침의 상태와 작업을 처리하는 로직을 분리

게시글을 아래로 당겨 게시글 리스트를 새로고침 해주는 처리 로직과 그 상태를 별도로 분리하였다.

const useRefresh = (callback: () => void) => {
  const [refreshing, setRefreshing] = useState(false);

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

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

  return {
    refreshing,
    onRefresh,
  };
};

데이터를 관리하는 로직을 별도의 훅으로 분리

게시글 리스트들의 상태와
새로고침하여 게시글 목록을 새로 대체할 때, 스크롤을 아래로 내려 새로 불러온 게시글 목록들을 기존의 목록리스트에 추가해줄 때 사용하는 게시글리스트를 관리하는 로직을 별도의 훅으로 분리하였다.

const useManageFeedPostList = () => {
  const [feedPostListData, setFeedPostListData] = useState<
    FeedPostListType | undefined
  >();

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

    if (feedPostListData) {
      newData.feedPosts.unshift(...feedPostListData.feedPosts);
    }

    setFeedPostListData(newData);
  };

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

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

useFetchFeedPostList 훅과 useRefresh, useManageFeedPostList 훅을 조합하여, 데이터를 가져와서 상태를 업데이트하는 전체적인 과정을 관리하는 책임을 갖는 useTagSearchFeedPostList 작성

const useTagSearchFeedPostList = ({ postCount }: Props) => {
  const [currentFeedTag, setCurrentFeedTag] = useState('전체');

  const { fetchFeedPostList } = useFetchFeedPostList();
  const { feedPostListData, addDataToFeedPostList, replaceFeedPostListData } =
    useManageFeedPostList();

  const getFeedList = async (isRefresh: boolean, updatedPostCount?: number) => {
    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);

    if (!newFeedPostListData) return;

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

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

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

  useEffect(() => {
    getFeedList(true);
  }, [currentFeedTag]);

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

export default useTagSearchFeedPostList;

결과

// 새로 고침의 상태와 작업을 처리합니다.
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] = useLazyQuery(TagSearchFeedPostListQuery);

  const fetchFeedPostList = async (
    variables: OperationVariables | undefined,
  ) => {
    try {
      const { data } = await getTagSearchFeedPostList({
        variables,
        fetchPolicy: 'no-cache',
      });
      return data.tagSearchFeedPostList;
    } catch (error) {
      console.log('🔴 ~ getTagSearchFeedPostList Error', error);
      showBasicModal({ title: '삭제된 게시물 입니다.' });
      return null;
    }
  };

  return { fetchFeedPostList };
};

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

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

    if (feedPostListData) {
      newData.feedPosts.unshift(...feedPostListData.feedPosts);
    }

    setFeedPostListData(newData);
  };

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

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

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

  const { fetchFeedPostList } = useFetchFeedPostList();
  const { feedPostListData, addDataToFeedPostList, replaceFeedPostListData } =
    useManageFeedPostList();

  const getFeedList = async (isRefresh: boolean, updatedPostCount?: number) => {
    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);

    if (!newFeedPostListData) return;

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

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

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

  useEffect(() => {
    getFeedList(true);
  }, [currentFeedTag]);

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

export default useTagSearchFeedPostList;
profile
Beyond the wall

0개의 댓글