객체지향 프로그래밍에서는 상속이 코드 재사용의 핵심이었는데, React는 왜 합성을 더 강조할까요? 컴포넌트를 확장하는 "올바른 방법"은 무엇일까요?

개념 정의

합성(Composition)은 컴포넌트를 조합하여 더 복잡한 UI를 구성 하는 패턴입니다. 상속이 "is-a" 관계를 만드는 반면, 합성은 "has-a" 관계 를 통해 기능을 조립합니다.

왜 필요한가

React 공식 팀은 수천 개의 컴포넌트를 다뤄본 경험에서, 컴포넌트 상속 계층이 필요한 경우를 발견하지 못했다고 밝혔습니다. 합성이 상속보다 유리한 이유는 다음과 같습니다.

  • **유연성 **: 조합 방식을 자유롭게 변경 가능
  • ** 명시성 **: props로 관계가 드러남 (암묵적 의존 없음)
  • ** 독립성 **: 각 컴포넌트가 독립적으로 변경 가능
  • ** 재사용성 **: 다양한 맥락에서 조합하여 사용 가능

상속의 문제점

JSX
// ❌ 상속 기반 설계 (비권장)
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

가장 기본적이면서 강력한 합성 패턴입니다.

JSX
// 범용 컨테이너
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 (특수화)

범용 컴포넌트를 특정 목적에 맞게 사전 설정합니다.

JSX
// 범용 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)

여러 영역에 각각 다른 콘텐츠를 주입합니다.

JSX
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로 컴포넌트 전달

JSX
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 컴포넌트로 모든 변형을 처리합니다.

JSX
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로 연결)
유연성단일 상속 제약자유로운 조합
재사용상속 트리에 갇힘어디서든 조합 가능
테스트부모 의존성 필요독립 테스트 가능

합성이 어려운 경우

합성만으로 해결하기 어려운 경우도 있습니다.

JSX
// 크로스 커팅 관심사: 로깅, 에러 바운더리, 인증 체크
// → 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 — 합성으로 확장
Containmentchildren을 활용한 가장 기본적인 합성 패턴
Specialization범용 컴포넌트를 특정 목적에 맞게 설정
Slot 패턴레이아웃의 여러 영역에 콘텐츠를 자유롭게 배치
크로스 커팅HOC나 커스텀 훅으로 해결

"이 컴포넌트를 상속해서 확장해야지"가 아니라 "이 컴포넌트를 어떻게 조합해서 쓸 수 있을까?"라고 생각하는 것이 React다운 설계의 시작입니다.

댓글 로딩 중...