
코드잇 부트캠프를 수료한 사람들끼리 아티클 공유 스터디를 시작했다.
항상 기술 블로그를 읽으면 까먹는 이슈가 발생해서 그 기록을 블로그 글로 남겨 보려고 한다.
프로젝트를 진행하면서 항상 고민이었던 공통 컴포넌트에 대한 아티클을 소개하려고 한다.
글의 분량이 너무 많아 어느 정도만 요약을 해보겠다.
📝원문: https://fe-developers.kakaoent.com/2024/240116-common-component/
공통 컴포넌트는 단지 재사용과 협업을 위한 컴포넌트 정도로 생각했다. 하지만 프로젝트를 하다보면 재사용하기 위해 공통 컴포넌트를 만들었는데도 아주 작은 차이 때문에 그대로 사용하기 어려운 경우가 비일비재하다. 대표적으로 모달의 경우, 버튼의 개수와 스타일에 따라 공통 컴포넌트를 만들었지만, prop을 사용하다 보니 이 컴포넌트가 과연 공통 컴포넌트인지 의문이 들었고 어느 정도의 공통성과 자유도를 가져야 공통 컴포넌트라고 정의할 수 있을 지에 대해 항상 고민했다. 이 블로그는 공통 컴포넌트를 좀 더 쓸모있고 가치 있게 만드는데 필요한 고민들에 집중하므로 내 고민들에 어느정도 답을 줄 수 있을 것 같다.
무분별한 prop의 확장은 공통 컴포넌트의 가치를 떨어뜨리므로 프로젝트마다 적절한 공통 컴포넌트 확장 규칙 컨벤션 설정이 필요하다.
공통 컴포넌트 !== 만능 컴포넌트
개발하고자 하는 공통 컴포넌트 역할의 경계를 명확히 하자
export default function Button() {
return <button className="default-button-class" />;
}
우리는 공통 컴포넌트를 사용함으로써 개발 시간을 줄이고 유지보수를 편하게 해야한다.
공통 컴포넌트가 많은 역할을 수행하도록 변경할수록 사용하는 쪽에서는 prop을 더 구체적으로 명시해야 하고 유지보수에 많은 시간 소모하게 되는데 이는 본래의 목적을 퇴색하게 된다.
예를 들어 프로젝트 내에서 버튼 컴포넌트를 반복적으로 사용되는 버튼의 기본 형태 라는 책임을 부여했다면 다른 형태의 버튼 디자인을 고려하지말자.
이렇듯 하나의 컴포넌트가 하나의 기능만 수행하도록 설계한 방법론이 SOLID-Single Responsibility Principle(단일 책임의 원칙)이다.
인터페이스를 활용해 공통 컴포넌트의 확장성을 확보할 수 있다.
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}
/>
);
}
다양한 상황에서 변수를 prop으로 넘기는 것은 한계가 있기 때문에 인터페이스의 상속과 rest parameters를 사용해 이를 해결할 수 있다.
리액트 타입스크립트의 경우 모든 DOMElement의 attribute를 prop으로 가지고 있는 인터페이스를 제공하기 때문에 이를 활용하여 확장성 있는 설계를 구성할 수 있다.
이때 주의할 점은 자신이 정한 명확한 컴포넌트 역할 을 해치지 않는 방향에서 prop에 rest parameters를 추가해야한다.
공통 컴포넌트들의 대부분은 이미 존재하는 네이티브 요소들을 사용할 경우 훨씬 효율적이고 높은 완성도를 가지게 된다.
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>
);
}
위 코드는 사용자가 체크박스 아이콘을 클릭하면 사실 아이콘이 아닌 네이티브 체크박스를 클릭하도록 구현된 예제다. 이는 ui를 보고 액션을 발생시키는 사람이나 단지 dom 구조를 보고 액션을 발생시키는 브라우저 입장에서 같은 결과값이 발생하도록 유도하게 된다. 또한 input요소가 제공하는 다양한 기능을 활용할 수 있다.
폼 요소를 활용해 개발할 때는 제어 방식과 비제어 방식 중 어떤 방향으로 개발할 지 먼저 결정하는 것이 좋다
폼 요소가 아닌 다른 컴포넌트에서 지속해서 값을 바라보고 리렌더링이 필요하다면 제어 방식이 적합
👍 제어 방식의 폼 요소는 값을 상태 값으로 관리하기 때문에 상태 값을 추적하여 값이 변경되었는지 확인할 수 있고 이는 상태 값을 기준으로 렌더링 되는 리액트 흐름에 적합한 방식이다.
👎 값이 변경될 때마다 해당 값을 사용하고자 하는 부모 컴포넌트까지 상태를 끌어올려야 하며 해당 부모 요소 하위 모든 요소를 리 렌더링하게 되는 사이드이펙트를 가져온다. 또한 CheckboxGroup과 같은 상위 공통 컴포넌트를 만든다고 하면 불필요한 props drilling이 발생한다.
상황에 따라 비제어 방식을 활용한다면 과도한 리렌더링을 막을 수 있고, 공통 컴포넌트를 사용한 개발의 DX를 향상시킬 수 있다.
👎 리액트에서는 사용자의 인터랙션에 따른 이벤트 시스탬이 자체적으로 구축되어있으므로 DOM을 직접 접근해 값을 세팅하는 경우 의도한 액션이 발생하지 않아 제약이 생길 수 있다.
필자의 경우엔 내부적으로 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}
/>
);
}
<Checkbox checked={true} onChange={(checked) => console.log(checked)} />
<Checkbox onChange={(checked) => console.log(checked)} />
웹 접근성을 훌륭하게 설계해 두었다면 UX의 향상 및 테스트 코드를 구성할 때 강점을 가져갈 수 있고, SEO 적인 측면에서도 도움을 받을 수 있다.
ARIA Field는 웹 콘텐츠를 좀 더 쉽게 접근하기 위해 DOMElement에 부여하는 attribute.
<button aria-label="Close" onclick="closeWindow()">X</button>
버튼은 화면 판독기에서 "X" 대신 "Close"로 읽힌다.
<label id="usernameLabel">Username:</label>
<input type="text" id="username" aria-labelledby="usernameLabel">
input 필드가 usernameLabel 요소에 의해 레이블이 지정
<label for="email">Email:</label>
<input type="text" id="email" aria-describedby="emailHelp">
<span id="emailHelp">We'll never share your email with anyone else.</span>
input 필드가 emailHelp 요소로 설명
<nav role="navigation">
<ul>
<li><a href="#home">Home</a></li>
<li><a href="#about">About</a></li>
<li><a href="#contact">Contact</a></li>
</ul>
</nav>
nav 요소는 role=navigation을 통해 역할을 명시적으로 부여받음
공통 컴포넌트를 좀 더 가치있게 구현하는 방법에 대한 포스트다.
개발자는 확장 규칙을 설계해 재사용될 범위를 명확히 해야하고, 네이티브 요소를 적극적으로 사용해 완성도 있는 컴포넌트를 만들어야 한다. 또한 웹 접근성을 고려해 다양한 사용자와 엔진에 대응해야 한다.
💪 공통 컴포넌트를 가치 있게 만들기 위한 이런 고민과 노력은 웹의 완성도뿐만 아니라 개발자 개인의 개발 역량 향상에도 도움이 된다고 필자는 말한다.
따라서 mui, react-bootstrap 같이 유명 라이브러리의 소스 코드를 열어보고 분석해 보는 습관을 기르면, 개발자가 어떤 의도로 코드를 구성했는지 파악하는 과정에서 웹과 사용자를 더 잘 이해하는 개발자로 성장하게 될 것이다.