기초

React 강좌 03강 — 컴포넌트와 Props: 재사용 가능한 UI 블록

🎯 학습 목표

  • 순수 함수 컴포넌트 원칙을 이해하고 지킬 수 있다
  • Props의 불변성 원칙과 위반 시 발생하는 문제를 설명할 수 있다
  • children prop을 활용한 컴포지션 패턴을 익힌다
  • 컴포넌트를 언제 분리할지 기준을 세울 수 있다
  • Props 기본값을 ES6 기본 매개변수로 설정할 수 있다

📖 핵심 개념 1 — 순수 함수 컴포넌트

React 컴포넌트는 순수 함수(pure function)여야 합니다. 순수 함수란 같은 입력에 대해 항상 같은 출력을 반환하고, 외부 상태를 변경하지 않는 함수입니다.

// ✅ 순수 함수 컴포넌트 — 같은 props → 항상 같은 UI
function UserCard({ name, email, role }) {
  return (
    <div className="user-card">
      <h3>{name}</h3>
      <p>{email}</p>
      <span className={`badge badge-${role}`}>{role}</span>
    </div>
  );
}

// ❌ 순수하지 않은 컴포넌트 — 렌더링마다 다른 결과 반환
let renderCount = 0; // 외부 변수 참조

function ImpureComponent({ name }) {
  renderCount++; // 외부 상태 변경 — 금지!
  return <p>{renderCount}번째 렌더: {name}</p>;
}

순수 함수 원칙을 지키면 React의 StrictMode에서 두 번 렌더링해도 동일한 결과가 나오고, 미래의 React 최적화(동시성 모드 등)와도 호환됩니다. 패널에서 “순수성을 지키지 않으면 동시성 모드(Concurrent Mode)에서 예측 불가능한 버그가 발생한다”고 강조했습니다.

📖 핵심 개념 2 — Props 불변성

Props는 부모 컴포넌트에서 자식으로 전달되는 읽기 전용 데이터입니다. 자식 컴포넌트는 절대 props를 직접 수정해서는 안 됩니다.

// ❌ Props 직접 수정 — 절대 금지!
function BadChild({ user }) {
  user.name = '수정된 이름'; // props 직접 변경 — React 원칙 위반
  user.score += 10;          // 부모 데이터를 몰래 변경
  return <p>{user.name}: {user.score}점</p>;
}

// ✅ 로컬 변수로 가공하거나 state를 사용
function GoodChild({ user }) {
  // 새 객체를 만들어 가공 (원본 user는 변경 없음)
  const displayName = user.name.toUpperCase();
  const adjustedScore = user.score + 10;

  return <p>{displayName}: {adjustedScore}점</p>;
}

// 수정이 필요하다면 부모에게 콜백으로 요청
function EditableChild({ user, onUpdate }) {
  return (
    <div>
      <p>{user.name}</p>
      {/* 직접 수정하지 않고 부모에게 위임 */}
      <button onClick={() => onUpdate({ ...user, name: '새 이름' })}>
        이름 변경
      </button>
    </div>
  );
}

Props를 직접 수정하면 부모 컴포넌트의 상태를 예측 불가능하게 만들고, React의 단방향 데이터 흐름 원칙을 깨뜨립니다. 이로 인해 디버깅이 매우 어려워집니다.

📖 핵심 개념 3 — children prop

React의 모든 컴포넌트는 자동으로 children prop을 받을 수 있습니다. 이를 활용하면 HTML처럼 컴포넌트를 래퍼(wrapper)로 사용하는 컴포지션 패턴이 가능합니다.

// children을 활용한 Card 컴포넌트
function Card({ title, children, footer }) {
  return (
    <div className="card">
      {title && <div className="card-header"><h3>{title}</h3></div>}
      <div className="card-body">
        {children} {/* 사용 시 태그 사이에 넣은 모든 내용 */}
      </div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}

// 사용 예시 — 어떤 내용이든 Card 안에 넣을 수 있음
function App() {
  return (
    <div>
      <Card title="사용자 정보" footer={<button>저장</button>}>
        <p>이름: 김개발</p>
        <p>이메일: dev@example.com</p>
      </Card>

      <Card title="공지사항">
        <ul>
          <li>서비스 점검: 3월 15일</li>
          <li>신기능 출시 예정</li>
        </ul>
      </Card>
    </div>
  );
}

📖 핵심 개념 4 — 컴포넌트 분리 기준

컴포넌트를 언제 분리할지는 경험 있는 개발자도 고민하는 문제입니다. 패널에서 합의한 분리 기준은 다음과 같습니다.

  • 재사용: 동일한 UI가 2곳 이상에서 쓰인다면 분리합니다.
  • 복잡도: 하나의 컴포넌트가 100줄을 넘어간다면 분리를 검토합니다.
  • 단일 책임 원칙(SRP): 컴포넌트가 하나의 일만 하도록 분리합니다. “이 컴포넌트가 하는 일이 뭔가요?”라는 질문에 “A와 B와 C…”처럼 여러 가지가 나온다면 분리 신호입니다.
  • 독립적 테스트: 단독으로 테스트하고 싶은 로직이 있다면 분리합니다.
// 분리 전 — 하나의 컴포넌트가 너무 많은 일을 함
function UserDashboard({ userId }) {
  // ... 데이터 페칭, 상태 관리, 복잡한 렌더링 모두 한 곳에
}

// 분리 후 — 각 컴포넌트가 하나의 책임만 가짐
function UserDashboard({ userId }) {
  return (
    <div>
      <UserProfile userId={userId} />
      <UserStats userId={userId} />
      <UserActivityFeed userId={userId} />
    </div>
  );
}

💻 코드 예제 — Props 기본값과 컴포넌트 설계

// Props 기본값: ES6 기본 매개변수 방식 권장 (defaultProps는 React 19에서 deprecated)
function Button({
  children,
  variant = 'primary',  // 기본값
  size = 'medium',      // 기본값
  disabled = false,     // 기본값
  onClick,
}) {
  const className = `btn btn-${variant} btn-${size}`;

  return (
    <button
      className={className}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

// Avatar 컴포넌트 — 여러 곳에서 재사용
function Avatar({ src, alt, size = 40, fallback }) {
  if (!src) {
    return (
      <div
        className="avatar-fallback"
        style={{ width: size, height: size }}
      >
        {fallback || alt?.[0]?.toUpperCase() || '?'}
      </div>
    );
  }

  return (
    <img
      src={src}
      alt={alt}
      width={size}
      height={size}
      className="avatar"
    />
  );
}

// 사용 예시
function App() {
  return (
    <div>
      <Button>기본 버튼</Button>
      <Button variant="danger" size="large">삭제</Button>
      <Button variant="secondary" disabled>비활성화</Button>

      <Avatar src="/profile.jpg" alt="김개발" size={48} />
      <Avatar alt="박프론트" size={32} fallback="P" />
    </div>
  );
}

export default App;

⚠️ 흔한 실수 (よくあるミス)

  • Props를 직접 수정: props.name = '새 이름' — React의 단방향 데이터 흐름을 깨뜨립니다. 절대 금지.
  • defaultProps 사용: React 19부터 deprecated입니다. ES6 기본 매개변수를 사용하세요.
  • 너무 많은 Props: Props가 5개 이상 넘어간다면 객체로 묶거나 컴포넌트 분리를 검토합니다. “Props drilling”이 시작되는 신호일 수 있습니다.
  • 컴포넌트 안에서 컴포넌트 정의: 렌더링마다 새 컴포넌트가 생성되어 성능 문제와 상태 초기화 버그가 발생합니다. 컴포넌트는 항상 파일 최상위에 정의하세요.
  • children을 배열로 가정: children은 단일 요소, 배열, 문자열 등 다양한 형태일 수 있습니다. 배열 메서드가 필요하다면 React.Children.toArray(children)을 사용하세요.

💡 실무 팁

  • 컴포넌트 파일 구조: 컴포넌트가 많아지면 src/components/Button/Button.jsx, src/components/Button/index.js 형태로 폴더별로 관리하면 임포트가 깔끔해집니다.
  • Storybook 도입 검토: 중규모 이상 프로젝트에서는 Storybook으로 컴포넌트를 독립적으로 문서화하고 시각적으로 테스트하는 것이 생산성을 크게 높입니다.
  • PropTypes vs TypeScript: 런타임 타입 검사가 필요하다면 PropTypes, 정적 타입 검사를 원한다면 TypeScript를 사용합니다. 현업에서는 TypeScript가 표준입니다 (13강에서 다룹니다).
  • 컴포넌트 네이밍: 컴포넌트 이름은 기능을 명확하게 드러내야 합니다. Card보다 UserProfileCard가, List보다 ProductList가 명확합니다.