컴포넌트 간 상태 공유의 어려움
상태의 소유자와 읽기·쓰기 경로를 구분하고, Local·Lift·Context·reducer·외부 store 중 가장 작은 충분한 범위를 선택합니다.
리액트에서 상태를 공유할 때 먼저 정할 것은 도구가 아니라 값의 소유자(owner)입니다. 같은 값을 읽거나 바꾸는 컴포넌트들을 찾고, 이들을 모두 포함하는 가장 가까운 공통 조상에 상태를 두면 단일 진실 공급원을 유지할 수 있습니다.
값과 이벤트의 방향도 구분해야 합니다.
- 소유자는 현재 값과 이벤트 핸들러를 아래로 전달합니다.
- 읽는 컴포넌트(reader)는 전달받은 값을 화면에 반영합니다.
- 쓰는 컴포넌트(writer)는 상태를 직접 소유하는 대신 클릭·입력 같은 사용자 의도를 콜백으로 알립니다.
- 중간 컴포넌트는 값을 사용하지 않고 다음 자식에게 통과시킬 수 있습니다.
이를 흔히 값은 아래로, 이벤트 의도는 위로 흐르는 단방향 계약이라고 설명합니다.
Owner · readers · writers
공유 상태는 독자와 작성자를 모두 포함하는 가장 가까운 공통 조상이 소유합니다. 값과 핸들러는 아래로 전달되고, 자식은 이벤트 의도를 통해 변경을 요청합니다.
값을 아래로 전달
filters는ContentShell을 통과해Summary와CardList에 도달합니다.핸들러도 아래로 전달
onFilterChange는ControlBar를 거쳐 입력 컴포넌트에 도달합니다.이벤트 의도는 위로
Filter와Toolbar는 콜백을 호출해 변경을 요청하고, 소유자가 다음 상태를 계산합니다.통과 경로를 비용으로 관찰
중간 단계가 생겼다는 사실만으로 Context나 store로 옮기지 않습니다.
소유자 경계
독자와 작성자를 모두 포함하는 가장 가까운 공통 조상이 단일 진실 공급원을 맡습니다.
Pass-through
중간 컴포넌트는 값을 사용하지 않고 전달할 수 있습니다. 이 명시적 경로 자체는 버그가 아닙니다.
확장 기준
경로 변경 비용과 실제 구독 범위를 측정한 뒤 Context나 외부 store가 필요한지 판단합니다.
렌더링과 단방향 데이터 흐름
useState로 상태를 갱신하면 그 상태를 소유한 컴포넌트의 렌더링이 요청됩니다. React는 기본적으로 그 아래 트리도 재귀적으로 렌더링하지만, memo 같은 최적화와 동일한 상태 값에 대한 bailout이 일부 작업을 건너뛸 수 있습니다. 렌더링 결과가 나와도 실제 DOM에는 필요한 변경만 커밋됩니다.
props는 부모가 자식에게 값과 이벤트 핸들러를 명시적으로 전달하는 기본 수단입니다. 이 경로가 길어지는 현상을 props drilling이라고 합니다. Props drilling 자체는 버그도 성능 문제도 아닙니다. 작은 트리에서는 데이터 출처와 계약을 가장 분명하게 보여 주는 단순한 해법일 수 있습니다.
다만 다음 신호가 함께 나타나면 전송 범위를 다시 검토할 수 있습니다.
- 값을 쓰지 않는 중간 컴포넌트가 여러 단계에 걸쳐 같은
props를 전달한다. - 소비자나 전달 경로를 바꿀 때 많은 컴포넌트의 인터페이스를 함께 수정해야 한다.
- 서로 독립적으로 변하는 값들이 하나의 넓은 전달 경로에 묶인다.
- 상태 변경 규칙과 이벤트 처리가 여러 컴포넌트에 흩어진다.
이때도 먼저 컴포넌트 합성이나 더 가까운 소유자로 트리를 단순화할 수 있는지 확인합니다.
공유 상태가 필요한 장면
장바구니: 상품 목록은 장바구니 값을 바꾸고, 아이콘과 모달은 같은 값을 읽습니다.
사용자 인증: 로그인 화면은 인증 동작을 시작하고, 헤더와 대시보드는 현재 세션을 읽습니다. 서버에서 가져온 세션 데이터라면 임의의 UI 전역 상태로 복제하기 전에 데이터 계층의 소유권을 확인합니다.
테마: 전환 버튼은 변경 의도를 보내고, 헤더·본문·푸터는 현재 테마를 읽습니다.
Props drilling 예시: 테마 전환
다음 예시는 App이 테마를 소유하고, Header가 현재 값을 사용하면서 ThemeToggle까지 변경 핸들러를 전달하는 계약을 보여 줍니다. MainContent도 값을 사용한 뒤 더 깊은 ContentSection으로 전달합니다.
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>
);
}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>
);
}export default function ThemeToggle({ theme, onToggle }) {
return (
<button type="button" onClick={onToggle}>
{theme === 'light' ? '다크 모드' : '라이트 모드'}로 전환
</button>
);
}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>
);
}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>
);
}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로 전달합니다. 이 pass-through가 몇 단계뿐이고 계약이 명확하다면 props를 그대로 유지해도 됩니다.
상태를 둘 범위 결정하기
상태마다 출처, 소유자, 읽는 곳, 쓰는 곳을 적은 뒤 가장 작은 충분한 범위를 선택합니다.
Source → scope → update rules
먼저 출처를 보존하고, 그다음 가장 작은 충분한 공유 범위를 고릅니다. Context는 전송 수단이고 reducer는 업데이트 규칙이며, 외부 store는 소비자가 많다는 이유만으로 자동 선택되지 않습니다.
| 단계 | 가장 작은 해법 | 소유권과 전달 계약 |
|---|---|---|
| 출처 | URL · server · cache | 경로와 검색 조건은 URL/router가, 응답과 캐시 수명은 데이터 계층이 소유합니다. 같은 값을 UI store에 복제하지 않습니다. |
| 지역 | Local · useState |
한 컴포넌트나 작은 하위 트리만 쓰는 UI 상태는 가까운 곳에 둡니다. |
| 공유 | Lift · 공통 조상 | 형제·인접 컴포넌트가 공유하면 가장 가까운 공통 조상이 소유합니다. 값과 핸들러는 아래로, 이벤트 의도는 위로 흐릅니다. |
| 전송 | Context | 먼 소비자에게 값을 전달하되 상태는 Provider를 렌더링하는 컴포넌트가 소유합니다. Context 자체는 상태 저장소가 아닙니다. |
| 규칙 | Reducer · useReducer |
전환 규칙이 복잡하면 같은 소유자에서 업데이트 로직을 모읍니다. 필요할 때 state와 dispatch를 Context로 전송합니다. |
| 외부 | 외부 store | React 밖의 변경 가능한 데이터 소스가 이미 있거나 독립 구독이 필요할 때 검토합니다. useSyncExternalStore로 연결할 수 있습니다. |
출처가 이미 있는가?
URL/search, 서버 응답, 캐시 수명은 해당 계층에 남기고 필요한 UI 표시 상태만 분리합니다.
한 컴포넌트만 쓰는가?
가까운
useState를 사용합니다.가까운 형제가 공유하는가?
가장 가까운 공통 조상으로 끌어올리고 props로 값과 핸들러를 전달합니다.
먼 하위 소비자가 많은가?
소유자는 유지한 채 Context로 전송 범위를 넓힙니다.
업데이트 규칙이 복잡한가?
useReducer로 전환 규칙을 모읍니다.React 밖의 구독이 필요한가?
독립 데이터 소스와 수명주기가 있을 때만 외부 store를 검토합니다.
Context 구독 비용
Provider의 value가 Object.is 비교에서 달라지면 소비자가 새 값을 받습니다. memo도 Context 갱신을 막지 않으므로 변경 주기가 다른 값은 전송 범위를 나눕니다.
렌더와 최적화 경계
부모 렌더는 기본적으로 하위 렌더로 이어지지만 bailout이 일부 작업을 건너뛸 수 있습니다. useMemo와 외부 store는 측정된 필요에 적용하는 최적화·구독 도구입니다.
Local과 Lift
한 컴포넌트나 작은 하위 트리만 쓰는 값은 useState로 지역에 둡니다. 형제 컴포넌트가 같은 값을 조율해야 하면 가장 가까운 공통 조상으로 상태를 끌어올리고, 값과 핸들러를 props로 전달합니다.
서로 관련 없는 상태까지 무조건 높은 조상으로 올리면 변경 범위와 인터페이스만 넓어집니다. 소비자 집합이 다른 값은 소유자도 나눌 수 있습니다.
Context는 소유자가 아니라 전송 범위
Context는 React 하위 트리에서 값을 멀리 전달하는 수단입니다. Context 객체 자체가 상태를 소유하지는 않습니다. 상태는 여전히 Provider를 렌더링하는 컴포넌트의 useState나 useReducer 등에 있습니다.
Provider의 value가 Object.is 비교에서 달라지면 그 Context를 구독하는 소비자들은 새 값을 받습니다. 소비자를 memo로 감싸도 새 Context 값의 전달을 막지는 않습니다. 따라서 객체와 함수를 매 렌더마다 새로 묶기 전에 실제 비용을 측정하고, 변경 주기가 서로 다른 값은 Context를 나누거나 구독 범위를 좁히는 편이 낫습니다. useMemo도 정확성을 위한 장치가 아니라 성능 최적화입니다.
Reducer와 외부 store
상태 전환 규칙이 많고 여러 이벤트가 같은 규칙을 공유한다면 useReducer로 업데이트 로직을 한곳에 모을 수 있습니다. 필요하다면 그 상태와 dispatch를 Context로 전달하되, 컴포넌트가 상태를 소유하고 reducer는 전환 규칙을, Context는 전송을 담당한다는 역할 구분은 그대로입니다.
외부 store는 이미 React 밖에 존재하는 변경 가능한 데이터 소스나 독립적인 구독·수명주기가 필요할 때 검토합니다. useSyncExternalStore는 이런 store를 React와 연결하는 표준 훅입니다. 소비자가 많다는 이유만으로 외부 store가 자동 정답이 되지는 않습니다.
UI 상태와 다른 출처 구분하기
탭 열림, 선택 상태, 임시 입력처럼 클라이언트 화면 상호작용에 속하는 값은 UI 상태입니다. 반면 URL 경로와 검색 조건은 URL/router가, 서버 응답과 캐시 수명은 서버 데이터·캐시 계층이 이미 소유할 수 있습니다. 같은 값을 별도의 client UI store에 복제하면 두 출처가 어긋날 수 있으므로, 먼저 기존 소유 계층을 사용하고 필요한 표시 상태만 분리합니다.
정리
- 공유 상태는 독자와 작성자를 모두 포함하는 가장 가까운 공통 조상이 소유한다.
- 값과 핸들러는 아래로 전달되고, 자식은 이벤트 의도를 통해 변경을 요청한다.
- Props drilling은 명시적인 전달 계약이며 그 자체로 버그나 성능 문제가 아니다.
- Context는 전송 범위를 넓히지만 상태 소유자를 대신하지 않는다.
- Local → Lift → Context/reducer → 외부 store 순서를 기계적으로 따르기보다 출처·범위·변경 규칙에 맞는 가장 작은 충분한 해법을 고른다.
- URL·서버·캐시 상태를 client UI 상태와 구분한다.