이 글에서 정리하는 내용
props가 왜 필요한지부터 부모에서 자식으로 데이터를 전달하는 방식, 문자열·숫자·배열·객체·함수 props 전달, children, props drilling까지 한 흐름으로 정리합니다. 읽고 나면 React 컴포넌트를 어떻게 연결해야 하는지 구조가 훨씬 선명해집니다.
- 이 글에서 정리하는 내용
- Props란 무엇인가
- 다양한 값은 어떻게 props로 전달할까
- children은 무엇이 다를까
- props drilling은 왜 생길까
- 정리
- 많이 받는 질문
Props란 무엇인가

React에서 컴포넌트는 혼자 움직이지 않습니다. 화면을 작은 조각으로 나눈 뒤, 필요한 데이터를 서로 주고받으면서 하나의 UI를 만듭니다. 이때 부모 컴포넌트가 자식 컴포넌트에게 값을 내려주는 통로가 바로 props입니다.
핵심은 데이터 흐름이 기본적으로 위에서 아래라는 점입니다. 부모가 가진 값은 자식에게 전달할 수 있지만, 자식이 부모 값을 직접 바꾸는 구조로 이해하면 금방 꼬이기 시작합니다. 그래서 props를 제대로 이해해야 컴포넌트를 나눌 때도 덜 흔들립니다.
여기서 한 가지를 더 같이 잡아두면 좋습니다. props는 자식 컴포넌트 입장에서 읽기 전용 값처럼 다뤄야 합니다. 전달받은 값을 자식 안에서 직접 바꾸는 것이 아니라, 필요하면 부모가 새로운 값을 다시 내려주는 흐름으로 생각해야 React 구조가 안정적으로 유지됩니다.
가장 기본적인 props 전달
type ProfileCardProps = {
name: string;
age: number;
};
function ProfileCard({ name, age }: ProfileCardProps) {
return <div>{name} / {age}세</div>;
}
export default function App() {
return <ProfileCard name="해비" age={29}/>;
}
이 코드에서 부모는 App이고 자식은 ProfileCard입니다. 부모는 JSX 태그 속성처럼 값을 넘기고, 자식은 함수 매개변수에서 그 값을 받습니다. HTML 속성과 비슷하게 보이지만, 직접 만든 컴포넌트에는 원하는 이름의 props를 자유롭게 설계할 수 있다는 점이 중요합니다.
다양한 값은 어떻게 props로 전달할까
props는 문자열만 넘기는 기능이 아닙니다. 숫자, 배열, 객체, 함수까지 자바스크립트 값이라면 대부분 전달할 수 있습니다. 이 감각이 잡혀야 컴포넌트를 실무 구조로 연결할 수 있습니다.
문자열, 숫자, 배열, 객체 전달
type ProductListProps = {
title: string;
products: string[];
user: {
nickname: string;
level: number;
};
};
function ProductList({ title, products, user }: ProductListProps) {
return (<section> <h2>{title}</h2> <p>총 {products.length}개</p> <p>작성자: {user.nickname} / Lv.{user.level}</p> <ul> {products.map((product) => (<li key={product}>{product}</li>))} </ul> </section>);
}
export default function App() {
return (<ProductList title="신상품 목록" products={["키보드", "마우스", "모니터"]} user={{ nickname: "haebi", level: 2 }}/>);
}
문자열은 따옴표로, 숫자·배열·객체처럼 자바스크립트 값은 중괄호로 감싸서 전달합니다. JSX에서는 이 중괄호가 매우 중요합니다. 중괄호를 기준으로 단순 문자열인지, 실제 JS 값인지가 나뉘기 때문입니다.
함수 props 전달과 이벤트 연결
type SaveButtonProps = {
onSave: () => void;
};
function SaveButton({ onSave }: SaveButtonProps) {
return <button onClick={onSave}>저장</button>;
}
export default function App() {
const handleSave = () => { console.log("저장 완료"); };
return <SaveButton onSave={handleSave}/>;
}
함수를 props로 넘기는 순간 컴포넌트 연결이 한 단계 확장됩니다. 자식 컴포넌트는 버튼을 렌더링하는 역할만 맡고, 클릭했을 때 무엇을 할지는 부모가 정합니다. 그래서 화면 조각과 동작 제어를 분리하기가 쉬워집니다.
| 전달하는 값 | JSX 예시 |
|---|---|
| 문자열 | title=”상품 목록” |
| 숫자·배열·객체·함수 | count={3}, items={list}, user={user}, onClick={handleClick} |
children은 무엇이 다를까
children은 따로 새로운 문법처럼 보이지만, 사실은 props의 한 종류입니다. 차이는 값을 속성으로 직접 적는 대신 컴포넌트 태그 사이에 넣는다는 점입니다.
이 개념을 이해하면 레이아웃 박스, 카드, 모달, 공통 섹션 컴포넌트를 만들 때 훨씬 유연해집니다. 감싸는 틀은 컴포넌트가 맡고, 내부 내용은 사용하는 쪽에서 자유롭게 넣을 수 있기 때문입니다.
children으로 내부 콘텐츠 받기
import type { ReactNode } from "react";
type CardProps = {
children: ReactNode;
};
function Card({ children }: CardProps) {
return <div className="card">{children}</div>;
}
export default function App() {
return (<Card> <h2>공지사항</h2> <p>오늘 점검은 오후 8시에 시작합니다.</p> </Card>);
}
여기서 Card는 내용을 직접 결정하지 않습니다. 대신 어떤 내용이 들어와도 감싸서 같은 스타일과 구조를 제공하는 역할을 합니다. 이런 패턴은 공통 UI를 재사용할 때 매우 자주 쓰입니다.
또한 children에는 꼭 태그만 들어가는 것이 아닙니다. 문자열, 숫자, 여러 개의 JSX 노드, 조건에 따라 사라지는 null 같은 값도 들어갈 수 있습니다. 그래서 children은 단순한 장식이 아니라, 공통 틀 컴포넌트를 유연하게 만드는 핵심 수단으로 이해하는 편이 좋습니다.
props drilling은 왜 생길까

처음에는 props가 아주 명확하고 편합니다. 그런데 컴포넌트 구조가 깊어지면, 어떤 값이 필요한 실제 자식까지 도달시키기 위해 중간 컴포넌트들이 계속 전달만 하게 되는 순간이 생깁니다. 이 상황을 props drilling이라고 부릅니다.
중요한 점은 깊게 전달된다고 해서 모두 props drilling은 아니라는 점입니다. 중간 컴포넌트도 그 값을 실제로 사용한다면 자연스러운 전달일 수 있습니다. 반대로 중간 컴포넌트가 값을 쓰지 않고 통과만 시키는 구조가 반복될 때 보통 불편함이 커집니다.
type ButtonProps = {
theme: string;
};
function Button({ theme }: ButtonProps) {
return <button className={theme}>확인</button>;
}
function Section({ theme }: {
theme: string;
}) {
return <Button theme={theme}/>;
}
function Page({ theme }: {
theme: string;
}) {
return <Section theme={theme}/>;
}
export default function App() {
return <Page theme="primary"/>;
}
이 예제에서 theme는 가장 아래의 Button에서 실제로 필요합니다. 하지만 그 사이에 있는 Page와 Section도 같은 props를 받아서 다시 넘겨야 합니다. 컴포넌트가 몇 단계 더 늘어나면 수정 포인트가 많아지고, 어떤 값이 어디서 왜 전달되는지 추적하기도 어려워집니다.
왜 구조가 꼬인다고 느껴질까
function Toolbar({ theme, user, onLogout }: {
theme: string;
user: {
name: string;
};
onLogout: () => void;
}) {
return <Header theme={theme} user={user} onLogout={onLogout}/>;
}
처음에는 theme 하나만 전달해서 괜찮아 보이지만, 나중에는 user, isAdmin, locale, onLogout 같은 값이 계속 늘어납니다. 그러면 중간 컴포넌트는 자기 역할보다 전달 역할이 더 커지고, 그 시점부터 구조가 불편하게 느껴집니다.
다만 여기서 바로 props가 나쁘다고 결론내릴 필요는 없습니다. 가까운 부모-자식 관계에서는 props가 가장 직관적입니다. 단지 깊게 반복 전달되는 상황이 생기면 Context 같은 다른 방법을 검토할 이유가 생긴다고 이해하면 충분합니다.
정리
props는 React 컴포넌트를 연결하는 가장 기본적인 데이터 전달 방식입니다. 부모가 자식에게 값을 내려주고, 자식은 그 값을 받아 화면을 그립니다. 문자열과 숫자만이 아니라 배열, 객체, 함수도 props로 전달할 수 있고, 태그 사이의 내용은 children으로 받습니다.
여기까지 이해하면 컴포넌트가 왜 분리되고, 데이터가 왜 위에서 아래로 흐르며, props drilling이 왜 생기는지 한 번에 연결됩니다. 이후 state, event, Context를 공부할 때도 기준점이 훨씬 명확해집니다.
props를 어디까지 넘길지 판단하려면 먼저 React 컴포넌트 분리 기준 글에서 컴포넌트 분리 기준을 함께 확인하는 것이 좋습니다.
많이 받는 질문
Q. props와 state는 무엇이 다른가요?
props는 부모가 자식에게 내려주는 값이고, state는 컴포넌트가 스스로 관리하는 값입니다. props는 외부에서 전달되고, state는 내부에서 바뀐다고 이해하면 시작점으로 충분합니다.
Q. props를 자식 컴포넌트에서 직접 바꿔도 되나요?
직접 변경하면 안 됩니다. props는 읽기 전용 스냅샷으로 다룹니다. 값 변경이 필요하면 부모 state를 바꾸거나, 부모가 새 값을 다시 전달하는 구조로 가야 합니다.
Q. children도 결국 props인가요?
맞습니다. children은 태그 사이에 넣은 내용을 전달받는 특별한 형태의 prop입니다. 그래서 일반 props처럼 자식 컴포넌트 안에서 사용할 수 있습니다.
Q. props drilling이 보이면 바로 Context를 써야 하나요?
항상 그런 것은 아닙니다. 전달 단계가 짧고 구조가 단순하면 props가 오히려 더 읽기 쉽습니다. 다만 여러 중간 컴포넌트가 같은 값을 계속 전달만 한다면 Context나 구조 분리를 검토할 시점입니다.
마지막 Toolbar 코드는 props 전달 구조를 설명하는 발췌 예제입니다. Header는 동일한 props를 받도록 별도로 정의하거나 가져와야 합니다.
기본값·콜백·ID를 구분하기
function Card({ title = "상품", children })의 기본값은 title을 생략하거나 undefined일 때만 적용됩니다. 빈 문자열이나 null을 자동으로 기본 제목으로 바꾸지 않습니다.
onClick={onChoose(product.id)}는 렌더 중 호출합니다. 클릭할 때 ID를 넘기려면 onClick={() => onChoose(product.id)}로 함수를 전달합니다. 자식은 변경 요청을 보내고 state setter는 부모가 소유합니다. 아래 실습에서는 선택 안내를 보여 주기 위해 useState를 한 줄 사용하며 갱신 규칙은 다음 state 글에서 자세히 다룹니다.
총 개수가 현재 배열 길이라는 뜻이면 count prop을 별도로 받지 않고 products.length로 계산합니다. 위 문자열 목록의 key는 이름이 서로 다르다는 전제의 발췌입니다. 이름 중복·변경이 가능한 데이터는 아래처럼 별도 id를 사용합니다. children 합성으로 중간 전달을 줄일 수 있는지 먼저 살핀 뒤 Context의 필요성을 판단하세요.
직접 실행할 실습
ProductButton의 onClick을 onChoose(product.id)를 나중에 호출하는 함수로 연결하세요. Card의 children을 렌더링하고 부모의 선택 안내가 클릭한 상품으로 바뀌게 하세요.
아래는 완성본의 src/App.jsx입니다. ZIP에는 별도 HTML·CSS, 진입점, 필요한 자식 컴포넌트, 고정 패키지 버전과 테스트가 함께 들어 있습니다. starter는 미완성 동작을 수정하는 시작본이고 complete는 비교용 완성본입니다.
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
부모가 가진 값을 props로 전달하고 자식이 받은 값을 화면에 표시합니다. 수정 전은 시작 상태, 수정 후는 비교할 완성 상태입니다.
수정 전
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
package.json
{
"name": "blogflow-react-2284",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7"
}
}
src/App.jsx
import React, { useState } from 'react';
export function Card({ title = '상품', children }) {
return (
<section>
<h2>{title}</h2>
</section>
);
}
export function ProductButton({ product, onChoose }) {
return (
<button type="button" onClick={() => {}}>
{product.name} 선택
</button>
);
}
const products = [
{ id: 'pen', name: '연필' },
{ id: 'book', name: '노트' },
];
export default function App() {
const [selectedId, setSelectedId] = useState(null);
const selected = products.find((product) => product.id === selectedId);
return (
<main>
<h1>상품 선택</h1>
<Card>
<p>총 {products.length}개</p>
{products.map((product) => (
<ProductButton key={product.id} product={product} onChoose={setSelectedId} />
))}
</Card>
<p role="status">선택: {selected?.name ?? '없음'}</p>
</main>
);
}
src/main.jsx
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);
src/style.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #fff;
font-family: sans-serif;
line-height: 1.6;
}
main {
width: min(100% - 32px, 760px);
margin: 32px auto;
}
section,
article {
padding: 16px;
border: 1px solid #aaa;
margin: 16px 0;
}
button,
input,
select {
font: inherit;
color: inherit;
background: #fff;
border: 1px solid #777;
padding: 8px 12px;
min-height: 44px;
}
button {
margin: 4px;
}
button:disabled {
color: #666;
background: #eee;
}
input,
select {
display: block;
width: 100%;
margin: 4px 0 16px;
}
:focus-visible {
outline: 2px solid #222;
outline-offset: 3px;
}
li {
margin: 8px 0;
}
test/App.test.jsx
import React from 'react';
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../src/App.jsx';
test('calls a parent callback with the selected ID only after clicking', async () => {
const user = userEvent.setup();
render(<App />);
expect(screen.getByRole('status').textContent).toBe('선택: 없음');
await user.click(screen.getByRole('button', { name: '연필 선택' }));
expect(screen.getByRole('status').textContent).toBe('선택: 연필');
await user.click(screen.getByRole('button', { name: '노트 선택' }));
expect(screen.getByRole('status').textContent).toBe('선택: 노트');
expect(screen.getByRole('heading', { name: '상품' })).toBeTruthy();
});
test/setup.js
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);
vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
test: { environment: 'jsdom', setupFiles: './test/setup.js' },
});
수정 방법
index.html
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
package.json
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
src/App.jsx
src/App.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.
부모가 가진 값을 props로 전달하고 자식이 받은 값을 화면에 표시합니다.
--- 수정 전/src/App.jsx
+++ 수정 후/src/App.jsx
@@ -4,13 +4,14 @@
return (
<section>
<h2>{title}</h2>
+ {children}
</section>
);
}
export function ProductButton({ product, onChoose }) {
return (
- <button type="button" onClick={() => {}}>
+ <button type="button" onClick={() => onChoose(product.id)}>
{product.name} 선택
</button>
);
수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.
src/main.jsx
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
src/style.css
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
test/App.test.jsx
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
test/setup.js
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
vite.config.js
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
수정 후
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
package.json
{
"name": "blogflow-react-2284",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7"
}
}
src/App.jsx
import React, { useState } from 'react';
export function Card({ title = '상품', children }) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
);
}
export function ProductButton({ product, onChoose }) {
return (
<button type="button" onClick={() => onChoose(product.id)}>
{product.name} 선택
</button>
);
}
const products = [
{ id: 'pen', name: '연필' },
{ id: 'book', name: '노트' },
];
export default function App() {
const [selectedId, setSelectedId] = useState(null);
const selected = products.find((product) => product.id === selectedId);
return (
<main>
<h1>상품 선택</h1>
<Card>
<p>총 {products.length}개</p>
{products.map((product) => (
<ProductButton key={product.id} product={product} onChoose={setSelectedId} />
))}
</Card>
<p role="status">선택: {selected?.name ?? '없음'}</p>
</main>
);
}
src/main.jsx
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);
src/style.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #fff;
font-family: sans-serif;
line-height: 1.6;
}
main {
width: min(100% - 32px, 760px);
margin: 32px auto;
}
section,
article {
padding: 16px;
border: 1px solid #aaa;
margin: 16px 0;
}
button,
input,
select {
font: inherit;
color: inherit;
background: #fff;
border: 1px solid #777;
padding: 8px 12px;
min-height: 44px;
}
button {
margin: 4px;
}
button:disabled {
color: #666;
background: #eee;
}
input,
select {
display: block;
width: 100%;
margin: 4px 0 16px;
}
:focus-visible {
outline: 2px solid #222;
outline-offset: 3px;
}
li {
margin: 8px 0;
}
test/App.test.jsx
import React from 'react';
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../src/App.jsx';
test('calls a parent callback with the selected ID only after clicking', async () => {
const user = userEvent.setup();
render(<App />);
expect(screen.getByRole('status').textContent).toBe('선택: 없음');
await user.click(screen.getByRole('button', { name: '연필 선택' }));
expect(screen.getByRole('status').textContent).toBe('선택: 연필');
await user.click(screen.getByRole('button', { name: '노트 선택' }));
expect(screen.getByRole('status').textContent).toBe('선택: 노트');
expect(screen.getByRole('heading', { name: '상품' })).toBeTruthy();
});
test/setup.js
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);
vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
test: { environment: 'jsdom', setupFiles: './test/setup.js' },
});
기대 결과: 처음 “선택: 없음”, 연필 선택 후 “선택: 연필”, 노트 선택 후 “선택: 노트”. 기본 Card 제목 “상품”과 총 2개가 표시됩니다.
압축을 풀고 complete에서 npm install 후 npm run dev를 실행합니다. 빌드·검증은 별도 터미널에서 npm test, npm run build로 실행합니다. 자세한 시작본 과제와 파일 설명은 README에 있습니다.
실습의 공식 참고 문서
이 글이 도움이 되었나요?
React 학습 순서
필수 19개 · 전체 23개
읽음 기록 관리
전체 과정 목차 (23개)
- 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
- 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
- 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
- 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
- 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조 현재 글
- 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
- 선택 참고 · React state 업데이트 안됨 문제 해결
- 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
- 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
- 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
- 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
- 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
- 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
- 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
- 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
- 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
- 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
- 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
- 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
- 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
- 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
- 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
- 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.