Intro

디자인의 잦은 변경으로 컴포넌트를 재사용하기 난감했던 경험이 있으신가요?

저는 우아한테크코스에서 프로젝트를 진행할 당시, 2주마다 데모 과정을 거치면서 불필요한 기능을 제거하고 새로운 기능을 추가하며 디자인이 자주 변경되는 경험을 하였습니다.

이에 따라 컴포넌트 재사용성이 낮아졌고, 이를 해결하기 위해 컴포넌트의 재사용성을 높이는 방법을 고민하게 되었습니다.

이 글에서는 제가 마주한 문제 상황들과 이를 개선하기 위해 고민한 과정들, 학습한 것들을 공유합니다.

몇몇 코드는 수도코드(pseudo code)로 작성하였습니다.

문제 상황

프로젝트 팀은 개발자들만으로 구성되었고, 기획부터 디자인까지 함께 진행하게 되었습니다. 아쉽게도 모든 팀원들이 브랜딩에 미숙했고, 데모를 거치면서 디자인의 일관성은 점점 유지하기 힘들었습니다. 조금씩 달라지는 디자인을 prop의 확장으로 대응했지만 이는 장기적으로 유효하지 않을 것이라고 직감이 말하고 있었습니다.

되돌아보기

이 문제를 해결하기 위해 재사용성에 대해 다시 생각해 보았습니다. 재사용성이란 무엇일까요? 이를 생각해 보기 위해선 ‘사용성’의 의미를 고려해 보아야 합니다. 사전적 의미는 다음과 같습니다.

사용성 : 기능이나 목적 따위에 맞게 사용할 수 있는 성질.

즉, 사용자가 어떤 것(A)으로 원하는 목적을 달성할 수 있다면 우리는 그 것(A)의 사용성이 좋다고 일컫습니다. 그렇다면 사용자가 A를 활용하여 원하는 목적을 ‘반복적’으로 달성할 수 있다면, 우리는 그것의 재사용성이 좋다고 생각할 수 있습니다. 편의를 위해 이 글에서는 A는 컴포넌트로 한정하겠습니다.

그렇다면 우리는 컴포넌트로 어떤 목적을 달성하려고 할까요? 컴포넌트가 존재하는 목적과도 관계가 있을 것 같습니다. 제가 생각한 컴포넌트의 본질은 다음과 같습니다.

  1. 외형(외관, 디자인)
  2. 기능

제가 생각했을 때, 컴포넌트는 위 항목 중 적어도 1가지 이상의 역할을 수행해야 한다고 생각합니다. 모듈의 관점에서 바라본다면 비즈니스 로직, 모델과 관련된 로직만 수행해도 컴포넌트라고 지칭할 수 있지만 프론트엔드 영역에서 통상적으로 사용되는 컴포넌트의 의미와는 거리가 있는 것 같습니다. 따라서 그런 기능의 모듈들은 일반적으로 lib, model과 같은 디렉토리에 분류됩니다.

기존의 저는 컴포넌트를 활용하여 외형을 재사용하기 위해 노력했습니다. 다만, 잦은 디자인의 변경은 컴포넌트가 갖는 외형에 대한 역할을 빈번하게 변경하는 상황을 만들었습니다.

디자인이 자주 변경된다 > 변경에 대한 예측이 어렵다 > 코드 수정이 발생할 확률이 높다 > 반복적인 목적(1) 달성이 어렵다 > 재사용성이 낮다

컴포넌트의 기능

이같은 문제를 해결하고자 컴포넌트로 달성하고자 하는 목적을 기능으로 변경하였습니다. 여기서 말하는 기능이란 무엇일까요?

저는 기능을 인터랙션으로 바라보았습니다. 컴포넌트도 결국 함수에 불과하기 때문에 다양한 로직을 수행할 수 있지만, 수행된 로직은 궁극적으로 UI로 표현됩니다. UI의 기능은 곧 인터랙션이기에 컴포넌트의 목적 중 하나를 인터랙션으로 바라보는 것이 근거있는 판단이라고 생각합니다.

중복 인터랙션

많은 컴포넌트들은 인터랙션을 기대합니다. 현재 글을 쓰며 보이는 벨로그의 출간하기 버튼은 클릭할 경우 출간 api를 요청할 것입니다.

const PublishButton = ({ post }: PublishButtonProps) => {
	return <button onClick={() => publish(post)}>출간하기</button>
}

그 옆에 있는 임시저장 버튼은 저장 api를 요청할 것입니다.

const SaveButton = ({ post }: SaveButtonProps) => {
	return <button onClick={() => save(post)}>임시저장</button>
}

두 컴포넌트 모두 클릭에 대한 인터랙션을 다루고 있습니다.

다음 예시를 보겠습니다.

결제 정보를 노출하는 다이얼로그와 출간 정보를 노출하는 다이얼로그입니다.

const PaymentDialog = () => {
  const [opened, open, close, toggle] = useBoolean(false)
  
  useOnEscape(close); // esc를 눌러 다이얼로그 닫기
  
  return <>
    <button className='payment-dialog-trigger'
      type='button'
      aria-haspopup="dialog"
      aria-controls='payment-dialog-section'
      aria-expanded={opened}
      onClick={toggle}>
      결제하기
    </button>
    {opened && 
      <>
        <div className='overlay' aria-hidden onClick={close}/>
    	  <section id='payment-dialog-section'>
	        // 결제 내용...
	      <button type='button' onClick={close}>닫기</button>
	    </section>	
	  </>
    }
    </>
}
 
const PublishDialog = () => {
  const [opened, open, close, toggle] = useBoolean(false)
  
  useOnEscape(close); // esc를 눌러 다이얼로그 닫기
  
  return <>
    <button className='publish-dialog-trigger'
      type='button'
      aria-haspopup="dialog"
      aria-controls='publish-dialog-section'
      aria-expanded={opened}
      onClick={toggle}>
      출간하기
    </button>
    {opened && 
      <>
        <div className='overlay' aria-hidden onClick={close}/>
    	  <section id='publish-dialog-section'>
	        // 출간 내용...
	      <button type='button' onClick={close}>닫기</button>
	    </section>	
	  </>
    }
    </>
}
 

두 컴포넌트 모두 접근성 및 다양한 인터랙션(esc, click)에 대해 중복적으로 다루고 있는 모습을 확인할 수 있습니다.

우리는 다이얼로그의 overlay를 클릭하면 닫힌다는 것을 알고 있습니다. 또한 표준(popover 엘리먼트)을 따른다면, esc를 눌러 닫을 수 있다는 것도 알 수 있습니다.

컴포넌트의 디자인은 자주 변경되어도, 그 컴포넌트가 본질적으로 가져야 할 기능은 쉽게 변하지 않습니다. 쉽게 변하지 않는 것을 재사용의 대상으로 결정하는 것은 타당합니다.

따라서, 컴포넌트가 본질적으로 가져야 할 기능에 대한 중복을 추출하기로 결정하였습니다. 이는 결국 컴포넌트에서 도메인을 제거하면 오롯이 드러나는 UI의 본질을 추출하는 과정입니다.

헤드리스 컴포넌트

장황했지만, 이와 같은 아이디어를 바탕으로 만든 컴포넌트를 헤드리스 컴포넌트라고 합니다. 디자인에 대한 제어권을 사용자에게 위임하고, 컴포넌트가 가져야 할 기능에 대한 역할만을 담당하는 것이죠.

스타일링에 대한 제어권을 위임한다는 것이 쉽게 와닿지 않습니다. 어떻게 사용자가 컴포넌트를 원하는대로 스타일링 할 수 있을까요?

스타일링 위임

일반적으로 사용부에서 컴포넌트를 스타일링 하는 방법은 다음과 같습니다.

const PostButton = () => {
 return <>
		// 클래스네임 활용
   		<Button className='post-button'>출간하기</Button> 
        // 합성
	   	<Button>
          <div style={{...}}>출간하기</div>
   		</Button>
        // 스타일 주입
	    <Button css={{...}}>출간하기</Button>
   </>
}

위 방법들은 다음과 같은 문제점들이 존재합니다.

  1. css-in-js와 맞지 않는다.
  2. 불필요한 껍데기가 생긴다.
  3. 기존에 존재하는 스타일링된 컴포넌트를 사용할 수 없다.

prop을 통해 조건적으로 제어할 수도 있지만 헤드리스 컴포넌트가 주장하는 ‘위임’과는 거리가 있어 보입니다.

대체 어떻게 스타일링을 위임할 수 있다는 것일까요?

Render Delegation

헤드리스의 컴포넌트를 활용한 대표적인 UI라이브러리인 Radix UI는 Render Delegation이라는 방식을 활용합니다.

사용부에서 원하는 스타일을 주입하면 그 스타일을 그대로 사용하면서 내부적인 기능을 병합시켜주는 것이죠. 인터페이스는 다음과 같습니다.

const AddToCartCheckbox = () => {
 return <Checkbox >
          <Checkbox.Indicator asChild>
            <MyIndicator onClick={() => { alert('hi')}}/>
          </Checkbox.Indicator>
 		</Checkbox> 
}

asChild라는 prop을 사용하면 자식으로 주입된 스타일링된 컴포넌트가 그대로 렌더링 됩니다.

이는 위에서 언급된 3가지 문제점을 모두 해결합니다. 직접 만든 컴포넌트를 그대로 이용할 뿐이니 말이죠.

구현부

합성을 통해 스타일링된 컴포넌트를 렌더링하는 것을 알았습니다. 그렇다면 구현부에선 어떻게 인터랙션에 대한 기능을 우리가 만든 컴포넌트에 부착하는 걸까요? 어떻게 우리가 등록한 onClick 이벤트가 다른 로직도 수행하도록 할 수 있는걸까요?

결론부터 말씀 드리면 React의 cloneElement api를 활용하여 우리가 주입한 컴포넌트의 prop을 조작합니다. Radix UI에선 이 역할을 Slot이 수행합니다.

Slot

Radix UI는 각 UI 컴포넌트 내부적으로 Slot을 활용합니다.

const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
  const { children, ...slotProps } = props;
 
  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...mergeProps(slotProps, children.props),
      ref: forwardedRef ? composeRefs(forwardedRef, (children as any).ref) : (children as any).ref,
    });
  }
 
  // asChild는 단일 자식 컴포넌트만 허용
  return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});
 

실제 구현부는 여기서 확인할 수 있습니다.

Slot을 간단하게 설명하자면, Slot의 자식 컴포넌트(children)의 기능들과 구현부에서 제공하고자 하는 기능(인터랙션 등)을 적절히 병합한 가공된 컴포넌트를 만들어내는 컴포넌트입니다.

cloneElement를 통해 컴포넌트(Dialog, Select 등)가 제공하고자 하는 기능과 외부에서 주입된 prop(onClick, onChange 등)을 mergeProps 함수로 병합하여 최종적으로 렌더링하는 모습을 확인할 수 있습니다.

function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
  // all child props should override
  const overrideProps = { ...childProps };
 
  for (const propName in childProps) {
    const slotPropValue = slotProps[propName];
    const childPropValue = childProps[propName];
 
    const isHandler = /^on[A-Z]/.test(propName);
    if (isHandler) {
      // if the handler exists on both, we compose them
      if (slotPropValue && childPropValue) {
        overrideProps[propName] = (...args: unknown[]) => {
          childPropValue(...args);
          slotPropValue(...args);
        };
      }
      // but if it exists only on the slot, we use only this one
      else if (slotPropValue) {
        overrideProps[propName] = slotPropValue;
      }
    }
    // if it's `style`, we merge them
    else if (propName === 'style') {
      overrideProps[propName] = { ...slotPropValue, ...childPropValue };
    } else if (propName === 'className') {
      overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ');
    }
  }
 
  return { ...slotProps, ...overrideProps };
}

혹시 모를 오역을 방지하기 위해 주석을 있는 그대로 가져왔습니다.

slotProps는 컴포넌트 구현부에서 주입하는 기능이 담긴 props, childProps는 사용부에서 주입하는 props입니다.

사용부에서 주입된 prop이 이벤트핸들러, 스타일, 클래스네임인 경우 적절히 가공하여 최종적으로 주입하는 것을 확인할 수 있습니다.

또한, 마지막 라인을 통해 사용부에서 주입한 컴포넌트의 prop이 가장 우선순위가 높다는 것도 알 수 있습니다.

const AddToCartCheckbox = () => {
 return <Checkbox >
          <Checkbox.Indicator asChild id='will-not-apply'>
            <MyIndicator id='will-apply'/>
          </Checkbox.Indicator>
 		</Checkbox> 
}

위와 같이 사용하는 경우 id는 will-apply가 되는 것이죠. 위임이라는 단어에 걸맞는 것 같습니다.

Slot을 사용해 컴포넌트를 구현한다면 다음과 같은 모습이 될 것입니다.

 
type ButtonProps = PropsWithChildren<{ asChild?: boolean }> & ComponentPropsWithoutRef<'button'>
 
const Button = (props: ButtonProps) => {
  const Component = props.asChild ? Slot : 'button'
  
  return <Component {...props} onClick={sayHi}/>
}

이렇듯, Radix UI는 cloneElement api를 활용하여 사용부의 컴포넌트를 그대로 활용하는 방식을 사용합니다. 이는 비단 Radix UI뿐 아니라 Headless UI, Chakra UI같은 다른 헤드리스 컴포넌트 라이브러리에서도 널리 사용되는 방식입니다.

비제어 컴포넌트

헤드리스 컴포넌트는 그 자체로 온전한 기능을 가진 ‘위젯’과 같은 컴포넌트입니다. 즉, 외부에서 주입되는 상태 없이 독립적으로 동작합니다.

다이얼로그 데모를 확인해보시면 단 하나의 외부 상태 없이 동작하는 것을 확인할 수 있습니다. 하지만 때론 컴포넌트를 직접 제어해야만 할 때도 존재합니다. 이를 위해 제어권에 대한 확장이 열려있어야 합니다.

Radix UI는 다음과 같이 외부 상태와 내부 상태를 연결합니다.

function useControllableState<T>({
  prop,
  defaultProp,
  onChange = () => {},
}: UseControllableStateParams<T>) {
  const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ defaultProp, onChange });
  const isControlled = prop !== undefined;
  const value = isControlled ? prop : uncontrolledProp;
  const handleChange = useCallbackRef(onChange);
 
  const setValue: React.Dispatch<React.SetStateAction<T | undefined>> = React.useCallback(
    (nextValue) => {
      if (isControlled) {
        const setter = nextValue as SetStateFn<T>;
        const value = typeof nextValue === 'function' ? setter(prop) : nextValue;
        if (value !== prop) handleChange(value as T);
      } else {
        setUncontrolledProp(nextValue);
      }
    },
    [isControlled, prop, setUncontrolledProp, handleChange]
  );
 
  return [value, setValue] as const;
}
 
const [opened = false, setOpened] = useControllableState({
      prop: openedProp,
      defaultProp: defaultOpened,
      onChange: onOpenedChange,
    });

실제 구현부는 여기서 확인할 수 있습니다.

외부 prop이 있는 경우(isControlled) 외부에서 주입된 onChange를 실행시켜줍니다. 상태를 변경하기 위해선 사용자는 onChange(다이얼로그의 경우 onOpenedChange)에 외부 상태를 변경하는 로직을 적절히 삽입해주어야 합니다.

외부 prop이 없는 경우엔 선언된 내부 상태를 변경합니다.

즉, 기본적으론 컴포넌트 내부에 선언된 상태를 통해 제어하고, 외부에서 prop을 주입하는 경우 외부 상태에 전적으로 의존합니다.

다음과 같이 사용할 수 있습니다.

const Checkbox = (props) => {
  const {
    children,
    disabled,
    checked: checkedProp,
    onClick: onClickProp,
    defaultChecked,
    onCheckedChange,
    ...restProps
  } = props;
	...
    const [checked = false, setChecked] = useControllableState({
    prop: checkedProp,
    defaultProp: defaultChecked,
    onChange: onCheckedChange,
  });
 
  const toggle = () => !disabled && setChecked(prev => !prev);
  
  ...
}

비제어 컴포넌트의 스타일링

여기서 한가지 의문이 발생합니다. 비제어 컴포넌트라면, 즉 컴포넌트의 상태가 구현부 내에 있다면 사용자는 어떻게 컴포넌트를 상황에 맞게 스타일링 할 수 있을까요?

예를 들어 Checkbox가 check된 경우 테두리의 색을 변경하고 싶을 수 있습니다. 하지만 우린 동작하는 체크박스만 갖고 있을뿐, 체크 여부를 알 수 없습니다.

const AddToCartCheckbox = () => {
 return <Checkbox style={{...?}}>
          <Checkbox.Indicator asChild >
            // check된 경우만 렌더링됨
            <MyIndicator/>
          </Checkbox.Indicator>
 		</Checkbox> 
}

이는 크게 두 가지 방식으로 해결할 수 있습니다.

첫 번째는 data-* attribute입니다. 구현부에서 상태에 따른 attribute를 제공하여 사용자가 스타일링 할 수 있게 합니다.

.MyIndicator[data-state='checked'] {
	border-color: red
}

두 번째 방법은 render props입니다. 사용자는 렌더링 될 UI를 주입하고, 구현부에선 렌더링에 필요한 정보를 전달합니다.

const AddToCartCheckbox = () => {
 return <Checkbox>
          <Checkbox.Indicator asChild >
            {({checked}) => <MyIndicator checked={checked}/>}
          </Checkbox.Indicator>
 		</Checkbox> 
}
 
const Indicator = ({children}) => {
  	const {checked} = useCheckbox();
  
	return resolveChildren(children, { checked })
}

활용

학습한 내용들을 현재 진행중인 사이드 프로젝트에 어떻게 적용할 수 있는지 간단하게 알아보며 글을 마무리 하겠습니다.

공통 UI

서비스 내에 다음과 같은 디자인이 다수 사용됩니다. 이를 Chip 컴포넌트로 구현하였습니다.

type ChipProps = PropsWithChildren<{
  active?: boolean;
}> &
  ComponentPropsWithoutRef<'span'>;
 
const Chip = ({ active = false, ...restProps }: ChipProps) => (
  <Layout active={active} {...restProps} />
);

Checkbox

전체 공개 컴포넌트는 토글하여 공개 여부를 조작할 수 있는 기능을 가지고 있습니다. 즉, 사용자의 인터랙션이 발생하는 Checkbox입니다. 잘 만들어진 UI를 그대로 사용할 수 있도록(위임) 구현해 보겠습니다.

Checkbox는 체크 표시가 나타나는 영역과 체크되었다는 것을 표시하는 인디케이터로 구성되어 있습니다.

이를 각각 Root와 Indicator로 네이밍 하겠습니다.

const Root = () => {...} 
const Indicator = () => {...} 
 
const Checkbox = {Root, Indicator}

Root를 먼저 구현하겠습니다. 일반적으로 Checkbox는 스타일링이 어려워 input 태그가 아닌, 커스텀 컴포넌트로 많이 사용합니다. 다만, input 태그가 아니더라도 네이밍이 주는 인터페이스는 사용자로 하여금 input의 표준 기능들을 기대하게 만듭니다. 따라서 input의 기능들을 활용할 수 있게끔 타입을 확장하였습니다.

type RootProps = PropsWithChildren<
  {
    asChild?: boolean
    onCheckedChange?: (checked: boolean) => void;
  }> & ComponentPropsWithoutRef<'input'>
 
const Root = () => {...} 

onCheckedChange prop을 제공하여 사용부에서 컴포넌트 내부 상태를 추적하며 핸들링 할 수 있도록 열어주었습니다.

const Root = (props: RootProps) => {
  const {
    children,
    disabled,
    checked: checkedProp,
    onClick: onClickProp,
    defaultChecked,
    onCheckedChange,
    ...restProps
  } = props;
 
  const [checked = false, setChecked] = useControllableState({
    prop: checkedProp,
    defaultProp: defaultChecked,
    onChange: onCheckedChange,
  });
 
  const toggle = () => !disabled && setChecked(prev => !prev);
  
  return ...
}

앞선 내용에서 살펴본 useControllableState을 활용하여 제어 여부에 따라 외부 상태와 내부 상태를 적절히 사용합니다. 이제 외부에서 checked prop이 제공되는 경우 해당 prop에 따라 제어되며 그렇지 않은 경우, 내부 상태에 따라 제어됩니다.

상태 공유

현재 의도하는 인터페이스는 다음과 같습니다.

<Checkbox.Root>
  <Checkbox.Indicator />
</Checkbox.Root>
 

Indicator는 체크박스의 체크 여부에 따라 렌더링이 결정됩니다.

내부 상태는 Root에 선언되었습니다. 따라서 이를 자식 컴포넌트에서 사용하기 위해 공유해 주어야합니다. 이를 위해 Context API를 사용했습니다.

const Root = (props: RootProps) => {
  ...
  
  return <CheckboxProvider value={{ checked, toggle }}>
    {children}
  </CheckboxProvider>
}
 

이제 Indicator에서 chekced 여부에 따라 렌더링을 결정할 수 있습니다.

const Indicator = ({children}) => {
 const { checked } = useCheckboxContext();
  
  return checked ? children : null
}

Slot

Checkbox의 각 UI는 사용부에 위임해야 합니다. 이를 위해 앞서 살펴본 Slot을 활용하겠습니다.

const Root = (props: RootProps) => {
  const {asChild, ...} = props;
  ...
  const Component = asChild ? Slot : 'div'
  
  return <CheckboxProvider>
    <Component
      {...restProps}
      onClick={compose(toggle, onClickProp)}
      data-state={getDataState(checked)}
      data-disabled={disabled}
      >
      {children}
    </Component>
  </CheckboxProvider>
}
 
const getDataState = (checked: boolean) => (checked ? 'checked' : 'unchecked');
 

사용부에서 asChild prop을 통해 Render Delegation한다면 Slot을, 그렇지 않다면 기본 엘리먼트를 활용합니다. (더 나은 확장성을 위해 Polymorphic Component를 활용할 수 있습니다. 추후 포스팅을 통해 다루겠습니다!)

Slot의 onClick에 외부에서 주입된 onClickProp과 내부적으로 제공되어야 하는 기능을 병합하여 전달합니다.

<Checkbox.Root onClick={() => {
  	alert('onClickProp으로 전달되는 prop입니다.')
  }}>
  <Checkbox.Indicator />
</Checkbox.Root>  
 

Indicator에도 적용해 주겠습니다.

const Indicator = ({children}) => {
  	...
  
    const Component = asChild ? Slot : 'div'
    
    const { checked } = useCheckboxContext();
  
    return checked ? (
      <Component {...restProps} data-state={getDataState(checked)}>
        {children}
      </Component>
    ) : null;
}

Render Props

children에 대한 렌더링도 사용부에 의존하여 사용부에서 좀 더 유연하게 컴포넌트를 사용하도록 만들겠습니다.

우선 children에 대한 타입을 변경해 주어야 합니다.

type RenderProps<P extends object = object> = (payload: P) => ReactNode;
 
type PropsWithRenderProps<P = object, R extends object = object> = Omit<P, 'children'> & {
  asChild?: boolean;
  children?: ReactNode | RenderProps<R>;
};
 
// before
type RootProps = PropsWithChildren<
  {
    asChild?: boolean
    onCheckedChange?: (checked: boolean) => void;
  }> & ComponentPropsWithoutRef<'input'>
    
type IndicatorProps = PropsWithChildren<{asChild?: boolean}>;
 
// after
type RootProps = PropsWithRenderProps<
  {
    onCheckedChange?: (checked: boolean) => void;
  } & ComponentPropsWithoutRef<'input'>,
  { checked: boolean; toggle: VoidFunction }
>;
 
type IndicatorProps = PropsWithRenderProps<null, { checked: boolean }>;
 

이제 children의 타입에 따라 렌더링 해줍니다. render props의 인자에는 각 서브 컴포넌트(ex. Indicator)의 관심사에 맞는 적절한 인자를 제공하면 좋을 것 같습니다.

const Root = () => {
	...
    
    return <CheckboxProvider ...>
    <Component ...>
      {resolveChildren(children, { checked, toggle })}
    </Component>
  </CheckboxProvider>
}
 
const Indicator = () => {
	...
    
    return checked ? (
      <Component ...>
        {resolveChildren(children, { checked })}
      </Component>
    ) : null;
}
 
 
const resolveChildren = <P extends object>(children: ReactNode | RenderProps<P>, props: P) => {
  const resolvedChildren = typeof children === 'function' ? children(props) : children;
 
  return resolvedChildren;
};

적용한 render props를 활용하여 전체 공개 컴포넌트를 다음과 같이 구현할 수 있습니다.

const ToggleSharingButton = () => {
  return <Checkbox.Root>
    {({checked}) => <Chip active={checked}>전체 공개</Chip>}
    </Checkbox.Root>
}

체크박스 영역과 인디케이터의 UI가 같기 때문에 Indicator 컴포넌트를 활용하지 않고도 사용할 수 있는 모습입니다.

Hidden Input

거의 완성되었지만, 아직 한 가지 문제점이 남아있습니다. 바로 form과 연동되지 않는 점인데요. 현재 Checkbox 컴포넌트는 input태그가 아니기 때문에 FormData에 추적될 수 없습니다. 하지만 Checkbox를 사용하는 사용자라면 당연히 form 내에서 활용할 수 있기를 기대합니다.

이를 위해 다음과 같이 hidden input을 삽입해 주었습니다.

const Root = (props: RootProps) => {
  return <CheckboxProvider ...>
    <Component ...>
      <input
        type="checkbox"
        {...restProps}
        disabled={disabled}
        checked={checked}
        aria-hidden
        hidden
      />
      {resolveChildren(children, { checked, toggle })}
    </Component>
  </CheckboxProvider>
}

실제 체크박스에 사용되는 모든 정보를 hidden input에도 전달하여 FormData에서 Checkbox를 추적할 수 있는 것처럼 구현하였습니다.

사용해보며

앞선 학습들을 통해 헤드리스 컴포넌트와 컴파운드 컴포넌트 패턴을 활용하여 약 10개의 재사용 가능한 컴포넌트를 구현했고 유용하게 사용했습니다.

실제로 사용한 후기는 어땠을까요?

장점

  1. 스타일링이 유연하다. 현재 제가 처한 상황은 잦은 디자인의 변경이 존재하는 상황이었습니다. 디자인의 구현 난이도 또한 각 컴포넌트가 가진 기능들의 구현 난이도 보다 낮았기에 리소스가 적은 요소를 반복하는 것이 결과적으로 리소스를 아끼는 일이 되었습니다.

  2. 높은 가독성. 기존에 prop으로 확장하던 구조는 prop이 늘어날수록 복잡성이 늘어나고 가독성이 떨어졌습니다. 또한, 인터페이스만으로는 어떻게 렌더링이 될 지 예상하기도 쉽지 않았습니다. 반면, 컴파운드 컴포넌트 패턴과 함께 구현된 헤드리스 컴포넌트는 컴포넌트 구조만으로 렌더링의 형태가 예상되었고, prop이 해당 역할을 맡은 컴포넌트에 직접 작성되기에 훨씬 직관적이었습니다.

  3. 적용(구현)이 되면 생산성과 확장성이 높다. 직접 구현하지 않고 라이브러리를 활용하는 것도 헤드리스 컴포넌트를 적용하는 하나의 방법입니다. 적용하는 순간 디자인의 변경이 잦은 프로젝트 초기에는 매우 뛰어난 생산성을 보여줍니다. 또한, 추후 브랜딩이 안정화되고 디자인 시스템이 구축되었을 때 UI kit으로 대체하기 매우 수월합니다.

단점

  1. 높은 구현 난이도. 초기 구현에 리소스가 발생합니다. 추상화 레벨을 높일수록, 사용자에게 더 많은 권한을 주려할수록 구현 난이도가 올라갑니다.

  2. 인터페이스 공유 필요 헤드리스 컴포넌트는 UI가 존재하지 않습니다. 따라서 사용법과 예제를 팀원과 공유해야합니다. 스타일링을 위한 data-* 속성같은 경우 구현부를 보기 전엔 알 수 없으며, 특히 대부분의 컴포넌트가 합성으로 구현되었고 몇몇 컴포넌트는 특정 컴포넌트 레이어 내에서 동작하기 때문에 anatomy를 반드시 작성하고 공유해야합니다.

마치며

헤드리스 컴포넌트를 학습하며 의문을 가졌던 재사용성에 대한 실마리가 조금은 풀린 것 같습니다. 자연스럽게 컴파운드 컴포넌트 패턴과 Radix UI도 함께 학습하게 되었는데, 다음엔 프로덕트 레벨에서 컴파운드 컴포넌트를 사용할 때 고려해야 할 점들을 다뤄보겠습니다!

참고