지난 시간에는 JSX 문법을 살펴봤습니다. 그 과정에서 우리는 자연스럽게 App이라는 이름의 함수를 봤습니다. 사실 이 App이 바로 리액트의 가장 중요한 단위인 **컴포넌트(Component)**입니다. 이번 시간에는 컴포넌트가 무엇이고 어떻게 만드는지, 그리고 컴포넌트끼리 데이터를 주고받는 통로인 props에 대해 알아보도록 하겠습니다.
컴포넌트는 왜 필요한가요?
화면 전체를 하나의 거대한 함수에 다 작성한다고 상상해보세요. 헤더, 사이드바, 메인 컨텐츠, 푸터, 버튼, 입력창 ... 코드는 금방 수백, 수천 줄이 되고, 어디를 고쳐야 할지 찾기조차 어려워집니다. 같은 모양의 버튼이 화면에 10개 있다면 똑같은 코드도 10번 작성해야 합니다.
리액트는 이 문제를 컴포넌트라는 개념으로 해결합니다. 컴포넌트는 화면의 한 조각을 표현하는 재사용 가능한 단위입니다. 헤더, 버튼, 카드, 입력창 같은 화면 요소를 각각 하나의 컴포넌트로 만들어두면, 필요한 곳에서 마치 HTML 태그처럼 가져다 쓸 수 있습니다.
첫 컴포넌트 만들기
리액트에서 컴포넌트는 결국 JSX를 반환하는 자바스크립트 함수입니다. 우리가 지난 시간 내내 본 App도 그렇습니다.
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라는 파일을 새로 만들고 다음과 같이 작성합니다.
function Greeting() {
return <h1>안녕하세요, 리액트!</h1>;
}
export default Greeting;그리고 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 속성을 쓰듯이 자연스럽게 작성하면 됩니다.
import Greeting from './Greeting';
function App() {
return (
<div>
<Greeting name="철수" />
<Greeting name="영희" />
<Greeting name="민수" />
</div>
);
}
export default App;Greeting을 세 번 사용하면서 매번 다른 name 값을 전달했습니다. 이제 Greeting 컴포넌트가 이 값을 받을 수 있게 수정합니다.
function Greeting(props) {
return <h1>안녕하세요, {props.name}님!</h1>;
}
export default Greeting;함수의 첫 번째 매개변수로 props라는 객체가 들어옵니다. 부모가 전달한 모든 속성이 이 객체의 프로퍼티로 담깁니다. name="철수"로 전달했으니 props.name은 '철수'입니다.
화면에는 다음과 같이 출력됩니다.
안녕하세요, 철수님!
안녕하세요, 영희님!
안녕하세요, 민수님!같은 컴포넌트가 props만 바꿔서 세 번 재사용된 것입니다. 이게 컴포넌트의 핵심 가치입니다.
다양한 타입의 props
props로는 문자열뿐 아니라 숫자, 불리언, 배열, 객체, 심지어 함수도 전달할 수 있습니다. 문자열이 아닌 값은 중괄호 { }로 감싸야 한다는 점만 기억하세요.
function App() {
const user = { name: '철수', email: 'cheolsu@example.com' };
return (
<UserCard
name="철수"
age={30}
isAdmin={true}
hobbies={['독서', '코딩', '여행']}
profile={user}
/>
);
}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)**을 사용할 수 있습니다.
function Greeting({ name }) {
return <h1>안녕하세요, {name}님!</h1>;
}
export default Greeting;매개변수 자리에서 바로 분해해서 받으면 함수 본문에서는 name만으로 사용할 수 있어 코드가 짧아집니다. 여러 props라면 그냥 나열하면 됩니다.
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이 전달되지 않을 수도 있는 상황이라면 구조분해 할당과 함께 기본값을 지정할 수 있습니다.
function Greeting({ name = '손님' }) {
return <h1>안녕하세요, {name}님!</h1>;
}<Greeting />처럼 name 없이 사용하면 자동으로 '손님'이 사용됩니다.
children — 컴포넌트 사이에 들어가는 자식
지금까지 우리는 <Greeting />처럼 자체 닫는 형태로만 컴포넌트를 사용했습니다. 그런데 HTML처럼 여는 태그와 닫는 태그 사이에 무언가를 넣고 싶을 때가 있습니다.
import Card from './Card';
function App() {
return (
<Card>
<h2>공지사항</h2>
<p>오늘은 휴무일입니다.</p>
</Card>
);
}이때 <Card>와 </Card> 사이에 들어간 내용은 자동으로 **children**이라는 특별한 prop에 담겨 전달됩니다.
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) 성격의 컴포넌트에서 매우 자주 사용됩니다.
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를 마음대로 바꾸면 데이터 흐름이 어지러워지고 디버깅이 매우 어려워집니다. 부모의 데이터를 바꿔야 하는 상황에서는 다음 글에서 배울 state와 콜백 함수를 활용합니다.
직접 해보기
지난 시간에 만든 src/App.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는 이렇게:
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만 바뀌면서 세 번 재사용된 것입니다. 이름이나 취미를 바꿔보고, 새 카드를 추가해보세요.
마무리
이번 글에서는 리액트의 핵심 단위인 컴포넌트를 만들고, 별도 파일로 분리하고, props로 데이터를 전달하는 방법을 살펴봤습니다. 구조분해 할당, 기본값, children, props의 읽기 전용 규칙까지 다뤘습니다. 이제 화면을 작은 조각들로 쪼개고 재사용할 수 있게 됐습니다.
지금까지 우리가 다룬 컴포넌트는 모두 정적이었습니다. 한 번 그려지고 나면 절대 변하지 않았죠. 하지만 실제 앱은 사용자 입력에 따라, 시간에 따라, 서버 응답에 따라 끊임없이 모습이 바뀝니다. 다음 글인 "리액트 기초 강좌 #5 State와 useState"에서는 컴포넌트가 변할 수 있는 데이터를 다루는 방법, 즉 state의 개념과 useState 훅을 배워보도록 하겠습니다.