목차
11 장

상태 끌어올리기 (lifting state up)

두 형제 컴포넌트가 같은 데이터를 공유할 때 쓰는 핵심 패턴. 언제 끌어올리고 언제 안 끌어올리는지, 그리고 12장 useContext로의 자연스러운 다리까지 다룹니다.

10장에서 외부 세계와 상호작용하는 도구인 useEffect를 다뤘습니다. 지금까지 본 컴포넌트는 모두 자기 자신의 state를 가지고 있었습니다. 하지만 실제 앱에서는 여러 컴포넌트가 같은 데이터를 공유 해야 하는 경우가 많습니다. 본 챕터에서는 그럴 때 쓰는 핵심 패턴인 **상태 끌어올리기 (lifting state up)**를 다루겠습니다.

본 챕터의 모델은 12장(useContext)으로 자연스럽게 이어집니다. 끌어올리기로 풀 수 있는 경우와, 깊이가 깊어져 다른 도구가 필요한 경우의 경계를 본 챕터 마지막에 짚겠습니다.

데이터는 한 방향으로 흐릅니다 #

4장에서 잠깐 짚었던 원칙입니다. 리액트의 데이터는 부모에서 자식으로 한 방향으로 흐릅니다. 자식이 부모의 데이터를 직접 바꾸지 못하고, 자식끼리 데이터를 직접 주고받지도 못합니다.

그렇다면 두 형제 컴포넌트가 같은 데이터를 공유 해야 할 때는 어떻게 할까요? 답은 단순합니다.

공통 부모로 state를 옮긴다.

이게 “상태 끌어올리기"입니다. 두 자식이 공통으로 쓸 state를 그들의 가장 가까운 공통 부모에 두고, 그 부모가 props로 자식들에게 내려보내는 방식입니다.

문제 상황 — 환율 계산기 #

원화와 달러를 서로 변환하는 컴포넌트 두 개를 만든다고 해 봅시다. 처음에는 각자가 자기 입력값을 가지게 만들어 보겠습니다.

src/CurrencyInput.jsx (첫 시도)
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;
src/App.jsx (첫 시도)
import CurrencyInput from './CurrencyInput';

function App() {
  return (
    <div>
      <CurrencyInput label="원화 (KRW)" />
      <CurrencyInput label="달러 (USD)" />
    </div>
  );
}

두 입력창은 잘 동작하지만 서로 무관합니다. 한쪽에 1000원을 입력해도 다른 쪽 칸에 환산된 달러가 자동으로 채워지지 않습니다. 두 컴포넌트가 각자 다른 state를 가지고 있기 때문입니다.

상태 끌어올리기 적용 #

이 문제는 두 입력창의 state를 공통 부모인 App으로 끌어올려 해결합니다.

src/App.jsx (수정)
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;
src/CurrencyInput.jsx (수정)
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로 받는 형태)
  • 진실의 원천이 되는 krw state는 App에 단 하나만 존재
  • USD는 krw로부터 계산해서 표시 (별도 state로 안 둠)
  • 사용자가 어느 칸에 입력하든 결국 같은 krw state를 갱신
  • state가 바뀌면 두 자식 모두 다시 렌더링되어 화면이 동기화됨

이제 한 칸에 값을 넣으면 다른 칸이 자동으로 환산된 값으로 채워집니다. 두 컴포넌트가 서로 통신하는 것처럼 보이지만, 실제로는 공통 부모를 거쳐 상호작용 하고 있는 것입니다.

자식 → 부모로 데이터를 올려보내는 방법 #

위 예제에서 자식 (CurrencyInput)은 자기가 받은 입력을 부모에게 어떻게 전달했을까요?

<CurrencyInput onChange={handleKrwChange} />

부모가 핸들러 함수를 props로 내려보내고, 자식이 그 함수를 호출하면서 값을 인자로 넘기는 패턴입니다. 자식이 직접 부모의 state를 건드리는 것이 아니라, 부모가 정해 놓은 “이런 일이 있을 때 알려 줘” 채널 (콜백 함수)을 통해 알려 주는 것입니다.

이 패턴은 6장 (이벤트 핸들링)에서 살짝 봤던 것의 확장입니다. 그때는 단순히 클릭을 알리는 정도였고, 이번에는 변경된 값까지 전달하는 것입니다.

어디까지 끌어올려야 하나 #

답은 **“그 데이터를 필요로 하는 모든 컴포넌트의 가장 가까운 공통 조상까지”**입니다.

다음 컴포넌트 트리를 상상해 보세요.

컴포넌트 트리
App
├── Header
└── Main
    ├── Sidebar
    └── Content
        ├── Article
        └── Comments

ArticleComments가 같은 데이터를 공유한다면 그 state는 Content에 있으면 됩니다. App까지 끌어올릴 필요는 없습니다.

HeaderArticle이 공유한다면 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가 있다고 가정해 봅시다.

src/Controls.jsx
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;
src/Display.jsx
function Display({ count }) {
  return (
    <div>
      <h2>{count}</h2>
      <p>{count % 2 === 0 ? '짝수' : '홀수'}</p>
    </div>
  );
}

export default Display;

두 컴포넌트는 같은 카운트를 공유합니다. Controls는 카운트를 변경하고, Display는 카운트를 보여 줍니다. 공통 부모인 App에 state를 둡니다.

src/App.jsx
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;

ControlsDisplay는 서로의 존재조차 모릅니다. 각자 부모하고만 대화하고, 부모가 그 사이를 중개합니다. 각 자식 컴포넌트는 단순해지고 재사용성이 좋아진다는 것이 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, ProfileMenuuser에 관심이 없습니다. 단지 아래로 내려보내기 위해 props를 받아야 합니다. 다음 12장 useContext에서 이 문제를 해결하는 Context를 다루겠습니다.

그리고 트리 전체가 아니라 특정 도메인 (전역 사용자 정보, 카트, 알림)의 복잡한 상태가 여러 곳에서 공유되어야 하면 Context도 부족해집니다. 그때는 Zustand / Jotai / Redux Toolkit 같은 외부 상태 라이브러리를 쓰는 편이 낫습니다. 이 책의 5부는 이 외부 도구 진영을 직접 다루지는 않지만, 12장에서 Context와의 경계를 짚겠습니다.

직접 해보기 #

9장에서 만든 가입 폼을, 자식 컴포넌트들로 분해하면서 상태를 부모 (SignupForm)에 둔 형태로 리팩터링해 봅시다.

src/TextField.jsx를 만듭니다 (재사용 가능한 입력 필드).

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:

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="..." />)만 더 쓰면 됩니다.

연습문제 #

  1. 위 환율 계산기에 통화 한 가지를 더 추가해 보세요. KRW / USD / JPY 세 입력창이 동시에 동기화됩니다. 진실의 원천은 KRW state 하나만 유지하고, USD와 JPY는 그것으로부터 계산합니다. 환율은 1 USD = 1300 KRW, 1 JPY = 9 KRW로 가정합니다.
  2. DisplayControls가 부모 없이 같은 카운트를 공유할 수 있을지 짧게 생각해 보세요. 답은 “없다 (lifting up 없이는)“입니다. 그 이유를 리액트의 단방향 데이터 흐름 원칙으로 한 단락으로 설명해 보세요. 다음 12장의 Context도 결국 트리 안에서 데이터를 흘리는 방식이라는 점에 주목하면 좋습니다.
  3. 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 변경 시 광범위한 리렌더링)과 외부 상태 라이브러리로의 경계도 함께 짚겠습니다.

X