본문으로 건너뛰기
Advertisement

컴포넌트 — Props, 합성 패턴

함수형 컴포넌트 기본

React 컴포넌트는 props를 받아 JSX를 반환하는 순수 함수입니다. 함수명은 반드시 대문자로 시작해야 합니다(소문자면 HTML 태그로 인식합니다).

// ✅ 올바른 컴포넌트 정의
function Greeting() {
return <h1>안녕하세요!</h1>;
}

// ✅ 화살표 함수도 가능
const Greeting = () => <h1>안녕하세요!</h1>;

// ❌ 소문자 시작 — HTML 태그로 인식됨
function greeting() {
return <h1>안녕하세요!</h1>;
}

Props 전달과 수신

Props(Properties)는 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하는 메커니즘입니다.

// Props 전달 (부모)
function App() {
return (
<UserCard
name="김철수"
age={28}
isAdmin={true}
hobbies={['코딩', '독서', '운동']}
address={{ city: '서울', district: '강남' }}
/>
);
}

// Props 수신 (자식)
function UserCard({ name, age, isAdmin, hobbies, address }) {
return (
<div className="user-card">
<h2>{name} {isAdmin && '👑'}</h2>
<p>나이: {age}</p>
<p>거주지: {address.city} {address.district}</p>
<ul>
{hobbies.map((hobby, i) => (
<li key={i}>{hobby}</li>
))}
</ul>
</div>
);
}

Props는 읽기 전용

// ❌ Props를 직접 변경하면 안 됨
function Counter({ count }) {
count++; // 절대 금지!
return <p>{count}</p>;
}

// ✅ 필요하면 로컬 변수에 복사하거나 state 사용
function Counter({ initialCount }) {
const [count, setCount] = React.useState(initialCount);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

children prop

children은 컴포넌트 태그 사이에 넣은 내용을 담는 특별한 prop입니다.

// 기본 children
function Card({ title, children }) {
return (
<div className="card">
<h3 className="card-title">{title}</h3>
<div className="card-body">
{children}
</div>
</div>
);
}

// 사용
function App() {
return (
<Card title="공지사항">
<p>오늘 서버 점검이 있습니다.</p>
<a href="/notice">자세히 보기</a>
</Card>
);
}

children 타입 다루기

import { Children, isValidElement } from 'react';

function List({ children }) {
// Children 유틸리티로 children 조작
const count = Children.count(children);
const items = Children.toArray(children);

return (
<div>
<p>항목 수: {count}</p>
<ul>
{items.map((child, index) => (
<li key={index}>{child}</li>
))}
</ul>
</div>
);
}

컴포넌트 합성(Composition) 패턴

React는 상속 대신 합성을 권장합니다.

슬롯 패턴

// 여러 슬롯을 가진 레이아웃 컴포넌트
function PageLayout({ header, sidebar, children, footer }) {
return (
<div className="layout">
<header>{header}</header>
<div className="content">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
<footer>{footer}</footer>
</div>
);
}

// 사용
function App() {
return (
<PageLayout
header={<NavBar />}
sidebar={<SideMenu />}
footer={<Footer />}
>
<ArticleList />
</PageLayout>
);
}

Wrapper 컴포넌트

// 기능을 추가하는 래퍼 컴포넌트
function ErrorBoundaryWrapper({ children }) {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Loading />}>
{children}
</Suspense>
</ErrorBoundary>
);
}

// 조건부 접근 래퍼
function PrivateRoute({ children, requiredRole }) {
const { user } = useAuth();

if (!user) return <Navigate to="/login" />;
if (requiredRole && !user.roles.includes(requiredRole)) {
return <AccessDenied />;
}

return children;
}

기본 Props 값

Default Parameters (권장)

function Button({ label, variant = 'primary', size = 'medium', disabled = false }) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
>
{label}
</button>
);
}

// 사용
<Button label="클릭" /> // 기본값 사용
<Button label="삭제" variant="danger" /> // variant만 변경
<Button label="저장" variant="success" size="large" />

defaultProps (구식, 함수형 컴포넌트에서는 지양)

// ⚠️ defaultProps는 함수형 컴포넌트에서 deprecated 방향으로 가고 있음
function Avatar({ src, alt, size }) {
return <img src={src} alt={alt} width={size} height={size} />;
}

// 구식 방법
Avatar.defaultProps = {
alt: '사용자 아바타',
size: 40,
};

// ✅ 권장 방법: default parameters 사용
function Avatar({ src, alt = '사용자 아바타', size = 40 }) {
return <img src={src} alt={alt} width={size} height={size} />;
}

Props 스프레드

// Props 스프레드로 전달
function Input({ label, id, ...rest }) {
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} {...rest} />
</div>
);
}

// 사용
<Input
label="이메일"
id="email"
type="email"
placeholder="example@email.com"
required
maxLength={100}
/>

실전 예제: UI 컴포넌트 라이브러리 구조

// Button 컴포넌트 (재사용 가능한 UI 기본 요소)
function Button({
children,
variant = 'primary',
size = 'md',
loading = false,
leftIcon,
rightIcon,
fullWidth = false,
onClick,
...rest
}) {
const sizeClasses = {
sm: 'px-3 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};

const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-red-600 text-white hover:bg-red-700',
ghost: 'bg-transparent border border-gray-300 hover:bg-gray-50',
};

return (
<button
className={[
'rounded font-medium transition-colors',
sizeClasses[size],
variantClasses[variant],
fullWidth ? 'w-full' : '',
loading ? 'opacity-70 cursor-not-allowed' : '',
].join(' ')}
disabled={loading}
onClick={onClick}
{...rest}
>
{loading && <span className="spinner mr-2" />}
{leftIcon && <span className="mr-2">{leftIcon}</span>}
{children}
{rightIcon && <span className="ml-2">{rightIcon}</span>}
</button>
);
}

// Modal 컴포넌트 (합성 패턴 활용)
function Modal({ isOpen, onClose, title, children, footer }) {
if (!isOpen) return null;

return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose} aria-label="닫기"></button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
);
}

// 사용 예시
function DeleteConfirmModal({ isOpen, onClose, onConfirm, itemName }) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="삭제 확인"
footer={
<>
<Button variant="ghost" onClick={onClose}>취소</Button>
<Button variant="danger" onClick={onConfirm}>삭제</Button>
</>
}
>
<p>정말로 <strong>{itemName}</strong>을 삭제하시겠습니까?</p>
<p className="text-red-600">이 작업은 되돌릴 수 없습니다.</p>
</Modal>
);
}

고수 팁

1. 컴포넌트 분리 기준

다음 신호가 보이면 컴포넌트를 분리하세요.

  • 같은 JSX 조각이 두 곳 이상 반복될 때
  • 렌더링 로직이 너무 복잡해질 때 (200줄 이상)
  • 독립적으로 테스트하고 싶은 단위가 생길 때

2. Props Drilling 방지

3단계 이상 Props를 아래로 전달해야 한다면 Context 또는 상태 관리 라이브러리를 고려하세요.

// ❌ Props Drilling
<App user={user}>
<Layout user={user}>
<Sidebar user={user}>
<UserMenu user={user} />
</Sidebar>
</Layout>
</App>

// ✅ Context로 해결
const UserContext = createContext(null);
<UserContext.Provider value={user}>
<Layout>
<Sidebar>
<UserMenu /> {/* Context에서 직접 접근 */}
</Sidebar>
</Layout>
</UserContext.Provider>

3. 타입 문서화 (JSDoc)

TypeScript를 사용하지 않더라도 JSDoc으로 Props를 문서화하면 IDE 자동완성이 동작합니다.

/**
* @param {{ name: string, role: 'admin' | 'user', onLogout: () => void }} props
*/
function UserBadge({ name, role, onLogout }) {
return (
<div>
<span>{name}</span>
<span className={`role-${role}`}>{role}</span>
<button onClick={onLogout}>로그아웃</button>
</div>
);
}
Advertisement