컴포넌트 합성
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 정리
컴포넌트 합성의 핵심 개념과 패턴을 학습했습니다:
- Props와 Children을 활용한 기본 합성 패턴
- 고차 컴포넌트(HOC) 패턴으로 로직 재사용
- Render Props 패턴으로 유연한 렌더링
- 컴포넌트 조합을 통한 복잡한 UI 구성
- 재사용 가능한 컴포넌트 시스템 설계
- 관심사의 분리와 유지보수성 향상
React에서는 상속보다는 합성을 통해 컴포넌트를 구성하는 것이 권장됩니다. 이를 통해 더 유연하고 재사용 가능한 컴포넌트 시스템을 만들 수 있습니다.
다음 장에서는 컴포넌트 최적화 기법에 대해 알아보겠습니다.