목차
4 장

컴포넌트와 props

함수 컴포넌트를 만들고 props로 데이터를 흘리는 기본 패턴을 살펴봅니다. 17장에서 TypeScript로 굳힐 인터페이스의 토대입니다.

3장에서 JSX의 문법을 봤습니다. 그 과정에서 App이라는 이름의 함수가 자주 등장했는데, 사실 이 App이 바로 리액트의 가장 중요한 단위인 **컴포넌트 (Component)**입니다. 본 챕터에서는 컴포넌트가 무엇이고 어떻게 만드는지, 그리고 컴포넌트끼리 데이터를 주고받는 통로인 props를 살펴봅니다.

본 챕터의 props 모델은 17장 (props와 children 타이핑)에서 TypeScript로 다시 굳히고, 24장 (Server vs Client Components)에서 server → client 컴포넌트로 props가 넘어갈 때의 직렬화 조건까지 확장됩니다. 본 챕터에서 컴포넌트 / props의 기본 원리를 단단히 잡아 두면 그 뒷장이 훨씬 가볍게 읽힙니다.

컴포넌트는 왜 필요한가 #

화면 전체를 하나의 거대한 함수에 다 적는다고 상상해 보세요. 헤더, 사이드바, 메인 콘텐츠, 푸터, 버튼, 입력창. 코드는 금방 수백, 수천 줄이 되고, 어디를 고쳐야 할지 찾기조차 어려워집니다. 같은 모양의 버튼이 화면에 10개 있다면 똑같은 코드도 10번 작성해야 합니다.

리액트는 이 문제를 컴포넌트라는 개념으로 해결합니다. 컴포넌트는 화면의 한 조각을 표현하는 재사용 가능한 단위입니다. 헤더, 버튼, 카드, 입력창 같은 화면 요소를 각각 하나의 컴포넌트로 만들어 두면, 필요한 곳에서 HTML 태그처럼 가져다 씁니다.

첫 컴포넌트 만들기 #

리액트에서 컴포넌트는 결국 JSX를 반환하는 자바스크립트 함수입니다. 3장 내내 본 App도 그렇습니다.

src/App.jsx
function Greeting() {
  return <h1>안녕하세요, 리액트!</h1>;
}

function App() {
  return (
    <div>
      <Greeting />
    </div>
  );
}

export default App;

Greeting이라는 새 함수를 정의하고, App 안에서 HTML 태그처럼 <Greeting />으로 사용했습니다. 함수 하나가 그대로 하나의 컴포넌트가 됩니다.

노트
컴포넌트 이름은 반드시 대문자로 시작 해야 합니다. <greeting />처럼 소문자로 시작하면 리액트는 이것을 일반 HTML 태그로 인식해서 의도와 다르게 동작합니다. 작명 규칙: PascalCase (UserCard, LoginButton)가 표준입니다.

컴포넌트를 별도 파일로 분리하기 #

컴포넌트가 늘어나면 한 파일에 다 쓰기보다 파일별로 나누는 게 좋습니다. 보통 컴포넌트 하나당 파일 하나로 만듭니다.

src/Greeting.jsx라는 파일을 새로 만들고:

src/Greeting.jsx
function Greeting() {
  return <h1>안녕하세요, 리액트!</h1>;
}

export default Greeting;

App.jsx에서는 이렇게 가져옵니다.

src/App.jsx
import Greeting from './Greeting';

function App() {
  return (
    <div>
      <Greeting />
    </div>
  );
}

export default App;

export default로 내보내고 import로 받아오는 일반적인 자바스크립트 모듈 패턴 그대로입니다. 파일 확장자 (.jsx)는 생략해도 Vite가 알아서 찾아 줍니다.

파일명도 컴포넌트 이름과 똑같이 PascalCase로 짓는 것이 관례입니다. Greeting.jsx, UserCard.jsx 같은 식입니다. 폴더 구조는 프로젝트마다 다르지만, 작은 프로젝트는 src/components/ 아래 모아 두는 게 보편적입니다.

컴포넌트에 데이터 전달하기 — props #

Greeting 컴포넌트는 항상 “안녕하세요, 리액트!“만 출력합니다. 사용자마다 다른 인사말을 보여주고 싶다면 어떻게 해야 할까요? props를 쓰면 됩니다.

props는 컴포넌트의 매개변수 같은 개념입니다. 부모가 자식 컴포넌트에 데이터를 내려보낼 때 씁니다. HTML 속성을 쓰듯 자연스럽게 작성하면 됩니다.

src/App.jsx
import Greeting from './Greeting';

function App() {
  return (
    <div>
      <Greeting name="철수" />
      <Greeting name="영희" />
      <Greeting name="민수" />
    </div>
  );
}

export default App;

Greeting을 세 번 사용하면서 매번 다른 name 값을 전달했습니다. 이제 Greeting 컴포넌트가 이 값을 받을 수 있게 수정합니다.

src/Greeting.jsx
function Greeting(props) {
  return <h1>안녕하세요, {props.name}!</h1>;
}

export default Greeting;

함수의 첫 번째 매개변수로 props라는 객체가 들어옵니다. 부모가 전달한 모든 속성이 이 객체의 프로퍼티로 담깁니다. name="철수"로 전달했으니 props.name'철수'입니다.

화면에는 다음과 같이 출력됩니다.

화면 출력
안녕하세요, 철수님!
안녕하세요, 영희님!
안녕하세요, 민수님!

같은 컴포넌트가 props만 바꿔서 세 번 재사용된 것입니다. 이게 컴포넌트의 핵심 가치입니다.

다양한 타입의 props #

props로는 문자열뿐 아니라 숫자, 불리언, 배열, 객체, 심지어 함수도 전달할 수 있습니다. 문자열이 아닌 값은 중괄호 { }로 감싸야 한다는 점만 기억하세요.

src/App.jsx
function App() {
  const user = { name: '철수', email: 'cheolsu@example.com' };

  return (
    <UserCard
      name="철수"
      age={30}
      isAdmin={true}
      hobbies={['독서', '코딩', '여행']}
      profile={user}
    />
  );
}
src/UserCard.jsx
function UserCard(props) {
  return (
    <div>
      <h2>{props.name} ({props.age})</h2>
      {props.isAdmin && <p>관리자 권한이 있습니다.</p>}
      <p>이메일: {props.profile.email}</p>
      <p>취미: {props.hobbies.join(', ')}</p>
    </div>
  );
}

export default UserCard;

문자열은 따옴표로 (name="철수"), 그 외 자바스크립트 값은 중괄호로 (age={30}) 전달한다고 기억하면 됩니다.

구조분해 할당으로 깔끔하게 받기 #

props.name, props.age처럼 매번 props.을 붙이는 게 번거롭다면 자바스크립트의 **구조분해 할당 (destructuring)**을 씁니다.

src/Greeting.jsx
function Greeting({ name }) {
  return <h1>안녕하세요, {name}!</h1>;
}

export default Greeting;

매개변수 부분에서 바로 분해해 받으면 함수 본문에서는 name만으로 사용할 수 있어 코드가 짧아집니다. 여러 props라면 그냥 나열합니다.

src/UserCard.jsx
function UserCard({ name, age, isAdmin, hobbies, profile }) {
  return (
    <div>
      <h2>{name} ({age})</h2>
      {isAdmin && <p>관리자 권한이 있습니다.</p>}
      <p>이메일: {profile.email}</p>
      <p>취미: {hobbies.join(', ')}</p>
    </div>
  );
}

export default UserCard;

실제 리액트 코드에서는 이 방식이 훨씬 자주 보입니다. 앞으로 이 책도 이 스타일로 쓰겠습니다.

기본값 지정하기 #

prop이 전달되지 않을 수도 있는 상황이라면 구조분해 할당과 함께 기본값을 지정할 수 있습니다.

src/Greeting.jsx
function Greeting({ name = '손님' }) {
  return <h1>안녕하세요, {name}!</h1>;
}

<Greeting />처럼 name 없이 사용하면 자동으로 '손님'이 쓰입니다.

children — 컴포넌트 사이에 들어가는 자식 #

지금까지 <Greeting />처럼 자체 닫는 형태로만 컴포넌트를 사용했습니다. 그런데 HTML처럼 여는 태그와 닫는 태그 사이에 무언가를 넣고 싶을 때가 있습니다.

src/App.jsx
import Card from './Card';

function App() {
  return (
    <Card>
      <h2>공지사항</h2>
      <p>오늘은 휴무일입니다.</p>
    </Card>
  );
}

이때 <Card></Card> 사이에 들어간 내용은 자동으로 **children**이라는 특별한 prop에 담겨 전달됩니다.

src/Card.jsx
function Card({ children }) {
  return (
    <div className="card" style={{ border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}>
      {children}
    </div>
  );
}

export default Card;

Card 컴포넌트는 안에 무엇이 들어올지 모르지만, children을 그 위치에 출력해 주기만 하면 됩니다. 이 패턴은 레이아웃 (Card, Modal, Layout 등)이나 래퍼 (Wrapper) 성격의 컴포넌트에서 매우 자주 사용됩니다.

17장 (props와 children 타이핑)에서는 children의 정확한 타입 — ReactNodeReactElementJSX.Element의 차이 — 을 다룹니다.

props는 읽기 전용입니다 #

마지막으로 가장 중요한 규칙입니다. 컴포넌트는 자신이 받은 props를 절대 수정해서는 안 됩니다. 다음은 잘못된 예입니다.

잘못된 예
function Greeting({ name }) {
  name = name.toUpperCase(); // 🚫 props를 직접 수정
  return <h1>안녕하세요, {name}!</h1>;
}

props는 부모에서 흘러내려 오는 데이터의 사본이고, 자식이 마음대로 바꿀 수 있는 값이 아닙니다. 가공이 필요하면 새 변수에 담아서 씁니다.

올바른 예
function Greeting({ name }) {
  const upperName = name.toUpperCase();
  return <h1>안녕하세요, {upperName}!</h1>;
}
노트

이 규칙은 단순한 스타일 가이드가 아니라 리액트의 동작 원리와 직결됩니다. 리액트는 데이터가 위에서 아래로 (부모 → 자식) 한 방향으로 흐른다는 가정 위에 만들어져 있어, 자식이 받은 props를 마음대로 바꾸면 데이터 흐름이 어지러워지고 디버깅이 매우 어려워집니다. 부모의 데이터를 바꿔야 하는 상황에서는 다음 5장에서 배울 state와 콜백 함수를 활용합니다.

이 규칙은 24장 (Server vs Client Components)에서 한 번 더 중요해집니다. Server Component가 Client Component에 props를 전달할 때 그 값은 직렬화 (JSON으로 변환)가능한 값만 가능합니다. 함수 / Date / Map 등은 직접 넘길 수 없습니다.

직접 해보기 #

지금까지 만든 src/App.jsx를 다음 구조로 바꿔 보세요.

src/UserCard.jsx를 새로 만들고:

src/UserCard.jsx
function UserCard({ name, age, hobbies }) {
  return (
    <div style={{ border: '1px solid #ccc', padding: '12px', margin: '8px', borderRadius: '8px' }}>
      <h2>{name} ({age})</h2>
      <p>취미: {hobbies.join(', ')}</p>
    </div>
  );
}

export default UserCard;

src/App.jsx는 이렇게:

src/App.jsx
import UserCard from './UserCard';

function App() {
  return (
    <>
      <h1>회원 목록</h1>
      <UserCard name="철수" age={30} hobbies={['독서', '코딩']} />
      <UserCard name="영희" age={28} hobbies={['여행', '요리', '사진']} />
      <UserCard name="민수" age={35} hobbies={['게임']} />
    </>
  );
}

export default App;

저장하면 세 명의 회원 카드가 화면에 그려집니다. 같은 UserCard 컴포넌트가 props만 바뀌면서 세 번 재사용된 것입니다.

연습문제 #

  1. UserCard에 새로운 prop isOnline (boolean)을 추가하고, true 일 때만 이름 옆에 🟢 이모지를 표시하도록 만들어 보세요. 세 회원 중 영희만 isOnline={true}로 전달합니다. 조건부 표시는 {isOnline && '🟢'} 패턴을 씁니다 (7장에서 더 깊이 다루겠습니다).
  2. Card라는 새 컴포넌트를 만들고 children으로 받은 내용을 테두리가 둘러진 박스 안에 표시하도록 작성해 보세요. 그 뒤 App에서 <Card><h2>공지사항</h2><p>본 사이트는 점검 중입니다.</p></Card> 형태로 사용합니다.
  3. Greeting 컴포넌트에 greeting prop을 추가하고 기본값 '안녕하세요'를 줘 보세요. <Greeting name="철수" /><Greeting name="John" greeting="Hello" /> 두 호출이 각각 “안녕하세요, 철수님!“과 “Hello, John님!“을 출력하도록 만듭니다.

한 줄 요약: 컴포넌트는 JSX를 반환하는 자바스크립트 함수다. 부모는 props로 자식에게 데이터를 내려보내고, 자식은 그것을 읽기 전용으로 쓴다. 구조분해 할당 / 기본값 / children 패턴이 일상의 전부다. props는 절대 수정하지 않는다.

다음 챕터 #

지금까지의 컴포넌트는 모두 정적이었습니다. 한 번 그려지면 다시는 변하지 않았습니다. 하지만 실제 앱은 사용자 입력에 따라, 시간에 따라, 서버 응답에 따라 끊임없이 모습이 바뀝니다. 다음 5장 State와 useState에서는 컴포넌트가 변할 수 있는 데이터를 다루는 방법, 즉 state의 개념과 useState 훅을 배우겠습니다.

X