본문 바로가기

컴포넌트 간 상태 공유의 어려움

상태의 소유자와 읽기·쓰기 경로를 구분하고, Local·Lift·Context·reducer·외부 store 중 필요를 충족하는 최소 범위를 선택합니다.

7장에서는 여러 컴포넌트가 같은 상태를 나눠 쓸 때 생기는 문제와 그 해법인 상태 관리를 다룹니다. React에서 상태를 공유할 때 먼저 정할 것은 도구가 아니라 그 값을 누가 가질지, 곧 소유자(owner)입니다. 같은 값을 읽거나 바꾸는 컴포넌트들을 찾고, 이들을 모두 품는 가장 가까운 공통 조상에 상태를 두면 값의 출처를 하나로 유지할 수 있습니다.

이때 값과 이벤트의 방향도 구분해야 합니다. 소유자는 현재 값과 이벤트 처리 함수를 아래로 넘기고, 값을 읽는 컴포넌트(reader)는 넘겨받은 값을 화면에 반영합니다. 값을 바꾸는 컴포넌트(writer)는 상태를 직접 갖는 대신, 클릭이나 입력 같은 사용자의 의도를 콜백으로 알립니다. 중간의 컴포넌트는 값을 쓰지 않고 다음 자식에게 넘겨주기만 할 수도 있습니다. 이를 흔히 값은 아래로, 이벤트의 의도는 위로 흐르는 단방향 흐름이라고 설명합니다.

App은 테마를 소유하고 자식은 값과 변경 의도를 나눕니다

REACT STATE · VALUES & INTENT

App은 테마를 소유하고 자식은 값과 변경 의도를 나눕니다

본문의 실제 여섯 컴포넌트입니다. 값과 핸들러는 아래로 전달하고 ThemeToggle은 onToggle 콜백으로 소유자의 상태 갱신을 요청합니다.

실제 버튼 한 번 · light → dark

ThemeToggle → onToggle()
App → setTheme(current => ...)
theme: light → dark
Header · 본문 · Footer → dark

Header와 MainContent는 값을 읽으면서 전달합니다. 이 예제에서 둘을 값을 쓰지 않는 중계자로 분류하지 않습니다.

실제 theme props 트리App이 theme과 toggleTheme을 소유합니다. Header는 값을 읽고 ThemeToggle로 handler를 전달합니다. MainContent는 값을 읽고 ContentSection으로 전달합니다. Footer도 읽으며 ThemeToggle의 onToggle 의도는 App으로 돌아갑니다.App · OWNERtheme · toggleThemeHeadertheme 읽기handler 전달MainContenttheme 읽기자식에 전달Footertheme 읽기ThemeToggletheme 읽기onToggle()ContentSectiontheme 읽기theme + handlerthemethemetheme변경 의도onToggle
실제 theme props 트리App이 theme과 toggleTheme을 소유합니다. Header는 값을 읽고 ThemeToggle로 handler를 전달합니다. MainContent는 값을 읽고 ContentSection으로 전달합니다. Footer도 읽으며 ThemeToggle의 onToggle 의도는 App으로 돌아갑니다.App · OWNERthemetoggleThemeHeadertheme 읽기handler 전달ThemeToggletheme 읽기onToggle()MainContenttheme 읽기자식에 전달ContentSectiontheme 읽기Footertheme 읽기themethemethemethemetheme변경 의도handleronToggle

App · OWNER

theme과 toggleTheme을 소유합니다.

Header → ThemeToggle

Header도 theme을 읽습니다. theme과 onToggle을 ThemeToggle로 전달하고, 버튼의 콜백이 App의 변경 함수를 실행합니다.

MainContent → ContentSection

두 컴포넌트가 theme을 읽습니다. MainContent가 값도 사용하면서 ContentSection으로 전달합니다.

Footer

App에서 받은 theme을 읽어 같은 테마를 표시합니다.

명시적 전달 계약

props를 유지해도 소유자와 변경 경로가 분명합니다. 전달 단계가 있다는 이유만으로 버그나 성능 문제는 아닙니다.

공유 범위

읽는 곳과 변경 요청을 보내는 곳을 포함하는 가까운 공통 소유자를 고릅니다. 소비자 집합이 다른 값은 소유자를 나눌 수 있습니다.

위 그림은 이 글의 테마 예제를 이루는 여섯 컴포넌트의 props 트리입니다. App이 소유자로서 theme과 toggleTheme을 가지고, Header와 MainContent, Footer에 theme을 넘깁니다. Header는 theme을 읽으면서 ThemeToggle에 theme과 처리 함수를 넘기고, MainContent는 theme을 읽으면서 ContentSection에 넘기며, Footer도 theme을 읽습니다. ThemeToggle의 onToggle()은 변경의 의도로서 App의 toggleTheme으로 돌아갑니다.

그림 위쪽의 예처럼 버튼을 한 번 누르면 ThemeToggle이 onToggle()을 부르고, App이 setTheme(current => ...)로 theme을 light에서 dark로 바꾸며, 헤더와 본문, 푸터가 모두 dark로 다시 그려집니다. 그림의 설명처럼 이 예제의 Header와 MainContent는 값을 읽으면서 넘기므로, 값을 쓰지 않고 넘기기만 하는 중계자로 분류하지 않습니다. 그림 아래 칸이 정리하듯 props를 그대로 써도 소유자와 변경의 경로가 분명하며, 넘기는 단계가 있다는 이유만으로 버그나 성능 문제가 되지는 않습니다. 공유 범위는 읽는 곳과 변경을 요청하는 곳을 모두 품는 가까운 공통 소유자로 고르고, 읽는 컴포넌트의 집합이 다른 값은 소유자를 나눌 수 있습니다.


렌더링과 단방향 데이터 흐름

useState로 상태를 갱신하면 그 상태를 가진 컴포넌트의 렌더링이 요청됩니다. React는 기본적으로 그 아래의 트리도 차례로 렌더링하지만, memo 같은 최적화나 같은 상태 값에 대한 건너뛰기(bailout)가 일부 작업을 생략할 수 있습니다. 렌더링 결과가 나와도 실제 DOM에는 필요한 변경만 커밋됩니다.

props는 부모가 자식에게 값과 이벤트 처리 함수를 분명하게 넘기는 기본 수단입니다. 이 경로가 길어지는 현상을 props drilling이라고 하는데, props drilling 자체는 버그도 성능 문제도 아닙니다. 작은 트리에서는 데이터의 출처와 전달 방식을 가장 분명하게 보여 주는 단순한 해법일 수 있습니다.

다만 몇 가지 신호가 함께 보이면 전달 범위를 다시 검토해 볼 수 있습니다. 값을 쓰지 않는 중간 컴포넌트가 여러 단계에 걸쳐 같은 props를 넘기거나, 소비자나 전달 경로를 바꿀 때 많은 컴포넌트의 인터페이스를 함께 고쳐야 하거나, 서로 독립적으로 바뀌는 값들이 하나의 넓은 전달 경로에 묶여 있거나, 상태를 바꾸는 규칙과 이벤트 처리가 여러 컴포넌트에 흩어져 있는 경우입니다. 이때도 먼저 컴포넌트 합성이나 더 가까운 소유자로 트리를 단순하게 만들 수 있는지 확인합니다.


공유 상태가 필요한 장면

공유 상태가 필요한 대표적인 장면은 이렇습니다. 장바구니에서는 상품 목록이 장바구니의 값을 바꾸고, 아이콘과 모달은 같은 값을 읽습니다. 사용자 인증에서는 로그인 화면이 인증을 시작하고, 헤더와 대시보드가 현재 세션을 읽습니다. 서버에서 가져온 세션 데이터라면 화면의 전역 상태로 복제하기 전에 데이터 계층이 그 값을 가지고 있지 않은지 먼저 확인합니다. 테마에서는 전환 버튼이 변경의 의도를 보내고, 헤더와 본문, 푸터가 현재 테마를 읽습니다.

Props drilling 예시: 테마 전환

다음 예시는 App이 테마를 가지고, Header가 현재 값을 쓰면서 ThemeToggle까지 변경 함수를 넘기는 구조를 보여 줍니다. MainContent도 값을 쓴 뒤 더 깊은 ContentSection으로 넘깁니다. 여섯 파일 모두 같은 src 폴더에 .jsx로 저장합니다.

src/App.jsx
import { useState } from 'react';
import Header from './Header';
import MainContent from './MainContent';
import Footer from './Footer';

export default function App() {
  const [theme, setTheme] = useState('light');

  function toggleTheme() {
    setTheme(current => (current === 'light' ? 'dark' : 'light'));
  }

  return (
    <div>
      <h1>테마 앱</h1>
      <Header theme={theme} toggleTheme={toggleTheme} />
      <MainContent theme={theme} />
      <Footer theme={theme} />
    </div>
  );
}
src/Header.jsx
import ThemeToggle from './ThemeToggle';

export default function Header({ theme, toggleTheme }) {
  const colors =
    theme === 'light'
      ? { backgroundColor: '#eee', color: '#333' }
      : { backgroundColor: '#333', color: '#eee' };

  return (
    <header style={{ ...colors, padding: 16 }}>
      <h2>헤더</h2>
      <p>현재 테마: {theme}</p>
      <ThemeToggle theme={theme} onToggle={toggleTheme} />
    </header>
  );
}
src/ThemeToggle.jsx
export default function ThemeToggle({ theme, onToggle }) {
  return (
    <button type="button" onClick={onToggle}>
      {theme === 'light' ? '다크 모드' : '라이트 모드'}로 전환
    </button>
  );
}
src/MainContent.jsx
import ContentSection from './ContentSection';

export default function MainContent({ theme }) {
  const backgroundColor = theme === 'light' ? '#f8f8f8' : '#555';
  const color = theme === 'light' ? '#333' : '#eee';

  return (
    <main style={{ backgroundColor, color, padding: 20 }}>
      <h2>메인 콘텐츠</h2>
      <ContentSection theme={theme} />
    </main>
  );
}
src/ContentSection.jsx
export default function ContentSection({ theme }) {
  const border =
    theme === 'light' ? '1px solid #ccc' : '1px solid #888';

  return (
    <section style={{ border, padding: 16 }}>
      <h3>콘텐츠 섹션</h3>
      <p>현재 테마는 {theme}입니다.</p>
    </section>
  );
}
src/Footer.jsx
export default function Footer({ theme }) {
  const colors =
    theme === 'light'
      ? { backgroundColor: '#eee', color: '#333' }
      : { backgroundColor: '#333', color: '#eee' };

  return (
    <footer style={{ ...colors, padding: 16 }}>
      <p>© 2026 테마 앱 · 현재 테마: {theme}</p>
    </footer>
  );
}

Header는 toggleTheme을 직접 실행하지 않고 ThemeToggle로 넘깁니다. 이렇게 넘기기만 하는 단계가 몇 개뿐이고 전달 방식이 분명하다면 props를 그대로 써도 됩니다.

여섯 파일을 같은 앱에 연결하면 처음에는 App의 theme이 light입니다. Header, MainContent, ContentSection, Footer는 이 값을 글자와 스타일에 쓰고, ThemeToggle은 다음 동작인 “다크 모드로 전환”을 보여 줍니다. 버튼은 자식에게 따로 있는 테마를 바꾸는 것이 아닙니다. onToggle()이 App에 정의된 toggleTheme을 실행하고, 업데이터가 이전의 light에서 다음 값 dark를 계산하며, 다음 렌더에서 같은 소유자의 새 값이 모든 읽는 컴포넌트에 전달됩니다. 이름이 비슷한 지역 상태를 자식마다 따로 만들면, 이 연결 대신 여러 값을 서로 맞춰야 하는 문제가 생깁니다.


상태를 둘 범위 결정하기

상태마다 그 값이 어디서 오는지, 누가 가지는지, 어디서 읽고 어디서 바꾸는지를 적은 뒤, 필요를 충족하는 가장 작은 범위를 고릅니다.

상태 출처와 공유 범위 결정표 — 상태 추적

App이 소유한 light는 onToggle 사건 뒤 dark로 바뀌어 이를 읽는 자식의 props/화면에 반영된다. 선택 표를 남기고 값 전달과 변경 의도, 현재 snapshot을 같은 상태로 보강할 수 있다.

상태의 출처를 보존하고 필요한 공유 범위를 고릅니다

REACT STATE · OWNERSHIP & TRANSITION

상태의 출처를 보존하고 필요한 공유 범위를 고릅니다

URL·서버 응답은 원래 소유 계층에서 읽습니다. UI 상태는 읽는 곳과 변경 의도를 보내는 곳을 포함하는 가장 작은 충분한 범위에 둡니다.

App이 소유하고 자식은 읽기와 변경 의도를 보낸다

App이 소유하고 자식은 읽기와 변경 의도를 보낸다현재 예제는 가까운 공통 소유자 App과 명시적 props로 충분합니다. URL·서버·캐시는 원래 계층이 소유하며 Context는 저장소가 아니라 전달 수단입니다.props의도App statelight → darkHeaderthemeonToggleMaintheme 읽기Footertheme 읽기
App이 소유하고 자식은 읽기와 변경 의도를 보낸다현재 예제는 가까운 공통 소유자 App과 명시적 props로 충분합니다. URL·서버·캐시는 원래 계층이 소유하며 Context는 저장소가 아니라 전달 수단입니다.props의도App statelight → darkHeaderthemeonToggleMaintheme 읽기Footertheme 읽기

현재 예제는 가까운 공통 소유자 App과 명시적 props로 충분합니다. URL·서버·캐시는 원래 계층이 소유하며 Context는 저장소가 아니라 전달 수단입니다.

원래 설명과 상세 기준

실제 테마 예제 · App이 가까운 공통 소유자

App: theme = light
Header · MainContent · Footer → 읽기
ThemeToggle → onToggle()
App → theme = dark

이 작은 트리에서는 명시적인 props 계약으로 충분합니다. 소비자 수만으로 Context나 외부 store를 자동 선택하지 않습니다.

상태의 출처·소비 범위·변경 규칙에 따른 선택
범위 · 해법 소유권과 전달 계약
출처
URL · server · cache
경로와 검색 조건은 URL/router가, 응답과 캐시 수명은 데이터 계층이 소유합니다. 같은 값을 UI store에 복제하지 않습니다.
지역
Local · useState
한 컴포넌트나 작은 하위 트리만 쓰는 UI 상태는 가까운 곳에 둡니다.
공유
Lift · 공통 조상
형제·인접 컴포넌트가 공유하면 가장 가까운 공통 조상이 소유합니다. 값과 핸들러는 아래로, 이벤트 의도는 위로 흐릅니다.
전송
Context
먼 소비자에게 값을 전달하되 상태는 Provider를 렌더링하는 컴포넌트가 소유합니다. Context 자체는 상태 저장소가 아닙니다.
규칙
Reducer · useReducer
전환 규칙이 복잡하면 같은 소유자에서 업데이트 로직을 모읍니다. 필요할 때 state와 dispatch를 Context로 전송합니다.
외부
외부 store
React 밖의 변경 가능한 데이터 소스가 이미 있거나 독립 구독이 필요할 때 검토합니다. useSyncExternalStore로 연결할 수 있습니다.
  1. 출처가 이미 있는가?

    URL/search, 서버 응답, 캐시 수명은 해당 계층에 남기고 필요한 UI 표시 상태만 분리합니다.

  2. 한 컴포넌트만 쓰는가?

    가까운 useState를 사용합니다.

  3. 가까운 형제가 공유하는가?

    가장 가까운 공통 조상으로 끌어올리고 props로 값과 핸들러를 전달합니다.

  4. 먼 하위 소비자가 많은가?

    소유자는 유지한 채 Context로 전송 범위를 넓힙니다.

  5. 업데이트 규칙이 복잡한가?

    useReducer로 전환 규칙을 모읍니다. 필요하면 state·dispatch를 Context로 전달합니다.

  6. React 밖의 구독이 필요한가?

    독립 데이터 소스와 수명주기가 있을 때만 외부 store를 검토하고 useSyncExternalStore로 연결할 수 있습니다.

Context 구독 비용

Provider의 value가 Object.is 비교에서 달라지면 소비자가 새 값을 받습니다. memo도 Context 갱신을 막지 않으므로 변경 주기가 다른 값은 전송 범위를 나눕니다.

렌더와 최적화 경계

부모 렌더는 기본적으로 하위 렌더로 이어지지만 bailout이 일부 작업을 건너뛸 수 있습니다. useMemo와 외부 store는 측정된 필요에 적용하는 최적화·구독 도구입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 표 행범위 · 해법 | 소유권과 전달 계약
원도표 표 행출처 · URL · server · cache | 경로와 검색 조건은 URL/router가, 응답과 캐시 수명은 데이터 계층이 소유합니다. 같은 값을 UI store에 복제하지 않습니다.
원도표 표 행지역 · Local · useState | 한 컴포넌트나 작은 하위 트리만 쓰는 UI 상태는 가까운 곳에 둡니다.
원도표 표 행공유 · Lift · 공통 조상 | 형제·인접 컴포넌트가 공유하면 가장 가까운 공통 조상이 소유합니다. 값과 핸들러는 아래로, 이벤트 의도는 위로 흐릅니다.
원도표 표 행전송 · Context | 먼 소비자에게 값을 전달하되 상태는 Provider를 렌더링하는 컴포넌트가 소유합니다. Context 자체는 상태 저장소가 아닙니다.
원도표 표 행규칙 · Reducer · useReducer | 전환 규칙이 복잡하면 같은 소유자에서 업데이트 로직을 모읍니다. 필요할 때 state와 dispatch를 Context로 전송합니다.
원도표 표 행외부 · 외부 store | React 밖의 변경 가능한 데이터 소스가 이미 있거나 독립 구독이 필요할 때 검토합니다. useSyncExternalStore로 연결할 수 있습니다.
원도표 설명가까운 useState를 사용합니다.
원도표 조건·주석한 컴포넌트만 쓰는가? 가까운 useState를 사용합니다.
원도표 설명useReducer로 전환 규칙을 모읍니다. 필요하면 state·dispatch를 Context로 전달합니다.
원도표 조건·주석업데이트 규칙이 복잡한가? useReducer로 전환 규칙을 모읍니다. 필요하면 state·dispatch를 Context로 전달합니다.
원도표 설명Provider의 value가 Object.is 비교에서 달라지면 소비자가 새 값을 받습니다. memo도 Context 갱신을 막지 않으므로 변경 주기가 다른 값은 전송 범위를 나눕니다.
원도표 설명부모 렌더는 기본적으로 하위 렌더로 이어지지만 bailout이 일부 작업을 건너뛸 수 있습니다. useMemo와 외부 store는 측정된 필요에 적용하는 최적화·구독 도구입니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
App 소유 lightApp: light / 자식: theme 읽기가까운 공통 조상이 현재 theme을 소유한다소비자가 많다는 이유만으로 도구를 바꾸지 않는다
변경 의도의도: onToggle() / 현재 snapshot: lightThemeToggle의 onToggle이 owner에게 사건을 보낸다자식이 props를 직접 바꾸지 않는다
다음 darkApp: dark / Header/Main/Footer: darkowner의 다음 렌더가 모든 소비자에게 dark를 전달한다값 전달과 사건의 방향이 다르다

상태 출처와 공유 범위 결정표 — 상태 추적

App이 소유한 light는 onToggle 사건 뒤 dark로 바뀌어 이를 읽는 자식의 props/화면에 반영된다. 선택 표를 남기고 값 전달과 변경 의도, 현재 snapshot을 같은 상태로 보강할 수 있다.

상태의 출처를 보존하고 필요한 공유 범위를 고릅니다

REACT STATE · OWNERSHIP & TRANSITION

상태의 출처를 보존하고 필요한 공유 범위를 고릅니다

URL·서버 응답은 원래 소유 계층에서 읽습니다. UI 상태는 읽는 곳과 변경 의도를 보내는 곳을 포함하는 가장 작은 충분한 범위에 둡니다.

App이 소유하고 자식은 읽기와 변경 의도를 보낸다

App이 소유하고 자식은 읽기와 변경 의도를 보낸다현재 예제는 가까운 공통 소유자 App과 명시적 props로 충분합니다. URL·서버·캐시는 원래 계층이 소유하며 Context는 저장소가 아니라 전달 수단입니다.props의도App statelight → darkHeaderthemeonToggleMaintheme 읽기Footertheme 읽기
App이 소유하고 자식은 읽기와 변경 의도를 보낸다현재 예제는 가까운 공통 소유자 App과 명시적 props로 충분합니다. URL·서버·캐시는 원래 계층이 소유하며 Context는 저장소가 아니라 전달 수단입니다.props의도App statelight → darkHeaderthemeonToggleMaintheme 읽기Footertheme 읽기

현재 예제는 가까운 공통 소유자 App과 명시적 props로 충분합니다. URL·서버·캐시는 원래 계층이 소유하며 Context는 저장소가 아니라 전달 수단입니다.

원래 설명과 상세 기준

실제 테마 예제 · App이 가까운 공통 소유자

App: theme = light
Header · MainContent · Footer → 읽기
ThemeToggle → onToggle()
App → theme = dark

이 작은 트리에서는 명시적인 props 계약으로 충분합니다. 소비자 수만으로 Context나 외부 store를 자동 선택하지 않습니다.

상태의 출처·소비 범위·변경 규칙에 따른 선택
범위 · 해법 소유권과 전달 계약
출처
URL · server · cache
경로와 검색 조건은 URL/router가, 응답과 캐시 수명은 데이터 계층이 소유합니다. 같은 값을 UI store에 복제하지 않습니다.
지역
Local · useState
한 컴포넌트나 작은 하위 트리만 쓰는 UI 상태는 가까운 곳에 둡니다.
공유
Lift · 공통 조상
형제·인접 컴포넌트가 공유하면 가장 가까운 공통 조상이 소유합니다. 값과 핸들러는 아래로, 이벤트 의도는 위로 흐릅니다.
전송
Context
먼 소비자에게 값을 전달하되 상태는 Provider를 렌더링하는 컴포넌트가 소유합니다. Context 자체는 상태 저장소가 아닙니다.
규칙
Reducer · useReducer
전환 규칙이 복잡하면 같은 소유자에서 업데이트 로직을 모읍니다. 필요할 때 state와 dispatch를 Context로 전송합니다.
외부
외부 store
React 밖의 변경 가능한 데이터 소스가 이미 있거나 독립 구독이 필요할 때 검토합니다. useSyncExternalStore로 연결할 수 있습니다.
  1. 출처가 이미 있는가?

    URL/search, 서버 응답, 캐시 수명은 해당 계층에 남기고 필요한 UI 표시 상태만 분리합니다.

  2. 한 컴포넌트만 쓰는가?

    가까운 useState를 사용합니다.

  3. 가까운 형제가 공유하는가?

    가장 가까운 공통 조상으로 끌어올리고 props로 값과 핸들러를 전달합니다.

  4. 먼 하위 소비자가 많은가?

    소유자는 유지한 채 Context로 전송 범위를 넓힙니다.

  5. 업데이트 규칙이 복잡한가?

    useReducer로 전환 규칙을 모읍니다. 필요하면 state·dispatch를 Context로 전달합니다.

  6. React 밖의 구독이 필요한가?

    독립 데이터 소스와 수명주기가 있을 때만 외부 store를 검토하고 useSyncExternalStore로 연결할 수 있습니다.

Context 구독 비용

Provider의 value가 Object.is 비교에서 달라지면 소비자가 새 값을 받습니다. memo도 Context 갱신을 막지 않으므로 변경 주기가 다른 값은 전송 범위를 나눕니다.

렌더와 최적화 경계

부모 렌더는 기본적으로 하위 렌더로 이어지지만 bailout이 일부 작업을 건너뛸 수 있습니다. useMemo와 외부 store는 측정된 필요에 적용하는 최적화·구독 도구입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 표 행범위 · 해법 | 소유권과 전달 계약
원도표 표 행출처 · URL · server · cache | 경로와 검색 조건은 URL/router가, 응답과 캐시 수명은 데이터 계층이 소유합니다. 같은 값을 UI store에 복제하지 않습니다.
원도표 표 행지역 · Local · useState | 한 컴포넌트나 작은 하위 트리만 쓰는 UI 상태는 가까운 곳에 둡니다.
원도표 표 행공유 · Lift · 공통 조상 | 형제·인접 컴포넌트가 공유하면 가장 가까운 공통 조상이 소유합니다. 값과 핸들러는 아래로, 이벤트 의도는 위로 흐릅니다.
원도표 표 행전송 · Context | 먼 소비자에게 값을 전달하되 상태는 Provider를 렌더링하는 컴포넌트가 소유합니다. Context 자체는 상태 저장소가 아닙니다.
원도표 표 행규칙 · Reducer · useReducer | 전환 규칙이 복잡하면 같은 소유자에서 업데이트 로직을 모읍니다. 필요할 때 state와 dispatch를 Context로 전송합니다.
원도표 표 행외부 · 외부 store | React 밖의 변경 가능한 데이터 소스가 이미 있거나 독립 구독이 필요할 때 검토합니다. useSyncExternalStore로 연결할 수 있습니다.
원도표 설명가까운 useState를 사용합니다.
원도표 조건·주석한 컴포넌트만 쓰는가? 가까운 useState를 사용합니다.
원도표 설명useReducer로 전환 규칙을 모읍니다. 필요하면 state·dispatch를 Context로 전달합니다.
원도표 조건·주석업데이트 규칙이 복잡한가? useReducer로 전환 규칙을 모읍니다. 필요하면 state·dispatch를 Context로 전달합니다.
원도표 설명Provider의 value가 Object.is 비교에서 달라지면 소비자가 새 값을 받습니다. memo도 Context 갱신을 막지 않으므로 변경 주기가 다른 값은 전송 범위를 나눕니다.
원도표 설명부모 렌더는 기본적으로 하위 렌더로 이어지지만 bailout이 일부 작업을 건너뛸 수 있습니다. useMemo와 외부 store는 측정된 필요에 적용하는 최적화·구독 도구입니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
App 소유 lightApp: light / 자식: theme 읽기가까운 공통 조상이 현재 theme을 소유한다소비자가 많다는 이유만으로 도구를 바꾸지 않는다
변경 의도의도: onToggle() / 현재 snapshot: lightThemeToggle의 onToggle이 owner에게 사건을 보낸다자식이 props를 직접 바꾸지 않는다
다음 darkApp: dark / Header/Main/Footer: darkowner의 다음 렌더가 모든 소비자에게 dark를 전달한다값 전달과 사건의 방향이 다르다

위 결정표는 상태의 출처를 지키면서 공유 범위를 고르는 순서를 보여 줍니다. 먼저 그 값의 출처가 이미 있는지 묻습니다. 경로와 검색 조건은 URL과 라우터가, 서버의 응답과 캐시의 수명은 데이터 계층이 가지므로, 같은 값을 화면용 저장소에 복제하지 않고 필요한 표시 상태만 나눕니다. 한 컴포넌트나 작은 하위 트리만 쓰는 UI 상태라면 가까운 곳의 useState에 두고, 가까운 형제들이 함께 쓴다면 가장 가까운 공통 조상으로 끌어올려 값과 처리 함수는 아래로, 이벤트의 의도는 위로 흐르게 합니다.

멀리 떨어진 하위 소비자가 많다면 소유자는 그대로 둔 채 Context로 전달 범위를 넓히며, 이때도 상태는 Provider를 렌더링하는 컴포넌트가 가지고 Context 자체는 저장소가 아닙니다. 상태를 바꾸는 규칙이 복잡하다면 같은 소유자에서 useReducer로 그 로직을 모으고, 필요하면 state와 dispatch를 Context로 넘깁니다. React 바깥에 이미 바뀌는 데이터 소스가 있거나 독립된 구독이 필요할 때만 외부 store를 검토하며, useSyncExternalStore로 React와 연결할 수 있습니다.

그림 위쪽의 예처럼 지금의 테마 예제는 가까운 공통 소유자인 App과 분명한 props로 충분하며, 소비자의 수만으로 Context나 외부 store를 자동으로 고르지 않습니다. 그림 아래 칸은 두 가지 주의점을 덧붙입니다. Provider의 value가 Object.is 비교에서 달라지면 소비자가 새 값을 받고 memo도 이를 막지 못하므로, 바뀌는 주기가 다른 값은 전달 범위를 나눕니다. 또 부모의 렌더링은 기본적으로 하위의 렌더링으로 이어지지만 건너뛰기가 일부 작업을 생략할 수 있으며, useMemo와 외부 store는 측정해서 필요할 때 쓰는 최적화와 구독 도구입니다.

Local과 Lift

한 컴포넌트나 작은 하위 트리만 쓰는 값은 useState로 그 자리에 둡니다. 형제 컴포넌트가 같은 값을 맞춰야 한다면 가장 가까운 공통 조상으로 상태를 끌어올리고(lift), 값과 처리 함수를 props로 넘깁니다. 서로 관련 없는 상태까지 무조건 높은 조상으로 올리면 바뀌는 범위와 인터페이스만 넓어지므로, 읽는 컴포넌트의 집합이 다른 값은 소유자도 나눌 수 있습니다.

Context는 소유자가 아니라 전달 범위

Context는 React의 하위 트리에서 값을 멀리 전달하는 수단일 뿐, Context 객체 자체가 상태를 가지지는 않습니다. 상태는 여전히 Provider를 렌더링하는 컴포넌트의 useState나 useReducer에 있습니다. Provider의 value가 Object.is 비교에서 달라지면 그 Context를 구독하는 소비자들이 새 값을 받으며, 소비자를 memo로 감싸도 새 Context 값의 전달을 막지 못합니다. 그래서 렌더할 때마다 객체와 함수를 새로 묶기 전에 실제 비용을 측정하고, 바뀌는 주기가 다른 값은 Context를 나누거나 구독 범위를 좁히는 편이 낫습니다. useMemo도 결과의 정확성을 위한 장치가 아니라 성능 최적화입니다.

Reducer와 외부 store

상태를 바꾸는 규칙이 많고 여러 이벤트가 같은 규칙을 함께 쓴다면 useReducer로 갱신 로직을 한곳에 모을 수 있습니다. 필요하다면 그 상태와 dispatch를 Context로 넘기되, 컴포넌트가 상태를 가지고 리듀서는 바꾸는 규칙을, Context는 전달을 맡는다는 역할의 구분은 그대로입니다. 외부 store는 React 바깥에 이미 바뀌는 데이터 소스가 있거나 독립된 구독과 수명 주기가 필요할 때 검토하며, useSyncExternalStore가 이런 store를 React와 잇는 표준 훅입니다(React 문서의 useSyncExternalStore). 소비자가 많다는 이유만으로 외부 store가 저절로 정답이 되지는 않습니다.

UI 상태와 다른 출처 구분하기

탭의 열림, 선택 상태, 입력 중인 값처럼 클라이언트 화면의 상호작용에 속하는 값은 UI 상태입니다. 반면 URL의 경로와 검색 조건은 URL과 라우터가, 서버의 응답과 캐시의 수명은 서버 데이터·캐시 계층이 이미 가지고 있을 수 있습니다. 같은 값을 별도의 클라이언트 UI 저장소에 복제하면 두 출처가 어긋날 수 있으므로, 먼저 그 값을 가진 기존 계층을 쓰고 필요한 표시 상태만 따로 둡니다. 상태를 어디에 둘지에 대한 더 자세한 기준은 React 문서의 컴포넌트 간 state 공유에서 볼 수 있습니다.


정리

  • 공유 상태는 읽는 컴포넌트와 바꾸는 컴포넌트를 모두 품는 가장 가까운 공통 조상이 가집니다.
  • 값과 처리 함수는 아래로 넘기고, 자식은 이벤트의 의도를 콜백으로 알려 변경을 요청합니다.
  • props drilling은 분명한 전달 방식이며, 그 자체로 버그나 성능 문제가 아닙니다.
  • Context는 전달 범위를 넓힐 뿐 상태의 소유자를 대신하지 않으며, value가 바뀌면 소비자가 모두 새 값을 받습니다.
  • Local, Lift, Context와 리듀서, 외부 store를 순서대로 기계적으로 따르기보다, 출처와 범위, 바꾸는 규칙에 맞는 가장 단순한 해법을 고릅니다.
  • URL과 서버, 캐시가 가진 상태를 클라이언트 UI 상태와 구분하고 복제하지 않습니다.

연습 문제

1. 공유하지 않아도 되는 값

테마 예제에 ContentSection에서만 열고 닫는 도움말을 더한다고 가정합니다. 테마는 지금처럼 여섯 컴포넌트가 쓰고, 도움말이 열렸는지는 다른 컴포넌트가 읽거나 바꾸지 않습니다. 두 값의 소유자는 어디에 두면 충분할까요? 테마를 바꾸려면 도움말 상태를 App으로 올려야 할까요?

해설 보기

테마의 공통 소유자는 지금처럼 App으로 두고, 도움말은 ContentSection의 지역 상태로 두면 충분합니다. 두 값을 읽는 컴포넌트의 집합이 다르므로 소유자도 달라도 됩니다.

테마는 여전히 props로 내려오므로, 테마를 바꾸려고 도움말의 지역 상태를 높은 조상으로 옮길 필요는 없습니다. 나중에 다른 컴포넌트도 도움말을 함께 다뤄야 한다는 요구가 생기면, 그때 공통 조상을 다시 찾습니다.

2. 자식마다 따로 둔 테마

ThemeToggle이 props로 받은 theme 대신 자기 안에 useState('light')를 두고, 버튼을 누르면 그 지역 상태만 뒤집도록 바꿨다고 가정합니다. 버튼을 한 번 누르면 화면은 어떻게 될까요?

해설 보기

버튼의 문구는 “라이트 모드로 전환”으로 바뀌지만, App의 theme은 바뀌지 않았으므로 헤더와 본문, 푸터는 여전히 light 그대로입니다. 이제 버튼의 상태와 앱의 테마가 서로 다른 두 값이 되어 어긋납니다.

같은 테마를 하나의 소유자인 App이 가지고, 버튼은 onToggle로 변경을 요청만 하도록 되돌려야 값의 출처가 하나로 유지됩니다.

다음 글 Context API 기초에서는 소유자는 그대로 둔 채 Context로 값을 멀리 있는 컴포넌트에 전달하는 방법을 다룹니다.