Context api는 props 없이 어떻게 상태를 공유하고 전파하는 걸까
들어가며
React의 Context API는 흔히 props drilling을 피하기 위해 사용합니다.
이때 상태의 공유는 마치 Provider에서 뿌려주는 것 같았습니다.
실제로 props 없이 어떻게 하위 컴포넌트들과 상태를 공유할 수 있는지 궁금해서 살펴보았습니다.
Context API 간단 사용법 복습
먼저 기본 사용법을 다시 확인해봅시다.
const ThemeContext = React.createContext('light');
function App() {
return (
<ThemeContext.Provider value='dark'>
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const theme = React.useContext(ThemeContext);
return <div>Theme: {theme}</div>;
}
const ThemeContext = React.createContext('light');
function App() {
return (
<ThemeContext.Provider value='dark'>
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const theme = React.useContext(ThemeContext);
return <div>Theme: {theme}</div>;
}
createContext
로 Context 객체를 생성합니다.Provider
로 값을 지정하고 Context로 관리되는 영역을 표시합니다.useContext
로 Consumer에서 읽을 수 있습니다.
겉으로 보기에 단순 전역 값에 접근하는 것과 유사하지만, 내부는 훨씬 정교하게 동작합니다.
1. Context 객체의 정체
createContext(defaultValue)
를 호출하면, 단순한 JS 객체 하나가 생성됩니다.
이 객체가 우리가 흔히 사용하는 ThemeContext
나 AuthContext
같은 Context 그 자체입니다.
export function createContext<T>(defaultValue: T): ReactContext<T> {
const context: ReactContext<T> = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: defaultValue,
_currentValue2: defaultValue, // 동시 렌더링을 지원하기 위한 보조 렌더러
_threadCount: 0, // 현재 이 context가 단일 렌더러 내에서 몇 개의 동시 렌더러를 지원하는지
Provider: (null: any),
Consumer: (null: any),
};
context.Provider = context; // 자신이 속한 context
context.Consumer = {
$$typeof: REACT_CONSUMER_TYPE,
_context: context,
};
return context;
}
export function createContext<T>(defaultValue: T): ReactContext<T> {
const context: ReactContext<T> = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: defaultValue,
_currentValue2: defaultValue, // 동시 렌더링을 지원하기 위한 보조 렌더러
_threadCount: 0, // 현재 이 context가 단일 렌더러 내에서 몇 개의 동시 렌더러를 지원하는지
Provider: (null: any),
Consumer: (null: any),
};
context.Provider = context; // 자신이 속한 context
context.Consumer = {
$$typeof: REACT_CONSUMER_TYPE,
_context: context,
};
return context;
}
이 Context 객체에서, Context api 동작을 이해하기 위해 중요한 부분은 두 가지입니다.
_currentValue
- 실제로 Context 값이 참조되고 관리되는 곳입니다.
- Provider의 value 값도 결국 여기에 저장됩니다.
useContext
를 통해 접근하는 값은 이곳입니다
Provider
- Provider를 통해 Context가 관리되는 영역을 지정할 수 있습니다.
- Provider는 Context를 참조하고 있어서, 리액트에게 어떤 Context의 Provider인지를 알려줍니다.
-
Context의
_currentValue
를 관리하는 역할을 합니다.
리액트 19 최신 문법에서는, React 19에서 Context 객체가 JSX에서 직접 Provider 역할을 할 수 있도록 API가 확장되었기 때문에, Provider를 사용할때
<Context.Provider>
혹은<Context>
로 사용할 수 있습니다.코드상에서도 Context 객체에서 생성된 Context와
Provider
는 같은 값을 가리키고 있는 것을 알 수 있습니다.
2. Provider 진입과 이탈의 동작 원리
const ThemeContext = React.createContext('system');
function App() {
return (
<Theme.Provider value='light'>
<Theme.Provider value='dark'></Theme.Provider>
</Theme.Provider>
);
}
const ThemeContext = React.createContext('system');
function App() {
return (
<Theme.Provider value='light'>
<Theme.Provider value='dark'></Theme.Provider>
</Theme.Provider>
);
}
전반적인 코드 흐름 예시는 다음과 같습니다.
렌더링 과정중에서, Provider를 만나게 되면 어떤 일이 일어나는지 알아보겠습니다.
Provider 진입 시
렌더링 중 Provider를 만나면 React는 pushProvider
를 실행하여 두 가지 작업을 합니다.
// ReactFiberNewContext.js
export function pushProvider<T>(
providerFiber: Fiber,
context: ReactContext<T>,
nextValue: T
): void {
push(valueCursor, context._currentValue, providerFiber);
context._currentValue = nextValue;
}
// ReactFiberNewContext.js
export function pushProvider<T>(
providerFiber: Fiber,
context: ReactContext<T>,
nextValue: T
): void {
push(valueCursor, context._currentValue, providerFiber);
context._currentValue = nextValue;
}
context._currentValue
(Context 객체 내부에서 전역적으로 공유되는 현재 값)를 전역 스택(valueStack
)에 백업합니다.context._currentValue
를 Provider의value
로 교체합니다.
그림으로 알아보기
- 일단 Context가 생성되면, Context의
_currentValue
에는 기본값이 저장됩니다.
- 그리고 렌더링 중에 Provider를 만나게 되면, 현재 저장되어있는 값을
valueStack
에 백업하고, Provider의 value를 저장합니다.
- 이러한 구조를 통해, 같은 Context를 공유하는 여러개의 Provider가 겹치더라도 정확히 현재 값을 가리킬 수 있습니다.
- Provider가 값을 교체하면, 하위 트리를 렌더링하는 동안 Consumer들은
context._currentValue
에서 갱신된 값을 읽게 됩니다.
Provider 이탈 시
Provider 범위를 벗어나면 popProvider
을 호출하여 이전 값을 복원합니다.
// ReactFiberNewContext.js
export function popProvider(
context: ReactContext<any>,
providerFiber: Fiber
): void {
const currentValue = valueCursor.current; // valueCursor는 전역 stack의 top
context._currentValue = currentValue;
pop(valueCursor, providerFiber);
}
// ReactFiberNewContext.js
export function popProvider(
context: ReactContext<any>,
providerFiber: Fiber
): void {
const currentValue = valueCursor.current; // valueCursor는 전역 stack의 top
context._currentValue = currentValue;
pop(valueCursor, providerFiber);
}
- 전역 스택에 저장해둔 이전 값을 꺼냅니다.
context._currentValue
에 이전 값을 저장하여 복원합니다
그림으로 알아보기
- 렌더링 중에 Provider의 닫는 태그를 만나게 되면, 전역 스택에 있는 값을 pop하고
_currentValue
에 저장합니다.
- 따라서, Provider의 영역안에서 정확한 값을 가리킬 수 있게 됩니다.
하나의 전역 스택만 쓰는 이유
React는 모든 Context가 하나의 전역 스택을 공유합니다.
- Context마다 별도의 스택을 만들면 메모리 낭비가 발생합니다.
- 렌더링은 DFS 순서로 이루어지므로, Provider 진입/이탈은 항상 LIFO로 짝을 이룹니다.
- 따라서 단일 전역 스택만으로도 여러 Context의 중첩을 정확히 처리할 수 있습니다.
3. useContext
로 Context 읽는 방법
생각보다 값을 읽는 로직은 간단합니다.
const theme = React.useContext(ThemeContext);
const theme = React.useContext(ThemeContext);
ThemeContext
을 전달하여 값을 읽을때useContext
에서는 내부적으로 결국context._currentValue
반환합니다.
코드로 살펴보기
// ReactFiberNewContext.js
function readContextForConsumer<T>(
consumer: Fiber | null,
context: ReactContext<T>
): T {
const value = context._currentValue; // 현재값 읽기
const contextItem = {
context,
memoizedValue: value,
next: null,
};
if (lastContextDependency === null) {
// 이 Fiber의 첫 Context 구독
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
};
consumer.flags |= NeedsPropagation;
lastContextDependency = contextItem;
} else {
// 이미 구독 리스트가 있으면 이어 붙임
lastContextDependency = lastContextDependency.next = contextItem;
}
return value;
}
// ReactFiberNewContext.js
function readContextForConsumer<T>(
consumer: Fiber | null,
context: ReactContext<T>
): T {
const value = context._currentValue; // 현재값 읽기
const contextItem = {
context,
memoizedValue: value,
next: null,
};
if (lastContextDependency === null) {
// 이 Fiber의 첫 Context 구독
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
};
consumer.flags |= NeedsPropagation;
lastContextDependency = contextItem;
} else {
// 이미 구독 리스트가 있으면 이어 붙임
lastContextDependency = lastContextDependency.next = contextItem;
}
return value;
}
이 코드를 통해 확인해야할 정보는 두 가지입니다.
- Context를 읽을때, 가공없이
context._currentValue
의 값을 그대로 리턴합니다. - 컴포넌트마다
dependencies
가 존재하고, Context를 읽을때dependencies
에 기록합니다.
컴포넌트마다 존재하는 dependencies의 특징은 다음과 같습니다.
dependencies
에는 다시 렌더링 되어야하는지 정보 lanes 와 firstContext 를 시작으로 구독중인 context들이 linked list 로 저장되어있습니다.- 이렇게 저장된
dependencies
는, 나중에 Context의 값이 변경되었을때, 해당 context를 구독한 컴포넌트를 빠르게 찾도록 도와줍니다. memoizedValue
는 이전 Context의 currentValue와 비교하여 업데이트 여부를 결정하는데 사용됩니다.
4. Context 가 변경되었을 때 전파되는 과정
Context는 단순 전역 변수처럼 값만 바꾸면 반영되지 않습니다.
가장 중요한 조건은 Provider의 props.value
변경입니다.
React가 이를 감지하고 Context를 구독하는 컴포넌트의 리렌더링을 유도합니다.
Context 변경 전파 과정
- React는 렌더링 과정에서 Provider의 value prop을 이전 값과 비교해 변경 여부를 감지합니다.
- 값이 바뀌었다면 Provider의 하위 트리를 DFS로 탐색하면서 각 컴포넌트의
dependencies
를 확인합니다. dependencies
에 변경된 Context가 포함되어 있다면 해당 컴포넌트의lanes
에SyncLane
라는 업데이트 플래그를 기록합니다.- 렌더링을 진행하면서
- (Provider의 value 변경을 감지하고
lanes
를 기록한) 같은 렌더 패스에서 곧바로 컴포넌트 리렌더링이 이루어집니다. childLanes
가 존재하면 하위 트리 탐색을 이어가며,- _
lanes
가 설정된 컴포넌트는 다시 계산되어 새 Context 값을 읽어옵니다. - 하나의 컴포넌트가 여러 Context를 구독 중이라면,
dependencies
중 하나라도 변경되면 전체 컴포넌트를 다시 계산합니다.
- (Provider의 value 변경을 감지하고
그림으로 알아보기
Context의 개수와 탐색
- Provider가 많고 자주 변경될수록 각 Provider마다 변경 여부를 확인하고 하위 트리를 DFS로 탐색해야 합니다.
- 즉, 동일한 서브트리를 여러 번 탐색하게 되어 비용이 커질 수 있습니다.
- 그래서 Context API는 전역 상태 관리 용도로 과도하게 사용하면 성능 저하가 발생할 수 있습니다.
정리하기
- Context API는 단순한 전역 변수처럼 보이지만, 실제로는 React의 렌더링 사이클과 최적화를 고려한 정교한 구조로 동작합니다.
- Provider는 Consumer에 값을 직접 "전달"하기보다는, Context 객체의 현재 값을 교체하고 "복원"하는 장치 역할을 합니다.
useContext
는 단순히 값을 읽는 것에 그치지 않고, 해당 컴포넌트가 어떤 Context에 의존하는지도 기록합니다.- 값이 변경되었을때는 즉시 전파되는 것이 아니라, 렌더링이 트리거되면서 Consumer에 업데이트 플래그가 표시되고 이후 렌더링 단계에서 새 값이 반영됩니다.
- 이런 메커니즘 덕분에 React는 Context를 전역처럼 간단히 사용할 수 있게 하면서도, 실제로는 필요한 Consumer만 선별적으로 리렌더링하는 최적화된 시스템을 제공합니다.