본문 바로가기

컴포넌트 합성

2. 컴포넌트 합성

React에서는 상속보다는 합성(Composition)을 통해 컴포넌트를 재사용하고 확장하는 것을 권장합니다. 이 장에서는 다양한 컴포넌트 합성 패턴과 실제 활용 방법을 학습해보겠습니다.

2.1 기본 합성 패턴

Props를 통한 합성

import { useState } from 'react';

function Button({ variant = 'primary', size = 'medium', children, onClick, ...props }) {
  const getButtonStyles = () => {
    const baseStyles = {
      border: 'none',
      borderRadius: '4px',
      cursor: 'pointer',
      fontWeight: 'bold',
      transition: 'all 0.2s ease',
      outline: 'none'
    };

    const variants = {
      primary: { backgroundColor: '#007bff', color: 'white' },
      secondary: { backgroundColor: '#6c757d', color: 'white' },
      success: { backgroundColor: '#28a745', color: 'white' },
      danger: { backgroundColor: '#dc3545', color: 'white' },
      outline: { 
        backgroundColor: 'transparent', 
        color: '#007bff', 
        border: '2px solid #007bff' 
      }
    };

    const sizes = {
      small: { padding: '6px 12px', fontSize: '12px' },
      medium: { padding: '10px 20px', fontSize: '14px' },
      large: { padding: '12px 24px', fontSize: '16px' }
    };

    return {
      ...baseStyles,
      ...variants[variant],
      ...sizes[size]
    };
  };

  return (
    <button
      
      onClick={onClick}
      {...props}
    >
      {children}
    </button>
  );
}

function Icon({ name, size = 16 }) {
  const icons = {
    heart: '❤️',
    star: '⭐',
    like: '👍',
    fire: '🔥',
    rocket: '🚀',
    check: '✅',
    cross: '❌',
    arrow: '➡️'
  };

  return (
    <span >
      {icons[name] || '❓'}
    </span>
  );
}

function ButtonCompositionExample() {
  const [likes, setLikes] = useState(0);
  const [favorites, setFavorites] = useState(0);

  return (
    <div >
      <h2>컴포넌트 합성 예제</h2>
      
      <div >
        <Button>기본 버튼</Button>
        
        <Button variant="secondary">
          <Icon name="star" />
          보조 버튼
        </Button>
        
        <Button variant="success" size="large">
          <Icon name="check" />
          성공 버튼
        </Button>
        
        <Button variant="danger" size="small">
          <Icon name="cross" />
          위험
        </Button>
        
        <Button variant="outline">
          <Icon name="heart" />
          아웃라인
        </Button>
      </div>

      <div >
        <Button
          variant="primary"
          onClick={() => setLikes(prev => prev + 1)}
        >
          <Icon name="like" />
          좋아요 ({likes})
        </Button>
        
        <Button
          variant="secondary"
          onClick={() => setFavorites(prev => prev + 1)}
        >
          <Icon name="star" />
          즐겨찾기 ({favorites})
        </Button>
      </div>

      <div>
        <Button
          variant="success"
          size="large"
          onClick={() => {
            setLikes(0);
            setFavorites(0);
          }}
        >
          <Icon name="rocket" />
          초기화
        </Button>
      </div>
    </div>
  );
}

Children을 활용한 합성

import { useState } from 'react';

function Card({ children, title, footer, variant = 'default' }) {
  const variants = {
    default: {
      borderColor: '#ddd',
      backgroundColor: 'white'
    },
    primary: {
      borderColor: '#007bff',
      backgroundColor: '#f8f9fa'
    },
    success: {
      borderColor: '#28a745',
      backgroundColor: '#d4edda'
    },
    warning: {
      borderColor: '#ffc107',
      backgroundColor: '#fff3cd'
    }
  };

  return (
    <div
      style={{
        border: `1px solid ${variants[variant].borderColor}`,
        borderRadius: '8px',
        backgroundColor: variants[variant].backgroundColor,
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
        overflow: 'hidden'
      }}
    >
      {title && (
        <div style={{
          padding: '15px',
          borderBottom: `1px solid ${variants[variant].borderColor}`,
          fontWeight: 'bold',
          fontSize: '18px'
        }}>
          {title}
        </div>
      )}
      
      <div >
        {children}
      </div>
      
      {footer && (
        <div style={{
          padding: '15px',
          borderTop: `1px solid ${variants[variant].borderColor}`,
          backgroundColor: 'rgba(0,0,0,0.03)'
        }}>
          {footer}
        </div>
      )}
    </div>
  );
}

function Modal({ isOpen, onClose, children, size = 'medium' }) {
  if (!isOpen) return null;

  const sizes = {
    small: { width: '400px', maxHeight: '300px' },
    medium: { width: '600px', maxHeight: '500px' },
    large: { width: '800px', maxHeight: '700px' }
  };

  return (
    <div
      
      onClick={onClose}
    >
      <div
        
        onClick={(e) => e.stopPropagation()}
      >
        <button
          onClick={onClose}
          
        >
          ×
        </button>
        {children}
      </div>
    </div>
  );
}

function ChildrenCompositionExample() {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [modalSize, setModalSize] = useState('medium');

  return (
    <div >
      <h2>Children 합성 예제</h2>
      
      <div >
        <Card title="기본 카드">
          <p>이것은 기본 카드의 내용입니다.</p>
          <p>여러 줄의 텍스트를 포함할 수 있습니다.</p>
        </Card>

        <Card
          title="Primary 카드"
          variant="primary"
          footer={
            <div >
              <Button size="small" variant="primary">확인</Button>
            </div>
          }
        >
          <p>Primary 스타일의 카드입니다.</p>
          <ul>
            <li>리스트 항목 1</li>
            <li>리스트 항목 2</li>
          </ul>
        </Card>

        <Card
          title="Warning 카드"
          variant="warning"
          footer="⚠️ 주의사항을 확인하세요"
        >
          <p>중요한 알림이나 경고 메시지를 표시할 때 사용합니다.</p>
        </Card>
      </div>

      <div >
        <h3>모달 크기 선택</h3>
        {['small', 'medium', 'large'].map(size => (
          <label key={size} >
            <input
              type="radio"
              name="modalSize"
              value={size}
              checked={modalSize === size}
              onChange={(e) => setModalSize(e.target.value)}
              
            />
            {size}
          </label>
        ))}
      </div>

      <Button
        variant="primary"
        onClick={() => setIsModalOpen(true)}
      >
        모달 열기
      </Button>

      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        size={modalSize}
      >
        <Card
          title="모달 안의 카드"
          footer={
            <div >
              <Button variant="secondary" onClick={() => setIsModalOpen(false)}>
                취소
              </Button>
              <Button variant="primary" onClick={() => setIsModalOpen(false)}>
                확인
              </Button>
            </div>
          }
        >
          <p>이 카드는 모달 안에 있습니다.</p>
          <p>컴포넌트들을 자유롭게 조합할 수 있습니다.</p>
        </Card>
      </Modal>
    </div>
  );
}

2.2 고차 컴포넌트 (HOC) 패턴

withLoading HOC

import { useState, useEffect } from 'react';

function withLoading(WrappedComponent, loadingMessage = '로딩 중...') {
  return function WithLoadingComponent(props) {
    const [isLoading, setIsLoading] = useState(true);

    useEffect(() => {
      // 실제로는 데이터 로딩 로직이 들어갈 자리
      const timer = setTimeout(() => {
        setIsLoading(false);
      }, 2000);

      return () => clearTimeout(timer);
    }, []);

    if (isLoading) {
      return (
        <div >
          <div ></div>
          <p>{loadingMessage}</p>
          <style jsx>{`
            @keyframes spin {
              0% { transform: rotate(0deg); }
              100% { transform: rotate(360deg); }
            }
          `}</style>
        </div>
      );
    }

    return <WrappedComponent {...props} />;
  };
}

function UserProfile({ user }) {
  return (
    <Card title="사용자 프로필">
      <div >
        <div >
          {user?.name?.charAt(0) || 'U'}
        </div>
        <div>
          <h3 >{user?.name || '사용자'}</h3>
          <p >{user?.email || 'user@example.com'}</p>
        </div>
      </div>
      <p><strong>직업:</strong> {user?.job || '개발자'}</p>
      <p><strong>위치:</strong> {user?.location || '서울, 한국'}</p>
      <p><strong>가입일:</strong> {user?.joinDate || '2024-01-01'}</p>
    </Card>
  );
}

function ProductList({ products }) {
  const sampleProducts = [
    { id: 1, name: '노트북', price: 1500000 },
    { id: 2, name: '마우스', price: 50000 },
    { id: 3, name: '키보드', price: 100000 }
  ];

  return (
    <Card title="제품 목록">
      <div >
        {(products || sampleProducts).map(product => (
          <div
            key={product.id}
            
          >
            <span>{product.name}</span>
            <span >
              {product.price.toLocaleString()}원
            </span>
          </div>
        ))}
      </div>
    </Card>
  );
}

// HOC 적용
const LoadingUserProfile = withLoading(UserProfile, '사용자 정보를 불러오는 중...');
const LoadingProductList = withLoading(ProductList, '제품 목록을 불러오는 중...');

function HOCExample() {
  return (
    <div >
      <h2>고차 컴포넌트 (HOC) 예제</h2>
      
      <div >
        <LoadingUserProfile />
        <LoadingProductList />
      </div>
      
      <div >
        <h4>💡 HOC 장점</h4>
        <ul >
          <li>로딩 로직을 여러 컴포넌트에서 재사용</li>
          <li>원본 컴포넌트를 수정하지 않고 기능 추가</li>
          <li>관심사의 분리 (로딩 상태 vs 데이터 표시)</li>
        </ul>
      </div>
    </div>
  );
}

2.3 Render Props 패턴

DataFetcher 컴포넌트

import { useState, useEffect } from 'react';

function DataFetcher({ url, children }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        // 실제 API 호출 시뮬레이션
        await new Promise(resolve => setTimeout(resolve, 1500));
        
        // 모의 데이터
        const mockData = {
          '/api/users': [
            { id: 1, name: '김개발', email: 'kim@example.com', role: 'Frontend' },
            { id: 2, name: '이디자인', email: 'lee@example.com', role: 'Designer' },
            { id: 3, name: '박백엔드', email: 'park@example.com', role: 'Backend' }
          ],
          '/api/posts': [
            { id: 1, title: 'React 시작하기', author: '김개발', date: '2024-01-01' },
            { id: 2, title: 'CSS 마스터하기', author: '이디자인', date: '2024-01-02' },
            { id: 3, title: 'Node.js 완전정복', author: '박백엔드', date: '2024-01-03' }
          ]
        };

        setData(mockData[url] || []);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return children({ data, loading, error });
}

function Toggle({ children }) {
  const [isOn, setIsOn] = useState(false);

  return children({
    isOn,
    toggle: () => setIsOn(!isOn),
    turnOn: () => setIsOn(true),
    turnOff: () => setIsOn(false)
  });
}

function RenderPropsExample() {
  return (
    <div >
      <h2>Render Props 패턴 예제</h2>
      
      {/* 데이터 페처 예제 */}
      <div >
        <h3>데이터 페처</h3>
        <div >
          <DataFetcher url="/api/users">
            {({ data, loading, error }) => (
              <Card title="사용자 목록">
                {loading && <p>사용자 데이터를 불러오는 중...</p>}
                {error && <p >오류가 발생했습니다.</p>}
                {data && (
                  <div>
                    {data.map(user => (
                      <div
                        key={user.id}
                        
                      >
                        <div >{user.name}</div>
                        <div >
                          {user.email} • {user.role}
                        </div>
                      </div>
                    ))}
                  </div>
                )}
              </Card>
            )}
          </DataFetcher>

          <DataFetcher url="/api/posts">
            {({ data, loading, error }) => (
              <Card title="게시글 목록">
                {loading && <p>게시글을 불러오는 중...</p>}
                {error && <p >오류가 발생했습니다.</p>}
                {data && (
                  <div>
                    {data.map(post => (
                      <div
                        key={post.id}
                        
                      >
                        <h4 >{post.title}</h4>
                        <div >
                          작성자: {post.author} • {post.date}
                        </div>
                      </div>
                    ))}
                  </div>
                )}
              </Card>
            )}
          </DataFetcher>
        </div>
      </div>

      {/* 토글 예제 */}
      <div>
        <h3>토글 컨트롤</h3>
        <div >
          <Toggle>
            {({ isOn, toggle, turnOn, turnOff }) => (
              <Card
                title="라이트 모드 / 다크 모드"
                variant={isOn ? 'primary' : 'default'}
              >
                <div >
                  <h4 >
                    {isOn ? '🌙 다크 모드' : '☀️ 라이트 모드'}
                  </h4>
                  <p >
                    현재 테마: {isOn ? '어둠' : '밝음'}
                  </p>
                </div>
                
                <div >
                  <Button size="small" onClick={toggle}>
                    토글
                  </Button>
                  <Button size="small" variant="secondary" onClick={turnOn}>
                    다크
                  </Button>
                  <Button size="small" variant="outline" onClick={turnOff}>
                    라이트
                  </Button>
                </div>
              </Card>
            )}
          </Toggle>

          <Toggle>
            {({ isOn, toggle }) => (
              <Card title="알림 설정">
                <div >
                  <div >
                    {isOn ? '🔔' : '🔕'}
                  </div>
                  <p >
                    알림이 {isOn ? '켜져' : '꺼져'} 있습니다
                  </p>
                </div>
                
                <div >
                  <Button
                    variant={isOn ? 'danger' : 'success'}
                    onClick={toggle}
                  >
                    {isOn ? '알림 끄기' : '알림 켜기'}
                  </Button>
                </div>
              </Card>
            )}
          </Toggle>
        </div>
      </div>

      <div >
        <h4>💡 Render Props 장점</h4>
        <ul >
          <li>로직과 렌더링을 분리하여 재사용성 증대</li>
          <li>동일한 로직으로 다양한 UI 구현 가능</li>
          <li>컴포넌트 간의 결합도 낮춤</li>
          <li>런타임에 동적으로 렌더링 결정 가능</li>
        </ul>
      </div>
    </div>
  );
}

2.4 실습: 재사용 가능한 대시보드 시스템

컴포넌트 합성을 활용한 대시보드

import { useState, useEffect } from 'react';

function DashboardGrid({ children, columns = 'repeat(auto-fit, minmax(300px, 1fr))' }) {
  return (
    <div >
      {children}
    </div>
  );
}

function Widget({ 
  title, 
  children, 
  actions, 
  loading = false, 
  error = null,
  variant = 'default'
}) {
  return (
    <Card
      title={
        <div >
          <span>{title}</span>
          {actions && (
            <div >
              {actions}
            </div>
          )}
        </div>
      }
      variant={variant}
    >
      {loading ? (
        <div >
          로딩 중...
        </div>
      ) : error ? (
        <div >
          ❌ {error}
        </div>
      ) : (
        children
      )}
    </Card>
  );
}

function StatCard({ title, value, change, icon, color = '#007bff' }) {
  const isPositive = change > 0;
  
  return (
    <div style={{
      display: 'flex',
      alignItems: 'center',
      padding: '20px',
      backgroundColor: '#f8f9fa',
      borderRadius: '8px',
      borderLeft: `4px solid ${color}`
    }}>
      <div >
        {icon}
      </div>
      <div >
        <div >
          {title}
        </div>
        <div >
          {typeof value === 'number' ? value.toLocaleString() : value}
        </div>
        {change !== undefined && (
          <div >
            {isPositive ? '📈' : '📉'} {Math.abs(change)}%
          </div>
        )}
      </div>
    </div>
  );
}

function ChartWidget({ data, type = 'bar' }) {
  const maxValue = Math.max(...data.map(item => item.value));
  
  return (
    <div>
      {type === 'bar' && (
        <div >
          {data.map((item, index) => (
            <div key={index} >
              <div >
                <span>{item.label}</span>
                <span>{item.value.toLocaleString()}</span>
              </div>
              <div >
                <div style={{
                  height: '100%',
                  width: `${(item.value / maxValue) * 100}%`,
                  backgroundColor: '#007bff',
                  transition: 'width 0.3s ease'
                }}></div>
              </div>
            </div>
          ))}
        </div>
      )}
      
      {type === 'pie' && (
        <div >
          {data.map((item, index) => {
            const colors = ['#007bff', '#28a745', '#ffc107', '#dc3545', '#6f42c1'];
            return (
              <div
                key={index}
                
              >
                {item.label}: {item.value}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

function ActivityFeed({ activities }) {
  return (
    <div >
      {activities.map((activity, index) => (
        <div
          key={index}
          
        >
          <div ></div>
          <div >
            <div >
              {activity.message}
            </div>
            <div >
              {activity.timestamp}
            </div>
          </div>
        </div>
      ))}
    </div>
  );
}

function CompositeDashboard() {
  const [refreshing, setRefreshing] = useState(false);
  
  // 모의 데이터
  const stats = [
    { title: '총 사용자', value: 12840, change: 8.2, icon: '👥', color: '#007bff' },
    { title: '매출', value: 45678900, change: -2.1, icon: '💰', color: '#28a745' },
    { title: '주문', value: 324, change: 15.3, icon: '📦', color: '#ffc107' },
    { title: '방문자', value: 8920, change: 23.7, icon: '📊', color: '#dc3545' }
  ];

  const salesData = [
    { label: '1월', value: 45000 },
    { label: '2월', value: 52000 },
    { label: '3월', value: 48000 },
    { label: '4월', value: 61000 },
    { label: '5월', value: 55000 }
  ];

  const categoryData = [
    { label: '전자제품', value: 45 },
    { label: '의류', value: 30 },
    { label: '도서', value: 15 },
    { label: '기타', value: 10 }
  ];

  const activities = [
    { 
      message: '새로운 주문이 접수되었습니다 (#1234)', 
      timestamp: '2분 전',
      type: 'success'
    },
    { 
      message: '재고 부족 알림: 노트북 (5개 남음)', 
      timestamp: '15분 전',
      type: 'warning'
    },
    { 
      message: '사용자 김개발님이 가입했습니다', 
      timestamp: '1시간 전',
      type: 'info'
    },
    { 
      message: '시스템 업데이트가 완료되었습니다', 
      timestamp: '2시간 전',
      type: 'success'
    }
  ];

  const handleRefresh = () => {
    setRefreshing(true);
    setTimeout(() => setRefreshing(false), 1500);
  };

  return (
    <div>
      <div >
        <h1 >📊 대시보드</h1>
        <Button 
          variant="primary" 
          onClick={handleRefresh}
          disabled={refreshing}
        >
          {refreshing ? '새로고침 중...' : '새로고침'}
        </Button>
      </div>

      <DashboardGrid>
        {/* 통계 카드들 */}
        {stats.map((stat, index) => (
          <Widget
            key={index}
            title={stat.title}
            loading={refreshing}
          >
            <StatCard {...stat} />
          </Widget>
        ))}

        {/* 매출 차트 */}
        <Widget
          title="월별 매출"
          loading={refreshing}
          actions={[
            <Button key="export" size="small" variant="outline">
              내보내기
            </Button>
          ]}
        >
          <ChartWidget data={salesData} type="bar" />
        </Widget>

        {/* 카테고리 분석 */}
        <Widget
          title="카테고리별 판매"
          loading={refreshing}
        >
          <ChartWidget data={categoryData} type="pie" />
        </Widget>

        {/* 활동 피드 */}
        <Widget
          title="최근 활동"
          loading={refreshing}
          actions={[
            <Button key="view-all" size="small" variant="secondary">
              전체 보기
            </Button>
          ]}
        >
          <ActivityFeed activities={activities} />
        </Widget>

        {/* 빠른 액션 */}
        <Widget title="빠른 액션">
          <div >
            <Button variant="primary" size="small">
              새 제품 추가
            </Button>
            <Button variant="secondary" size="small">
              주문 관리
            </Button>
            <Button variant="success" size="small">
              사용자 관리
            </Button>
            <Button variant="warning" size="small">
              설정
            </Button>
          </div>
        </Widget>
      </DashboardGrid>

      <div >
        <h4>🧩 컴포넌트 합성의 장점</h4>
        <ul >
          <li><strong>재사용성:</strong> Widget, Card, Button 등을 다양한 조합으로 사용</li>
          <li><strong>유연성:</strong> 각 컴포넌트는 독립적이면서도 조합 가능</li>
          <li><strong>확장성:</strong> 새로운 위젯이나 기능을 쉽게 추가</li>
          <li><strong>유지보수:</strong> 각 컴포넌트의 책임이 명확히 분리</li>
        </ul>
      </div>
    </div>
  );
}

export { 
  ButtonCompositionExample, 
  ChildrenCompositionExample, 
  HOCExample, 
  RenderPropsExample, 
  CompositeDashboard 
};

2.5 정리

컴포넌트 합성의 핵심 개념과 패턴을 학습했습니다:

  1. Props와 Children을 활용한 기본 합성 패턴
  2. 고차 컴포넌트(HOC) 패턴으로 로직 재사용
  3. Render Props 패턴으로 유연한 렌더링
  4. 컴포넌트 조합을 통한 복잡한 UI 구성
  5. 재사용 가능한 컴포넌트 시스템 설계
  6. 관심사의 분리와 유지보수성 향상

React에서는 상속보다는 합성을 통해 컴포넌트를 구성하는 것이 권장됩니다. 이를 통해 더 유연하고 재사용 가능한 컴포넌트 시스템을 만들 수 있습니다.

다음 장에서는 컴포넌트 최적화 기법에 대해 알아보겠습니다.