[React] Input 컴포넌트 Compound Component Pattern를 이용해서 유연하게 만들어보기

JangGwon·2024년 3월 16일

도입 배경

현재 진행하고있는 'Hands up 중고 경매 거래 서비스' 팀 프로젝트에서 팀원들이 사용 할 공용 컴포넌트 중에서 Input 컴포넌트를 제작을 담당했었는데, 피그마로 디자인한 대로라면 다양한 배치 스타일의 Input 컴포넌트가 필요했습니다.


예를 들면?

위 사진대로 Input 스타일 구성 경우의 수를 추려보니 총 4가지정도 였습니다.

  • input 안에 label이 들어가있는 경우
  • input 안에 button이 있는 경우
  • label이 input 밖에 있는 경우
  • button이 input 밖에 있는 경우

그렇게 어떻게 만들지 고민해봤는데, 첫 번째 방법으로는 경우의 수 모든 조합의 컴포넌트를 만들기였습니다. 이 방법은 중복되는 코드가 매우 많을것을 뿐더러 재사용성이 좋지 않아보였습니다. 무엇보다도.. 컴포넌트 각각에 이름 만들어줄 상상을 하니 벌써부터 어지러웠습니다.
또, props로 디자인 구성 관련 값들을 받아서 해결하는 방식은 수 많은 props관리, 수 많은 분기처리로 인해 컴포넌트가 복잡해지고 추후 유지보수가 힘들거같았습니다. 그렇게 다른 방법을 찾아보게 되었고 합성 컴포넌트 패턴을 발견하게 되었습니다.




합성 컴포넌트 패턴(Compound Component Pattern)이란?

하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 방식

장점

  • 컴포넌트를 기능별로 나누어 관리함으로써 각 컴포넌트의 책임이 명확해지며 하나의 컴포넌트에 하나의 기능만을 담당해야 한다는 원칙인 단일 책임 원칙(SRP)을 쉽게 부합시킬 수 있다.
  • 주어진 문제를 다양한 하위 컴포넌트를 가지고 조합하여 해결이 가능하다.
  • 명확하고 간단한 코드 구조를 유지하면서 쉽게 사용자 지정 및 확장할 수 있는 UI 컴포넌트를 설계할 수 있다
  • 특정한 컴포넌트에 변경사항이 생긴 경우 해당 컴포넌트만 수정하면 되며 유지보수가 편하다.

이런 장점들이 있으며 컴포넌트들의 재사용성을 살리고 다양한 조합을 유연하게 만들 수 있다는 장점과 추후 추가할 기능들이 생긴다면 유연하게 대처 할 수 있을거같다는 장점에 합성 컴포넌트 패턴을 사용하기로 결정했습니다.



1차 적용기

구상

제일 처음에는 어떤 컴포넌트들로 나눌지 구상해보니 필요한 컴포넌트는 총 6가지로 추려졌습니다.

Input 전체를 감싸는 부모 컴포넌트Input Wrapper 컴포넌트
하위 컴포넌트들로는 Label 컴포넌트,
Submit 이벤트를 담당해줄 버튼 컴포넌트,
Input 내용을 지워줄 clear 버튼 컴포넌트
Input 엘리먼트처럼 보이게해줄 InputBox 컴포넌트
실제 input 이벤트를 담당할 Input 컴포넌트


컴포넌트 구현

상위 컴포넌트


const InputWrapper = ({ className,  children }) => {
  const [inputText, setInputText] = useState("");

  const providerValue = { inputText, setInputText };
  return (
    <InputContext.Provider value={providerValue}>
      <div className={`${className} flex`}>{children}</div>
    </InputContext.Provider>
  );
};

contextAPi 사용

하위 컴포넌트인 InputForm으로부터 값을 입력 받으면 SubmitButton, ClearButton 등 다른 컴포넌트에서도 값을 제어할 수 있게 하는것이 문제였는데 이 문제는 contextAPi를 활용하였습니다.
처음에는 input의 value값이 필요한 컴포넌트들끼리 ref나 state를 props로 공유해볼까 하는 생각이 있었지만, 매번 사용할 때마다 보일러 플레이팅 문제로 DX저하 시킨다는 생각에 contextAPi를 사용하였습니다.


하위 컴포넌트들

// input 내용 삭제 역할 하는 버튼 

const ClearButton = ({ className, children, buttonText, ...props}) => {
  const { setInputText } = useContext(InputContext);

  return (
    <button
      onClick={() => setInputText("")}
      {...props}
      className={className}>
      {children}
    </button>
  );
};

// 제출 역할 해주는 버튼 

const SubmitButton = ({
  className,
  children,
  onButtonClick,
  ...props
}) => {
  const { inputText } = useContext(InputContext);
  return (
    <button
      onClick={() => onButtonClick(inputText)}
      {...props}
      className={className}>
      {children}
    </button>
  );
};
// Input 디자인 느낌나게 해주는 역할 
const InputInnerBox = ({
  className,
  children,
}: React.PropsWithChildren<InputInnerBoxProps>) => {
  return (
    <div
      className={className}>
      {children}
    </div>
  );
};

// 실제 input Element 역할을 해줄 컴포넌트 

const InputForm = ({ className, ...props }) => {
  const { inputText, setInputText } = useContext(InputContext);

  return (
    <input
      value={inputText}
      onChange={(event) => setInputText(event.target.value)}
      className={className}
      {...props}
    />
  );
};

export default Input;

묶어주기

Object.assign 함수를 이용하면 합성 컴포넌트로 사용되는 컴포넌트를 한 곳에서 제어하고 응집시킬수 있습니다.

const Input = Object.assign(InputWrapper, {
  Label,
  InputInnerBox,
  SubmitButton,
  InputForm,
  ClearButton
});


이렇게 합성 컴포넌트 패턴을 이용해 Input 컴포넌트를 1차적으로 만들어봤습니다.








리펙토링 (2차 적용기)

프로젝트 MVP모델 구현이 끝난 지금 코드 전반적으로 수정할 게 뭐 없나? 하고 프로젝트 코드들을 둘러보던 도중 손봐야할 부분들을 발견했습니다.

발견한 문제사항 - 불필요한 필수 상위 컴포넌트 존재로 인한 DX저하 문제

코드 예시

<Input>
  <Input.InputBox>  <- 한 depth 더 들어가야함
   <Input.InputForm/>
   <Input.ClearButton/>
  </Input.InputBox>
</Input>

제가 본 문제사항으로는 InputBox 컴포넌트 주변에 Label이나 Button배치가 필요없는 상황에도 Input컴포넌트인데 실제 inputElement를 가지고 있는 컴포넌트를 만나기까지 InputBox를 거쳐야합니다. 불필요하게도 한 개의 depth를 더 지나야 실제 input이 나온다는거죠.
실제 Input에서 입력창 디자인 역할을 해주는 컴포넌트는 InputBox, 실제 inputElement를 가지고 있는 컴포넌트는 InputForm 사실 이 두 컴포넌트만으로 Input 컴포넌트의 역할을 다하는데 괜히 Input 옆에 Label이나 Button을 배치 할 수 있게끔 하고 싶다는 욕심에 Input Wrapper로 한 단계 더 덮어버린거같습니다.


해결

  <Input>   
   <Input.InputForm placeholder ="검색"/>
   <Input.ClearButton/>
  </Input>

그렇게 InputBox컴포넌트를 기존 Input에서 분리시킴과 동시에 Input으로 이름을 변경했습니다.
물론 위 이미지처럼 Input 테두리 안에서만 컴포넌트 배치가 가능하곘지만, 정확히는 전보다 Input의 역할에 더 충실해졌습니다.


Field

하지만 기존의 Input처럼 사용해야하는 경우를 위해 Field 컴포넌트를 만들었습니다. 이전 Input 컴포넌트와 사용법은 비슷하지만 용도에 따른 이름은 Field가 맞다고 생각했습니다.
<Field>
  <Label>이상</Label>
  <InputBox>   
   <Input.InputForm/>
   <Input.Label labelText = "원"/>
  </InputBox>
</Field>

어... 돌이켜보면 명칭들만 바꾼거같기도...?





etc

합성 컴포넌트 패턴을 잘 활용한다면 재사용성이 좋은 코드 그리고 주어진 문제를 다양한 하위 컴포넌트를 가지고 조합하여 해결이 가능한 코드들을 쉽게 작성 할 수 있다는 것을 알게 되었습니다. 하지만, 특정 기능을 위해 불필요한 중간 컴포넌트가 도입되는 경우, 복잡성만 높이는 경우 안티패턴이 될 수 있을거 같았습니다. 그래서 좀 더 다양항 상황에서 적용해보며 연습하려합니다. 좀 더 연습해보고 돌아왔을때는 이 코드들이 다시 레거시처럼 보이겠죠? 😭

0개의 댓글