상태 끌어올리기 (lifting state up)
두 형제 컴포넌트가 같은 데이터를 공유할 때 쓰는 핵심 패턴. 언제 끌어올리고 언제 안 끌어올리는지, 그리고 12장 useContext로의 자연스러운 다리까지 다룹니다.
10장에서 외부 세계와 상호작용하는 도구인 useEffect를 다뤘습니다. 지금까지 본 컴포넌트는 모두 자기 자신의 state를 가지고 있었습니다. 하지만 실제 앱에서는 여러 컴포넌트가 같은 데이터를 공유 해야 하는 경우가 많습니다. 본 챕터에서는 그럴 때 쓰는 핵심 패턴인 **상태 끌어올리기 (lifting state up)**를 다루겠습니다.
본 챕터의 모델은 12장(useContext)으로 자연스럽게 이어집니다. 끌어올리기로 풀 수 있는 경우와, 깊이가 깊어져 다른 도구가 필요한 경우의 경계를 본 챕터 마지막에 짚겠습니다.
데이터는 한 방향으로 흐릅니다 #
4장에서 잠깐 짚었던 원칙입니다. 리액트의 데이터는 부모에서 자식으로 한 방향으로 흐릅니다. 자식이 부모의 데이터를 직접 바꾸지 못하고, 자식끼리 데이터를 직접 주고받지도 못합니다.
그렇다면 두 형제 컴포넌트가 같은 데이터를 공유 해야 할 때는 어떻게 할까요? 답은 단순합니다.
공통 부모로 state를 옮긴다.
이게 “상태 끌어올리기"입니다. 두 자식이 공통으로 쓸 state를 그들의 가장 가까운 공통 부모에 두고, 그 부모가 props로 자식들에게 내려보내는 방식입니다.
문제 상황 — 환율 계산기 #
원화와 달러를 서로 변환하는 컴포넌트 두 개를 만든다고 해 봅시다. 처음에는 각자가 자기 입력값을 가지게 만들어 보겠습니다.
import { useState } from 'react';
function CurrencyInput({ label }) {
const [amount, setAmount] = useState('');
return (
<div>
<label>{label}: </label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
);
}
export default CurrencyInput;import CurrencyInput from './CurrencyInput';
function App() {
return (
<div>
<CurrencyInput label="원화 (KRW)" />
<CurrencyInput label="달러 (USD)" />
</div>
);
}두 입력창은 잘 동작하지만 서로 무관합니다. 한쪽에 1000원을 입력해도 다른 쪽 칸에 환산된 달러가 자동으로 채워지지 않습니다. 두 컴포넌트가 각자 다른 state를 가지고 있기 때문입니다.
상태 끌어올리기 적용 #
이 문제는 두 입력창의 state를 공통 부모인 App으로 끌어올려 해결합니다.
import { useState } from 'react';
import CurrencyInput from './CurrencyInput';
const RATE = 1300; // 1 USD = 1300 KRW
function App() {
const [krw, setKrw] = useState('');
const usd = krw === '' ? '' : (Number(krw) / RATE).toFixed(2);
function handleKrwChange(value) {
setKrw(value);
}
function handleUsdChange(value) {
setKrw(value === '' ? '' : (Number(value) * RATE).toString());
}
return (
<div>
<CurrencyInput label="원화 (KRW)" value={krw} onChange={handleKrwChange} />
<CurrencyInput label="달러 (USD)" value={usd} onChange={handleUsdChange} />
</div>
);
}
export default App;function CurrencyInput({ label, value, onChange }) {
return (
<div>
<label>{label}: </label>
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export default CurrencyInput;핵심 변화:
CurrencyInput은 더 이상 자기 state를 안 가집니다 (controlled component를 props로 받는 형태)- 진실의 원천이 되는
krwstate는App에 단 하나만 존재 - USD는
krw로부터 계산해서 표시 (별도 state로 안 둠) - 사용자가 어느 칸에 입력하든 결국 같은
krwstate를 갱신 - state가 바뀌면 두 자식 모두 다시 렌더링되어 화면이 동기화됨
이제 한 칸에 값을 넣으면 다른 칸이 자동으로 환산된 값으로 채워집니다. 두 컴포넌트가 서로 통신하는 것처럼 보이지만, 실제로는 공통 부모를 거쳐 상호작용 하고 있는 것입니다.
자식 → 부모로 데이터를 올려보내는 방법 #
위 예제에서 자식 (CurrencyInput)은 자기가 받은 입력을 부모에게 어떻게 전달했을까요?
<CurrencyInput onChange={handleKrwChange} />부모가 핸들러 함수를 props로 내려보내고, 자식이 그 함수를 호출하면서 값을 인자로 넘기는 패턴입니다. 자식이 직접 부모의 state를 건드리는 것이 아니라, 부모가 정해 놓은 “이런 일이 있을 때 알려 줘” 채널 (콜백 함수)을 통해 알려 주는 것입니다.
이 패턴은 6장 (이벤트 핸들링)에서 살짝 봤던 것의 확장입니다. 그때는 단순히 클릭을 알리는 정도였고, 이번에는 변경된 값까지 전달하는 것입니다.
어디까지 끌어올려야 하나 #
답은 **“그 데이터를 필요로 하는 모든 컴포넌트의 가장 가까운 공통 조상까지”**입니다.
다음 컴포넌트 트리를 상상해 보세요.
App
├── Header
└── Main
├── Sidebar
└── Content
├── Article
└── CommentsArticle과 Comments가 같은 데이터를 공유한다면 그 state는 Content에 있으면 됩니다. App까지 끌어올릴 필요는 없습니다.
Header와 Article이 공유한다면 App까지 끌어올려야 합니다. 그게 둘의 가장 가까운 공통 조상이기 때문입니다.
너무 위로 올리면 그 state가 필요 없는 컴포넌트들도 props를 받기만 하고 그대로 내려보내는 일이 생깁니다. 이걸 **“prop drilling (프롭 드릴링)”**이라고 부르는데, 다음 12장에서 이 문제를 해결하는 도구인 Context를 다루겠습니다. 본 챕터에서는 “공통 부모까지만"이라는 원칙을 기억하세요.
단일 진실의 원천 (Single Source of Truth) #
상태 끌어올리기의 또 한 가지 중요한 시사점은 같은 정보를 여러 곳에 중복 저장하지 않는다는 원칙입니다. 위 환율 예제를 다시 보세요.
const [krw, setKrw] = useState('');
const usd = krw === '' ? '' : (Number(krw) / RATE).toFixed(2);USD를 별도의 state로 만들지 않고 krw로부터 계산 하고 있습니다. 만약 USD도 별도 state로 만들었다면 두 값을 항상 일치시키는 게 까다로워집니다. 한쪽이 바뀔 때 다른 쪽을 따라 바꿔 주는 effect가 필요해지고, 자칫하면 동기화 버그가 생깁니다.
계산 가능한 값은 state로 만들지 마세요. 진짜 state는 사용자가 직접 입력하거나 외부에서 받아온 “원천 정보” 뿐이고, 거기서 파생되는 값은 그냥 변수로 계산 하면 됩니다.
이 원칙은 10장에서 본 “useEffect를 쓰지 말아야 할 경우"와 같은 맥락입니다. 단순 계산은 그냥 변수, 진짜 외부 세계와의 동기화만 useEffect로.
더 큰 예시 — 카운터 + 표시 컴포넌트 #
하나 더 살펴보겠습니다. +1 / -1 버튼이 들어 있는 Controls와, 카운트와 짝수 / 홀수 여부를 보여 주는 Display가 있다고 가정해 봅시다.
function Controls({ onIncrement, onDecrement, onReset }) {
return (
<div>
<button onClick={onIncrement}>+1</button>
<button onClick={onDecrement}>-1</button>
<button onClick={onReset}>리셋</button>
</div>
);
}
export default Controls;function Display({ count }) {
return (
<div>
<h2>{count}</h2>
<p>{count % 2 === 0 ? '짝수' : '홀수'}</p>
</div>
);
}
export default Display;두 컴포넌트는 같은 카운트를 공유합니다. Controls는 카운트를 변경하고, Display는 카운트를 보여 줍니다. 공통 부모인 App에 state를 둡니다.
import { useState } from 'react';
import Controls from './Controls';
import Display from './Display';
function App() {
const [count, setCount] = useState(0);
return (
<div style={{ padding: '16px' }}>
<Display count={count} />
<Controls
onIncrement={() => setCount(prev => prev + 1)}
onDecrement={() => setCount(prev => prev - 1)}
onReset={() => setCount(0)}
/>
</div>
);
}
export default App;Controls와 Display는 서로의 존재조차 모릅니다. 각자 부모하고만 대화하고, 부모가 그 사이를 중개합니다. 각 자식 컴포넌트는 단순해지고 재사용성이 좋아진다는 것이 lifting state up의 큰 장점입니다.
Display만 따로 떼어서 다른 화면에서 쓸 수도 있고, Controls만 다른 카운터의 컨트롤러로도 쓸 수 있습니다. 두 컴포넌트가 직접 연결돼 있다면 이런 재사용은 불가능했을 것입니다.
끌어올리기의 한계 — 다음 챕터로의 다리 #
상태 끌어올리기는 강력한 패턴이지만 한 가지 약점이 있습니다. 그 데이터를 필요로 하는 컴포넌트 사이의 거리가 멀어지면 중간 컴포넌트들이 자기와 무관한 props를 그저 전달만 하는 상황이 생깁니다. 컴포넌트 트리가 깊어질수록 이 prop drilling이 부담스러워집니다.
<App user={user}>
<Layout user={user}>
<Sidebar user={user}>
<ProfileMenu user={user}>
<UserAvatar user={user} /> {/* 진짜 user가 필요한 경우 */}
</ProfileMenu>
</Sidebar>
</Layout>
</App>Layout, Sidebar, ProfileMenu는 user에 관심이 없습니다. 단지 아래로 내려보내기 위해 props를 받아야 합니다. 다음 12장 useContext에서 이 문제를 해결하는 Context를 다루겠습니다.
그리고 트리 전체가 아니라 특정 도메인 (전역 사용자 정보, 카트, 알림)의 복잡한 상태가 여러 곳에서 공유되어야 하면 Context도 부족해집니다. 그때는 Zustand / Jotai / Redux Toolkit 같은 외부 상태 라이브러리를 쓰는 편이 낫습니다. 이 책의 5부는 이 외부 도구 진영을 직접 다루지는 않지만, 12장에서 Context와의 경계를 짚겠습니다.
직접 해보기 #
9장에서 만든 가입 폼을, 자식 컴포넌트들로 분해하면서 상태를 부모 (SignupForm)에 둔 형태로 리팩터링해 봅시다.
src/TextField.jsx를 만듭니다 (재사용 가능한 입력 필드).
function TextField({ label, name, value, onChange, type = 'text' }) {
return (
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'inline-block', width: '80px' }}>{label}: </label>
<input
type={type}
name={name}
value={value}
onChange={(e) => onChange(name, e.target.value)}
/>
</div>
);
}
export default TextField;src/SignupForm.jsx:
import { useState } from 'react';
import TextField from './TextField';
function SignupForm() {
const [form, setForm] = useState({ name: '', email: '', password: '' });
function handleFieldChange(name, value) {
setForm(prev => ({ ...prev, [name]: value }));
}
function handleSubmit(e) {
e.preventDefault();
console.log('가입 정보:', form);
}
const isValid = form.name && form.email && form.password.length >= 8;
return (
<form onSubmit={handleSubmit} style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>회원 가입</h2>
<TextField label="이름" name="name" value={form.name} onChange={handleFieldChange} />
<TextField label="이메일" name="email" type="email" value={form.email} onChange={handleFieldChange} />
<TextField label="비밀번호" name="password" type="password" value={form.password} onChange={handleFieldChange} />
<button type="submit" disabled={!isValid}>가입</button>
{!isValid && (
<p style={{ color: 'red', fontSize: '12px' }}>모든 필드를 입력하고 비밀번호는 8자 이상이어야 합니다.</p>
)}
</form>
);
}
export default SignupForm;여기서 일어나는 일:
TextField는 자기 state가 없습니다. 표시할 값 (value)과 변경 통로 (onChange)만 props로 받음- 진짜 state (
form)는 부모인SignupForm에 있음 - 자식이 입력을 받으면
onChange(name, value)로 부모에 전달 - 부모가 객체의 해당 필드를 갱신
- state가 바뀌면 부모가 다시 렌더링되고 자식들도 새 props를 받음
자식 컴포넌트 (TextField)가 입력 종류와 상관없이 일반화되어 있어, 새 필드를 추가하려면 한 줄 (<TextField label="..." name="..." />)만 더 쓰면 됩니다.
연습문제 #
- 위 환율 계산기에 통화 한 가지를 더 추가해 보세요. KRW / USD / JPY 세 입력창이 동시에 동기화됩니다. 진실의 원천은 KRW state 하나만 유지하고, USD와 JPY는 그것으로부터 계산합니다. 환율은 1 USD = 1300 KRW, 1 JPY = 9 KRW로 가정합니다.
Display와Controls가 부모 없이 같은 카운트를 공유할 수 있을지 짧게 생각해 보세요. 답은 “없다 (lifting up 없이는)“입니다. 그 이유를 리액트의 단방향 데이터 흐름 원칙으로 한 단락으로 설명해 보세요. 다음 12장의 Context도 결국 트리 안에서 데이터를 흘리는 방식이라는 점에 주목하면 좋습니다.- 9장의 가입 폼 예제를 위
TextField+SignupForm패턴으로 직접 리팩터링해 보세요. 라디오 / 체크박스 / textarea 별 자식 컴포넌트도 만들어 봅니다. 어떤 컴포넌트들이 props만 전달하는 “중개자” 역할인지, 그게 prop drilling의 시작인지 직접 느껴 보면 12장이 자연스럽게 읽힙니다.
한 줄 요약: 데이터는 부모 → 자식 한 방향으로 흐른다. 두 컴포넌트가 같은 state를 공유해야 하면 공통 부모로 끌어올린다. 자식이 부모에 알리는 통로는 콜백 함수 prop (
onChange,onClick등). 계산 가능한 값은 별도 state로 만들지 말고 변수로 (Single Source of Truth). 자식 컴포넌트가 controlled가 되면 더 작고 재사용하기 좋은 단위가 된다.
다음 챕터 #
다음 12장 useContext에서는 prop drilling 문제를 풀어 주는 Context API를 다루겠습니다. 트리 어디서든 데이터를 한 번에 꺼내 쓸 수 있는 통로를 만드는 모델입니다. 그리고 Context의 비용 (value 변경 시 광범위한 리렌더링)과 외부 상태 라이브러리로의 경계도 함께 짚겠습니다.