4강. Props (속성)

부모 컴포넌트가 자식 컴포넌트에게 데이터를 전달하는 방법인 Props를 깊이 있게 학습한다.

1. Props란 무엇인가?

Props의 개념

Props는 Properties(속성)의 줄임말로, 부모 컴포넌트가 자식 컴포넌트에게 데이터를 전달할 때 사용하는 방법이다.

비유로 이해하기:

컴포넌트를 함수라고 생각하면, Props는 그 함수에 전달하는 매개변수(파라미터)다.

// 일반 자바스크립트 함수
function greet(name) {  // name은 매개변수
  return `안녕, ${name}!`;
}
greet("철수");  // "철수"는 인자

// React 컴포넌트
function Greeting(props) {  // props는 매개변수
  return <h1>안녕, {props.name}!</h1>;
}
<Greeting name="철수" />  // name="철수"는 prop

데이터 흐름: 단방향 (Top-Down)

React의 핵심 원칙: 단방향 데이터 흐름

데이터는 오직 위에서 아래로만 흐른다. 물이 높은 곳에서 낮은 곳으로 흐르는 것과 같다.

// 할아버지 → 아버지 → 자식 순서로 흐름
function Grandparent() {
  const data = "중요한 정보";
  return <Parent info={data} />;  // 아래로 전달
}

function Parent(props) {
  return <Child info={props.info} />;  // 더 아래로 전달
}

function Child(props) {
  return <div>{props.info}</div>;  // 최종 사용
}
⚠️ 자식이 부모에게 직접 데이터를 전달할 수는 없다!
(나중에 배울 "콜백 함수"를 통해 간접적으로 가능)

Props의 핵심 특징

왜 Props를 수정하면 안 될까?

React는 Props가 변하지 않는다고 가정하고 최적화를 수행한다. Props를 수정하면 예측 불가능한 버그가 발생하고 성능이 저하된다.

// ❌ 절대 하면 안 되는 것
function Profile(props) {
  props.name = "다른 이름";  // 오류! Props는 읽기 전용!
  return <h1>{props.name}</h1>;
}

// ✅ 올바른 방법: 새로운 변수를 만들어 사용
function Profile(props) {
  const displayName = props.name.toUpperCase();
  return <h1>{displayName}</h1>;
}

2. Props 전달하고 받기

Props 전달 방법 (부모 → 자식)

HTML 속성을 쓰는 것과 비슷하게 컴포넌트 태그에 속성을 추가한다.

// App.jsx (부모 컴포넌트)
function App() {
  return (
    <div>
      {/* 문자열 prop: 따옴표 사용 */}
      <Greeting name="김철수" />

      {/* 숫자 prop: 중괄호 사용 */}
      <Age value={25} />

      {/* 불리언 prop */}
      <User isAdmin={true} />
      <User isAdmin={false} />
      <User isAdmin />  {/* true는 생략 가능 */}

      {/* 배열 prop */}
      <List items={[1, 2, 3, 4, 5]} />

      {/* 객체 prop */}
      <Profile user={{ name: "철수", age: 25 }} />

      {/* 여러 props 동시에 전달 */}
      <Card
        title="제목"
        content="내용"
        author="작성자"
        date="2024-01-01"
      />
    </div>
  );
}
중요: 문자열 vs 다른 타입

문자열: 따옴표로 감싸기 → name="철수"
그 외 모두: 중괄호로 감싸기 → age={25}, isActive={true}

// ❌ 잘못된 예
<Age value="25" />  // 숫자가 아니라 문자열 "25"가 전달됨!

// ✅ 올바른 예
<Age value={25} />  // 숫자 25가 전달됨

Props 받기 (자식 컴포넌트)

자식 컴포넌트 함수의 첫 번째 매개변수로 props 객체를 받는다.

// Profile.jsx (자식 컴포넌트)
function Profile(props) {
  // props는 객체 형태로 전달됨
  console.log(props);
  // { name: "김철수", age: 25, isMember: true }

  return (
    <div style={{ border: '1px solid #ddd', padding: '20px', margin: '10px' }}>
      <h2>이름: {props.name}</h2>
      <p>나이: {props.age}세</p>
      <p>회원여부: {props.isMember ? "정회원" : "준회원"}</p>

      {/* props를 이용한 조건부 렌더링 */}
      {props.isMember && <span style={{ color: 'green' }}>✓ 인증됨</span>}
    </div>
  );
}

export default Profile;

Props 사용 예제: 재사용 가능한 버튼

[실습 1] 다양한 버튼 만들기

Button.jsx
function Button(props) {
  // props로 버튼의 모양과 동작을 커스터마이징
  const buttonStyle = {
    backgroundColor: props.color || '#007bff',
    color: 'white',
    padding: '10px 20px',
    border: 'none',
    borderRadius: '5px',
    fontSize: props.size === 'large' ? '18px' : '14px',
    cursor: 'pointer'
  };

  return (
    <button
      style={buttonStyle}
      onClick={props.onClick}
      disabled={props.disabled}
    >
      {props.text}
    </button>
  );
}

export default Button;
App.jsx
import Button from './Button';

function App() {
  const handleClick = () => {
    alert('버튼 클릭!');
  };

  return (
    <div style={{ padding: '20px' }}>
      {/* 같은 Button 컴포넌트를 다양하게 재사용 */}
      <Button
        text="저장"
        color="#28a745"
        onClick={handleClick}
      />

      <Button
        text="삭제"
        color="#dc3545"
        onClick={handleClick}
      />

      <Button
        text="큰 버튼"
        color="#6f42c1"
        size="large"
        onClick={handleClick}
      />

      <Button
        text="비활성화됨"
        color="#6c757d"
        disabled={true}
        onClick={handleClick}
      />
    </div>
  );
}

Props의 기본값 설정

Props가 전달되지 않았을 때 사용할 기본값을 설정할 수 있다.

// 방법 1: || 연산자 사용
function Button(props) {
  const color = props.color || '#007bff';  // props.color가 없으면 기본값
  return <button style={{ backgroundColor: color }}>{props.text}</button>;
}

// 방법 2: defaultProps 사용 (React 전통 방식)
function Button(props) {
  return <button style={{ backgroundColor: props.color }}>{props.text}</button>;
}

Button.defaultProps = {
  color: '#007bff',
  text: '클릭',
  size: 'medium'
};

// 방법 3: 구조 분해 할당에서 기본값 (가장 현대적)
function Button({ color = '#007bff', text = '클릭', size = 'medium' }) {
  return <button style={{ backgroundColor: color }}>{text}</button>;
}

3. 구조 분해 할당 (Destructuring)

왜 구조 분해 할당을 사용할까?

매번 props.name, props.age처럼 props.를 반복하는 것은 번거롭다. 구조 분해 할당을 사용하면 코드가 훨씬 간결해진다.

Before (props 객체 사용)
function Profile(props) {
  return (
    <div>
      <h2>{props.name}</h2>
      <p>나이: {props.age}</p>
      <p>직업: {props.job}</p>
      <p>이메일: {props.email}</p>
    </div>
  );
}
After (구조 분해 할당 사용)
function Profile({ name, age, job, email }) {
  return (
    <div>
      <h2>{name}</h2>
      <p>나이: {age}</p>
      <p>직업: {job}</p>
      <p>이메일: {email}</p>
    </div>
  );
}
같은 기능이지만 훨씬 읽기 쉽다! 실무에서는 대부분 이 방식을 사용한다.

구조 분해 할당 완전 정복

1. 기본 사용법

// 일반 객체 구조 분해
const user = { name: "철수", age: 25 };
const { name, age } = user;
console.log(name);  // "철수"
console.log(age);   // 25

// Props 구조 분해
function Greeting({ name, age }) {
  return <h1>{name}님, {age}세</h1>;
}

2. 기본값 설정

function Button({ text = "클릭", color = "blue", size = "medium" }) {
  return (
    <button style={{
      backgroundColor: color,
      fontSize: size === 'large' ? '20px' : '14px'
    }}>
      {text}
    </button>
  );
}

// 사용
<Button />  // 모든 기본값 사용: text="클릭", color="blue", size="medium"
<Button text="저장" />  // text만 변경, 나머지는 기본값
<Button text="삭제" color="red" />  // text, color 변경

3. 변수명 변경하기

function User({ name: userName, age: userAge }) {
  // name을 userName으로, age를 userAge로 받기
  return <div>{userName}님은 {userAge}세입니다.</div>;
}

4. 나머지 props 받기 (Rest operator)

function Card({ title, content, ...rest }) {
  // title, content를 제외한 나머지 props는 rest 객체에 담김
  console.log(rest);  // { author: "철수", date: "2024-01-01", ... }

  return (
    <div {...rest}>  {/* 나머지 props를 div에 전달 */}
      <h2>{title}</h2>
      <p>{content}</p>
    </div>
  );
}

// 사용
<Card
  title="제목"
  content="내용"
  className="card"
  id="card-1"
  style={{ border: '1px solid #ddd' }}
/>

5. 중첩된 객체 구조 분해

function UserProfile({ user: { name, age, address: { city } } }) {
  return (
    <div>
      <h2>{name}</h2>
      <p>나이: {age}</p>
      <p>도시: {city}</p>
    </div>
  );
}

// 사용
<UserProfile
  user={{
    name: "철수",
    age: 25,
    address: {
      city: "서울",
      street: "강남대로"
    }
  }}
/>
[실습 2] 구조 분해 할당 연습

사용자 카드 컴포넌트를 만들어보자:

function UserCard({
  name,
  age,
  job = "미입력",  // 기본값
  avatar = "https://via.placeholder.com/150",  // 기본값
  isOnline = false  // 기본값
}) {
  return (
    <div style={{
      border: '2px solid #ddd',
      borderRadius: '10px',
      padding: '20px',
      width: '300px',
      position: 'relative'
    }}>
      {/* 온라인 상태 표시 */}
      {isOnline && (
        <div style={{
          position: 'absolute',
          top: '10px',
          right: '10px',
          width: '12px',
          height: '12px',
          backgroundColor: '#28a745',
          borderRadius: '50%'
        }} />
      )}

      {/* 프로필 이미지 */}
      <img
        src={avatar}
        alt={name}
        style={{
          width: '100px',
          height: '100px',
          borderRadius: '50%',
          marginBottom: '10px'
        }}
      />

      {/* 사용자 정보 */}
      <h2 style={{ margin: '10px 0' }}>{name}</h2>
      <p style={{ color: '#666' }}>{age}세 | {job}</p>
      <p style={{ fontSize: '12px', color: isOnline ? '#28a745' : '#dc3545' }}>
        {isOnline ? '🟢 온라인' : '⚫ 오프라인'}
      </p>
    </div>
  );
}

// App.jsx에서 사용
function App() {
  return (
    <div style={{ display: 'flex', gap: '20px', padding: '20px' }}>
      <UserCard
        name="김철수"
        age={28}
        job="프론트엔드 개발자"
        isOnline={true}
      />

      <UserCard
        name="이영희"
        age={25}
        job="디자이너"
        isOnline={false}
        avatar="https://via.placeholder.com/150/FF6B6B"
      />

      <UserCard
        name="박민수"
        age={30}
        {/* job은 기본값 "미입력" 사용 */}
      />
    </div>
  );
}

4. Children Props

Children이란?

Children은 컴포넌트 태그 사이에 넣은 내용을 전달하는 특별한 prop이다. 일반 props와 달리 속성으로 전달하지 않는다.

일반 Prop vs Children Prop

// 일반 Props: 속성으로 전달
<Button text="클릭" />

// Children: 태그 사이에 전달
<Button>클릭</Button>
두 방식 모두 가능하지만, Children은 JSX 요소를 전달할 때 특히 유용하다.

Children의 활용

1. 기본 사용법

// Card.jsx - 틀(레이아웃) 컴포넌트
function Card({ children }) {
  return (
    <div style={{
      border: '1px solid #ddd',
      borderRadius: '10px',
      padding: '20px',
      boxShadow: '2px 2px 5px rgba(0,0,0,0.1)',
      margin: '10px'
    }}>
      {children}  {/* 여기에 전달받은 내용이 들어감 */}
    </div>
  );
}

// App.jsx - 사용하는 쪽
function App() {
  return (
    <div>
      {/* 간단한 텍스트 */}
      <Card>
        안녕하세요!
      </Card>

      {/* HTML 요소 */}
      <Card>
        <h2>제목</h2>
        <p>내용입니다.</p>
      </Card>

      {/* 복잡한 구조 */}
      <Card>
        <h3>사용자 정보</h3>
        <ul>
          <li>이름: 김철수</li>
          <li>나이: 25세</li>
        </ul>
        <button>더보기</button>
      </Card>
    </div>
  );
}

2. Children과 일반 Props 함께 사용

function Card({ title, children, footer }) {
  return (
    <div style={{ border: '1px solid #ddd', padding: '20px' }}>
      {/* 제목 영역 */}
      {title && (
        <div style={{
          borderBottom: '1px solid #ddd',
          paddingBottom: '10px',
          marginBottom: '10px',
          fontWeight: 'bold'
        }}>
          {title}
        </div>
      )}

      {/* 본문 영역 (Children) */}
      <div style={{ minHeight: '100px' }}>
        {children}
      </div>

      {/* 푸터 영역 */}
      {footer && (
        <div style={{
          borderTop: '1px solid #ddd',
          paddingTop: '10px',
          marginTop: '10px',
          fontSize: '12px',
          color: '#666'
        }}>
          {footer}
        </div>
      )}
    </div>
  );
}

// 사용
<Card
  title="공지사항"
  footer="작성일: 2024-01-01"
>
  <p>중요한 공지사항 내용입니다.</p>
  <p>모든 회원은 확인해주세요.</p>
</Card>

3. 실전 예제: Modal 컴포넌트

[실습 3] Children을 활용한 Modal 만들기

function Modal({ isOpen, onClose, title, children }) {
  if (!isOpen) return null;  // 모달이 닫혀있으면 아무것도 렌더링 안 함

  return (
    <div style={{
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      backgroundColor: 'rgba(0,0,0,0.5)',  // 반투명 배경
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center',
      zIndex: 1000
    }}>
      {/* 모달 박스 */}
      <div style={{
        backgroundColor: 'white',
        borderRadius: '10px',
        padding: '20px',
        maxWidth: '500px',
        width: '90%',
        maxHeight: '80vh',
        overflow: 'auto'
      }}>
        {/* 헤더 */}
        <div style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          borderBottom: '1px solid #ddd',
          paddingBottom: '10px',
          marginBottom: '20px'
        }}>
          <h2 style={{ margin: 0 }}>{title}</h2>
          <button
            onClick={onClose}
            style={{
              border: 'none',
              background: 'none',
              fontSize: '24px',
              cursor: 'pointer'
            }}
          >
            ×
          </button>
        </div>

        {/* 본문 (Children) - 여기에 다양한 내용 가능 */}
        <div>
          {children}
        </div>
      </div>
    </div>
  );
}

// App.jsx에서 사용
function App() {
  const [showModal, setShowModal] = React.useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>
        모달 열기
      </button>

      {/* 같은 Modal 컴포넌트, 다양한 내용 */}
      <Modal
        isOpen={showModal}
        onClose={() => setShowModal(false)}
        title="공지사항"
      >
        {/* 이 부분이 Modal의 children으로 전달됨 */}
        <p>중요한 공지사항입니다.</p>
        <ul>
          <li>첫 번째 내용</li>
          <li>두 번째 내용</li>
        </ul>
        <button onClick={() => setShowModal(false)}>
          확인
        </button>
      </Modal>
    </div>
  );
}

4. 레이아웃 컴포넌트 패턴

Children을 사용하면 일관된 레이아웃을 쉽게 만들 수 있다.

// Container.jsx
function Container({ children }) {
  return (
    <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
      {children}
    </div>
  );
}

// Section.jsx
function Section({ title, children }) {
  return (
    <section style={{ marginBottom: '40px' }}>
      <h2 style={{
        borderBottom: '2px solid #333',
        paddingBottom: '10px',
        marginBottom: '20px'
      }}>
        {title}
      </h2>
      {children}
    </section>
  );
}

// App.jsx - 레이아웃 조립
function App() {
  return (
    <Container>
      <Section title="최신 뉴스">
        <p>뉴스 내용 1</p>
        <p>뉴스 내용 2</p>
      </Section>

      <Section title="인기 글">
        <ul>
          <li>인기 글 1</li>
          <li>인기 글 2</li>
        </ul>
      </Section>
    </Container>
  );
}

Children의 장점

5. 종합 실습: 쇼핑몰 상품 목록

지금까지 배운 모든 Props 개념을 활용하여 실전 프로젝트를 만들어본다.

[종합 실습] 완전한 상품 카드 시스템 만들기

1단계: Product 컴포넌트 (구조 분해 할당 + 기본값)
function Product({
  image = "https://via.placeholder.com/200",
  name = "상품명",
  price = 0,
  discount = 0,
  rating = 0,
  reviews = 0,
  isNew = false,
  isBestSeller = false,
  stock = 0
}) {
  // 할인가 계산
  const discountedPrice = price - (price * discount / 100);
  const isOutOfStock = stock === 0;

  return (
    <div style={{
      border: '1px solid #ddd',
      borderRadius: '10px',
      overflow: 'hidden',
      width: '250px',
      position: 'relative',
      opacity: isOutOfStock ? 0.6 : 1
    }}>
      {/* 배지들 */}
      <div style={{ position: 'absolute', top: '10px', left: '10px', zIndex: 1 }}>
        {isNew && (
          <span style={{
            backgroundColor: '#ff6b6b',
            color: 'white',
            padding: '5px 10px',
            borderRadius: '5px',
            fontSize: '12px',
            fontWeight: 'bold',
            marginRight: '5px'
          }}>
            NEW
          </span>
        )}
        {isBestSeller && (
          <span style={{
            backgroundColor: '#ffd700',
            color: '#000',
            padding: '5px 10px',
            borderRadius: '5px',
            fontSize: '12px',
            fontWeight: 'bold'
          }}>
            BEST
          </span>
        )}
      </div>

      {/* 품절 표시 */}
      {isOutOfStock && (
        <div style={{
          position: 'absolute',
          top: '50%',
          left: '50%',
          transform: 'translate(-50%, -50%)',
          backgroundColor: 'rgba(0,0,0,0.7)',
          color: 'white',
          padding: '10px 20px',
          borderRadius: '5px',
          fontSize: '18px',
          fontWeight: 'bold',
          zIndex: 2
        }}>
          품절
        </div>
      )}

      {/* 상품 이미지 */}
      <img
        src={image}
        alt={name}
        style={{ width: '100%', height: '200px', objectFit: 'cover' }}
      />

      {/* 상품 정보 */}
      <div style={{ padding: '15px' }}>
        <h3 style={{
          margin: '0 0 10px 0',
          fontSize: '16px',
          fontWeight: 'bold',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
          whiteSpace: 'nowrap'
        }}>
          {name}
        </h3>

        {/* 평점 */}
        <div style={{ marginBottom: '10px', fontSize: '14px' }}>
          <span style={{ color: '#ffd700' }}>
            {"★".repeat(rating)}{"☆".repeat(5-rating)}
          </span>
          <span style={{ color: '#666', marginLeft: '5px' }}>
            ({reviews})
          </span>
        </div>

        {/* 가격 */}
        <div>
          {discount > 0 ? (
            <>
              <span style={{
                color: '#ff6b6b',
                fontWeight: 'bold',
                fontSize: '18px',
                marginRight: '5px'
              }}>
                {discount}%
              </span>
              <span style={{
                textDecoration: 'line-through',
                color: '#999',
                fontSize: '14px',
                marginRight: '5px'
              }}>
                {price.toLocaleString()}원
              </span>
              <div style={{
                fontSize: '20px',
                fontWeight: 'bold',
                color: '#000',
                marginTop: '5px'
              }}>
                {discountedPrice.toLocaleString()}원
              </div>
            </>
          ) : (
            <div style={{
              fontSize: '20px',
              fontWeight: 'bold'
            }}>
              {price.toLocaleString()}원
            </div>
          )}
        </div>

        {/* 재고 표시 */}
        <div style={{
          marginTop: '10px',
          fontSize: '12px',
          color: stock < 10 ? '#ff6b6b' : '#666'
        }}>
          {isOutOfStock ? '품절' : `재고 ${stock}개`}
        </div>

        {/* 구매 버튼 */}
        <button
          disabled={isOutOfStock}
          style={{
            width: '100%',
            padding: '12px',
            marginTop: '15px',
            border: 'none',
            borderRadius: '5px',
            backgroundColor: isOutOfStock ? '#ccc' : '#007bff',
            color: 'white',
            fontSize: '16px',
            fontWeight: 'bold',
            cursor: isOutOfStock ? 'not-allowed' : 'pointer'
          }}
        >
          {isOutOfStock ? '품절' : '장바구니에 담기'}
        </button>
      </div>
    </div>
  );
}

export default Product;
2단계: ProductList 컴포넌트 (Children 활용)
function ProductList({ title, children }) {
  return (
    <div style={{ padding: '20px' }}>
      <h1 style={{
        fontSize: '32px',
        marginBottom: '30px',
        borderBottom: '3px solid #333',
        paddingBottom: '15px'
      }}>
        {title}
      </h1>

      <div style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
        gap: '20px'
      }}>
        {children}
      </div>
    </div>
  );
}

export default ProductList;
3단계: App.jsx에서 조립
import Product from './Product';
import ProductList from './ProductList';

function App() {
  return (
    <div style={{ backgroundColor: '#f5f5f5', minHeight: '100vh' }}>
      <ProductList title="🔥 인기 상품">
        <Product
          image="https://via.placeholder.com/200/4A90E2"
          name="무선 블루투스 이어폰"
          price={89000}
          discount={20}
          rating={5}
          reviews={234}
          isNew={true}
          isBestSeller={true}
          stock={15}
        />

        <Product
          image="https://via.placeholder.com/200/50E3C2"
          name="스마트 워치 프로"
          price={350000}
          discount={15}
          rating={4}
          reviews={89}
          isBestSeller={true}
          stock={5}
        />

        <Product
          image="https://via.placeholder.com/200/F5A623"
          name="노트북 거치대"
          price={45000}
          rating={5}
          reviews={456}
          isNew={true}
          stock={30}
        />

        <Product
          image="https://via.placeholder.com/200/BD10E0"
          name="메카니컬 키보드"
          price={120000}
          discount={10}
          rating={4}
          reviews={178}
          stock={0}
        />

        <Product
          image="https://via.placeholder.com/200/7ED321"
          name="게이밍 마우스"
          price={65000}
          rating={5}
          reviews={320}
          stock={2}
        />

        <Product
          image="https://via.placeholder.com/200/D0021B"
          name="USB-C 멀티 허브"
          price={35000}
          discount={25}
          rating={3}
          reviews={67}
          isNew={true}
          stock={50}
        />
      <ProductList>
    </div>
  );
}

export default App;

실습 체크리스트

이 실습에서 사용한 Props 기법들:

구조 분해 할당: Product 컴포넌트의 모든 props
기본값 설정: 모든 props에 기본값 적용
다양한 데이터 타입: 문자열, 숫자, 불리언
조건부 렌더링: 할인, 신상품, 베스트셀러, 품절 표시
계산된 값: discountedPrice, isOutOfStock
Children: ProductList의 children
스타일링: props에 따른 동적 스타일
재사용성: 같은 컴포넌트, 다른 props로 다양한 상품 표현

Props 정리

개념 설명 예시
Props 부모 → 자식 데이터 전달 <User name="철수" />
읽기 전용 자식은 props 수정 불가 props.name = "X" ❌
구조 분해 props. 반복 제거 function({name, age})
기본값 props 없을 때 사용 {name = "익명"}
Children 태그 사이 내용 <Card>내용</Card>