[Refactoring] Chapter4. 불필요한 useEffect동기화를 제거해보자

rlorxl·2024년 8월 28일
0

회고

목록 보기
6/7
post-thumbnail

리액트로 개발을 하고 있으면 습관적으로 useEffect를 자주 사용하게 된다. react query를 도입하기 전에는 주로 api요청에 useEffect에서 fetchData와 같은 함수를 호출하는식으로 사용하는 빈도가 가장 높았고 react query를 도입한 후에는 초기값을 지정하거나 부수효과를 일으키는 용도로 사용하곤 한다.

개발하는 초기에 예상치 못한 오류는 거의 이 useEffect를 제대로 다루지 못해서 발생하는 오류가 정말 많았다. 이번엔 useEffect를 습관적으로 사용하면서 불필요하게 사용된 상태 동기화가 있는지 찾아보고 리팩토링해본 코드를 공유한다.

useEffect()의 목적

useEffect를 사용하는 목적은 여러가지가 있지만 그 중에 동기화에 관련된 내용이다.

동기화?

동기화는 서로 다른 두개 이상의 상태 일관성을 맞추는 일이며 이상적으로는 여러 번 실행되어도 버그가 없어야 한다.

불필요한 상태 동기화

상태 업데이트가 useEffect() 안에서 이루어지는 경우 상태 변화의 흐름이 자연스럽지 않고 useEffect()의 실행을 기다려야 한다.

📎 불필요한 useEffect상태 동기화의 문제점

상태가 ‘일시적으로’ 최신화 되지 않고 고여 있음

  • 상태의 일관성을 보장할 수 없음
  • 성능에 악영향을 줌
    • 존재해서는 안되는 상태의 조합으로 렌더링이 되는 것.
    • useEffect에 의해 뒤늦게 상태 동기화가 된 후 한번더 렌더링이 일어나는 것.

연속된 꼬리물기 상태 동기화

코드 중 가장 치명적이라고 생각하는 코드를 가져왔다.

const useRecommend = ({ keyword, productList }: IUseRecommend) => {
  const [randomItems, setRandomItems] = useState<KeywordItemList>({});
  const [recommendList, setRecommendList] = useState<ProductData[]>([]);

  const { status: session } = useSession();
  const isUnAuthenticatedUser = session === "unauthenticated";

  const setRecommendItems = (keywordItemList: KeywordItemList) => {
    const randomItems: KeywordItemList = {};

    Object.entries(keywordItemList).forEach(([key, value]) => {
      randomItems[key] = random(value);
    });

     setRandomItems(randomItems);
  };

  useEffect(() => {
    if (isUnAuthenticatedUser) {
      setRecommendList(randomItems["추천아이템"]);
      return;
    }
    setRecommendList(randomItems[keyword]);
  }, [randomItems]);

   useEffect(() => {
    if (!keyword) return;
    setRecommendItems(productList);
  }, [keyword, productList]);

  return { recommendList, setRecommendItems };
};

코드를 보면 useEffect가 두개인데 아래의 useEffect에서 keyword와 productList의 변경이 있을 때 setRecommendItems 를 호출하고 있다.

문제점


setRandomItems 함수명
setRandomItems와 같은 함수명은 setState로 오해할 소지가 있기 때문에 좋지 않은 이름이다.

꼬리물기 상태 동기화
두번째 useEffect에서 setRecommendItems 의 호출 후

[setRecommendItems] → [setRandomItems] → 첫번째 useEffect실행 > → [setRecommendList]

와 같은 흐름으로 불필요한 상태의 동기화가 연이어 발생되도록 구현되어 있다.

상태의 변화에 따른 useEffect동기화가 연속적으로 일어나도록 되어있어 꼬리물기 상태 동기화라고 이름 붙였다.

불필요한 상태
randomItems 변수가 useState와 지역 변수로 중복되어 존재한다.


이후 문제점을 수정하는 코드로 리팩토링을 진행했다.

const useRecommend = ({ keyword, productList }: IUseRecommend) => {
  const [recommendList, setRecommendList] = useState<ProductData[]>([]);

  const { status: session } = useSession();
  const isUnAuthenticatedUser = session === "unauthenticated";

  const handleRecommendItems = (keywordItemList: KeywordItemList) => {
    const randomItems: KeywordItemList = {};

    Object.entries(keywordItemList).forEach(([key, value]) => {
      randomItems[key] = random(value);
    });

    if (isUnAuthenticatedUser) {
      setRecommendList(randomItems["추천아이템"]);
      return;
    }

    setRecommendList(randomItems[keyword]);
  };

  useEffect(() => {
    if (!keyword) return;

    handleRecommendItems(productList);
  }, [keyword, productList]);

  return { recommendList, setRecommendItems };
};

export default useRecommend;

함수명 변경
setRecommendItemshandleRecommendItems 로 함수명을 변경했다.

불필요한 state제거
상태 동기화가 연이어서 일어나게 된 이유는 불필요한 randomItems 가 상태로 존재했기 때문이다.

randomItmes 는 recommendList상태를 계산하는 목적으로만 사용되기 때문에 handleRecommendItems 함수 내부의 지역 변수로써만 존재해야 불필요한 상태 동기화를 삭제할 수 있다.

randomItems를 의존성 배열로 가지는 useEffect를 제거하고 handleRecommendItems 에서는 recommendList 를 업데이트하는 역할을 하는데에 적합한 함수로 리팩토링을 진행했다.

초기값을 useEffect내부에서 업데이트

다음은 초기값을 useEffect내부에서 업데이트하고 있는 코드이다.

const [marketList, setMarketList] = useState<MainProductData[]>([]);

useEffect(() => {
  if (isLoading) return;

  const copiedData = marketData.slice();
  setMarketList(copiedData.reverse());
}, [marketData, isLoading]);

marketData와 isLoading은 props이고 props가 변경될 때 useEffect에서 marketList를 업데이트 해준다.

초기값을 useEffect에서 업데이트 하는 방식은 습관적으로 자주 쓰는 코드인데 이렇게 useEffect에서 상태 동기화를 통해 상태를 업데이트 했을 때 불필요한 상태 업데이트와 리렌더링이 생길 수 있다.

marketData, isLoading의 변경 → useEffect실행 → setMarketList

처음에 생각할 수 있는 리팩토링 방법은 이런식이다.

const [marketList, setMarketList] = useState<MainProductData[]>(() =>
    marketData && !isLoading ? marketData.slice().reverse() : [],
  );

useEffect를 사용하지 않고 useState의 초기값으로 함수를 넣어주었다.

문제점

useState초기 렌더링에서만 기본값을 사용하기 때문에 props가 업데이트 되어도 marketList는 여전히 빈배열을 유지하게 된다.

방법은 간단하다.

useState상태를 사용하지 않고 일반 변수를 사용하는것이다.

marketList를 일반 변수로 바꾸고 props가 변경됨에 따라 계산된 값을 변수에 할당한다.
그리고 isLoading은 hasValue라는 더 선언적인 변수명으로 수정해주었다.

const marketList: MainProductData[] = hasValue && marketData ? marketData.slice().reverse() : [];

수정된 코드에서는 marketList가 단순히 계산된 값이므로, 불필요한 상태 업데이트와 렌더링을 방지할 수 있고 더 직관적으로 코드를 파악할 수 있다.

커스텀 훅의 상태를 부모로 올리기

마지막은 커스텀 훅을 사용해 불필요하게 상태를 업데이트하고 있는 코드이다.

리팩토링 중 관심사의 분리를 하면서 컴포넌트의 로직을 커스텀 훅으로 분리하는 리팩토링을 진행했었다.

useLookBook

const { lookbookList } = useLookbook({ lookbooks: lookbooks ?? [] });

useLookBook은 기본값(데이터)을 전달받아 useState, useEffect로 lookbookList를 업데이트하고 반환한다.

const useLookbook = ({ lookbooks }: { lookbooks: LookbookData[] }) => {
  const [lookbookList, setLookBookList] = useState<LookbookData[]>([]);

  const sortingWithFavoriteCounts = (lookbooks: LookbookData[]) => {
    return lookbooks.sort((a, b) => b.favorite.length - a.favorite.length);
  };

  useEffect(() => {
    if (!lookbooks.length) return;
    setLookBookList(sortingWithFavoriteCounts(lookbooks).splice(0, 7));
  }, [lookbooks]);

  return { lookbookList };
};

useLookbook의 역할이 확장되지 않고 단지 리스트를 업데이트하고 반환해주고 있어 여기서도 똑같이 불필요한 상태관리를 제거했다.

const sortingWithFavoriteCounts = (lookbooks: LookbookData[]) => {
  return lookbooks.sort((a, b) => b.favorite.length - a.favorite.length);
};

const lookbookList = lookbooks ? sortingWithFavoriteCounts(lookbooks).splice(0, 7) : [];

부모 컴포넌트에서 리스트를 일반 변수로 관리하고 useQuery로 반환된 기본값에 따라 계산하여 리스트를 업데이트하였다.

기존 코드에서는 lookbooks 가 변경될 때마다 useEffect가 실행되고 setLookBookList가 호출되어 컴포넌트가 리렌더링된다. 수정된 코드에서는 이런 상태 변경이 없으므로 불필요한 리렌더링이 발생하지 않고 lookbookList가 매번 첫마운트 시점에 계산된다.

0개의 댓글