
지금까지 컴포넌트 확장성을 서비스 내에서 자주 반복적으로 발생하는 상태 변경이나 동작들을 props로 정의할 수 있는, 재사용이 가능한 컴포넌트 정도로 단순하게 생각하고 있었다.
더 자세하게 찾아보니 진짜 확장성은 기존에 알던 단순한 개념을 넘어서는 것이었다.
진정한 확장성은 컴포넌트가 다양한 상황과 요구사항에 유연하게 적응할 수 있는 능력으로, 단순한 재사용성을 넘어서 미래의 변화에 대응할 수 있는 유연성과 적응력을 의미한다. 이를 위해서는 설계 단계부터 다양한 시나리오를 고려하고 확장 가능한 아키텍처를 구축하는 것이 중요하다는 개념이었다.
개념들을 살펴보니 다형성의 경우 styled-components로만 가능한 줄 알았고, 나머지는 이미 알고 있던 것들이었다. 하지만 이것들을 확장성의 개념으로 바라보는 관점이 새로웠고, 내가 개념 이해가 부족했다는 생각이 든다.
이번 기회에 한 번에 정리해본다.
컴포넌트가 다양한 HTML 요소로 렌더링될 수 있도록 하는 패턴이다. as prop을 통해 동일한 스타일과 로직을 유지하면서 다른 요소로 렌더링할 수 있다.
interface PolymorphicButtonProps<T extends React.ElementType = 'button'> {
as?: T;
variant?: 'primary' | 'secondary' | 'danger';
children: ReactNode;
}
type ButtonProps<T extends React.ElementType> = PolymorphicButtonProps<T> &
Omit<ComponentProps<T>, keyof PolymorphicButtonProps>;
const PolymorphicButton = <T extends React.ElementType = 'button'>({
as,
variant = 'primary',
children,
...props
}: ButtonProps<T>) => {
const Component = as || 'button';
const baseStyles = 'px-4 py-2 rounded transition-colors';
const variantStyles = {
primary: 'bg-blue-500 hover:bg-blue-600 text-white',
secondary: 'bg-gray-500 hover:bg-gray-600 text-white',
danger: 'bg-red-500 hover:bg-red-600 text-white'
};
return (
<Component
className={`${baseStyles} ${variantStyles[variant]}`}
{...props}
>
{children}
</Component>
);
};
// 사용 예시
<PolymorphicButton>기본 버튼</PolymorphicButton>
<PolymorphicButton as="a" href="#" variant="secondary">
링크로 렌더링
</PolymorphicButton>
<PolymorphicButton as="div" variant="danger">
div로 렌더링
</PolymorphicButton>
여러 하위 컴포넌트가 조합되어 하나의 완전한 기능을 만드는 패턴이다. Context를 통해 상태와 설정을 공유하며, 의미있는 구조를 만들 수 있다. 대부분의 경우 DX(Developer Experience) 향상을 위해 사용된다.
const CardContext = React.createContext<{ variant?: string }>({});
const Card = ({
children,
variant = 'default',
className = ''
}: {
children: ReactNode;
variant?: string;
className?: string;
}) => (
<CardContext.Provider value={{ variant }}>
<div className={`border rounded-lg overflow-hidden ${className}`}>
{children}
</div>
</CardContext.Provider>
);
const CardHeader = ({ children }: { children: ReactNode }) => {
const { variant } = React.useContext(CardContext);
const bgColor = variant === 'primary' ? 'bg-blue-100' : 'bg-gray-100';
return (
<header className={`${bgColor} px-4 py-3 border-b`}>
{children}
</header>
);
};
const CardBody = ({ children }: { children: ReactNode }) => (
<main className="px-4 py-3">
{children}
</main>
);
const CardFooter = ({ children }: { children: ReactNode }) => (
<footer className="px-4 py-3 bg-gray-50 border-t">
{children}
</footer>
);
// 네임스페이스 바인딩 (주로 DX를 위함)
Card.Header = CardHeader;
Card.Body = CardBody;
Card.Footer = CardFooter;
// 사용 예시
<Card variant="primary">
<Card.Header>
<h3>제목</h3>
</Card.Header>
<Card.Body>
<p>내용이다. variant 정보가 Context를 통해 전달된다.</p>
</Card.Body>
<Card.Footer>
<button>액션</button>
</Card.Footer>
</Card>
컴포넌트가 렌더링 로직을 외부에 위임하는 패턴이다. 데이터나 상태만 제공하고 UI는 사용하는 곳에서 결정할 수 있어 매우 유연하다.
interface DataFetcherProps<T> {
url: string;
children: (data: T | null, loading: boolean, error: string | null) => ReactNode;
}
const DataFetcher = <T,>({ url, children }: DataFetcherProps<T>) => {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
setLoading(true);
setError(null);
fetch(url)
.then(res => res.json())
.then(setData)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [url]);
return <>{children(data, loading, error)}</>;
};
// 사용 예시
<DataFetcher<{ message: string }> url="/api/data">
{(data, loading, error) => (
<div>
{loading && <p>로딩 중...</p>}
{error && <p>에러: {error}</p>}
{data && <p>데이터: {data.message}</p>}
</div>
)}
</DataFetcher>
컴포넌트를 받아서 새로운 컴포넌트를 반환하는 함수다. 횡단 관심사(cross-cutting concerns)를 처리할 때 유용하다. 로딩, 인증, 에러 처리 같은 공통 로직을 여러 컴포넌트에 일괄 적용할 수 있다.
function withLoading<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithLoadingComponent(props: P & { isLoading?: boolean }) {
const { isLoading, ...restProps } = props;
if (isLoading) {
return (
<div className="loading-spinner" aria-live="polite">
로딩 중...
</div>
);
}
return <WrappedComponent {...(restProps as P)} />;
};
}
const UserProfile = ({ name, email }: { name: string; email: string }) => (
<div className="user-profile">
<h3>{name}</h3>
<p>{email}</p>
</div>
);
// HOC 적용
const LoadingUserProfile = withLoading(UserProfile);
// 사용 예시
<LoadingUserProfile
name="김철수"
email="kim@example.com"
isLoading={false}
/>
<LoadingUserProfile
name="이영희"
email="lee@example.com"
isLoading={true}
/>
ref를 전달받아 실제 DOM 요소에 접근할 수 있게 하는 패턴이다. 외부에서 포커스, 스크롤, 값 조작 등을 할 수 있어 확장성이 높아진다.
const Input = forwardRef<
HTMLInputElement,
ComponentProps<'input'> & {
label?: string;
error?: string;
}
>(({ label, error, className = '', ...props }, ref) => (
<div className="input-group">
{label && (
<label htmlFor={props.id} className="input-label">
{label}
</label>
)}
<input
ref={ref}
className={`input ${error ? 'input-error' : ''} ${className}`}
aria-invalid={!!error}
aria-describedby={error ? `${props.id}-error` : undefined}
{...props}
/>
{error && (
<span
id={`${props.id}-error`}
className="error-message"
role="alert"
>
{error}
</span>
)}
</div>
));
// 사용 예시
const App = () => {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<div>
<Input
ref={inputRef}
id="username"
label="사용자 이름"
placeholder="이름을 입력하세요"
error="이 필드는 필수다"
/>
<button onClick={focusInput}>입력창 포커스</button>
</div>
);
};
TypeScript의 제네릭을 활용해서 타입 안전성을 유지하면서 다양한 데이터 타입을 처리할 수 있다. 런타임 에러를 줄이고 개발 시점에 타입 검증이 가능하다.
interface SelectOption<T = string> {
value: T;
label: string;
}
interface GenericSelectProps<T> {
options: SelectOption<T>[];
value?: T;
onChange: (value: T) => void;
placeholder?: string;
}
const GenericSelect = <T extends string | number>({
options,
value,
onChange,
placeholder = '선택하세요'
}: GenericSelectProps<T>) => (
<select
value={value || ''}
onChange={(e) => {
const selectedOption = options.find(opt => String(opt.value) === e.target.value);
if (selectedOption) onChange(selectedOption.value);
}}
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
);
// 사용 예시
const App = () => {
const [selectedColor, setSelectedColor] = useState<string>('');
const [selectedNumber, setSelectedNumber] = useState<number>(0);
const colorOptions: SelectOption<string>[] = [
{ value: 'red', label: '빨강' },
{ value: 'blue', label: '파랑' }
];
const numberOptions: SelectOption<number>[] = [
{ value: 1, label: '하나' },
{ value: 2, label: '둘' }
];
return (
<div>
{/* 문자열 타입 */}
<GenericSelect
options={colorOptions}
value={selectedColor}
onChange={setSelectedColor}
placeholder="색상을 선택하세요"
/>
{/* 숫자 타입 */}
<GenericSelect
options={numberOptions}
value={selectedNumber}
onChange={setSelectedNumber}
placeholder="숫자를 선택하세요"
/>
</div>
);
};
컴포넌트 확장성의 핵심은 단순히 props를 받는 것을 넘어서, 구조적으로 재사용 가능하고 조합 가능하며, 타입 안전하게 설계하는 것이다.
이런 패턴들을 적절히 조합하면 확장 가능하고 유지보수하기 좋은 컴포넌트를 만들 수 있다.