2026년 3월 10일
이 글을 읽고 나면, 컴포넌트에 데이터를 전달하고(props), 사용자와 상호작용하는 동적인 UI를 만들 수 있습니다(state, 이벤트).
1편에서 우리는 컴포넌트와 JSX를 배우고, 자기소개 카드를 만들었습니다. 하지만 그 카드는 항상 같은 내용만 보여주는 정적인 카드였습니다.
이번 편에서는 두 가지 핵심 개념을 배웁니다:
함수에 인자(argument)를 전달하는 것과 같은 개념입니다. 함수가 인자에 따라 다른 결과를 반환하듯, 컴포넌트도 props에 따라 다른 화면을 보여줍니다.
함수:
function greet(name) { return `안녕, ${name}!`; }
greet("철수") → "안녕, 철수!"
greet("영희") → "안녕, 영희!"
컴포넌트:
<Greeting name="철수" /> → 화면에 "안녕, 철수!" 표시
<Greeting name="영희" /> → 화면에 "안녕, 영희!" 표시
같은 컴포넌트인데, 전달하는 데이터에 따라 다른 결과를 보여줍니다.1편에서 만든 자기소개 카드를 props를 활용하여 개선해 보겠습니다.
📁 src/ProfileCard.tsx
// props의 타입을 정의합니다 (TypeScript)
interface ProfileCardProps {
name: string;
role: string;
skills: string;
color?: string; // ?는 선택적(optional)이라는 뜻입니다
}
function ProfileCard({ name, role, skills, color = "#4F46E5" }: ProfileCardProps) {
return (
<div
style={{
border: "1px solid #ddd",
borderRadius: "12px",
padding: "24px",
maxWidth: "300px",
textAlign: "center",
}}
>
<div
style={{
width: "80px",
height: "80px",
borderRadius: "50%",
backgroundColor: color,
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "32px",
margin: "0 auto 16px",
}}
>
{name[0]}
</div>
<h2 style={{ margin: "0 0 8px" }}>{name}</h2>
<p style={{ color: "#666", margin: "0 0 12px" }}>{role}</p>
<p style={{ fontSize: "14px", color: "#888" }}>기술 스택: {skills}</p>
</div>
);
}
export default ProfileCard;이전 코드와 달라진 부분을 살펴보겠습니다.
interface ProfileCardProps: 이 컴포넌트가 어떤 props를 받는지 정의한 것입니다. TypeScript에서는 이렇게 데이터의 형태를 미리 선언합니다. 이렇게 하면 잘못된 데이터를 전달했을 때 에디터가 즉시 경고해 줍니다. TypeScript의 자세한 내용은 4편에서 다루므로, 지금은 "props의 이름과 종류를 적어두는 곳"이라고 이해하면 됩니다.
color?: string: 물음표(?)가 붙으면 "전달하지 않아도 되는" 선택적 props라는 뜻입니다.
color = "#4F46E5": props가 전달되지 않았을 때 사용할 기본값(default value)입니다.
{ name, role, skills, color }: 이것은 **구조 분해 할당(Destructuring)**이라는 JavaScript 문법입니다. props 객체에서 필요한 값만 꺼내서 사용하는 것입니다.
// 구조 분해 할당 없이 사용하면
function ProfileCard(props: ProfileCardProps) {
return <h2>{props.name}</h2>;
}
// 구조 분해 할당을 사용하면
function ProfileCard({ name }: ProfileCardProps) {
return <h2>{name}</h2>;
}
// 매번 props.name, props.role로 쓰지 않아도 되어 코드가 간결해집니다.이제 App에서 이 컴포넌트를 다양한 데이터로 사용합니다:
📁 src/App.tsx
import ProfileCard from "./ProfileCard";
function App() {
return (
<div
style={{
display: "flex",
gap: "20px",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
backgroundColor: "#f5f5f5",
}}
>
<ProfileCard
name="김철수"
role="프론트엔드 개발자"
skills="React, TypeScript"
color="#4F46E5"
/>
<ProfileCard
name="이영희"
role="백엔드 개발자"
skills="Java, Spring Boot"
color="#059669"
/>
<ProfileCard
name="박민수"
role="디자이너"
skills="Figma, Photoshop"
/> {/* color를 전달하지 않으면 기본값 "#4F46E5"가 사용됩니다 */}
</div>
);
}
export default App;같은 ProfileCard 컴포넌트를 3번 사용했지만, 각각 다른 이름, 역할, 색상이 표시됩니다. 이것이 props의 힘입니다.
React에서 매우 중요한 규칙이 하나 있습니다. 자식 컴포넌트는 전달받은 props를 수정할 수 없습니다.
// ❌ 절대 이렇게 하면 안 됩니다
function ProfileCard({ name }: ProfileCardProps) {
name = "다른 이름"; // props를 직접 수정 — 금지!
return <h2>{name}</h2>;
}이 규칙이 존재하는 이유는 React의 단방향 데이터 흐름 철학 때문입니다. 데이터는 항상 부모에서 자식으로만 흐릅니다. 만약 자식이 props를 마음대로 바꿀 수 있다면, 데이터가 어디서 변경되었는지 추적하기 어려워집니다.
[올바른 흐름]
부모가 데이터를 관리 → props로 자식에게 전달 → 자식은 받은 데이터를 화면에 표시
[잘못된 흐름]
부모가 데이터 전달 → 자식이 마음대로 수정 → 부모는 데이터가 바뀐 줄 모름 → 버그그렇다면 화면에서 데이터를 변경하려면 어떻게 해야 할까요? 이때 사용하는 것이 State입니다.
Props가 "외부에서 받는 데이터"라면, State는 "내부에서 직접 관리하는 데이터"입니다.
Props: 부모로부터 전달받음. 읽기 전용. 자신이 변경 불가.
State: 컴포넌트 자신이 소유. 자신이 변경 가능. 변경되면 화면이 다시 그려짐.
비유:
Props = 부모님이 써주신 용돈 (내가 금액을 바꿀 수 없음)
State = 내 저금통 (내가 넣고 빼는 것을 직접 관리)State가 변경되면, React는 해당 컴포넌트를 **자동으로 다시 렌더링(re-render)**합니다. 즉, state가 바뀌면 화면이 알아서 업데이트됩니다. 이것이 1편에서 설명한 "데이터가 바뀌면 화면을 다시 그린다"의 실체입니다.
React에서 state를 만들려면 **useState**라는 함수를 사용합니다. 이것은 React가 제공하는 **Hook(훅)**이라는 특별한 함수 중 하나입니다.
💡 Hook이란?
Hook은 React가 제공하는 특별한 함수들의 총칭입니다.
use로 시작하는 이름을 가지며, 컴포넌트에 다양한 기능을 추가할 수 있게 해줍니다.useState는 그중 가장 기본적인 Hook입니다.
간단한 카운터를 만들어 보겠습니다:
📁 src/Counter.tsx
import { useState } from "react"; // 👈 useState를 import합니다
function Counter() {
const [count, setCount] = useState(0); // 👈 state 선언
return (
<div style={{ textAlign: "center", padding: "40px" }}>
<h1>카운터</h1>
<p style={{ fontSize: "48px", margin: "20px 0" }}>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
export default Counter;useState(0) 한 줄에 많은 의미가 담겨 있습니다. 하나씩 분해해 보겠습니다.
const [count, setCount] = useState(0);
// ↑ ↑ ↑
// │ │ └── 초기값: count의 시작 값은 0
// │ └── 변경 함수: count를 바꿀 때 사용하는 함수
// └── 상태 값: 현재 count의 값count: 현재 상태 값입니다. 처음에는 0입니다.
setCount: 상태를 변경하는 함수입니다. setCount(5)라고 호출하면 count가 5가 되고, 화면이 다시 그려집니다.
useState(0): 초기값을 0으로 설정합니다.
[count, setCount]: 이것도 구조 분해 할당입니다. useState는 배열을 반환하는데, 첫 번째 요소가 값, 두 번째 요소가 변경 함수입니다.
버튼을 클릭하면 아래와 같은 일이 일어납니다:
1. 사용자가 "+1" 버튼 클릭
2. onClick 이벤트 발생
3. setCount(count + 1) 실행 → count가 0에서 1로 변경
4. React가 "state가 바뀌었구나!" 감지
5. Counter 컴포넌트를 다시 실행(리렌더링)
6. 이번에는 count가 1이므로, <p>{count}</p>가 <p>1</p>이 됨
7. 화면에 1이 표시됨여기서 중요한 점은, 개발자가 DOM을 직접 수정하지 않는다는 것입니다. document.getElementById같은 코드는 어디에도 없습니다. setCount로 데이터만 바꾸면, React가 화면 업데이트를 알아서 처리합니다.
State를 변경할 때는 반드시 set 함수를 통해서만 변경해야 합니다.
// ❌ 절대 이렇게 하면 안 됩니다
count = count + 1; // 직접 수정 — React가 변경을 감지하지 못함
// ✅ 항상 set 함수를 사용합니다
setCount(count + 1); // set 함수 사용 — React가 변경을 감지하고 화면을 업데이트직접 수정하면 JavaScript 변수의 값은 바뀌지만, React는 그 변경을 감지하지 못합니다. React는 setCount가 호출되었을 때만 "아, 데이터가 바뀌었구나"를 알고 화면을 다시 그립니다.
state에는 숫자뿐 아니라 문자열, 불리언, 배열, 객체 등 모든 타입의 데이터를 담을 수 있습니다.
// 문자열
const [name, setName] = useState("홍길동");
// 불리언 (참/거짓)
const [isVisible, setIsVisible] = useState(false);
// 배열
const [items, setItems] = useState(["사과", "바나나"]);
// 객체
const [user, setUser] = useState({ name: "홍길동", age: 25 });React에서 이벤트를 처리하는 방법은 HTML과 비슷하지만, 몇 가지 차이가 있습니다.
// HTML 방식
<button onclick="handleClick()">클릭</button>
// React 방식
<button onClick={handleClick}>클릭</button>| 차이점 | HTML | React |
|---|---|---|
| 속성 이름 | onclick (소문자) | onClick (카멜케이스) |
| 전달 값 | 문자열 "handleClick()" | 함수 자체 {handleClick} |
| 이벤트 | 발생 시점 | 주로 사용하는 요소 |
|---|---|---|
onClick | 요소를 클릭했을 때 | 버튼, div 등 |
onChange | 입력값이 변경되었을 때 | input, select, textarea |
onSubmit | 폼이 제출되었을 때 | form |
onMouseEnter | 마우스가 요소 위에 올라갔을 때 | 모든 요소 |
onMouseLeave | 마우스가 요소를 벗어났을 때 | 모든 요소 |
이벤트가 발생했을 때 실행할 함수를 **이벤트 핸들러(Event Handler)**라고 합니다. 작성하는 방법은 두 가지가 있습니다.
function App() {
// 방법 1: 함수를 미리 선언
const handleClick = () => {
alert("버튼이 클릭되었습니다!");
};
return (
<div>
{/* 방법 1 사용 */}
<button onClick={handleClick}>클릭 (방법 1)</button>
{/* 방법 2: 인라인으로 직접 작성 */}
<button onClick={() => alert("클릭!")}>클릭 (방법 2)</button>
</div>
);
}간단한 동작은 인라인(방법 2)으로 작성하고, 로직이 복잡한 경우에는 함수를 따로 선언(방법 1)하는 것이 일반적입니다.
⚠️ 흔한 실수 — 함수를 "실행"하지 말고 "전달"하세요
tsx// ❌ 잘못된 방법 — 함수를 즉시 실행해 버림 <button onClick={handleClick()}>클릭</button> // ✅ 올바른 방법 — 함수 자체를 전달 <button onClick={handleClick}>클릭</button>
handleClick()처럼 괄호를 붙이면, 컴포넌트가 렌더링되는 순간 함수가 바로 실행되어 버립니다.handleClick처럼 괄호 없이 함수 이름만 전달해야, 버튼이 클릭되었을 때 실행됩니다.
State와 이벤트를 조합하면, 사용자의 동작에 반응하는 인터랙티브한 UI를 만들 수 있습니다.
📁 src/ToggleButton.tsx
import { useState } from "react";
function ToggleButton() {
const [isOn, setIsOn] = useState(false);
const handleToggle = () => {
setIsOn(!isOn); // true → false, false → true로 전환
};
return (
<button
onClick={handleToggle}
style={{
padding: "12px 24px",
fontSize: "18px",
borderRadius: "8px",
border: "none",
backgroundColor: isOn ? "#4F46E5" : "#ccc",
color: "white",
cursor: "pointer",
}}
>
{isOn ? "ON" : "OFF"}
</button>
);
}
export default ToggleButton;이 코드에서 isOn ? "ON" : "OFF"는 삼항 연산자입니다. isOn이 true이면 "ON", false이면 "OFF"를 반환합니다. 이처럼 state 값에 따라 화면을 다르게 표시할 수 있습니다.
사용자가 텍스트를 입력하면, 그 내용이 실시간으로 화면에 표시되는 예제입니다.
📁 src/GreetingForm.tsx
import { useState } from "react";
function GreetingForm() {
const [name, setName] = useState("");
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
};
return (
<div style={{ padding: "40px", textAlign: "center" }}>
<h1>인사하기</h1>
<input
type="text"
value={name}
onChange={handleChange}
placeholder="이름을 입력하세요"
style={{
padding: "8px 16px",
fontSize: "16px",
borderRadius: "8px",
border: "1px solid #ddd",
marginBottom: "16px",
}}
/>
<p style={{ fontSize: "24px" }}>
{name ? `안녕하세요, ${name}님!` : "이름을 입력해 주세요."}
</p>
</div>
);
}
export default GreetingForm;이 예제에서 중요한 개념이 하나 있습니다.
value={name}: input의 값을 state가 관리합니다. 이것을 **제어 컴포넌트(Controlled Component)**라고 합니다. input의 값이 항상 state와 동기화되기 때문에, 데이터가 어디에 있는지 명확합니다.
onChange={handleChange}: 사용자가 한 글자를 입력할 때마다 handleChange가 실행되고, setName으로 state가 업데이트되고, 화면이 다시 그려집니다.
event.target.value: event는 이벤트 객체이고, target은 이벤트가 발생한 요소(여기서는 input), value는 그 요소의 현재 값입니다.
사용자가 "홍" 입력
→ onChange 발생
→ event.target.value = "홍"
→ setName("홍")
→ name이 "홍"으로 변경
→ 화면 다시 그림: "안녕하세요, 홍님!"
사용자가 "길" 추가 입력
→ onChange 발생
→ event.target.value = "홍길"
→ setName("홍길")
→ 화면 다시 그림: "안녕하세요, 홍길님!"💡
React.ChangeEvent<HTMLInputElement>는 뭔가요?이것은 TypeScript의 타입 선언입니다. "이 이벤트는 HTML input 요소에서 발생한 change 이벤트입니다"라는 정보를 제공합니다. 이렇게 타입을 명시하면,
event.target.value를 작성할 때 에디터가 자동완성을 해주고, 잘못된 접근을 경고해 줍니다. TypeScript의 자세한 내용은 4편에서 다룹니다.
실제 애플리케이션에서는 props와 state를 함께 사용하는 경우가 대부분입니다. 부모 컴포넌트가 state로 데이터를 관리하고, 자식 컴포넌트에 props로 전달하는 패턴입니다.
배운 개념을 모두 활용하여 투두 리스트를 만들어 보겠습니다.
먼저 할 일 하나를 표시하는 컴포넌트를 만듭니다:
📁 src/TodoItem.tsx
interface TodoItemProps {
text: string;
isDone: boolean;
onToggle: () => void;
onDelete: () => void;
}
function TodoItem({ text, isDone, onToggle, onDelete }: TodoItemProps) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid #eee",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<input type="checkbox" checked={isDone} onChange={onToggle} />
<span
style={{
textDecoration: isDone ? "line-through" : "none",
color: isDone ? "#999" : "#333",
}}
>
{text}
</span>
</div>
<button
onClick={onDelete}
style={{
background: "none",
border: "none",
color: "#ff4444",
cursor: "pointer",
fontSize: "14px",
}}
>
삭제
</button>
</div>
);
}
export default TodoItem;여기서 새로운 것이 등장했습니다. 함수를 props로 전달하고 있습니다.
onToggle: () => void와 onDelete: () => void는 부모 컴포넌트로부터 전달받는 함수입니다. void는 "반환값이 없다"는 뜻입니다. 자식 컴포넌트가 props를 직접 수정할 수 없기 때문에, 부모가 "이 함수를 실행하면 데이터를 바꿔줄게"라고 함수를 전달하는 것입니다.
데이터 흐름:
부모 (App) 자식 (TodoItem)
┌──────────────────────┐ ┌──────────────────────┐
│ │ props │ │
│ state: todos ───────│──────────────│──→ text, isDone │
│ │ │ │
│ toggleTodo 함수 ────│──────────────│──→ onToggle │
│ deleteTodo 함수 ────│──────────────│──→ onDelete │
│ │ │ │
│ ← 데이터 변경 ───── │ ◄────────────│── 버튼 클릭 시 호출 │
└──────────────────────┘ └──────────────────────┘
자식은 데이터를 직접 수정하지 않고,
부모에게 받은 함수를 호출하여 "바꿔달라"고 요청합니다.이제 전체를 관리하는 App 컴포넌트를 작성합니다:
📁 src/App.tsx
import { useState } from "react";
import TodoItem from "./TodoItem";
interface Todo {
id: number;
text: string;
isDone: boolean;
}
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState("");
// 할 일 추가
const addTodo = () => {
if (input.trim() === "") return; // 빈 문자열 방지
const newTodo: Todo = {
id: Date.now(), // 현재 시간을 ID로 사용 (간단한 방법)
text: input,
isDone: false,
};
setTodos([...todos, newTodo]); // 👈 기존 배열에 새 항목 추가
setInput(""); // 입력창 초기화
};
// 완료/미완료 토글
const toggleTodo = (id: number) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, isDone: !todo.isDone } : todo
)
);
};
// 할 일 삭제
const deleteTodo = (id: number) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
return (
<div
style={{
maxWidth: "480px",
margin: "40px auto",
padding: "20px",
fontFamily: "sans-serif",
}}
>
<h1 style={{ textAlign: "center" }}>할 일 목록</h1>
{/* 입력 영역 */}
<div style={{ display: "flex", gap: "8px", marginBottom: "20px" }}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTodo()} // 👈 Enter 키로도 추가
placeholder="할 일을 입력하세요"
style={{
flex: 1,
padding: "10px 14px",
fontSize: "16px",
border: "1px solid #ddd",
borderRadius: "8px",
}}
/>
<button
onClick={addTodo}
style={{
padding: "10px 20px",
fontSize: "16px",
backgroundColor: "#4F46E5",
color: "white",
border: "none",
borderRadius: "8px",
cursor: "pointer",
}}
>
추가
</button>
</div>
{/* 할 일 목록 */}
<div
style={{
border: "1px solid #ddd",
borderRadius: "8px",
overflow: "hidden",
}}
>
{todos.length === 0 ? (
<p style={{ textAlign: "center", color: "#999", padding: "20px" }}>
아직 할 일이 없습니다. 위에서 추가해 보세요!
</p>
) : (
todos.map((todo) => (
<TodoItem
key={todo.id}
text={todo.text}
isDone={todo.isDone}
onToggle={() => toggleTodo(todo.id)}
onDelete={() => deleteTodo(todo.id)}
/>
))
)}
</div>
{/* 통계 */}
{todos.length > 0 && (
<p
style={{
textAlign: "center",
color: "#666",
marginTop: "16px",
fontSize: "14px",
}}
>
총 {todos.length}개 중 {todos.filter((t) => t.isDone).length}개 완료
</p>
)}
</div>
);
}
export default App;코드의 핵심 패턴을 살펴보겠습니다.
배열 state를 수정하는 방법:
// 추가: 기존 배열을 복사하고 새 항목을 뒤에 붙입니다
setTodos([...todos, newTodo]);
// 수정: map으로 특정 항목만 변경한 새 배열을 만듭니다
setTodos(todos.map((todo) =>
todo.id === id ? { ...todo, isDone: !todo.isDone } : todo
));
// 삭제: filter로 특정 항목을 제외한 새 배열을 만듭니다
setTodos(todos.filter((todo) => todo.id !== id));이 세 가지 패턴은 React에서 배열 state를 다룰 때 가장 자주 사용하는 패턴입니다. 공통점은 기존 배열을 직접 수정하지 않고, 항상 새로운 배열을 만들어서 set 함수에 전달한다는 것입니다.
// ❌ 기존 배열을 직접 수정 — React가 변경을 감지하지 못함
todos.push(newTodo);
setTodos(todos);
// ✅ 새 배열을 만들어서 전달 — React가 변경을 감지함
setTodos([...todos, newTodo]);key props:
todos.map((todo) => (
<TodoItem key={todo.id} ... />
))key는 React가 리스트의 각 항목을 구별하기 위해 사용하는 특별한 props입니다. 각 항목에 고유한 값을 key로 전달해야 합니다. key가 없으면 React가 어떤 항목이 추가/삭제/변경되었는지 효율적으로 판단하기 어렵습니다.
이번 2편에서 다룬 내용을 정리합니다.
📌 핵심 정리
- Props는 부모에서 자식으로 데이터를 전달하는 방법입니다. 읽기 전용이며 자식이 수정할 수 없습니다.
- State는 컴포넌트 내부에서 관리하는 변경 가능한 데이터입니다.
useState로 생성하고, set 함수로만 변경합니다.- State가 변경되면 React가 컴포넌트를 자동으로 다시 렌더링합니다.
- 이벤트 핸들러는
onClick,onChange등의 props로 함수를 전달하여 사용자 동작에 반응합니다.- 함수를 props로 전달하면, 자식 컴포넌트가 부모의 state를 간접적으로 변경할 수 있습니다.
- 배열 state를 다룰 때는 기존 배열을 수정하지 않고, 항상 새 배열을 만들어서 전달합니다.
다음 3편에서는 useEffect, 조건부 렌더링, 리스트 렌더링을 배웁니다. useEffect를 사용하면 컴포넌트가 화면에 나타났을 때 특정 동작(API 호출 등)을 실행할 수 있고, 조건부 렌더링과 리스트 렌더링을 통해 더 풍부한 UI를 구성할 수 있습니다.
댓글 0