리액트 공통 컴포넌트의 책임에 대해 고민하기

박민우·2023년 12월 9일
1

🎉 React

목록 보기
1/4
post-thumbnail

올해도 아좌좌 프로젝트 초기에 공통 컴포넌트를 설계하며 컴포넌트의 책임에 대해서 고민해보았고, 그 생각의 과정을 담은 글입니다.


📌 공통 컴포넌트의 책임

일반적으로 공통 컴포넌트는, 다양한 페이지와 컴포넌트에서 사용할 수 있는 가장 기본적인 단위의 컴포넌트를 말합니다. 저는 이번 프로젝트에서 Icon, Dropdown, Input, CircleProgressBar 등의 공통 컴포넌트를 담당했습니다.

가장 작은 단위의 컴포넌트인 공통 컴포넌트를 만들면서, 공통 컴포넌트의 책임을 어디까지로 정의해야할 지 고민되기 시작했습니다.

만약 컴포넌트에 필요한 data가 있다면

  • 이를 컴포넌트 내에서 컴포넌트 자체의 state로 정의해야 할지
  • 외부에서 prop으로 넘겨받아 사용해야 할지

를 결정해야 했습니다.

예를 들어 사용자로부터 text를 입력받는 Input 컴포넌트를 만든다면, 다음과 같이 고민할 수 있습니다.

1. Input 컴포넌트 내 자체 state를 정의 O

  • 외부에서 text를 나타내는 state와 이 state의 값을 변경하는 setState 메서드를 prop으로 전달받습니다.
  • 전달받은 state값을 초기값으로 Input 컴포넌트 내 자체 state(앞으로 편의상 state_input로 지칭)를 정의하고, 이를 Input 컴포넌트 내 input 태그와 연결해 사용자가 입력하는 text가 바뀔 때마다 state_input이 업데이트 되도록 해줍니다.
  • state_input이 업데이트 될 때마다 setState함수를 실행해 컴포넌트 외부의 state도 동기화되도록 해줍니다.

2. Input 컴포넌트 내 자체 state 정의 X

  • 외부에서 text를 나타내는 state와 이 state의 값을 변경하는 setState 메서드를 prop으로 전달받습니다.
  • input 태그에 입력되는 사용자 입력이 변경될 때마다, setState를 실행해 외부의 state를 업데이트하고, 이 값이 다시 Input 컴포넌트에 prop으로 전달되면서 Input 컴포넌트가 전달받는 state값이 변경됩니다.

이렇게 공통 컴포넌트가 자체 state를 가져야 할지의 고민으로부터 시작해 컴포넌트의 책임에 대해서 여러 글들을 찾아보며 고민해보았습니다.

그 결과 이는 상황에 따라 다르다 (Case By Case😭) 라는 결론을 내렸습니다. 결국 상황과 조건에 따라 판단해야한다는 뜻인데, 이를 판단할 때 어떠한 점들을 고려할 수 있는지에 대해서 알게 되었습니다.


📌 역할과 중복

1. 역할

첫 번째로, 컴포넌트의 역할입니다.

모든 컴포넌트는 그 종류와 범위는 다를 수 있어도 각자 고유한 역할이 있어야합니다.

이번에는 toggleButton 컴포넌트를 예시로 들어보겠습니다.

toggleButton에 대해서 생각하면 대부분 위와 같은 이미지를 떠올리실 겁니다.

이러한 이미지에서도 직관적으로 알 수 있듯이 toggleButton이란 말 그대로 토글을 해주는 역할을 하는 버튼입니다. 이는 즉, 특정 값을 계속 switch 해주는 동작을 해야한다고 생각할 수 있습니다.

이렇게 컴포넌트의 고유한 역할을 생각해보았을 때, 값을 토글하는 EX) setState(!state) 같은 로직은 토글 버튼의 고유한 기능이므로 컴포넌트 안에 정의되어야 하는 것이 바람직하다고 생각할 수 있습니다.

즉, toggleButton의 고유한 역할인 값을 toggle 한다라는 측면에서 생각해보았을 때 toggleButton 컴포넌트는 자체 state를 가지는 것이 자연스럽다고 판단할 수 있습니다.

2. 중복

두 번째로는 중복입니다.

위에서 말했듯이 toggleButton은 특정 값을 toggle 해주는 역할을 담당할 것이고, 이 로직을 간단히 코드로 나타내면 다음과 비슷할 것입니다.

const toggleState = (state, setState) => {
    setState(!state);
}

만약 toggleButton 내부에 위와 같은 로직을 작성하지 않고, 컴포넌트 외부에서 이를 작성해서 넣어준다면, toggleButton을 사용하는 모든 곳에서 위와 같은 toggleState 함수를 정의해서 넣어줘야 할 것입니다.

이를 중복의 측면에서 생각해보면, toggleButton이 사용되는 페이지가 많아질 수록 위 로직을 계속 정의해줘야 할 것이고, 결국 이는 불필요한 중복이라고 생각할 수 있습니다.

따라서 이러한 경우에는 toggleButton 내에 자체 state를 정의하고 그 state를 이용해 toggle 하는 로직까지 정의하는게 적절하다고 생각했습니다.

또한, 실제로 toggle 하려고 하는 외부의 state를 변경하는 setState 메서드만 외부에서 toggleButton 컴포넌트로 넘겨주고 필요할 때마다 이를 호출해 실제 state와 컴포넌트 내부의 state를 동기화 시켜줄 수도 있을 것입니다.

=> 이렇게 컴포넌트의 고유한 역할에 대한 로직을 내부에 정의함으로써 외부로 노출할 필요가 없는 부분은 캡슐화가 되는 효과 또한 얻을 수 있습니다.


📌 컴포넌트 정의 예시 - DebouceSwitchButton

그러면 위와 같이 컴포넌트의 역할과 중복을 고려해 실제 프로젝트에서 공통 컴포넌트를 어떻게 구현했는지 보여드리겠습니다.

DebounceSwitchButton

위에 보이는 버튼이 DebounceSwitchButton 입니다. toggle 버튼과 비슷하게 보이지만, 단순히 값을 toggle 하는 기능과 더불어 다른 책임들도 가지고 있습니다.

  • 클릭될 때마다 컴포넌트 자체의 상태는 계속 toggle 되어 사용자 입장에서는 값이 바뀐 것처럼 보여야 합니다.
  • 실제 서버 상태의 toggle을 일으키는 api 호출은 디바운스를 이용해 처리하고, 그 처리 자체도 원래 기존값과 다를 때만 호출해줍니다. (값이 toggle되기 때문에 컴포넌트의 상태가 변하지 않는다면 서버의 상태도 변하지 않아야 하므로)

    예를 들어, 사용자가 빠르게 두번 클릭했을 때는 사용자 입장에서는 값이 바뀌었다가 다시 원래대로 돌아온 것 처럼 느껴야하므로 클라이언트의 상태를 2번 toggle 해줍니다. 하지만, 디바운스를 적용한 서버 api 호출은, 결국은 2번 toggle 되어 값이 바뀌지 않았으므로 호출해줄 필요가 없습니다.

이러한 책임을 가진 DebounceSwichButton은 값을 toggle 해주는 자신의 고유한 역할도 담당할 뿐 아니라, debounce를 적용한 서버 api 호출을 통해 api 호출을 최소화하는 역할도 담당합니다. 실제로 위 동영상을 통해 빠르게 버튼을 2번 클릭했을 때는, 서버 api를 호출하는 콘솔이 출력되지 않는 것을 확인할 수 있습니다.

구현 방법

DebounceSwichButton을 코드 상에서 어떻게 구현했는지 살펴보겠습니다.

props

interface DebounceSwitchButtonProps {
  defaultIsOn: boolean; // 실제로 toggle 하려는 상태의 초기값  
  submitToggleAPI: () => void; // 실제 서버의 값을 toggle하는 API 함수  
  toggleName: ToggleName; // toogleButton 사용 용도 
}

DebounceSwichButton은 다음과 같은 props를 외부에서 전달받습니다.

  • defaultIsOn : toggle하려는 서버 상태의 초기값
  • submitToggleAPI() : 실제 서버 상태의 값을 toggle하는 API 통신 함수
  • toggleName : toggleButton의 사용 용도를 나타내는 string literal 값

toggle 상태의 초기값과 toggle API 통신 함수는 컴포넌트 호출 시 매번 달라져야하는 값이므로 defaultIsOnsubmitToggleAPI는 props로 정의해 외부에서 전달받도록 했습니다. 또한, toggleName도 외부에서 전달받도록 하고, 컴포넌트 내부에서 toggleName에 따른 분기처리를 통해 렌더링 해줄 버튼의 text와 icon 등을 결정했습니다.
=> 즉, 위와 같은 3개의 값 모두 컴포넌트 자체에서 판단할 수 있는 data가 아니고, 호출 시마다 달라져야 하는 값이기 때문에 컴포넌트의 고유한 역할에서 제외된다고 생각해 prop으로 정의했습니다.

state

const [isOn, setIsOn] = useState(defaultIsOn); // 컴포넌트에서 toggle 되는 state
const [originalIsOn, setOriginalIsOn] = useState(defaultIsOn); // 

const toggleIsOn = () => { // 컴포넌트 자체 상태 toggle 함수
  setIsOn(!isOn);
};

const submitIfReallyChanged = () => {
  if (isOn !== originalIsOn) {
    submitToggleAPI();
    setOriginalIsOn(isOn);
  }
};

useDebounce(submitIfReallyChanged, 500, [isOn]); // 디바운스 적용
  • [isOn, setIsOn] : defaultIsOn을 초기값으로 하는 컴포넌트에서 toggle 되는 컴포넌트 자체 state
  • [originalIsOn, setOriginalIsOn] : 값이 여러번 toggle 되었을 때 값이 실제로 변경되었을 때만 서버 api를 호출해주기 위해 isOn과 비교할 state
  • toggleIsOn() : 컴포넌트 자체 상태를 toggle 함수
  • submitIfReallyChanged() : 값이 여러 번 toggle 되었을 때 값이 실제로 변경되었을 때만 서버 api를 호출해주는 함수
  • useDebounce() : 컴포넌트 내부에서 디바운스 적용

컴포넌트 자체 toggle state, toggle 함수, 값이 여러번 toggle 되었을 때 값이 실제로 변경되었을 때만 서버 api를 호출해주는 함수, 디바운스 등은 컴포넌트 내부에서 정의해주었습니다.
이러한 값들은 외부에서 어떤 초기값, 서버 API 통신 함수를 전달받더라도, 동일하게 사용될 수 있는 data들이기 때문에 DebounceSwitchButton 컴포넌트의 고유한 역할이 될 수 있고, 중복 측면에서도 컴포넌트 내부에 정의하는 것이 좋겠다고 생각했습니다.

호출 예시 및 전체 코드

다음은 실제로 DebounceSwitchButton 컴포넌트를 호출하는 예시 및 전체 코드입니다.

호출 예시

 <DebounceSwitchButton
        defaultIsOn={serverIsOn} // toggle하려는 서버 상태의 기본값이 on or off
        submitToggleAPI={submitToggleAPI} 
        toggleName="remind"
      />

전체 코드


type ToggleName = 'public' | 'ajaja' | 'remind';

const toggleText = {
  public: {
    on: '계획 공개',
    off: '계획 비공개',
  },
  ajaja: {
    on: '월요일 18:00 마다\n응원 메세지 알림 활성화',
    off: '응원 메세지 알림 비활성화',
  },
  remind: {
    on: '리마인드 알림 활성화',
    off: '리마인드 알림 비활성화',
  },
};

interface DebounceSwitchButtonProps {
  defaultIsOn: boolean;
  submitToggleAPI: () => void;
  toggleName: ToggleName;
}

export default function DebounceSwitchButton({
  defaultIsOn,
  submitToggleAPI,
  toggleName,
}: DebounceSwitchButtonProps) {
  const [isOn, setIsOn] = useState(defaultIsOn);
  const [originalIsOn, setOriginalIsOn] = useState(defaultIsOn);

  const toggleIsOn = () => {
    setIsOn(!isOn);
  };

  const submitIfReallyChanged = () => {
    if (isOn !== originalIsOn) {
      submitToggleAPI();
      setOriginalIsOn(isOn);
    }
  };

  useDebounce(submitIfReallyChanged, 500, [isOn]);

  return (
    <div className={classNames('debounce-switch')}>
      <IconSwitchButton
        onIconName={toggleName === 'public' ? 'PLAN_OPEN' : 'NOTIFICATION_ON'}
        offIconName={
          toggleName === 'public' ? 'PLAN_CLOSE' : 'NOTIFICATION_OFF'
        }
        onClick={toggleIsOn}
        isActive={isOn}
      />
      <span className={classNames('debounce-switch__text')}>
        {isOn ? toggleText[toggleName].on : toggleText[toggleName].off}
      </span>
    </div>
  );
}

📌 맺음말

공통 컴포넌트의 책임에 대해 고민하면서, 컴포넌트의 역할과 중복을 고려하려 책임을 결정할 수 있다는 사실을 알게 되었습니다. 여기서 끝나는 것이 아니라, "리액트 컴포넌트를 잘 설계한다는 것은 무엇일까?" 라는 고민으로 확장시켜 컴포넌트에 대해 더욱 본질적으로 공부해볼 계획입니다🔥

profile
꾸준히, 깊게

0개의 댓글