개요
개인적으로 너———무 싫어하는 코드 패턴이다. (좋아하는 사람이 있을까..?)
가독성을 심각하게 떨어트리며, 심지어 어차피 내부를 볼 수밖에 없으므로 의미없게 뎁스만 몇단계 높히는 꼴이다.
겉으로 보기에는 코드 길이가 짧아서 깔끔하게 보이니까 깔끔하게 코드를 잘 짰다고 착각하게 만들 수 있는 코드다.
하지만 이건 누가 집에 놀러온다고 했을 때 급하게 어질러져 있는 짐들을 침대 밑에다 전부 쳐박는 것과 같다.
example 1
const { volume, property, brightness, buttonInformation, renderItem, handleChangeValueVolume, handleChangeValueBrightness, } = useSettingHomeContainer(); 진짜 최악 ㅋㅋㅋ 그리고 useDispensingContainer 예전 코드도 보자 ㅋㅋㅋㅋㅋ
example 2
화면을 담당하는
스크린
컴포넌트에선 구성 컴포넌트들을 조합하며 스크린을 구성하는 형태가 되어야 하며, 컴포넌트 간의 구성과 조화를 이루는 모습이 잘 나타나야 한다.그래야 큰 구조부터 작은 구조로 내려가는 흐름과, 각각의 역할들이 잘 드러나고, 보고싶은 부분을 제외하고는 내부 코드를 보지 않고 넘어갈 수 있게 된다. 다른 말로는 정말로 내가 수정하고 싶은 코드를 삽질 없이 찾아갈 수 있다는 뜻이다.
안좋은 예시를 보자.
// UserMain Screen return( <Wrapper> <AppHeader title={<TitleText name={name} />} canGoBack /> <UserMainContainer disabled={disabled} /> </Wrapper> )
스크린
화면인데 어떻게 컴포넌트들이 구성되어 있는지 알 수 있는가??UserMainContainer
안에 어떤 컴포넌트들이 어떻게 구성되어 있는지, 해당 컴포넌트의 목적이 무엇인지 알 수 있는가? 전혀 알 수 없다.그리고
UserMainContainer
라는 컴포넌트 이름과 disabled
라는 프롭명이 매치가 되는가? 컴포넌트 내부에 해당 프롭이 어떤 영향을 줄 건지 예측이 가능한가? 전혀 아니다.UserMainContainer
코드를 보자.원랜 몸상태 관련 로직만 있으면 돼서 괜찮았는데,
카테고리 관련 코드들이 들어오면서, 한곳에 다 있기엔 투머치하게 된 상황임도 같이 고려해야함
// UserMainContainer 컴포넌트 export const UserMainContainer: VFC<Props> = ({ disabled }) => { const [categoryIndex, changeCategoryIndex] = useState(0); const [groupPositionList, setGroupPositionList] = useState< { height: number; width: number; x: number; y: number; heightSum: number }[] >([]); const scrollRef = useRef<ScrollView>(); const addGroupPosition = useCallback( (positionInfo: { height: number; width: number; x: number; y: number }) => { setGroupPositionList((prev) => { return prev.concat({ ...positionInfo, heightSum: (prev[prev.length - 1]?.heightSum || 0) + positionInfo.height + verticalScale(72), // 그룹별 marginBottom 값 }); }); }, [], ); const onPressCategory = useCallback( // eslint-disable-next-line @typescript-eslint/no-shadow (categoryIndex: number) => { const positionToMove = (groupPositionList[categoryIndex - 1] || 0)?.heightSum + 1; scrollRef?.current?.scrollTo({ y: positionToMove }); }, [groupPositionList], ); const handleCategoryWhenScroll = useCallback( ({ nativeEvent }: { nativeEvent: NativeScrollEvent }) => { if ( groupPositionList[categoryIndex + 1] && nativeEvent.contentOffset.y > groupPositionList[categoryIndex].heightSum ) { changeCategoryIndex(categoryIndex + 1); return; } if ( groupPositionList[categoryIndex - 1] && nativeEvent.contentOffset.y < groupPositionList[categoryIndex - 1].heightSum ) { changeCategoryIndex(categoryIndex - 1); } }, [categoryIndex, groupPositionList], ); const { id: userId } = useGetUserState(); const { bodyConditions } = useGetBodyConditionList(userId); const [selectedConditionList, setSelectedConditionList] = useState<BodyConditionObject>({}); const handlePressCell = (bodyCondition: BodyConditionInfo): void => { const maxSeverity = Object.keys(bodyCondition.id).length; const newSelectedConditions = { ...selectedConditionList }; const isSelected = newSelectedConditions.hasOwnProperty(bodyCondition.name); // 새로운 몸상태 선택 or 기존에 선택된 몸상태 심각도 변경 if (!isSelected) { newSelectedConditions[bodyCondition.name] = { ...bodyCondition, id: { 1: bodyCondition.id[1] }, }; setSelectedConditionList(newSelectedConditions); return; } const nextSeverity = Number(Object.keys(newSelectedConditions[bodyCondition.name].id)[0]) + 1; // 선택된 몸상태 목록에는 심각도 값을 하나로만 유지하므로 [0]만 참조하면 된다. // 심각도가 최대치인 경우에는 몸상태 취소 if ( newSelectedConditions[bodyCondition.name].id.hasOwnProperty(maxSeverity) ) { delete newSelectedConditions[bodyCondition.name]; setSelectedConditionList(newSelectedConditions); return; } // 다음 심각도 선택 newSelectedConditions[bodyCondition.name].id = { [nextSeverity]: bodyCondition.id[nextSeverity], }; setSelectedConditionList(newSelectedConditions); }; return ( <> <CategorySelector categories={Object.keys(bodyConditions)} categoryIndex={categoryIndex} onPressCategory={onPressCategory} /> <ScrollView ref={scrollRef as LegacyRef<ScrollView>} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: verticalScale(356 + 60) }} // footer 높이 + 카테고리 간 간격 onScroll={handleCategoryWhenScroll} > <GroupedBodyConditionList data={bodyConditions} selectedConditionList={selectedConditionList} onPressCell={handlePressCell} addGroupPosition={addGroupPosition} /> </ScrollView> <UserMainStartDispensingComponent selectedConditionList={selectedConditionList} setSelectedConditionList={setSelectedConditionList} disabled={disabled} /> </> ); };
커스텀 훅들을 써가며 나름대로 깔끔하게 정리하려고 한 흔적이 느껴진다. 하지만 코드가 잘 읽히진 않는다. 왜일까?
- 한가지 함수는 하나의 역할을 해야한다.
라는 기조에 따라 큰 흐름(문맥)별로 묶어서 가독성을 높힐 필요가 있다.
크게 보면 두가지 흐름이 있다.
- 카테고리 선택 컴포넌트와 관련된 훅과 콜백 함수들
- 몸상태 셀 선택과 관련된 훅과 콜백 함수들
두 가지를 각각의 커스텀 훅으로 묶고, 드러내는 것이 문맥의 이해를 도울 수 있는 부분들은 드러내도록 추상화를 한다.
카테고리 관련 로직 리팩토링
const [categoryIndex, changeCategoryIndex] = useState(0); const [groupPositionList, setGroupPositionList] = useState< { height: number; width: number; x: number; y: number; heightSum: number }[] >([]); const scrollRef = useRef<ScrollView>(); const addGroupPosition = useCallback( (positionInfo: { height: number; width: number; x: number; y: number }) => { setGroupPositionList((prev) => { return prev.concat({ ...positionInfo, heightSum: (prev[prev.length - 1]?.heightSum || 0) + positionInfo.height + verticalScale(72), // 그룹별 marginBottom 값 }); }); }, [], ); const onPressCategory = useCallback( // eslint-disable-next-line @typescript-eslint/no-shadow (categoryIndex: number) => { const positionToMove = (groupPositionList[categoryIndex - 1] || 0)?.heightSum + 1; scrollRef?.current?.scrollTo({ y: positionToMove }); }, [groupPositionList], ); const handleCategoryWhenScroll = useCallback( ({ nativeEvent }: { nativeEvent: NativeScrollEvent }) => { if ( groupPositionList[categoryIndex + 1] && nativeEvent.contentOffset.y > groupPositionList[categoryIndex].heightSum ) { changeCategoryIndex(categoryIndex + 1); return; } if ( groupPositionList[categoryIndex - 1] && nativeEvent.contentOffset.y < groupPositionList[categoryIndex - 1].heightSum ) { changeCategoryIndex(categoryIndex - 1); } }, [categoryIndex, groupPositionList], );
코드는 아래의 훅 하나에 세부 로직들을 다 넣어버리고, 핵심 부분들만 받고, 반환하도록 리팩토링한다.
const { scrollRef, categoryIndex, addGroupPosition, onPressCategory, handleCategoryWhenScroll, } = useCategories();
몸상태 관련 로직 리팩토링
마찬가지 관점으로, 추상화의 레벨을 맞춰주면서 몸상태 관련 로직도 커스텀 훅 안에 세부로직을 담고, 핵심 부분만 전달받고 반환하는 형태로 리팩토링 한다.
const [selectedConditionList, setSelectedConditionList] = useState<BodyConditionObject>({}); const { id: userId } = useGetUserState(); const { bodyConditions } = useGetBodyConditionList(userId); const handlePressCell = useCallback( (bodyCondition: BodyConditionInfo): void => { const maxSeverity = Object.keys(bodyCondition.id).length; const newSelectedConditions = { ...selectedConditionList }; const isSelected = newSelectedConditions.hasOwnProperty( bodyCondition.name, ); // 새로운 몸상태 선택 or 기존에 선택된 몸상태 심각도 변경 if (!isSelected) { newSelectedConditions[bodyCondition.name] = { ...bodyCondition, id: { 1: bodyCondition.id[1] }, }; setSelectedConditionList(newSelectedConditions); return; } const nextSeverity = Number(Object.keys(newSelectedConditions[bodyCondition.name].id)[0]) + 1; // 선택된 몸상태 목록에는 심각도 값을 하나로만 유지하므로 [0]만 참조하면 된다. // 심각도가 최대치인 경우에는 몸상태 취소 if ( newSelectedConditions[bodyCondition.name].id.hasOwnProperty(maxSeverity) ) { delete newSelectedConditions[bodyCondition.name]; setSelectedConditionList(newSelectedConditions); return; } // 다음 심각도 선택 newSelectedConditions[bodyCondition.name].id = { [nextSeverity]: bodyCondition.id[nextSeverity], }; setSelectedConditionList(newSelectedConditions); }, [selectedConditionList], ); const unSelectBodyCondition = useCallback( (bodyConditionName: string): void => { const newSelectedConditions = { ...selectedConditionList }; if (selectedConditionList.hasOwnProperty(bodyConditionName)) { delete newSelectedConditions[bodyConditionName]; setSelectedConditionList(newSelectedConditions); } }, [selectedConditionList], );
const { bodyConditions, selectedConditionList, handlePressCell, unSelectBodyCondition, } = useSelectBodyConditions();
UserMainContainer 리팩토링 완료 코드
근데 원래 목적이었던, 스크린에서 컴포넌트간의 구성과 조화가 눈에 보이지 않는다는 점은 동일하다.
export const UserMainContainer: VFC<Props> = ({ disabled }) => { const { scrollRef, categoryIndex, addGroupPosition, onPressCategory, handleCategoryWhenScroll, } = useCategories(); const { bodyConditions, selectedConditionList, handlePressCell, unSelectBodyCondition, } = useSelectBodyConditions(); return ( <> <CategorySelector categories={Object.keys(bodyConditions)} categoryIndex={categoryIndex} onPressCategory={onPressCategory} /> <ScrollView ref={scrollRef as LegacyRef<ScrollView>} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: verticalScale(356 + 60) }} // footer 높이 + 카테고리 간 간격 onScroll={handleCategoryWhenScroll} > <GroupedBodyConditionList data={bodyConditions} selectedConditionList={selectedConditionList} onPressCell={handlePressCell} addGroupPosition={addGroupPosition} /> </ScrollView> <UserMainStartDispensingComponent selectedConditionList={selectedConditionList} unSelectBodyCondition={unSelectBodyCondition} disabled={false} /> </> ); };
최종 코드
좋은 코드는 길이가 짧은 코드가 아니다. 잘 읽히는 코드임에 유의하자.
// UserMain Screen - 리팩토링 전 코드 return( <Wrapper> <AppHeader title={<TitleText name={name} />} canGoBack /> <UserMainContainer disabled={disabled} /> </Wrapper> )
// UserMain Screen - 리팩토링 후 코드 const { scrollRef, categoryIndex, addGroupPosition, onPressCategory, handleCategoryWhenScroll, } = useCategories(); const { bodyConditions, selectedConditionList, handlePressCell, unSelectBodyCondition, } = useSelectBodyConditions(); return ( <Wrapper> <AppHeader title={<TitleText name={name} />} canGoBack /> <CategorySelector categories={Object.keys(bodyConditions)} categoryIndex={categoryIndex} onPressCategory={onPressCategory} /> <ScrollView ref={scrollRef as LegacyRef<ScrollView>} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: verticalScale(356 + 60) }} // footer 높이 + 카테고리 간 간격 onScroll={handleCategoryWhenScroll} > <GroupedBodyConditionList data={bodyConditions} selectedConditionList={selectedConditionList} onPressCell={handlePressCell} addGroupPosition={addGroupPosition} /> </ScrollView> <UserMainStartDispensingComponent selectedConditionList={selectedConditionList} unSelectBodyCondition={unSelectBodyCondition} disabled={false} /> </Wrapper> );