잘 만들어진 공통 컴포넌트는 웹 프런트엔드 개발의 속도를 높여줄 뿐만 아니라 프로젝트의 더 높은 완성도를 만들어내는 데 큰 역할을 하는데 어떻게 하면 공통 컴포넌트를 더 가치 있게 만들 수 있을지 알아보자
확장 규칙 설계
명확한 컴포넌트 역할(만능 컴포넌트 지양하기)
공통 컴포넌트의 확장성을 확보하기 전에 가장 우선시되어야 할 것은 개발하고자 하는 공통 컴포넌트 역할의 경계를 명확하게 하는 것입니다. 일반적으로 공통 컴포넌트의 확장성을 고려할 때 공통이라는 명칭 때문에 수많은 케이스를 대응하기를 원하는 경우가 많습니다. 일명 만능 컴포넌트가 되기를 바라게 되는 것입니다. 그러면 어떤 일이 발생할까요? 처음에 만들었었던 공통 컴포넌트가 점점 단순화되고 어느 순간 그냥 텅 빈 DOMElement 그 자체가 되어있을지도 모릅니다. 따라서 공통 컴포넌트의 역할과 책임을 명확하게 정의해야 합니다.
“디자인이 없는 Button 컴포넌트를 만들고 prop으로 className을 부여하는 게 좀 더 확장성 있는 설계가 아닌가?”
맞는 말입니다. :)
하지만 확장성 있는 설계에 앞서 공통 컴포넌트의 궁극적인 목적을 생각해 보아야 합니다.
우리는 공통 컴포넌트를 사용함으로써 개발시간을 줄이고 유지보수를 간편하게 할 수 있어야 합니다. 공통 컴포넌트가 많은 역할을 수행하도록 변경할수록 사용하는 쪽에서는 prop을 더 구체적으로 명시해야 하고 유지보수에 많은 시간 소모하게 되는데 이는 본래의 목적을 퇴색하게 됩니다.
만약 전혀 다른 형태의 버튼이 필요하다면 다음과 같이 추가적인 공통 컴포넌트를 구현하는 것이 개발의 생산성 향상과 유지보수 측면에서 더 바람직할 것입니다.
// common/BaseButton.tsx
import cn from "classnames";
interface Props {
className?: string;
}
export default function BaseButton({ className }: Props) {
return <button className={cn('base-button-class', className)} />;
}
// common/MelonButton.tsx
export default function MelonButton() {
return <BaseButton className="melon-button-class" />;
}
// common/KakaoButton.tsx
export default function KakaoButton() {
return <BaseButton className="kakao-button-class" />;
}
인터페이스 정책
컴포넌트의 역할이 고정되었다면 그다음은 인터페이스를 활용하여 공통 컴포넌트의 확장성을 확보합니다.
앞서 설명했듯이 공통 컴포넌트의 스타일이 조금씩 다른 경우 기존의 공통 컴포넌트를 재활용하기도 새로운 컴포넌트를 만들기도 애매할 때가 있습니다. 하지만 오늘날에는 TailwindCSS가 굉장히 잘 되어있기 때문에 className prop에 classnames 라이브러리의 기능과 TailwindCSS의 클래스를 입력해 충분히 많은 디자인에 대응할 수 있습니다. 그런데도 그때마다 모든 className을 설정하는 것은 굉장히 번거로운 작업이고 휴먼에러가 발생할 가능성이 있습니다. 이때 보통 variant를 활용하여 대응할 수 있습니다.
import cn from 'classnames';
interface Props {
className?: string;
variant?: 'primary' | 'secondary' | 'none';
}
export default function MelonButton({ className, variant }: Props) {
return (
<button
className={cn(
'melon-button-class',
{
'primary-class': variant === 'primary',
'secondary-class': variant === 'secondary',
},
className,
)}/>);
}
위의 경우 공통 컴포넌트 사용자가 요구사항에 따라 적절한 variant를 부여하여 다양한 디자인에 대응할 수 있게 되고, 특히 공통 컴포넌트 개발자는 새로운 유형의 버튼 추가나 수정에 대응하기 쉬워집니다.
모든 상황을 variant로 대응하는 것에는 한계가 있을땐 인터페이스의 상속과 rest parameters를 사용해 이를 해결할 수 있습니다.
주의할 점은 prop에 rest parameters를 추가할 때, 자신이 정한 **명확한 컴포넌트 역할**을 해치는 방향인지 고민해야 합니다. rest parameters를 추가했다고 해서 기존의 prop을 느슨하게 하는 것은 올바르지 않습니다.
import cn from 'classnames';
import { ButtonHTMLAttributes } from 'react';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'none';
}
export default function MelonButton({ className, variant, ...rest }: Props) { return (
<button
className={cn(
'melon-button-class',
{
'primary-class': variant === 'primary',
'secondary-class': variant === 'secondary',
},
className,
)} {...rest} />);
}
네이티브 요소의 활용
공통 컴포넌트들의 대부분은 이미 존재하는 네이티브 요소들을 사용할 경우 훨씬 효율적이고 높은 완성도를 가지게 됩니다.
네이티브 요소를 활용할 때 고민해야 하는 부분들을 소개하고, 어떻게 구현할 수 있을지 체크박스 예제를 통해 살펴보겠습니다.
네이티브 숨기기
체크박스 컴포넌트는 다음과 같이 간단하게 구현할 수 있습니다.
import { useState } from 'react';
export default function Checkbox() {
const [checked, setChecked] = useState(false);
return (
<div
style={{
width: 'fit-content',
position: 'relative',
}}
>
{checked ? <CheckedIcon /> : <UncheckedIcon />}
<input
type="checkbox"
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
opacity: 0,
}}
checked={checked}
onChange={ev => setChecked(ev.target.checked)}
/>
</div>
);
}
위 코드는 사용자가 체크박스 아이콘을 클릭하면, 사실은 아이콘이 아닌 네이티브 체크박스를 클릭하도록 구현된 예제입니다.
또한 input 요소가 제공하는 다양한 기능들을 활용할 수 있습니다.
controlled vs uncontrolled
네이티브 form 요소 들을 활용하여 개발할 때에는 controlled 방식과 uncontrolled 방식 중 어떤 방향으로 개발할지 먼저 결정하는 것이 좋습니다.
// controlled checkbox
interface Props {
checked: boolean;
onChange?: (checked: boolean) => void;
}
export default function Checkbox({ checked, onChange }: Props) {
return (
<input
type="checkbox"checked={checked}onChange={ev => onChange?.(ev.target.checked)}/>);
}
하지만 이 방식은 해당 부모 요소 하위의 모든 요소를 리 렌더링 하게 되는 사이드이펙트를 가져옵니다. CheckboxGroup과 같은 상위 공통 컴포넌트를 만든다고 했을 때 불필요한 props drilling이 발생합니다.
따라서 상황에 따라 uncontrolled 방식으로 제어한다면, 과도한 리렌더링을 막을 수 있고 공통컴포넌트를 사용한 개발의 DX(Developer Experience)를 향상할 수 있습니다.
// uncontrolled checkbox
interface Props {
onChange?: (checked: boolean) => void;
}
export default function Checkbox({ onChange }: Props) {
return (
<input type="checkbox" onChange={ev => onChange?.(ev.target.checked)} />);
}
하지만 controlled방식 이지만 마치 uncontrolled인 것처럼 사용할 수도 있도록 인터페이스를 구성하는 방식도 있습니다.
// mixed checkbox
import { ChangeEvent, useState } from 'react';
interface Props {
checked?: boolean;
onChange?: (checked: boolean) => void;
}
export default function Checkbox({
checked: controlledChecked,
onChange,
}: Props) {
const isControlled = controlledChecked !== undefined;
const [checked, setChecked] = useState(false);
const handleChange = (ev: ChangeEvent<HTMLInputElement>) => {
const checked = ev.target.checked;
if (!isControlled) {
setChecked(checked);
}
onChange?.(checked);
};
return (
<input
type="checkbox"onChange={handleChange}checked={isControlled ? controlledChecked : checked}/>);
}
forwardRef
form 요소를 공통컴포넌트로 만들다 보면 놓치기 쉬운 것이 ref일 것입니다. 리액트에서 요소 참조를 위해 사용되는 ref는 일반적으로 prop으로 전달할 수 없으므로 forwardRef를 사용해야 합니다. 만약 forwardRef로 감싸지 않는다면 내부적으로 ref를 사용해야 하는 react-hook-form 같은 일부 라이브러리 사용에 제약이 있을 수 있습니다.
import { InputHTMLAttributes, forwardRef } from 'react';
interface Props extends InputHTMLAttributes<HTMLInputElement> {}
export default forwardRef<HTMLInputElement, Props>(function Checkbox(
{ ...rest },
ref,) {
return <input type="checkbox" ref={ref} {...rest} />;
});
따라서 가능한 모든 인풋 요소는 forwardRef로 감싸서 export 하는 것을 권장합니다.
웹 접근성
웹 접근성이란 시각 장애인이나 고령자분들같이 정보를 습득하는 데 어려움이 있는 분들도 스크린 스캐너를 통해 웹을 이용할 수 있도록 설계하는 것입니다. 여기서 중요한 것은 사람이 아니라 프로그램이 읽을 수 있도록 페이지를 구성한다는 점입니다. 이에 따라 웹 접근성을 훌륭하게 설계해 두었다면 UX의 향상 및 테스트 코드를 구성할 때 강점을 가져갈 수 있고, SEO 적인 측면에서도 도움을 받을 수 있습니다.
따라서 웹 접근성을 고려한 설계는 좀 더 가치 있는 공통 컴포넌트를 만드는 데 필요한 작업이라 할 수 있습니다.
Semantic Tag
시맨틱 태그는 DOMElement를 활용해 어떤 콘텐츠를 담고 있는지 나타내는 방식이다.
ARIA Field
ARIA Field(Accessible Rich Internet Applications)는 웹 콘텐츠를 좀 더 쉽게 접근하기 위해 DOMElement에 부여하는 attribute입니다
편의성 인터랙션 추가하기
잘 짜인 공통 컴포넌트의 경우 사용자의 편의성을 제공하기 위해 구현된 기능들이 많습니다. 예를 들어 ESC를 눌러 모달창을 닫는다든지, 방향키를 통해 드롭다운의 포커스를 이동한다든지 알게 모르게 편의성을 위한 기능들을 제공합니다. 이러한 기능들은 네이티브 요소를 활용한다면 많은 영역을 커버할 수 있겠지만, 네이티브 요소로 제공되지 않는 경우 직접 구현을 해주는 것이 더 나은 UX를 제공할 수 있게 합니다.
요점
개발자는 확장 규칙을 설계하여 재사용될 범위를 명확히 해야 하고, 네이티브 요소를 적극적으로 사용하여 완성도 있는 컴포넌트를 만들어야 합니다. 또한 웹 접근성을 고려하여 다양한 사용자와 엔진에 대응해야 합니다. 이 밖에도 오늘날에는 SSR이 유행하기 시작하면서 서버 컴포넌트를 고려해야 하기도 하며 모노레포와 Github Packages등 공통 컴포넌트를 더 적극적으로 활용할 수 있는 환경도 구축되고 있습니다. 따라서 우리는 공통 컴포넌트를 더 가치 있게 만드는 것에 시간을 투자해야만 합니다.
“따라서 mui, react-bootstrap 같이 유명 라이브러리의 소스 코드를 열어보고 분석해 보는 습관을 기르면, 개발자가 어떤 의도로 코드를 구성했는지 파악하는 과정에서 웹과 사용자를 더 잘 이해하는 개발자로 성장하게 될 것입니다.”
출처
https://fe-developers.kakaoent.com/2024/240116-common-component/
'개발 > React, NextJs' 카테고리의 다른 글
React classnames 모듈 사용하기 (0) | 2024.03.08 |
---|---|
Suspense란 무엇인가 (0) | 2024.03.07 |
React에서 inline style을 쓰지 말아야하는 이유 (0) | 2024.02.21 |