추상화를 통한 리팩토링 예시

Tags
알고케어
Property
발표자
한어진

개요

기존 코드

전체 유저 목록이 있고, 검색을 한다면 검색 결과만 필터링해서 보여주도록 되어있던 간단한 레거시 코드였다.
  • 검색어 입력 인풋텍스트
  • 유저목록 리스트
... export const UserListComponent: VFC<Props> = ({ userProfileList }) => { const [searchText, setSearchText] = useState<string>(''); ...검색어에 따라 유저목록을 필터링 하는 기능 return ( <> <UserListSearchInput searchText={searchText} setSearchText={setSearchText} /> <UserCellList userProfileList={filteredUserList} /> </> ); };

추가 요청사항

  1. 복용 여부에 따라 그룹핑을 해달라
  1. 각 그룹에 유저가 비어있을 경우에는 특정 카드를 노출시켜달라

추가 요청사항 반영코드

UserGroupByDoseStatus를 나름 잘 구성했고 추가로 필요한 컴포넌트는 컴포지션으로 받도록 하여 커스텀 가능성을 열어둔 것까지 만족한다.
하지만 기존에는 단순히 검색에 따라 필터링되는 하나의 기능이었는데, 이제는 그룹핑 기능비어있을 때는 별도의 컴포넌트를 노출하도록 하는 기능이 섞이면서 읽기 어려운 코드가 되었다.
... export const UserCellListContainer: VFC<Props> = ({ userProfileList, searchText, }) => { // 검색 결과에 따라 필터링 하는 로직 const searchedUserList = useMemo(() => { const filteredList = userProfileList.filter((item) => item.name.includes(searchText), ); if (filteredList.length > 0) { return filteredList; } const firstSyllableFilteredList = userProfileList.filter((item) => getFirstSyllables(item.name).includes(searchText), ); return firstSyllableFilteredList; }, [userProfileList, searchText]); // 복용 완료 여부에 따라 필터링 하는 로직 const doseIncompletedUserList = useMemo(() => { const filteredList = userProfileList.filter( ({ isDoseCompleted }) => !isDoseCompleted, ); return filteredList; }, [userProfileList]); const doseCompletedUserList = useMemo(() => { const filteredList = userProfileList.filter( ({ isDoseCompleted }) => isDoseCompleted, ); return filteredList; }, [userProfileList]); // 검색 키워드가 있다면, 검색된 유저만 노출 if (searchText) { return ( <Wrapper> <Spacer spaceDirection='column' spacing={scale(8)} /> <UserCellList userProfileList={searchedUserList} /> </Wrapper> ); } // 검색 키워드가 없다면, 복용 여부에 따라 유저 그룹핑하여 노출 return ( <Wrapper> <UserGroupByDoseStatus title='복용 미완료' userProfileList={doseIncompletedUserList} emptyUserListText='오늘 모두 복용했어요!' emptyIcon={<DoseIncompletedIcon {...EmptyIconStyle} />} /> <UserGroupByDoseStatus title='복용 완료' userProfileList={doseCompletedUserList} emptyUserListText='아직 아무도 없어요 :(' emptyIcon={<DoseCompletedIcon {...EmptyIconStyle} />} /> </Wrapper> ); }; ...
 

리팩토링

단순히 검색기능만 필요했던 과거와 달리, 추가 요구사항이 반영되면서 로직이 한눈에 파악하기 어렵게 되었다.
편안하게 읽히지 않는다는 것은 코드 설계가 잘못되었을 수 있다는 뜻으로, 리팩토링을 생각해보면 좋다.

[1] SearchedUserList로 추상화

판단 근거

  1. 아래 3가지가 묶여서 하나의 “검색된 유저목록" 이라는 결과를 낳는 것인데, 이들이 각각 떨어져서 구현되어 있어서 한눈에 코드를 파악하기 어려움.
      • 검색 관련 상태,
      • 로직,
      • 유저목록 컴포넌트
 
  1. 또한 아래의 로직이 한눈에 파악되어야 하는데, 그렇지 못한 상태다.
      • 검색했을 때
        • 검색된 유저만 보여주고, 없을 경우에는 빈 화면으로 둔다.
      • 검색하지 않았을 때
        • 복용완료 유저와 복용 미완료 유저를 구분하여 보여준다.
        • 그룹에 유저가 없을 경우에는 알맞은 카드를 노출시킨다.
 
현재는 로직과 관련된 코드들이 너무 많아서 위의 핵심 로직이 제대로 한눈에 보이지 않는다.
관련 코드끼리 묶어서 컴포넌트로 한번 더 추상화 함으로써 위의 두가지 문제를 동시에 해결하려고 한다.
 

결과 코드

  • 검색과 관련된 필터 기능과, 필터링된 유저만 보여주는 기능을 SearchedUsers라는 컴포넌트로 분리하여서 관련 코드 끼리 묶여줬고, 필수 프롭은 노출시켜서 프롭과 컴포넌트명만 보고도 결과를 유추할 수 있도록 리팩토링 하였다.
... export const UserCellListContainer: VFC<Props> = ({ userProfileList, searchText, }) => { const doseIncompletedUserList = useMemo(() => { const filteredList = userProfileList.filter( ({ isDoseCompleted }) => !isDoseCompleted, ); return filteredList; }, [userProfileList]); const doseCompletedUserList = useMemo(() => { const filteredList = userProfileList.filter( ({ isDoseCompleted }) => isDoseCompleted, ); return filteredList; }, [userProfileList]); if (searchText) { return ( <Wrapper> <Spacer spaceDirection='column' spacing={scale(8)} /> <SearchedUsers userProfileList={userProfileList} searchText={searchText} /> </Wrapper> ); } return ( <Wrapper> <UserGroupByDoseStatus title='복용 미완료' userProfileList={doseIncompletedUserList} emptyUserListText='오늘 모두 복용했어요!' emptyIcon={<DoseIncompletedIcon {...EmptyIconStyle} />} /> <UserGroupByDoseStatus title='복용 완료' userProfileList={doseCompletedUserList} emptyUserListText='아직 아무도 없어요 :(' emptyIcon={<DoseCompletedIcon {...EmptyIconStyle} />} /> </Wrapper> ); }; ...

기대 효과

프롭으로 검색에 필요한 핵심 값인 유저 목록과, 검색어가 전달된다.
프롭과 컴포넌트 명만으로도 유저 목록 중에서 검색어에 따라 필터링해서 보여질 거라는게 세부 구현 코드를 보지 않아도 단번에 파악할 수 있다.
<SearchedUsers userProfileList={userProfileList} searchText={searchText} />
 
검색어가 있으면 검색된 유저만 보여주고, 검색어가 없을 경우에는 복용 완료, 복용 미완료 유저로 그룹을 나누어 보여준다는게 한눈에 보이게 되었다.
if (searchText) { return ( <Wrapper> <Spacer spaceDirection='column' spacing={scale(8)} /> <SearchedUsers userProfileList={userProfileList} searchText={searchText} /> </Wrapper> ); } return ( <Wrapper> <UserGroupByDoseStatus title='복용 미완료' userProfileList={doseIncompletedUserList} emptyUserListText='오늘 모두 복용했어요!' emptyIcon={<DoseIncompletedIcon {...EmptyIconStyle} />} /> <UserGroupByDoseStatus title='복용 완료' userProfileList={doseCompletedUserList} emptyUserListText='아직 아무도 없어요 :(' emptyIcon={<DoseCompletedIcon {...EmptyIconStyle} />} /> </Wrapper> ); };
 

[2] UserGroupByDoseStatus 추상화 레벨 맞추기

판단 근거

아래 코드는 뭔가 깔끔하지 않다. 왜일까?
검색어와 관련된 부분은 상태, 로직, 컴포넌트 모두 묶어서 SearchedUsers라는 하나의 컴포넌트로 분리하여 추상화시켰다.
하지만 복용완료/미완료 유저를 분리해서 보여주는 컴포넌트의 로직이 남아있어서 컴포넌트 간의 추상화 레벨이 맞지 않아서다.
... export const UserCellListContainer: VFC<Props> = ({ userProfileList, searchText, }) => { const doseIncompletedUserList = useMemo(() => { const filteredList = userProfileList.filter( ({ isDoseCompleted }) => !isDoseCompleted, ); return filteredList; }, [userProfileList]); const doseCompletedUserList = useMemo(() => { const filteredList = userProfileList.filter( ({ isDoseCompleted }) => isDoseCompleted, ); return filteredList; }, [userProfileList]); if (searchText) { return ( <Wrapper> ... <SearchedUsers userProfileList={userProfileList} searchText={searchText} /> </Wrapper> ); } return ( <Wrapper> <UserGroupByDoseStatus title='복용 미완료' ... /> <UserGroupByDoseStatus title='복용 완료' ... /> </Wrapper> ); }; ...

결과 코드 & 기대효과

  • 복용완료 여부에 따라 유저를 필터링하는 기능을 UserGroupByDoseStatus 컴포넌트 내부로 넣었고, 복용여부는 isDoseCompleted라는 프롭으로 받도록 변경하였다.
  • doseIncompletedUserListdoseCompletedUserList코드는 boolean 반전 빼고는 완전히 동일한 코드인데 중복해서 선언되어 있었는데, 이 부분도 같이 해결하였다.
결과적으로 추상화레벨을 맞춤으로써 훨씬 읽기 좋은 코드가 되었다.
... export const UserCellListContainer: VFC<Props> = ({ userProfileList, searchText, }) => { if (searchText) { return ( <Wrapper> <Spacer spaceDirection='column' spacing={scale(8)} /> <SearchedUsers userProfileList={userProfileList} searchText={searchText} /> </Wrapper> ); } return ( <Wrapper> <UserGroupByDoseStatus title='복용 미완료' isDoseCompleted={false} userProfileList={userProfileList} emptyUserListText='오늘 모두 복용했어요!' emptyIcon={<DoseUncompletedIcon {...EmptyIconStyle} />} /> <UserGroupByDoseStatus title='복용 완료' isDoseCompleted={true} userProfileList={userProfileList} emptyUserListText='아직 아무도 없어요 :(' emptyIcon={<DoseCompletedIcon {...EmptyIconStyle} />} /> </Wrapper> ); }; ...
💡
isDoseCompleted boolean 프롭이므로 명시한다/안한다로 프롭 처리를 할 수도 있지만, 핵심 프롭이므로 확실하게 true, false가 보이도록 명시하였다.
 
핵심 로직을 한 눈에 볼 수 있게 되었다.
  • 검색했을 때
    • 검색된 유저만 보여주고, 없을 경우에는 빈 화면으로 둔다.
  • 검색하지 않았을 때
    • 복용완료 유저와 복용 미완료 유저를 구분하여 보여준다.
    • 그룹에 유저가 없을 경우에는 알맞은 카드를 노출시킨다.
 

[3] 최종 코드 비교 (리팩토링 전/후)

리팩토링 전

... export const UserCellListContainer: VFC<Props> = ({ userProfileList, searchText, }) => { // 전체 글자, 초성이 섞여있는 경우를 고려하면 복잡하니 // 우선 전체 글자라 치고 filter해본 후, filter 결과가 없으면 // 초성 검색을 시도하는 식으로 로직 구현 const searchedUserList = useMemo(() => { const filteredList = userProfileList.filter((item) => item.name.includes(searchText), ); if (filteredList.length > 0) { return filteredList; } const firstSyllableFilteredList = userProfileList.filter((item) => getFirstSyllables(item.name).includes(searchText), ); return firstSyllableFilteredList; }, [userProfileList, searchText]); const doseIncompletedUserList = useMemo(() => { const filteredList = userProfileList.filter( ({ isDoseCompleted }) => !isDoseCompleted, ); return filteredList; }, [userProfileList]); const doseCompletedUserList = useMemo(() => { const filteredList = userProfileList.filter( ({ isDoseCompleted }) => isDoseCompleted, ); return filteredList; }, [userProfileList]); if (searchText) { return ( <Wrapper> <Spacer spaceDirection='column' spacing={scale(8)} /> <UserCellList userProfileList={searchedUserList} /> </Wrapper> ); } return ( <Wrapper> <UserGroupByDoseStatus title='복용 미완료' userProfileList={doseIncompletedUserList} emptyUserListText='오늘 모두 복용했어요!' emptyIcon={<DoseIncompletedIcon {...EmptyIconStyle} />} /> <UserGroupByDoseStatus title='복용 완료' userProfileList={doseCompletedUserList} emptyUserListText='아직 아무도 없어요 :(' emptyIcon={<DoseCompletedIcon {...EmptyIconStyle} />} /> </Wrapper> ); }; ...

리팩토링 후

... export const UserCellListContainer: VFC<Props> = ({ userProfileList, searchText, }) => { if (searchText) { return ( <Wrapper> <Spacer spaceDirection='column' spacing={scale(8)} /> <SearchedUsers userProfileList={userProfileList} searchText={searchText} /> </Wrapper> ); } return ( <Wrapper> <UserGroupByDoseStatus title='복용 미완료' isDoseCompleted={false} userProfileList={userProfileList} emptyUserListText='오늘 모두 복용했어요!' emptyIcon={<DoseUncompletedIcon {...EmptyIconStyle} />} /> <UserGroupByDoseStatus title='복용 완료' isDoseCompleted={true} userProfileList={userProfileList} emptyUserListText='아직 아무도 없어요 :(' emptyIcon={<DoseCompletedIcon {...EmptyIconStyle} />} /> </Wrapper> ); }; ...