상세 컨텐츠

본문 제목

Effective Component - 변경에 유리한 컴포넌트 설계로 재사용성 높이기

Programming/React

by 쌩우 2022. 6. 16. 10:43

본문

⏰Effective Component

제품의 성장 과정에 있어서 잦은 변경이라는 것은,
더 나아지기 위한 성장통과 마찬가지이다.
놓치고 있었던 고객의 니즈를 발견한 것이라고도 할 수 있다.

그렇다면 이런 변경에 대해서 유연하고 효율적으로 대응하려면 어떻게 컴포넌트를 설계해야할까?

목차

  1. Headless 기반의 추상화
  2. 변하는 것 vs 상대적으로 변하지 않는 것
  3. [한 가지 역할만 하기]
  4. 또는 한 가지 역할만 하는 컴포넌트의 조합으로 구성하기
  5. 도메인 분리하기
  6. 도메인을 포함하는 컴포넌트와 그렇지 않은 컴포넌트 분리하기

1. 💀Headless UI 기반의 추상화하기

컴포넌트는 크게 세 가지 영역으로 구분된다.

  • 데이터
  • UI : 어떻게 데이터를 보여줄지?
  • 사용자 상호작용 : 어떻게 상호작용할지?

예를 들어 달력 기능을 하는 컴포넌트를 구성한다고 가정해보자.

calendar


달력을 이루는 데이터 구성 자체는 변하지 않을 것이지만,
디자인에 해당하는 UI는 언제든 변경 가능하다.

과연 달력이라는 기능에 대해서 어떻게 추상화하고 분류할 수 있을까?
먼저, 날짜 영역을 분리하여 추상화 가능하다.
그렇다면 날짜라는 데이터와 디자인을 분리해보면 어떨까?

💾데이터 추상화

달력을 2X2 배열의 데이터로 추상화 가능하다.
배열의 각 요소는 Date 객체로 사용하면 될 것이다.
또한, 특정 달의 데이터를 보여줘야하므로 현재의 날짜에 대한 값을 함께 추상화하면 좋을 것이다.
이제 추상화한 데이터는 custom hooks 반환값으로 추출하여 사용할 수 있도록 분리가 가능하다.
아래 예시에서는 useCalendar라는 hooks로 정의되었다.

export default function Calendar() {
    const { headers, body, view } = useCalendar()
}

이제 useCalendar에서는 데이터에만 집중할 수 있게 되었다.
반대로 Calendar라는 컴포넌트에서는 데이터를 어떻게 조작할지 보다, 어떻게 보여줄지에 대한 고민에 집중할 수 있게 되었다.

👆상호 작용 추상화

상호작용에 대한 추상화는 UI와의 분리로부터 가능해진다.
예를 들어 Button 컴포넌트에 Long Press라는 동작을 정의한다고 가정해보자.
일단 추상화가 되지 않은 경우의 예시는 아래와 같다.

interface Props extends ComponentProps<typeof Button> {
 onLongPress?: (event: LongPressEvent) => void;
}
export function PressButton({ onLongPress, ...props }: Props) {
 return (
 <Button
     onKeyDown={(e) => {
     // ...
     }}
     onKeyUp={(e) => {
     // ...
     }}
     onMouseDown={(e) => {
     // ...
     }}
     onMouseUp={(e) => {
     // ...
     }}
 {...props}
 />
 )

이번에도 이전에 데이터 추상화를 진행했던것처럼 hooks를 활용하면 어떨까?

interface Props extends ComponentProps<typeof Button> {
 onLongPress?: (event: LongPressEvent) => void;
}
export function PressButton(props: Props) {
 const longPressProps = useLongPress();
 return <Button {...longPressProps} {...props} />
}
funtion useLongPress() {
 return {
     onKeyDown={e => {
     // ...
     }}
     onKeyUp={e => {
     // ...
     }}
     onMouseDown={e => {
     // ...
     }}
     onMouseUp={e => {
     // ...
 }}
 )
}

컴포넌트에서는 동작에 대한 것을 고민할 필요없이, 어떻게 보여질지에 대한 고민만 하면 되게 바뀌었다.
또다른 장점으로는 동일한 동작을 다른 컴포넌트에서도 수행해야 하는 경우에 useLongPress hook만 가져다 사용하면 되게끔 재사용성이 증가하였다.
이처럼 hooks를 활용한 모듈화는 각각의 모듈이 한 가지 일을 수행하면서 변경에 유리한 형태로 탈바꿈해나가기 위함이다.

2. 🧱Composition

복잡한 컴포넌트의 경우에는 어떻게 추상화를 해야할까?
예를 들어 select 컴포넌트를 구현한다고 가정해보자.
select라는 컴포넌트 하나를 구현하기 위해서 select option을 보기 위해 클릭해야하는 버튼이나, 실제로 보여지게 되는 option, 선택된 후에 나타나는 모습 등등 다양한 단위로 구성이 가능해진다.

일단 아래와 같이 코드를 작성했다고 생각해보자.

function ReactFrameworkSelect({ defaultValue }) {
 const [isOpen, open, close] = useBoolean();
 const [selected, change] = useState(defaultValue);
 return (
     <>
         <InputButton label=”React Framework” value={selected} onClick={open} />
         {isOpen ? (
         <Options onClose={close}>
             {options.map(value => {
                 return (
                     <Button
                         selected={selected === value}
                         onClick={() => change(value)}
                     >
                         {value}
                     </Button>
                 );
             })}
         </Options>
         ) : null}
     </>
 );

해당 컴포넌트의 결과물 모습은 다음과 같을 것이다.


그런 다음, 이 컴포넌트를 재사용 하는 경우를 떠올려보자.
React Framework라는 label만 변경되어도 재사용이 어렵다는 것을 금방 알 수 있다.
props에서 label을 받도록 수정이 필요해지는 것이다.
또한, <InputButton>이라는 컴포넌트 이외에 다른 컴포넌트로 사용하고 싶은 경우가 발생할 수도 있을 것이다. 이처럼 변경에 유연하지 못한 상태를 어떻게 개선할 수 있을까?

첫번째로는 Menu의 노출 여부를 제어하는 내부 상태인 isOpen을 분리해보자.
분리한 상태는 Dropdown이라는 컴포넌트로 관리할 수 있다.
상태를 바꾸기 위한 상호작용은 Dropdown.Trigger로 관리해보자.
옵션 영역도 분리해줄 수 있다. 여닫힘 상태에 따라 노출 여부가 결정되므로 Dropdown.Menu로 구성해보자.
마지막으로 메뉴를 구성하는 각 아이템을 별도로 분리하여 상호작용을 담당하도록 해보자. 다루고 있는 데이터 / 담당하고 있는 역할 기준으로 하여 Dropdown.Item으로 분리가 가능할 것이다.
새롭게 구성한 Select 컴포넌트를 확인해보자.

function Select({ label, trigger, value, onChange, options }: Props) {
 return (
     <Dropdown label={label} value={value} onChange={onChange}>
     <Dropdown.Trigger as={trigger} />
     <Dropdown.Menu>
         {options.map(option => (
         <Dropdown.Item>{option}</Dropdown.Item>
         })}
     </Dropdown.Menu>
     </Dropdown>
 );

Dropdown은 내부적으로 Trigger와 연결된 Menu의 노출 여부를 결정한다.
Item에서 발생한 onClick 이벤트는 Dropdown의 onChange 이벤트로 이어지게 된다.

이를 이용하여 처음에 봤던 ReactFrameworkSelect 컴포넌트를 재구성해보자.

function FrameworkSelect() {
 const {
 	data: { frameworks },
 } = useFrameworks();
 const [selected, change] = useState();
 return (
 	<Select
         trigger={<InputButton value={selected} />}
         value={selected}
         onChange={change}
         options={frameworks}
 	/>
 )

이전 구성과는 달리, InputButton이라는 컴포넌트가 props로 전달되면서, Select와 InputButton 컴포넌트가 독립성을 유지하게 되었다. 덕분에 각 컴포넌트는 변경 시에도 유연성을 지닐 수 있게 되었다.

여기까지의 개념을 확실히 숙지하고 난 뒤, 또다른 컴포넌트를 구현해보자.
모달 내부의 체크박스 UI를 제공한다고 가정해보자.


개념적으로 open/close라거나 제공된 옵션을 선택한다거나 하는 점에서 이전의 select 컴포넌트와 크게 다를 것이 없다. Dropdown 컴포넌트를 활용해 구현해보면 아래처럼 가능해진다.

FrameworkSelect

function FrameworkSelect({
 selectedFrameworks,
 onFrameworkChange,
 frameworks,
}: Props) {
 return (
     <Dropdown value={selectedFrameworks} onChange={onFrameworkChange}>
         //as라는 props로 합성. Button 컴포넌트로 Modal 표시 여부에 대한 상호작용.
         <Dropdown.Trigger
         as={<Button>{String(selectedFrameworks ?? ‘선택하기’)}</Button>}
         />
         //Trigger로 인해 표시되는 부분. Select에서의 Menu와 유사. Menu와 달리 Trigger 아래에 표시되는 것이 아니라 별도로 정의.
         <Dropdown.Modal
             controls={
                 <Flex>
                     //onChange 발생시키는 버튼. Modal을 하나의 form으로 간주한다.
                     //form 형태를 활용하기 위해 각각 reset, submit 타입 지정
                     <Button type=”reset”>초기화</Button>
                     <Button type=”submit”>적용하기</Button>
                 </Flex>
             }
         >
             {frameworks.map(framework => {
                 return <Dropdown.Item>{framework}</Dropdown.Item>;
             })}
         </Dropdown.Modal>
     </Dropdown>
 );

3. 🖖도메인 분리하기

모든 컴포넌트에서 데이터에 접근할 수 있지만, 컴포넌트 주입받은 것처럼 데이터도 주입받으면?
데이터는 언제 주입받고, 언제 스스로 가져와야 할까?

아래의 컴포넌트를 구현하기 위해서 좀 전에 만든 FrameworkSelect 컴포넌트를 재사용 가능할까?

쉽지 않을 것이다.

그렇다면 처음부터 Framework라는 도메인을 분리했다면 어땠을까?

framework 도메인을 분리하자
도메인 맥락을 제거하면, 일반적인 이름으로 바뀌는 걸 알 수 있다.
interface Props {
    options: Array<{ label: string }>;
    value?: string[];
    onChange?: (selecteds: string[]) => void;
    valueAs? : (value?: string[]) => string;
}

framework라는 도메인 지식이 없더라도, 일반적인 select UI의 관점에서 이해 가능한 수준으로 변경되었다.

우리는 여기서 표준에 가까울 수록 이해하기 쉬운 인터페이스라는 결론을 도출할 수 있다.

도메인 맥락이 분리된 컴포넌트는 다음과 같이 MultiSelect라는 일반적인 이름으로 구현 가능해졌다.

function MultiSelect({
 value,
 onChange,
 options,
 valueAs = value => String(value ?? ‘선택하기’),
}: Props) {
 return (
	 <Dropdown value={value} onChange={onChange}>
	 	<Dropdown.Trigger as={<Button>{valueAs(value)}</Button>}/> 
     	<Dropdown.Modal 
     		controls={
		 		<Flex>
			 		<Button type=”reset”>초기화</Button>
			 		<Button type=”submit”>적용하기</Button>
		 		</Flex>
		 	}
 		>
 			{options.map(({ label }) => {
		 		return <Dropdown.Item>{label}</Dropdown.Item>;
		 	})}
	 	</Dropdown.Modal>
 	</Dropdown>
 );
}

일반적인 용도로 구현된 MultiSelect 컴포넌트를 가져다 도메인을 포함하는 컴포넌트 FrameworkSelect를 다시금 구현해보자.

function FrameworkSelect() {
 const {
 data: { frameworks },
 } = useFrameworks();
 const [selected, change] = useState();
 return (
 <MultiSelect
 trigger={<Button value={selected.join()} />}
 value={selected}
 onChange={change}
 options={frameworks}
 />
 );
}

FrameworkSelect는 컨테이너는 아니지만 useFreamworks hook으로 데이터에 직접 접근한다.
필요한 데이터를 컴포넌트가 직접 관리할 때 자율적인 컴포넌트가 되고 외부 의존성이 없어서 재사용이 쉬워지기 때문이다.

🎬Action Item

1. 인터페이스를 먼저 고민하기

의도가 무엇인가?
이 컴포넌트의 기능은 무엇인가?
어떻게 표현되어야 하는가?

상단의 FrameworkSelect 컴포넌트를 구현해야 하는 경우, MultiSelect 컴포넌트가 이미 만들어져 있다고 가정하고 컴포넌트를 구현해보는 것이다.

새로운 예시를 통해서 살펴보자.


새로운 형태의 UI 때문에, 새로운 컴포넌트를 구현해야 한다고 순간 착각할 수도 있다.
하지만 잘 생각해보면 기존의 Select 컴포넌트를 활용할 수 있다는 사실을 알 수 있다.
결국은 선택 가능한 옵션 메뉴와 선택된 값을 나타내고 있기 때문이다.

Select 컴포넌트로 이 새로운 컴포넌트를 구현해보자.

export function Example() {
  return (
    <RegisterLayout title=”내 계좌 등록하기”>
      <Select options={banks}>
        {({ selected: bank }) => (
          <SwitchCase
            value={bank}
            case={{토스뱅크: <TossBankRegisterForm />,
                // ...
            }}
          />
         )}
      </Select>
 	</RegisterFormLayout>
 );
}

선택된 값인 selected를 Select 컴포넌트의 render prop으로 넘겨주고 있다. render prop에 맞추어 실제로 render 하도록 Select 컴포넌트를 구현해주기만 하면 된다.

2. 컴포넌트를 나누는 이유

컴포넌트로 분리하면 실제로 복잡도를 낮추는가?
컴포넌트로 분리하면 재사용 가능한 컴포넌트인가?


참고

관련글 더보기

댓글 영역