합성 vs 상속 — React가 상속 대신 합성을 권장하는 이유
객체지향 프로그래밍에서는 상속이 코드 재사용의 핵심이었는데, React는 왜 합성을 더 강조할까요? 컴포넌트를 확장하는 "올바른 방법"은 무엇일까요?
개념 정의
합성(Composition)은 컴포넌트를 조합하여 더 복잡한 UI를 구성 하는 패턴입니다. 상속이 "is-a" 관계를 만드는 반면, 합성은 "has-a" 관계 를 통해 기능을 조립합니다.
왜 필요한가
React 공식 팀은 수천 개의 컴포넌트를 다뤄본 경험에서, 컴포넌트 상속 계층이 필요한 경우를 발견하지 못했다고 밝혔습니다. 합성이 상속보다 유리한 이유는 다음과 같습니다.
- **유연성 **: 조합 방식을 자유롭게 변경 가능
- ** 명시성 **: props로 관계가 드러남 (암묵적 의존 없음)
- ** 독립성 **: 각 컴포넌트가 독립적으로 변경 가능
- ** 재사용성 **: 다양한 맥락에서 조합하여 사용 가능
상속의 문제점
// ❌ 상속 기반 설계 (비권장)
class Button extends React.Component {
render() {
return <button className="btn">{this.props.children}</button>;
}
}
class PrimaryButton extends Button {
render() {
return <button className="btn btn-primary">{this.props.children}</button>;
}
}
class DangerButton extends Button {
render() {
return <button className="btn btn-danger">{this.props.children}</button>;
}
}
// 문제: 아이콘이 있는 PrimaryButton이 필요하면?
// IconPrimaryButton extends PrimaryButton? → 상속 깊어짐
// Button의 내부 구현이 바뀌면 모든 자식에 영향
합성 패턴
1. children을 활용한 Containment
가장 기본적이면서 강력한 합성 패턴입니다.
// 범용 컨테이너
function Card({ children, className = '' }) {
return <div className={`card ${className}`}>{children}</div>;
}
function CardHeader({ children }) {
return <div className="card-header">{children}</div>;
}
function CardBody({ children }) {
return <div className="card-body">{children}</div>;
}
// 사용: 어떤 콘텐츠든 담을 수 있음
<Card>
<CardHeader>
<h2>프로필</h2>
</CardHeader>
<CardBody>
<p>사용자 정보</p>
<Avatar src={user.avatar} />
</CardBody>
</Card>
2. Specialization (특수화)
범용 컴포넌트를 특정 목적에 맞게 사전 설정합니다.
// 범용 Dialog
function Dialog({ title, message, onConfirm, onCancel, variant = 'default' }) {
return (
<div className={`dialog dialog-${variant}`}>
<h2>{title}</h2>
<p>{message}</p>
<div className="dialog-actions">
{onCancel && <button onClick={onCancel}>취소</button>}
<button onClick={onConfirm}>확인</button>
</div>
</div>
);
}
// 특수화된 Dialog들 — 상속이 아닌 합성
function ConfirmDeleteDialog({ itemName, onConfirm, onCancel }) {
return (
<Dialog
title="삭제 확인"
message={`'${itemName}'을(를) 정말 삭제하시겠습니까?`}
variant="danger"
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
}
function WelcomeDialog({ onConfirm }) {
return (
<Dialog
title="환영합니다"
message="서비스에 오신 것을 환영합니다!"
variant="success"
onConfirm={onConfirm}
/>
);
}
3. Slot 패턴 (Named Slots)
여러 영역에 각각 다른 콘텐츠를 주입합니다.
function PageLayout({ header, sidebar, children, footer }) {
return (
<div className="page-layout">
<header className="page-header">{header}</header>
<div className="page-body">
<aside className="page-sidebar">{sidebar}</aside>
<main className="page-content">{children}</main>
</div>
<footer className="page-footer">{footer}</footer>
</div>
);
}
// 사용: 각 영역에 원하는 콘텐츠를 자유롭게 배치
<PageLayout
header={<Navigation />}
sidebar={<CategoryList />}
footer={<Copyright />}
>
<ArticleList />
<Pagination />
</PageLayout>
4. Props로 컴포넌트 전달
function List({ items, renderItem, renderEmpty }) {
if (items.length === 0) {
return renderEmpty ? renderEmpty() : <p>항목이 없습니다</p>;
}
return (
<ul>
{items.map((item, index) => (
<li key={item.id}>{renderItem(item, index)}</li>
))}
</ul>
);
}
// 같은 List, 다른 렌더링
<List
items={users}
renderItem={(user) => <UserCard user={user} />}
renderEmpty={() => <EmptyState icon="users" message="사용자가 없습니다" />}
/>
<List
items={products}
renderItem={(product) => <ProductThumbnail product={product} />}
/>
합성으로 Button 변형 만들기
상속 없이 하나의 Button 컴포넌트로 모든 변형을 처리합니다.
function Button({
variant = 'default',
size = 'medium',
icon,
loading = false,
disabled = false,
children,
...rest
}) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
{...rest}
>
{loading && <Spinner size="small" />}
{icon && !loading && <Icon name={icon} />}
{children}
</button>
);
}
// 특수화: 자주 쓰는 조합을 미리 설정
function DeleteButton({ children = '삭제', ...props }) {
return <Button variant="danger" icon="trash" {...props}>{children}</Button>;
}
function SubmitButton({ loading, children = '저장', ...props }) {
return <Button variant="primary" type="submit" loading={loading} {...props}>{children}</Button>;
}
합성 vs 상속 비교
| 관점 | 상속 | 합성 |
|---|---|---|
| 관계 | is-a (PrimaryButton is a Button) | has-a (Dialog has a Button) |
| 결합도 | 높음 (부모 변경이 전파) | 낮음 (props로 연결) |
| 유연성 | 단일 상속 제약 | 자유로운 조합 |
| 재사용 | 상속 트리에 갇힘 | 어디서든 조합 가능 |
| 테스트 | 부모 의존성 필요 | 독립 테스트 가능 |
합성이 어려운 경우
합성만으로 해결하기 어려운 경우도 있습니다.
// 크로스 커팅 관심사: 로깅, 에러 바운더리, 인증 체크
// → HOC(Higher-Order Component) 또는 커스텀 훅으로 해결
function withErrorBoundary(Component) {
return function WrappedComponent(props) {
return (
<ErrorBoundary>
<Component {...props} />
</ErrorBoundary>
);
};
}
// 또는 커스텀 훅
function useAuth() {
const { user, isAuthenticated } = useContext(AuthContext);
// ...
return { user, isAuthenticated };
}
주의할 점
상속으로 컴포넌트를 확장하려는 시도
클래스형 컴포넌트에서 class SpecialButton extends BaseButton처럼 상속하면, 부모 클래스의 변경이 모든 자식에 영향을 주는 ** 취약한 기반 클래스 문제 **가 발생합니다. React 공식 문서에서도 컴포넌트 상속을 사용하는 것을 권장하는 사례를 찾지 못했다고 명시합니다.
props를 과도하게 추가하여 범용 컴포넌트를 만드는 실수
하나의 컴포넌트에 variant, size, icon, loading, disabled 등 props를 계속 추가하면 API가 비대해집니다. 합성(Specialization)으로 목적별 컴포넌트를 만드는 것이 더 나은 설계입니다.
정리
| 항목 | 설명 |
|---|---|
| 핵심 원칙 | Composition over Inheritance — 합성으로 확장 |
| Containment | children을 활용한 가장 기본적인 합성 패턴 |
| Specialization | 범용 컴포넌트를 특정 목적에 맞게 설정 |
| Slot 패턴 | 레이아웃의 여러 영역에 콘텐츠를 자유롭게 배치 |
| 크로스 커팅 | HOC나 커스텀 훅으로 해결 |
"이 컴포넌트를 상속해서 확장해야지"가 아니라 "이 컴포넌트를 어떻게 조합해서 쓸 수 있을까?"라고 생각하는 것이 React다운 설계의 시작입니다.