
최근 공용 컴포넌트를 개발하면서 확장성과 안정성에 대한 고민이 있었습니다.
이러한 고민 속에서 채널톡의 디자인 시스템을 발견하게 되었고, 이를 통해 제 고민에 대한 해답을 찾을 수 있을 것 같아 자세히 살펴보았습니다.
특히 공용 컴포넌트와 테스트 코드에 관한 제 고민에 직접적인 도움이 될 것 같았습니다.
이 글에서는 제가 살펴본 컴포넌트들 중 인상 깊었던 부분들을 중심으로 정리해보았습니다.
FormControl 컴포넌트는 Context API를 사용해 자식 컴포넌트들의 상태를 관리합니다.
코드를 먼저 보겠습니다.
export const FormControl = forwardRef<HTMLElement, FormControlProps>(
  function FormControl(
    {
      children,
      id: idProp,
      labelPosition = 'top',
      size = 'm',
      hasError,
      required,
      readOnly,
      disabled,
      ...rest
    },
    forwardedRef
  ) {
    const getHelperTextProps = useCallback<HelperTextPropsGetter>(
      (ownProps) => ({
        id: helperTextId,
        visible: isNil(hasError) || !hasError,
        ref: setHelperTextNode,
        className: classNames(
          styles.FormHelperTextWrapper,
          labelPosition === 'left' && styles['position-left']
        ),
        ...ownProps,
      }),
      [helperTextId, labelPosition, hasError]
    )
    // ... 컴포넌트의 나머지 부분
  }
)
이 방식은 자주 사용되는 props를 미리 정의하고 자식 컴포넌트에 전달합니다. 이는 다른 디자인 패턴과 비교했을 때 몇 가지 장점을 제공합니다:
1. 일관된 UI: FormControl의 상태(error, disabled 등)를 자식 컴포넌트들과 공유함으로써, 전체적으로 일관된 사용자 인터페이스를 제공할 수 있습니다.
2. 유연한 사용: 개발자는 단순히 태그를 작성하고 props를 내려주는 것만으로도 필요한 기능을 구현할 수 있습니다.
3. 명확한 연결: Text 컴포넌트를 별도로 만들고 Ref를 주입하는 대신, FormControl과 직접 연결합니다. 이를 통해 폼 요소들 간의 관계가 더욱 명확해집니다.
이러한 접근 방식은 컴포넌트 간의 결합도를 낮추면서도 필요한 기능을 효과적으로 구현할 수 있어 보여서 좋았습니다.
2.1 aria-describedby 속성 활용:
aria-describedby
글로벌 aria-describedby속성은 속성이 설정된 요소를 설명하는 요소(들)를 식별합니다.//예시 <div id="group1" aria-describedby="error1"> <input type="text" id="name" /> </div> <div id="error1">이름은 필수 입력 항목입니다.</div>
aria-describedby라는 속성을 고려해준 코드도 좋았습니다.
const describerId = useMemo(() => {
  if (errorMessageNode) {
    return errorMessageId
  }
  if (helperTextNode) {
    return helperTextId
  }
  return undefined
}, [errorMessageNode, helperTextNode, errorMessageId, helperTextId])
에러 메시지나 도움말 텍스트를 Ref로 연결하여 describerId를 labelProps나 FieldProps에 내려주어 스크린 리더 사용자에게 추가 정보를 제공합니다.
2.2 aria 속성 자동 생성 훅:
export function ariaAttr(condition?: boolean) {
  return condition ? true : undefined
}
export function useFormFieldProps<
  Props extends FormFieldProps & SizeProps<FormFieldSize>,
>(props?: Props) {
  // ... 훅 로직
  return {
    ...rest,
    'aria-disabled': ariaAttr(disabled), 
    'aria-invalid': ariaAttr(hasError),
    'aria-required': ariaAttr(required),
    'aria-readonly': ariaAttr(readOnly),
    size,
    disabled,
    hasError,
    required,
    readOnly,
  }
}
이 훅을 사용하면 컴포넌트에 필요한 aria 속성을 자동으로 추가할 수 있습니다.
TextField 컴포넌트에서 주목한 점은 생명주기를 고려해서 작성했다는 점이 인상적이었습니다.
3.1 안전한 Clear 기능 구현:
const handleClear = useCallback(() => {
  const input = inputRef.current
  if (activeInput && input) {
    const setValue = Object?.getOwnPropertyDescriptor(
      HTMLInputElement.prototype,
      'value'
    )?.set
    const event = new Event('input', { bubbles: true })
    setValue?.call(input, '')
    input.dispatchEvent(event)
  }
}, [activeInput])
이 방식은 Object.getOwnPropertyDescriptor를 사용해 HTMLInputElement의 기본 setter를 가져와 사용합니다.
장점이 여러가지가 있습니다.
Object.getOwnPropertyDescriptor
Object.getOwnPropertyDescriptor() 메서드는 주어진 객체 자신의 속성(즉, 객체에 직접 제공하는 속성, 객체의 프로토타입 체인을 따라 존재하는 덕택에 제공하는 게 아닌)에 대한 속성 설명자(descriptor)를 반환합니다.
또한 'input' 이벤트를 발생시켜 React의 onChange 핸들러를 트리거하는 방식으로, React의 생명주기에 맞춘 안전한 초기화를 구현합니다.
Real Dom에서는 onInput이 실시간 입력변화를 담당하지만 React에서는 onChange 메소드가 대체합니다. Real Dom에서는 onChange는 체크박스 같은 변화여서 여기서는 input 이벤트를 발생시킵니다.
3.2 Focus와 Blur 처리:
코드에서는 Focus와 Blur를 사용하고 있지만 글에서는 Focus에 대해서만 다루겠습니다.
const focus = useCallback(() => {
  clearTimeout(focusTimeout.current)
  focusTimeout.current = window.setTimeout(() => {
    inputRef.current?.focus()
  }, 0)
}, [window])
setTimeout을 활용하여 렌더링이 완료된 이후 focus/blur를 사용하여 안정적으로 사용할 수 있게 작성하였습니다.
또한 clearTimeout으로 중복 실행을 방지하였습니다.
3.3 텍스트 선택 범위 설정:
const setSelectionRange = useCallback(
  (start?: number, end?: number, direction?: SelectionRangeDirections) => {
    if (type && ['number', 'email', 'hidden'].includes(type)) {
      return
    }
    inputRef.current?.setSelectionRange(
      start || 0,
      end || 0,
      direction || 'none'
    )
  },
  [type]
)
setSelectionRange 메서드를 활용해 초기 텍스트 선택 기능을 구현하였습니다.
여기에서는 Text 선택 범위를 지정하는 함수를 구현했다는 점이 좋았습니다.
3.4 안전한 getBoundingClientRect 구현:
const getBoundingClientRect = useCallback((): DOMRect => {
  if (inputRef.current) {
    return inputRef.current.getBoundingClientRect()
  }
  return new DOMRect(undefined, undefined, 0, 0)
}, [])
요소가 없을 경우에도 일관된 타입을 반환하여 안전성을 높입니다.
채널톡의 디자인 시스템은 컴포넌트 설계에 있어 다음과 같은 중요한 포인트를 보여줍니다:
이러한 접근 방식은 확장성 있고 안정적인 공용 컴포넌트를 만드는 데 큰 도움이 될 것 같습니다. 특히 웹 접근성과 안전한 DOM 조작 방식은 많은 개발자들이 놓치기 쉬운 부분인데, 이를 세심하게 고려한 점이 인상적이었습니다.
앞으로 이러한 방식을 참고하여 더 좋은 코드를 쓸 수 있으면 좋겠습니다. 감사합니다.
출처 :
채널톡 디자인시스템 오픈 소스
https://github.com/channel-io/bezier-react
getOwnPropertyDescriptor MDN https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor
onInput, onChange 관련 자료
https://stackoverflow.com/questions/38256332/in-react-whats-the-difference-between-onchange-and-oninput
https://github.com/facebook/react/issues/3964