리팩토링 17분 읽기

인디버디 리팩토링하기 (1)

시간이 없다는 핑계로 대충 코드를 짜면서 이렇게 짜면 나중에 무조건 고쳐야하는데...하고 생각을 했고, 그 벌을 지금 받으려고 한다.

인디버디를 마치고

인디버디 프로젝트가 05.31을 기점으로 종료되었다. 프로젝트는 끝났지만, 진행하면서 내 아쉬웠던 코드들을 리팩토링 해볼 생각이다.

시간이 없다는 핑계로 대충 코드를 짜면서 이렇게 짜면 나중에 무조건 고쳐야하는데...하고 생각을 했고, 그 벌을 지금 받으려고 한다.

31일 이후로 시간이 꽤 지났기 때문에 내가 짠 코드를 내가 모르는 문제가 발생할 수 있지만, 그때 왜 그렇게 짰는지 생각하면서 리팩토링을 해볼 생각이다.

HOME 리팩토링하기

제일 먼저 손을 볼 곳은 메인 페이지를 담당하는 home컴포넌트이다. 대부분의 내 코드에서 내가 느꼈던 문제들이 비슷하기 때문에 여기서 좀 잘 잡고 가면 다른 컴포넌트들의 문제들도 잘 해결할 수 있을 것이라고 생각한다.

아래 사진은 홈화면의 ui 모습이다.

Home

Home 컴포넌트에서 문제라고 생각했던 부분을 봐보자.

코드는 길지 않다. 기본적으로 이 컴포넌트는 페이지를 담당하고 있기 때문에 각 기능을 담당하는 하위 컴포넌트들을 단순히 불러와서 사용하고 있다. 여기서 좀 생각했던 부분은 위 코드에서 2번과 5번 줄이다.

컴포넌트의 프롭스로 상태변경 함수를 내려준다.

props로 상태 변경 함수 자체를 내려주는 것이 과연 바람직한가에 대해서 고민을 했다.

장점이 있다면 상태 변경 함수를 하위 컴포넌트에서 언제든 원하는 값으로 상태를 변경하도록 만든다는 것?

코드를 작성하는 단계에서는 이것이 장점이라고 할 수 있지만, 상태 변경이 어떻게 이루어지게 되는지 해당 상태를 사용하는 상위 컴포넌트 입장에서는 알 수 없기 때문에 좋지 못하다고 생각한다.

상태를 꼭 상위에서 선언했어야 했나?

위 코드를 보면 기본적으로 선언형 방식으로 컴포넌트를 사용하고 있다. 또한, 사용하는 show라는 상태는 단순히 NoticeModal컴포넌트를 보여줄지 말지 정하는 상태이다. 그렇다면 이 상태를 굳이 Home에서 사용해야 했을까? 그냥 NoticeModal에서 사용하면 되지 않을까?

이에 대해서는 고민이 좀 있다.

const Home = () => {

  return (
    <>
      <NoticeModal />
      <StyledContainer>
        <CategoryContainer />
        <GameContainer />
      </StyledContainer>
    </>
  );
};

만약 상태를 하위 컴포넌트에서 사용하면 위의 모습이 될 것이다. 위의 방식은 show라는 상태가 필요한 다른 컴포넌트가 있을 때 그 컴포넌트도 내부에서 show 상태를 선언해서 사용해야 한다는 문제가 있다.

하지만 장점이 있다. NoticeModal이 리랜더링이 될 때 Home이 리랜더링이 되지 않는다는 것이다. 이것만으로도 상태를 하위에 내려주는 것이 좋을 것 같다고 생각한다. 그렇게 되면 첫번째 문제도 해결이 된다. 또한 Home이 좀 더 선언적인 방식의 컴포넌트가 될 수 있다.

이러한 이유로 리팩토링을 위와 같이 진행했다.

Home 하위 컴포넌트

다음은 Home에서 사용하는 하위 컴포넌트들을 리팩토링해볼 것이다. 이제부터는 코드가 많이 지저분하고 수정할 부분이 많이 보일 것이라고 생각한다.

CategoryContainer

홈 화면에서 상단에 카테고리를 보여주는 컴포넌트이다.

const CategoryContainer = () => {
  const [categories, setCategories] = useState<CategoryType[]>([]);
  const navigation = useNavigate();

  const onClickCreateButtonHandler = () => {
    navigation('/register');
  };

  useEffect(() => {
    (async () => {
      try {
        const { data }: { data: CategoryType[] } = await axios(
          `${process.env.REACT_APP_API_URL}/api/categories`
        );
        const newCategories: CategoryType[] = [];

        data.forEach((category) => {
          const newCategory = {
            ...category,
            categoryName: categoryData[category.categoryName].text,
            categoryIcon: categoryData[category.categoryName].icon
          };

          category.categoryName === 'OTHER'
            ? newCategories.unshift(newCategory)
            : newCategories.push(newCategory);
        });

        setCategories(newCategories);
      } catch (err) {
        console.error(err);
      }
    })();
  }, []);

  return (
    <StyledWrapper>
      <StyledTitleContainer>
        <Title text='카테고리' />
        <CreateChannelButton
          text='게임채널 추가'
          onClick={onClickCreateButtonHandler}
        />
      </StyledTitleContainer>
      <StyledCategoryCardContainer>
        {categories.length > 0 ? (
          categories.map((category) => (
            <CategoryCard key={category.categoryId} {...category} />
          ))
        ) : (
          <StyledErrorMsg>카테고리가 존재하지 않습니다.</StyledErrorMsg>
        )}
      </StyledCategoryCardContainer>
    </StyledWrapper>
  );
};

위 코드에서 마음에 들지 않은 부분은 크게 두 곳이다.

비동기로 카테고리 데이터를 받아와서 사용가능 하도록 변경하는 부분

useEffect(() => {
    (async () => {
      try {
        const { data }: { data: CategoryType[] } = await axios(
          `${process.env.REACT_APP_API_URL}/api/categories`
        );
        const newCategories: CategoryType[] = [];

        data.forEach((category) => {
          const newCategory = {
            ...category,
            categoryName: categoryData[category.categoryName].text,
            categoryIcon: categoryData[category.categoryName].icon
          };

          category.categoryName === 'OTHER'
            ? newCategories.unshift(newCategory)
            : newCategories.push(newCategory);
        });

        setCategories(newCategories);
      } catch (err) {
        console.error(err);
      }
    })();
  }, []);

여기서는 비동기 코드와 데이터를 변경하는 코드, 상태를 변경하는 코드로 나눌 수 있을 것 같다. 여기서 내가 이용할 코드는 프로젝트를 하면서 만든 데이터 요청 코드를 이용할 것이다. 코드는 아래와 같다.

export const getData = (
  url: string,
  success: (res: AxiosResponse<any, any>) => void,
  fail: (err?: AxiosError) => void,
  config?: AxiosRequestConfig
) => {
  (async () => {
    try {
      const res = await axios(url, config);
      success(res);
    } catch (err: any) {
      fail(err);
    }
  })();
};

위 코드는 단순히 url과 필요시 config, 성공 혹은 실패 시 실행할 함수를 받는다.

위 코드를 적용하기 전에 성공 시 실행할 함수를 만들어줘야 한다. 여기서는 카테고리 배열을 만드는 함수와 상태 변경 함수를 사용하게 될 것이다.

const makeCategoryArray = (data: CategoryType[]) => {
  const newCategories: CategoryType[] = [];

  data.forEach((category) => {
    const newCategory = {
      ...category,
      categoryName: categoryData[category.categoryName].text,
      categoryIcon: categoryData[category.categoryName].icon
    };

    category.categoryName === 'OTHER'
      ? newCategories.unshift(newCategory)
      : newCategories.push(newCategory);
  });

  return newCategories;
};

먼저 위의 코드에서 카테고리 배열을 만드는 부분을 함수로 추출한다. 이 함수는 재사용과 확장성이 없는 코드라고 볼 수 있다. 하지만 특정 행동을 하는 코드를 하나의 함수로 묶어 추출하는 것에 의미가 있다고 할 수 있다.

이 함수는 컴포넌트 밖에서 선언되어 있다. 컴포넌트 내부에서 선언하게 되면 컴포넌트가 리랜더링이 될 때마다 다시 선언되기 때문에 상태를 사용하지 않는 함수들은 외부로 빼주었다.

useEffect(() => {
    (async () => {
      try {
        const { data }: { data: CategoryType[] } = await axios(
          `${process.env.REACT_APP_API_URL}/api/categories`
        );
        const newCategories = makeCategoryArray(data);

        setCategories(newCategories);
      } catch (err) {
        console.error(err);
      }
    })();
  }, []);

이제 위 코드를 보면 확실히 코드가 줄었다는 것을 알 수 있다. 물론 코드가 항상 짧아야 좋은 코드인 것은 아니다. 코드를 보았을 때 그 의미를 쉽게 알 수 있도록 짜는 것이 좋은 코드이다.

이 정도면 getData함수를 사용할 수 있을 것 같다.

useEffect(() => {
    getData(
      `${process.env.REACT_APP_API_URL}/api/categories`,
      (res) => {
        setCategories(makeCategoryArray(res.data));
      },
      console.error
    );
  }, []);

위의 코드로 변경되었다.

카테고리 각각을 보여주는 부분

return (
    <StyledWrapper>
      <StyledTitleContainer>
        <Title text='카테고리' />
        <CreateChannelButton
          text='게임채널 추가'
          onClick={onClickCreateButtonHandler}
        />
      </StyledTitleContainer>
      <StyledCategoryCardContainer>
        {categories.length > 0 ? (
          categories.map((category) => (
            <CategoryCard key={category.categoryId} {...category} />
          ))
        ) : (
          <StyledErrorMsg>카테고리가 존재하지 않습니다.</StyledErrorMsg>
        )}
      </StyledCategoryCardContainer>
    </StyledWrapper>
  );

위 코드에서 카테고리를 map을 통해 CategoryCard컴포넌트를 나열하는 부분을 분리하고 싶다. 위의 TitleCreateChannelButton을 보면 선언형이지만 카테고리를 보여주는 부분은 명령형으로 볼 수 있기 때문이다. 이 선언형과 명령형의 수준을 맞추는 것이 좋다고 생각했다.

그래서 새로운 컴포넌트를 만들었다.

const CategoryCardList = ({ categories }: { categories: CategoryType[] }) => {
  return (
    <StyledCategoryCardContainer>
      {categories.length > 0 ? (
        categories.map((category) => (
          <CategoryCard key={category.categoryId} {...category} />
        ))
      ) : (
        <StyledErrorMsg>카테고리가 존재하지 않습니다.</StyledErrorMsg>
      )}
    </StyledCategoryCardContainer>
  );
};

위 컴포넌트를 통해서 카테고리 컨테이너는 아래와 같이 바뀔 수 있다.

return (
    <StyledWrapper>
      <StyledTitleContainer>
        <Title text='카테고리' />
        <CreateChannelButton
          text='게임채널 추가'
          onClick={onClickCreateButtonHandler}
        />
      </StyledTitleContainer>
      <CategoryCardList categories={categories} />
    </StyledWrapper>
  );

결과

최종적으로 코드는 아래로 변하게 되었다.

const CategoryContainer = () => {
  const [categories, setCategories] = useState<CategoryType[]>([]);
  const navigation = useNavigate();

  const onClickCreateButtonHandler = () => {
    navigation('/register');
  };

  useEffect(() => {
    getData(
      `${process.env.REACT_APP_API_URL}/api/categories`,
      (res) => {
        setCategories(makeCategoryArray(res.data));
      },
      console.error
    );
  }, []);

  return (
    <StyledWrapper>
      <StyledTitleContainer>
        <Title text='카테고리' />
        <CreateChannelButton
          text='게임채널 추가'
          onClick={onClickCreateButtonHandler}
        />
      </StyledTitleContainer>
      <CategoryCardList categories={categories} />
    </StyledWrapper>
  );
};