본문 바로가기
프론트엔드/React

[리액트] 합성 패턴으로 컴포넌트 만들기 - 1 (인풋)

by zzocco94 2023. 1. 13.

Intro

기획팀 요청으로 선택 버튼이 있는 인풋을 구현한 적이 있다.

 

요청 사항

- 인풋은 유저가 직접 값을 입력할 수 없음

- 인풋의 값은 선택 버튼 클릭 후 등장하는 드롭다운의 옵션 중 선택

고려 사항

흰색 박스가 input처럼 보이지만, 실제 input 태그는 하늘색으로 표시한 영역이고

흰색 박스는 label과 input을 감싸는 div 태그이다.

공통 컴포넌트 Label, Input, Button을 조합해서 아래처럼 만들었다.

 

import styled from "styled-components";
import Label from "./components/Label/Label";
import Input from "./components/Input/Input";
import Button from "./components/Button/Button";  

  <Form>
    <FormTitle>새로운 캐릭터 생성하기</FormTitle>
    <InputList>
      <InputWrap>
        <InputContents labelWidth={80}>
          <Label>직업</Label>
          <Input readOnly value={inputValue} />
        </InputContents>
        <Button onClick={() => setShowModal((prev) => !prev)}>
          <BtnName>선택</BtnName>
        </Button>
      </InputWrap>
      <InputWrap>
        <InputContents labelWidth={80}>
          <Label>필살기</Label>
          <Input readOnly value={inputValue} />
        </InputContents>
        <Button onClick={() => setShowDropdown((prev) => !prev)}>
          <BtnName>선택</BtnName>
        </Button>
      </InputWrap>
    </InputList>
    <Button onClick={() => {}}>
      <CreateBtnName>생성</CreateBtnName>
    </Button>
  </Form>

 

선택 버튼이 있는 인풋이 자주 사용될 것 같아서

SelectInput이라는 공통 컴포넌트로 만들면 좋을 것 같았다.

당시에는 코드의 양이 줄었다며 좋아했었는데,

이것은 고통의 서막이었다..

 

<SelectInput label="직업" otherProps.. />
<SelectInput label="필살기" otherProps.. />

 


2차 요청

이번에는 조금 다른 UI의 인풋을 만들어달라는 요청이 왔다.

문제

기존에 만들어 둔 SelectInput을 재사용하기 어려웠다. 

그대로 사용하자니 prop이 점점 늘어나고,

컴포넌트 내부에 조건부 렌더링 코드가 많아져 복잡해질 것 같았다.

 

실제 업무에서도 SelectInput을 그대로 활용하기가 어려워서

팀원들이 UI가 조금 다른 인풋을 각자 만들어서 쓰곤 했다.

 

공통으로 못 쓰는 공통 컴포넌트라니..!

재사용성을 높이는 방법에 대해 고민하던 중,

카카오엔터테인먼트 기술 블로그에서

[합성 컴포넌트로 재사용성 극대화하기]라는 글을 읽게 되었다.

 

 


리액트 디자인 패턴 - 합성 컴포넌트

기술 블로그에 따르면, 합성 컴포넌트 패턴은

하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤

분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미한다.

 

기존

원래는 필요한 요소들을 props로 넘겨주었다면

 

<Dialog
    dimmed
    title="타이틀"
    checkBoxList={[
        {
            title: '버튼명',
            isChecked: true,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
    ]}
    labelButtonList={[
        { 
            title: '버튼레이블',
        }
    ]}
/>

 

합성 컴포넌트 

요구사항이 추가된다 하더라도 서브 컴포넌트만 중간에 끼워 넣으면 바로 대응할 수 있다.

위 코드보다 더 직관적이고 상황별로 유연하게 대처할 수 있어 좋다고 느꼈다.

 

<Dialog>
  <Dialog.Dimmed />
  <Dialog.Title>타이틀</Dialog.Title>
  <Dialog.CheckBox isChecked hasArrowButton>
    버튼명
  </Dialog.CheckBox>
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  {/* 혹시 여기에 무언가 설명이 들어가야 한다면 아래처럼 추가만 하면 됩니다. 더이상 이미 구현된 Dialog를 수정할 필요는 없습니다.
    <Dialog.Description>설명</Dialog.Description> 
  */}
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  <Dialog.LabelButton>버튼레이블</Dialog.LabelButton>
</Dialog>

 

사실 나도 Label, Input, Button을 조합해서 사용했었지만

더 세세하게 분리할 필요가 있었던 것이다.

바로 리팩토링을 해보았다!

 


리팩토링

1. 영역 나누기

영역은 아래와 같이 3개로 나눴다.

토스 FE 개발자 한재엽님의 글 [변경에 유연한 컴포넌트]에서

right 라는 이름으로 역할을 표현할 수 있다고 해서 응용해 보았다.

 

Left : 인풋 왼쪽 (ex. 외부 label)

Center : 흰색 박스 영역 (ex. 내부 label, input, input 값 길이 등)

Right: 인풋 오른쪽 (ex. button)

 

 

 

2. 조합

서브 컴포넌트를 만들어주고, Input.tsx에서 조합해 준다

 

import InputLeft from "./components/InputLeft";
import InputCenter from "./components/InputCenter";
import InputRight from "./components/InputRight";
import InputLabel from "./components/InputLabel";
import InputField from "./components/InputField";
import InputButton from "./components/InputButton";
import InputLayout from "./components/InputLayout";

const Input = Object.assign(InputLayout, {
    Left: InputLeft,
    Center: InputCenter, 
    Right: InputRight,
    Label: InputLabel,
    Field: InputField,
    Button: InputButton,
}); 

export default Input;

 

InputButton, InputLabel, InputField는

기존에 만들었던 공통 컴포넌트를 import 해서 만들었다.

 

interface InputButtonProps {
  children?: ReactNode;
  onClick: () => void;
}


function InputButton({children, onClick}: InputButtonProps) {
return (
  <Button onClick={onClick}>{children}</Button>
)
}

interface InputFieldProps {
    max?: number;
    readOnly?: boolean;
    value: string;
    onChange?: ChangeEventHandler<HTMLInputElement>;
}


function InputField (props : InputFieldProps) {
return (
  <Input {...props} />
);
}

interface InputLabelProps {
  children?: ReactNode;
  width?: number;
}


function InputLabel({children, width}: InputLabelProps) {
return (
  <Wrapper width={width}>
    <Label>{children}</Label>
  </Wrapper>
)
}

 

3. children 넣기

각 서브컴포넌트에 children를 넣어준다.

 

import Input from "./components/Input/Input";

<Input>
  <Input.Center>
    <Input.Label width={80}>유형1</Input.Label>
    <Input.Field readOnly value={inputValue}  />
  </Input.Center>
  <Input.Right>
  <BtnWrap>
      <Input.Button onClick={() => setShowDropdown(prev => !prev)}>선택</Input.Button>
    </BtnWrap>
  </Input.Right>
</Input>

 


완성

유형 1, 2, 3을 각각 만들어보았다.

UI가 조금 달라도 필요한 요소만 조합해서 쓰니 재사용이 쉬웠다.

아직 스타일 커스텀은 고려하지 않았기 때문에, 스타일 커스텀 방법까지 찾아볼 예정이다.



<InputList>
    <Input>
      <Input.Center>
        <Input.Label width={80}>유형1</Input.Label>
        <Input.Field readOnly value={inputValue}  />
      </Input.Center>
      <Input.Right>
      <BtnWrap>
          <Input.Button onClick={() => setShowDropdown(prev => !prev)}>선택</Input.Button>
        </BtnWrap>
      </Input.Right>
    </Input>
    <Input>
      <Input.Center>
        <Input.Label width={60}>유형2</Input.Label>
        <InputFieldWrap>
          <Input.Field readOnly value={inputValue}  />
          <InputLength>1/20</InputLength>
        </InputFieldWrap>
      </Input.Center>
      <Input.Right>
        <BtnWrap>
          <Input.Button onClick={() => setShowDropdown(prev => !prev)}>선택</Input.Button>
        </BtnWrap>
      </Input.Right>
    </Input>
    <Input>
      <Input.Left>
        <Input.Label width={60}>유형3</Input.Label>
      </Input.Left>
      <Input.Center>
        <Input.Field readOnly value={inputValue}  />
      </Input.Center>
      <Input.Right>
        <BtnWrap>
          <Input.Button onClick={() => setShowDropdown(prev => !prev)}>선택</Input.Button>
        </BtnWrap>
      </Input.Right>
    </Input>
</InputList>

 

 

참고 자료 

- 토스 FE 개발자 한재엽님 : [변경에 유연한 컴포넌트]

- 카카오 엔터테인먼트 기술 블로그 : [합성 컴포넌트로 재사용성 극대화하기]

 

 

 

댓글