지난 시간에는 외부 세계와 상호작용하는 도구인 useEffect를 배웠습니다. 지금까지 우리가 다룬 컴포넌트는 모두 자기 자신의 state를 가지고 있었습니다. 하지만 실제 앱에서는 여러 컴포넌트가 같은 데이터를 공유해야 하는 경우가 많습니다. 이번 시간에는 그럴 때 사용하는 핵심 패턴인 **상태 끌어올리기(lifting state up)**를 배워보겠습니다.
데이터는 한 방향으로 흐릅니다
#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
└── Comments만약 Article과 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는 사용자가 직접 입력하거나 외부에서 받아온 "원천 정보"뿐이고, 거기서 파생되는 값은 그냥 변수로 계산하면 됩니다.
이 원칙은 useEffect 글에서 본 "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만 다른 카운터의 컨트롤러로도 쓸 수 있습니다. 두 컴포넌트가 직접 연결돼 있다면 이런 재사용은 불가능했겠죠.
직접 해보기
#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="..." />)만 더 쓰면 됩니다.
마무리
이번 글에서는 두 형제 컴포넌트가 같은 데이터를 공유하는 핵심 패턴인 **상태 끌어올리기(lifting state up)**를 배웠습니다. 정리하면:
- 데이터는 부모 → 자식 한 방향으로 흐른다
- 두 컴포넌트가 같은 state를 공유해야 하면 공통 부모로 끌어올린다
- 자식이 부모에 알리는 통로는 콜백 함수 prop (
onChange,onClick등) - 계산 가능한 값은 별도 state로 만들지 말고 변수로 (Single Source of Truth)
- 자식 컴포넌트가 controlled가 되면 더 작고 재사용하기 좋은 단위가 된다
상태 끌어올리기는 강력한 패턴이지만, 그 데이터를 필요로 하는 컴포넌트 사이의 거리가 멀어지면 중간 컴포넌트들이 자기와 무관한 props를 그저 전달만 하는 상황이 생깁니다. 컴포넌트 트리가 깊어질수록 이 "prop drilling"이 부담스러워지죠. 다음 글인 "리액트 기초 강좌 #12 useContext"에서는 이 문제를 해결하는 도구인 Context를 배워보겠습니다.