고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

AppBar 컴포넌트 리팩토링 이야기

고윤태 2023. 12. 14. 10:38
안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 최근에 제가 진행한 리팩토링에 대해 공유하고자 글을 작성하게 되었습니다.

💻 개발 환경

React-native(expo) + TypeScript로 진행되었습니다.

🧐 진행하게 된 계기

진행 중인 프로젝트의 앱의 완성을 위해 여러 스크린을 작업을 진행하고 있었습니다.

디자이너 분이 처음에 설계하셨던 디자인 시스템의 AppBar 컴포넌트는

 

 

이러한 UI를 가지고 있었습니다.

하지만 실제 디자인 작업이 진행되다 보니 AppBar에서 어느 순간부터 Label(문자열) 뿐만 아니라

 

위의 사진처럼 햄버거 메뉴를 구현해야 하는 케이스도 존재하였고

 

 

이렇게 Title이 기존과 다른 케이스도 존재하기도 했습니다.

또한 AppBar의 BackButton을 누르면 기존에는 스크린을 닫는 동작만 존재했지만 Dialog를 띄워 사용자에게 직접 나갈 것인지 확인을 하는 로직을 반영하기도 했어야 했습니다.

 

이러한 상황에 저라는 개발자는 어떤 식으로 대응했는지 글을 통해 전달드리겠습니다.

(현재의 디자인 시스템을 저희 상황에 맞게 변경하기로 디자이너님과 얘기는 다 끝냈습니다.)

 

🤔 기능 산출 및 개발 계획

초기 상태

저는 현재 아래 코드의 AppBar 컴포넌트를 구현했었습니다.

 

AppBar.tsx

import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import TYPOS from './typo';
import Left24 from './icons/Left24';
import Color from '../../constants/color';
import { useNavigation } from '@react-navigation/native';

interface Props {
  title?: string;
  label?: string;
}

const AppBar = (props: Props) => {
  const { title, label } = props;

  const navigation = useNavigation();

  const onBackButtonHandler = () => {
    navigation.goBack();
  };

  return (
    <View style={styles.header}>
      <Pressable onPress={onBackButtonHandler}>
        <Left24 color={Color.black} />
      </Pressable>
      <Text style={[TYPOS.headline3, styles.title]}>{title}</Text>
      <View style={styles.labelContainer}>
        <Text style={[TYPOS.medium, { color: Color.neutral2 }]}>{label}</Text>
      </View>
    </View>
  );
};

export default AppBar;

const styles = StyleSheet.create({
  header: {
    flexDirection: 'row',
    alignItems: 'center',
    height: 56,
    backgroundColor: Color.white,
    paddingHorizontal: 16,
  },
  title: {
    marginLeft: 8,
  },
  labelContainer: {
    marginLeft: 'auto',
  },
});

 

Example.tsx

import React from 'react';
import AppBar from '../components/ui/AppBar';

const Screen = () => {
  return (
    <>
      <AppBar />
      <AppBar title='title' />
      <AppBar label='label' />
      <AppBar title='title' label='label' />
    </>
  );
};

export default Screen;

 

결과

진행

제가 추가 또는 변경해야 하는 기능을 나열해 보겠습니다.

  1. Label 위치에 Label 또는 Icon을 사용 (변경)
  2. title을 제외하고 BackButton 옆에 엘리먼트를 배치 (추가)
  3. BackButton Press 시 특정 경우에만 custom Event를 할당 (추가)

1번과 2번 항목 둘 다 유연하게 대처하는 방법은 과연 무엇일까요? React를 사용해서 어느 정도 개발을 해보신 분이라면 바로 감을 잡았을 거라고 생각합니다.

 

바로 Props를 통해 ReactNode를 넘기는 방법입니다. React에서 컴포넌트 개발 시에 흔히 사용하고 있는 패턴이라고 생각합니다.

 

Props를 통해 ReactNode 넘기기 예시 코드

import React from 'react';
import { View, Text } from 'react-native';

interface ParentProps {
  children: React.ReactNode;
}

const Parent = ({ children }: ParentProps) => {
  return (
    <View>
      <Text>Parent</Text>
      {children}
    </View>
  );
};

const Screen = () => {
  return (
    <Parent>
      <Text>Children</Text>
    </Parent>
  );
};

export default Screen;

 

Props를 통해 ReactNode 넘기기 예시 코드의 결과

Props를 통해 ReactNode를 넘기는 경우의 장점

컴포넌트의 추상화 수준을 높일 수 있습니다. 즉, 컴포넌트는 보여줄 내용에 대해 걱정할 필요 없이 레이아웃이나 기능에 집중할 수 있으며, 실제 내용은 사용하는 측에서 결정할 수 있습니다.

 

1, 2번은 해결한 상태입니다.

그렇다면 3번의 경우는 어떤 식으로 해결하면 좋을까요? "굴러들어 온 돌이 박힌 돌 뺀다"라는 말 들어보셨나요?

저 말처럼 특정 경우에만 박힌 돌(Screen 닫기 Event)을 굴러들어 온 돌(Custom Event)이 박힌 돌을 빼고 자리를 차치하도록 구현하면 될 것 같습니다.

 

Props를 통해 이벤트 핸들링 예제 코드

import React from 'react';
import { Text, Pressable } from 'react-native';

interface Props {
  newPressHandler?: () => void;
}

const CustomButton = ({ newPressHandler }: Props) => {
  const standardPressHandler = () => {
    console.log('standard');
  };

  return (
    <Pressable
      onPress={newPressHandler ? newPressHandler : standardPressHandler}
    >
      <Text>Pressable</Text>
    </Pressable>
  );
};

const Screen = () => {
  return (
    <>
      <CustomButton />
      <CustomButton
        newPressHandler={() => {
          console.log('new Event');
        }}
      />
    </>
  );
};

export default Screen;

 

Props를 통해 이벤트 핸들링 예제 코드 설명

위의 코드를 보시면 아실 수 있듯이 newPressHandler라는 Props가 할당이 되었을 때는 CustomButtom 컴포넌트의 Press Event를 newPressHandler를 통해 전달받은 함수를 사용하고 아니라면 standardPressHandler를 사용하도록 코드를 구현했습니다.

👀 컴포넌트 개발(CODE)

AppBar.tsx

import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import TYPOS from './typo';
import Left24 from './icons/Left24';
import Color from '../../constants/color';
import { useNavigation } from '@react-navigation/native';

interface Props {
  title?: string;
  leftContent?: React.ReactNode;
  rightContent?: React.ReactNode;
  onCustomBackButtonHandler?: () => void;
}

const AppBar = (props: Props) => {
  const { title, onCustomBackButtonHandler, leftContent, rightContent } = props;

  const navigation = useNavigation();

  const onBackButtonHandler = () => {
    navigation.goBack();
  };

  return (
    <View style={styles.header}>
      <Pressable
        onPress={
          onCustomBackButtonHandler
            ? onCustomBackButtonHandler
            : onBackButtonHandler
        }
      >
        <Left24 color={Color.black} />
      </Pressable>
      {leftContent && <View style={styles.leftContent}>{leftContent}</View>}
      <Text style={[TYPOS.headline3, styles.title]}>{title}</Text>
      <View style={styles.rightContent}>{rightContent}</View>
    </View>
  );
};

export default AppBar;

const styles = StyleSheet.create({
  header: {
    flexDirection: 'row',
    alignItems: 'center',
    height: 56,
    backgroundColor: Color.white,
    paddingHorizontal: 16,
  },
  leftContent: {
    marginLeft: 8,
  },
  title: {
    marginLeft: 8,
  },
  rightContent: {
    marginLeft: 'auto',
  },
});

코드 설명

props

 

이름 역할 필수
title AppBar에 표시될 제목입니다. X
leftContent 왼쪽 영역에 표시될 사용자 정의 컨텐츠입니다. X
rightContent 오른쪽 영역에 표시될 사용자 정의 컨텐츠입니다.  X
onCustomBackButtonHandler 뒤로 가기 버튼 대신 사용할 사용자 정의 버튼의 핸들러 함수입니다. X

 완성된 AppBar

사용한 예제 코드

import React from 'react';
import AppBar from '../components/ui/AppBar';
import { Text, View } from 'react-native';
import TYPOS from '../components/ui/typo';
import Color from '../constants/color';
import Burger24 from '../components/ui/icons/Burger24';

const Screen = () => {
  return (
    <>
      <AppBar />
      <AppBar title='title' />
      <AppBar
        title='titleLabel'
        rightContent={
          <Text style={[TYPOS.body1, { color: Color.neutral2 }]}>Label</Text>
        }
      />
      <AppBar
        title='titleIcon'
        rightContent={<Burger24 color={Color.black} />}
      />
      <AppBar
        leftContent={
          <View>
            <Text style={[TYPOS.headline4, { color: Color.black }]}>Top</Text>
            <Text style={[TYPOS.body3, { color: Color.neutral2 }]}>Bottom</Text>
          </View>
        }
        rightContent={<Burger24 color={Color.black} />}
      />
    </>
  );
};

export default Screen;

결과 화면

🫠 추가 작업

위의 작업 결과를 보시면 처음에 목표한 대로 유연한 AppBar 컴포넌트를 구현을 완료한 상태입니다.

저에게는 너무나도 만족스러운 결과였죠!

AppBar를 사용하다 보니 저도 모르게 반복을 하고 있는 코드가 있었습니다.

 

 

바로 이렇게 AppBar의 Burger를 이용하여 Dropdown Menu를 구성하는 곳에서 반복이 발생했습니다.

 

 

 

저는 이러한 코드를 계속 반복해서 사용했습니다.

당연히 개발자로서 코드의 반복을 좋아할 수는 없습니다.

 

 

반복되는 코드를 줄이기 위해 Header의 Dropdown Menu를 구성하는 컴포넌트를 만들어보도록 하겠습니다.

만들기 전 유연한 컴포넌트 설계를 하기 위해 가장 먼저 해야 할 일은 무엇일까요?

제 생각에는 기존에 반복되는 코드들과 유사한 코드들을 집합하여 공통점을 찾아 뽑아내는 것이라고 생각합니다.

 

 

새로 찾아보니 똑같은 코드지만 Burger 아이콘이 아닌 Settings 아이콘을 사용하는 케이스도 발견했습니다.

이 코드들을 기반으로 제가 비교하여 얻을 수 있는 정보는 이렇습니다.

 

  • 공통점
    • useMenuControl(custom hook)을 사용하여 가시성을 제어한다.
    • Presssable, Icon, MenuBackdrop, ListValue를 사용하여 UI를 구성한다.
  • 차이점 
    • 사용하는 Icon이 다르다
    • ListValue를 구성하는 Item 요소가 다르다.

이러한 정리를 통해 Menu를 구성할 Item과 Icon을 Props로 받아 컴포넌트로 구현하면 효율적이게 될 것이다.라고 생각했습니다.

 

HeaderDropdownMenu.tsx

import { useRef } from 'react';
import { Pressable, View, ViewStyle } from 'react-native';
import useMenuControl from '../../hooks/useMenuControl';
import ListValue from './dropdown/ListValue';
import MenuBackdrop from './dropdown/MenuBackdrop';

interface Menu {
  label: string;
  onClickHandler?: (closeMenu: () => void) => void;
}

interface Props {
  iconContainerStyle?: ViewStyle;
  icon: React.ReactNode;
  menus: Menu[];
}

const HeaderDropdownMenu = ({ iconContainerStyle, icon, menus }: Props) => {
  const iconParentRef = useRef<View | null>(null);
  const { isVisibleMenu, closeMenu, openMenu, menuTop } = useMenuControl({
    targetRef: iconParentRef,
  });

  return (
    <>
      <Pressable
        onPress={openMenu}
        ref={iconParentRef}
        style={iconContainerStyle}
      >
        {icon}
      </Pressable>
      <MenuBackdrop
        isVisible={isVisibleMenu && !!menuTop}
        close={() => {
          closeMenu();
        }}
        menuStyle={{ top: menuTop, width: 146, right: 16 }}
      >
        {menus.map((m) => (
          <ListValue
            key={m.label}
            label={m.label}
            onClickHandler={() => {
              if (m.onClickHandler) {
                m.onClickHandler(closeMenu);
              }
            }}
          />
        ))}
      </MenuBackdrop>
    </>
  );
};

export default HeaderDropdownMenu;

 

코드 설명

 

props

 

이름 역할 필수
iconContainerStyle 아이콘을 감싸는 컨테이너의 스타일을 정의합니다. 이 스타일은 ViewStyle 타입으로, React Native에서 제공하는 스타일 속성을 사용할 수 있습니다. X
icon 드롭다운 메뉴를 여는데 사용되는 아이콘 요소입니다. 이는 React 노드로, 클릭 가능한 아이콘 컴포넌트가 될 수 있습니다. O
menus 드롭다운 메뉴에 표시될 항목들의 배열입니다. 각 메뉴 항목은 label과 onClickHandler 속성을 가집니다. O

 

useMenuControl(custom hook)을 사용하여 드롭다운 메뉴의 상태(표시 여부, 위치 등)를 관리합니다.

이 훅은 iconParentRef를 사용하여 드롭다운 메뉴의 위치를 계산하고, 메뉴의 표시 상태를 제어합니다.

menus의 onClickHandler의 parameter로 close 메서드를 넘겨 Press 시 선택적으로 Dropdown을 닫을 수 있도록 합니다.

그래서 useMenuControl hook이 뭔데요?

useMenuControl custom hook은 제가 MenuBackdrop 컴포넌트를 사용하는 곳에서 가시성 관리와 위치해야 할 포지션을 계산하기 위해 구현했던 custom hook입니다.

 

useMenuControl.tsx

import { useState, useEffect, RefObject } from 'react';
import { Dimensions, View } from 'react-native';
import useModal from './useModal';

interface Props<T extends View> {
  listLength?: number;
  targetRef: RefObject<T>;
}

const FULL_HEIGHT = Dimensions.get('window').height;
const SCROLL_VIEW_MAX_HEIGHT = 240;

const useMenuControl = <T extends View>({
  listLength = 0,
  targetRef,
}: Props<T>) => {
  const {
    isVisible: isVisibleMenu,
    closeModal: closeMenu,
    openModal: openMenu,
  } = useModal();
  const [menuTop, setMenuTop] = useState(0);
  const [width, setWidth] = useState(0);

  useEffect(() => {
    if (!isVisibleMenu) {
      return;
    }

    targetRef.current?.measure((_x, _y, width, height, _pageX, pageY) => {
      setWidth(width);

      if (
        FULL_HEIGHT -
          (pageY +
            height +
            12 +
            Math.min(SCROLL_VIEW_MAX_HEIGHT, listLength * 48)) >
        10
      ) {
        setMenuTop(pageY + height + 12);
      } else {
        setMenuTop(
          pageY - Math.min(SCROLL_VIEW_MAX_HEIGHT, listLength * 48) - 12
        );
      }
    });
  }, [isVisibleMenu]);

  return { isVisibleMenu, closeMenu, openMenu, menuTop, width };
};

export default useMenuControl;

 

코드 설명

 

Props

 

이름 역할 필수
listLength 드롭다운 메뉴의 항목 수입니다. 이 값은 메뉴의 높이 계산에 사용됩니다. X
targetRef 드롭다운 메뉴를 표시할 타겟 요소에 대한 참조입니다. 이 참조는 메뉴의 위치를 계산하는 데 사용됩니다. O

 

동작 방식

  1. useModal 훅을 사용하여 메뉴의 가시성을 제어합니다. useModal에서 isVisible, closeModal, openModal 함수를 제공하여 메뉴의 열기, 닫기 동작을 수행합니다.
  2. useEffect 훅은 isVisibleMenu의 값이 변경될 때마다 실행됩니다. 메뉴가 가시적인 상태가 되면, 타깃 요소의 위치(pageY)와 높이(height)를 계산하여 메뉴의 상단 위치(menuTop)를 결정합니다.
  3. 메뉴가 화면 하단에 위치할 충분한 공간이 있는지 확인하여, 충분한 공간이 없을 경우 메뉴를 타깃 요소 위에 배치합니다.

useModal(custom hook) 내용은 여기서 확인하실 수 있습니다.

https://yun-tech-diary.tistory.com/entry/React-Native-useModal-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

React Native useModal 구현하기

안녕하세요 이번에 회사 앱에 flow를 깊게 확인하기 위해 이것저것 해보는 와중 제 눈에 띈 것이 하나 있습니다. 바로 Modal 입니다. Modal은 앱에서 흔히 볼 수 있는 UI입니다. Modal을 사용하는 곳에

yun-tech-diary.tistory.com

 

반환값

  • isVisibleMenu: 메뉴의 가시성 상태입니다. 메뉴가 현재 표시되고 있는지 여부를 나타냅니다.
  • closeMenu: 메뉴를 닫는 함수입니다.
  • openMenu: 메뉴를 여는 함수입니다.
  • menuTop: 계산된 메뉴의 상단 위치입니다. 이 값은 메뉴를 화면에 적절한 위치에 배치하는 데 사용됩니다.
  • width: 타깃 요소의 너비입니다. 이 값은 메뉴의 너비를 설정하는 데 사용될 수 있습니다.

사용 후 코드 비교

기존 코드

// TS
  const burgerRef = useRef<View | null>(null);
  const { isVisibleMenu, closeMenu, openMenu, menuTop } = useMenuControl({
    targetRef: burgerRef,
  });


// TSX
                  <Pressable
                    onPress={openMenu}
                    ref={burgerRef}
                    style={{ marginLeft: 8 }}
                  >
                    <Burger24 color={Color.black} />
                  </Pressable>
                  <MenuBackdrop
                    isVisible={isVisibleMenu && !!menuTop}
                    close={() => {
                      closeMenu();
                    }}
                    menuStyle={{ top: menuTop, width: 146, right: 16 }}
                  >
                    <ListValue
                      label="글 수정하기"
                      onClickHandler={() => {
                        closeMenu();
                        navigation.navigate('ModifyProduct', {
                          id,
                        });
                      }}
                    />
                    <ListValue
                      label="삭제"
                      onClickHandler={() => {
                        closeMenu();
                        openDeleteDialog();
                      }}
                    />
                  </MenuBackdrop>

 

HeaderDropdownMenu를 사용한 후 코드

// TSX
            <HeaderDropdownMenu
              icon={<Burger24 color={Color.black} />}
              menus={[
                { label: '글 수정하기' },
                { label: '삭제', onClickHandler: onDeleteHandler },
              ]}
            />

 

어떤가요? 직업 이렇게 before, after를 비교해 보니 너무나도 만족스러운 변화라고 생각합니다.

하나의 단점이 있다면 컴포넌트의 이름이 너무나도 종속적이라는 것입니다.

제가 예전에 작성했던 글에 작성했던 내용입니다만 저는 UI적인 컴포넌트의 이름이 종속적인 네이밍을 가지는 것을 좋아하지 않습니다.

 

컴포넌트의 이름이 종속적인 경우 단점

  1. 재사용성 감소: 컴포넌트 이름이 특정 콘텍스트나 사용 사례에 종속되면, 다른 상황에서 해당 컴포넌트를 재사용하기 어려워질 수 있습니다. 예를 들어, UserProfileAvatar라는 이름은 사용자 프로필에만 한정된 것처럼 보여, 다른 곳에서 사용하기에는 적합하지 않을 수 있습니다.
  2. 유지보수의 어려움: 컴포넌트의 기능이나 사용 범위가 변경되었을 때, 이름이 해당 변경사항을 반영하지 못하면 혼란을 야기할 수 있습니다. 예를 들어, HomePageBanner가 다른 페이지에서도 사용되기 시작하면 이름이 더 이상 적절하지 않게 됩니다.
  3. 명확성 부족: 종속적인 이름은 컴포넌트의 실제 기능이나 목적을 명확하게 전달하지 못할 수 있습니다. 이는 새로운 개발자가 코드베이스에 익숙해지는 데 어려움을 줄 수 있습니다.
  4. 확장성 제한: 특정 상황에 종속된 이름은 컴포넌트의 확장성을 제한할 수 있습니다. 컴포넌트가 원래의 목적을 넘어서 다양한 기능을 수행하게 될 때, 이름이 이러한 확장을 반영하지 못하면 개선이나 리팩토링에 제약이 될 수 있습니다.

이러한 이유 때문에 최대한 기피하는 편입니다만.... 적절한 이름이 떠오르지 않아서 저런 결정을 해버렸습니다.

결국 저 컴포넌트는 Header에서만 사용한다는 족쇄를 채워버렸습니다.

(추후 괜찮은 네이밍이 떠오르면 변경할 예정입니다.)

 

😁 글 작성 후기

그동안 제가 작성했던 글들은 custom hook 내용 또는 컴포넌트를 만든 방법, 리팩토링과 같은 주제의 글을 작성했었지만 저도 한 번쯤은 리팩토링과 진행하면서 발생한 히스토리를 같이 작성해보고 싶었습니다.

그러한 이유는 제가 평소에 좋아하고 추구하는 기술 블로그 글들의 특징입니다.

이유는 단순히 코드라는 것만 얻어가는 것이 아니라 그 사람이 이 코드를 개발하게 된 이유, 필요성, 고민했던 과정, 막혀서 해결했던 경험 등을 지금의 나는 겪지 못했지만 언제든 겪을 수 있는 일이고 비슷한 상황이 찾아온다면 그 읽어서 얻었던 경험을 토대로 해결을 찾아가는데 도움을 얻을 수 있기 때문이라고 생각합니다.

그렇기에 제가 평소에 작성했던 글들도 코드만 툭 하고 올리는 것이 아닌 제가 개발하게 된 이유, 필요성, 고민했던 과정들을 다 담는 글을 작성하고 있습니다.

저의 그러한 글 작성법 때문에 지금 당장 문제를 해결해야 하시는 분들 께는 "아 이 사람 뭔데 글이 길지? 그래서 코드 어딨지?" 하고 좋지 못한 블로그라고 인상을 남길 수도 있지만 "급할수록 돌아가라"는 말이 있듯이

 

 

바쁜 와중에 제 글을 발견하신 것도 운명이라 생각하시고 여유를 가지고 천천히 읽어보시면서 현재 자기의 상황과 비교해 보고 제가 했던 고민에 대해서도 같이 나였다면 어떤 고민을 했을까 라는 접근을 가져보시는 것도 좋을 것 같습니다.

 

읽어주셔서 감사합니다!