
React 개발자라면 누구나 한 번쯤은 Cotext API를 사용해 봤을 것이다. 하지만 많은 개발자들이 Context API를 단순히 상태 관리 도구로 생각하고 있는 경우가 많다. 오늘은 이에 대해 얘기하고, Context API의 진짜 목적과 내부 구현을 파헤쳐 보려 한다.
나는 Context API가 상태 관리 도구보다는 의존성 주입(Dependency Injection) 도구라 생각했다. 적어도 상태 관리 도구라 생각하는 것보다는 좋은 답변이라 생각한다. 다만 이 글의 결론에도 적어뒀지만, 이 글을 적으며 그 관점에 변화가 생겼다. 내부 동작을 분석하며 의존성 주입의 도구에 그치지 않고 컴포넌트 렌더링 사이클과 동기화시켜 안정적으로 사용할 수 있게 관리해주는 API 정도의 개념으로 이해하게 되었다. 이 이유에 대해서는 결론 부분에 자세히 적어뒀다.
React의 기본 데이터 흐름은 부모에서 자식으로 props를 통해 전달되는 단방향이다. 그러나 여러 중첩 레벨에 걸쳐 동일한 데이터를 전달해야 하는 경우(예: 테마, 언어 설정, 인증 정보 등) 이 방식은 번거로워진다. Context API는 이런 "prop drilling" 문제를 해결하기 위한 도구다.
React 공식 문서에서도 Context는 "컴포넌트 트리를 통해 데이터를 명시적으로 전달하지 않고도 공유할 수 있는 방법"이라고 설명한다. 여기서 핵심은 "데이터 공유"이지 "상태 관리"가 아니다.

위 그림에서 currentValue는 뭔지 valueStack은 무엇인지 복잡해보이는 Context 구조에 의문이 들 수 있다. 왜 내가 이런 형태의 그림을 그렸는지는 함께 Context API의 세부 구현을 뜯어보다보면 알게 될거라 생각한다.
Context API의 내부 동작을 이해하기 위해 실제 시나리오를 통해 단계별로 살펴보자. 아래와 같은 간단한 카운터 예제를 기준으로 설명하겠다:
import {createContext, useContext, useState} from 'react'
// 1. Context 생성
const CounterContext = createContext<{
count: number;
setCount: React.Dispatch<React.SetStateAction<number>>;
} | null>(null);
// 2. 중첩된 카운터 컴포넌트
const NestedCounter = () => {
const context = useContext(CounterContext);
if (!context) {
throw new Error("Context must be used within a Provider");
}
const { count, setCount } = context;
return (
<div>
<h2>Nested Counter</h2>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
</div>
);
};
// 3. 메인 카운터 컴포넌트
const Counter = () => {
const context = useContext(CounterContext);
const [localCount, setLocalCount] = useState(20);
if (!context) {
throw new Error("Context must be used within a Provider");
}
const { count, setCount } = context;
return (
<div>
<h1>Main Counter</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
{/* 중첩된 Provider */}
<CounterContext.Provider value={{
count: localCount,
setCount: setLocalCount
}}>
<NestedCounter />
</CounterContext.Provider>
</div>
);
};
// 4. 앱 컴포넌트
function App() {
const [count, setCount] = useState(0);
return (
<CounterContext.Provider value={{count, setCount}}>
<Counter />
</CounterContext.Provider>
);
}
이제 이 코드가 실행될 때 내부적으로 어떤 일이 일어나는지 순서대로 살펴보자.
const CounterContext = createContext<{
count: number;
setCount: React.Dispatch<React.SetStateAction<number>>;
} | null>(null);
createContext의 내부 구현은 다음과 같다.
export function createContext<T>(defaultValue: T): ReactContext<T> {
// TODO: 두 번째 인자로 사용되던 선택적 'calculateChangedBits' 함수에 대한 경고를
// 미래 사용을 위해 예약해두어야 할까?
const context: ReactContext<T> = {
$$typeof: REACT_CONTEXT_TYPE,
// 여러 동시 렌더러를 지원하기 위한 해결책으로,
// 일부 렌더러를 primary(주)로, 나머지를 secondary(부)로 분류합니다.
// 최대 두 개의 동시 렌더러만 예상됩니다:
// - React Native(주)와 Fabric(부)
// - React DOM(주)와 React ART(부)
// 부 렌더러들은 context 값을 별도의 필드에 저장합니다.
_currentValue: defaultValue,
_currentValue2: defaultValue,
// 단일 렌더러 내에서 현재 context가 지원하는 동시 렌더러 수를 추적합니다.
// 예: 병렬 서버 렌더링
_threadCount: 0,
// 순환 참조를 위한 필드들
Provider: (null: any),
Consumer: (null: any),
};
if (enableRenderableContext) {
context.Provider = context;
context.Consumer = {
$$typeof: REACT_CONSUMER_TYPE,
_context: context,
};
} else {
(context: any).Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,
};
(context: any).Consumer = context;
}
return context;
}
createContext는 단순하게 Contexct 타입의 리액트 엘리먼트를 생성해 반환한다. 이 과정에서 리액트의 버전에 따라 Provider가 Context 그 자체가 되거나, Consumer가 Context 그 자체가 된다(레거시).
중요한 포인트는 Context 타입의 리액트 요소를 생성해 반환하고, defaultValue를 _currentValue 필드에 저장한다는 것이다. 즉, 요약하면 다음과 같다:
CounterContext 객체가 생성된다._currentValue와 _currentValue2에 기본값 null이 설정된다.Provider와 Consumer 속성이 설정된다. (최신 React에서는 일반적으로 enableRenderableContext = true)
<CounterContext.Provider value={{count, setCount}}>
<Counter />
</CounterContext.Provider>
App 컴포넌트가 렌더링되면서 첫 번째 Provider가 마운트될 때 React는 beginWork 함수에서 updateContextProvider 함수를 호출한다:
function updateContextProvider(current, workInProgress, renderLanes) {
let context = workInProgress.type; // CounterContext
const newProps = workInProgress.pendingProps;
const newValue = newProps.value; // {count: 0, setCount: function}
// Provider 값 설정
pushProvider(workInProgress, context, newValue);
// 자식 노드 처리
const newChildren = newProps.children;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;
}
우선 각 변수 및 파라미터에 대해 알아보자:
current: 현재 렌더링 되어있는 Fiber노드(즉, 구버전의 Fiber 노드)workInProgress: 현재 렌더링을 진행하고 있는 Fiber노드(즉, 새버전의 Fiber 노드)newValue: Provider의 value로 전달한 값(count, setCount)핵심은 pushProvider 함수다:
export function pushProvider<T>(
providerFiber: Fiber,
context: ReactContext<T>,
nextValue: T,
): void {
if (isPrimaryRenderer) {
// 1. 현재 값을 스택에 저장
push(valueCursor, context._currentValue, providerFiber);
// 2. 새 값으로 업데이트
context._currentValue = nextValue; // {count: 0, setCount: function}
} else {
// 보조 렌더러 처리 (React Native 등)
push(valueCursor, context._currentValue2, providerFiber);
context._currentValue2 = nextValue;
}
}
function push<T>(cursor: StackCursor<T>, value: T, fiber: Fiber): void {
index++; // 스택 포인터 증가
valueStack[index] = cursor.current; // 이전 값(null)을 스택에 저장
cursor.current = value; // 커서 값 업데이트
}
보다싶이 valueStack이라는 스택 자료구조에, Provider에서 전달받은 값을 저장하고, Context의 currentValue 값을 새로운 값으로 갱신한다.
이 과정을 통해:
1. valueStack에 이전 값(null)이 저장된다.
2. CounterContext._currentValue가 {count: 0, setCount: function}으로 설정된다.
이렇게 Provider는 해당 Context의 현재 값을 설정하고, 이전 값을 스택에 저장하는 역할을 한다.

const context = useContext(CounterContext);
Counter 컴포넌트가 렌더링될 때 useContext 훅이 호출되면, React는 내부적으로 readContext 함수를 호출한다:
export function readContext<T>(context: ReactContext<T>): T {
return readContextForConsumer(currentlyRenderingFiber, context);
}
function readContextForConsumer<T>(
consumer: Fiber | null,
context: ReactContext<T>,
): T {
// 1. 현재 렌더러에 맞는 값 가져오기
const value = isPrimaryRenderer
? context._currentValue // {count: 0, setCount: function}
: context._currentValue2;
// 2. 컴포넌트와 Context 간의 의존성 등록
const contextItem = {
context: ((context: any): ReactContext<mixed>),
memoizedValue: value,
next: null,
};
if (lastContextDependency === null) {
// 첫 번째 의존성인 경우
lastContextDependency = contextItem;
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
};
consumer.flags |= NeedsPropagation;
} else {
// 추가 의존성인 경우
lastContextDependency = lastContextDependency.next = contextItem;
}
return value; // {count: 0, setCount: function} 반환
}
이 과정에서 일어나는 일:
1. CounterContext._currentValue에서 현재 값({count: 0, setCount: function})을 읽어온다.
2. Counter 컴포넌트와 CounterContext 간의 의존성이 등록된다.
3. 이 의존성 정보는 나중에 Context 값이 변경될 때 어떤 컴포넌트를 리렌더링할지 결정하는 데 사용된다.
여기서 중요한 점은 의존성 추적이다. React는 어떤 컴포넌트가 어떤 Context를 사용하는지 추적하여, Context 값이 변경될 때 해당 컴포넌트만 효율적으로 리렌더링할 수 있다. 컨텍스트의 값을 읽는것 자체는 단순히 Context의 _currentValue 값을 반환할 뿐이다.

<CounterContext.Provider value={{
count: localCount,
setCount: setLocalCount
}}>
<NestedCounter />
</CounterContext.Provider>
Counter 컴포넌트 내부에 중첩된 Provider가 렌더링될 때, 다시 updateContextProvider와 pushProvider 함수가 호출된다:
// pushProvider의 동작
// 1. 현재 값({count: 0, setCount: function})을 스택에 저장
push(valueCursor, context._currentValue, providerFiber);
// 2. 새 값으로 업데이트
context._currentValue = {count: 20, setCount: setLocalCount};
이제 valueStack과 _currentValue의 상태는 다음과 같다:
valueStack: [null, {count: 0, setCount: function}]CounterContext._currentValue: {count: 20, setCount: setLocalCount}이처럼 중첩된 Provider는 Context 값을 오버라이드하며, 이전 값은 스택에 보존된다.

const context = useContext(CounterContext);
NestedCounter 컴포넌트에서 useContext가 호출되면, 다시 readContext 함수가 실행된다:
// readContextForConsumer의 동작
const value = isPrimaryRenderer
? context._currentValue // {count: 20, setCount: setLocalCount}
: context._currentValue2;
이 시점에서 CounterContext._currentValue는 가장 가까운 Provider에서 설정한 값인 {count: 20, setCount: setLocalCount}이므로, NestedCounter는 이 값을 사용한다.
동시에 NestedCounter 컴포넌트와 CounterContext 간의 의존성도 등록된다.

Counter 컴포넌트가 언마운트되거나 리렌더링될 때, 중첩된 Provider도 언마운트된다. 이때 popProvider 함수가 호출된다:
export function popProvider(context: ReactContext<any>): void {
if (isPrimaryRenderer) {
pop(valueCursor); // valueStack에서 이전 값 복원
context._currentValue = valueCursor.current;
} else {
pop(valueCursor);
context._currentValue2 = valueCursor.current;
}
}
function pop<T>(cursor: StackCursor<T>): void {
cursor.current = valueStack[index]; // {count: 0, setCount: function}
valueStack[index] = null;
index--;
}
이 과정을 통해:
1. valueStack에서 이전 값({count: 0, setCount: function})을 꺼낸다.
2. CounterContext._currentValue를 이전 값으로 복원한다.
3. valueStack의 상태: [null]
이렇게 Provider가 언마운트되면 스택에서 이전 값을 복원하여 Context 값의 계층 구조를 유지한다.

지금까지 살펴본 시나리오를 바탕으로 Context API의 핵심 메커니즘을 정리해보자.
React는 valueStack이라는 배열을 사용하여 중첩된 Provider의 값들을 관리한다:
const valueStack: Array<any> = [];
let index = -1;
이 스택은 LIFO(Last-In-First-Out) 방식으로 작동한다:
Provider가 마운트될 때: 이전 값이 스택에 저장되고, 새 값이 설정된다.Provider가 언마운트될 때: 스택에서 이전 값을 꺼내어 복원한다.이러한 스택 기반 구조 덕분에 중첩된 Provider가 올바르게 동작할 수 있다.

컴포넌트가 Context를 소비할 때, React는 의존성을 링크드 리스트 형태로 추적한다:
const contextItem = {
context: context,
memoizedValue: value,
next: null
};
여러 Context를 사용하는 경우, 이 의존성들은 링크드 리스트로 연결된다:
// 첫 번째 의존성
lastContextDependency = contextItem1;
consumer.dependencies = {
firstContext: contextItem1
};
// 두 번째 의존성
lastContextDependency = lastContextDependency.next = contextItem2;
// 결과: contextItem1 -> contextItem2
이러한 의존성 추적 덕분에:
1. Context 값이 변경될 때 해당 Context를 사용하는 컴포넌트만 리렌더링된다.
2. 컴포넌트가 여러 Context를 사용해도 모든 의존성을 효율적으로 관리할 수 있다.
나는 Context API를 의존성 주입도구라 생각했다. 다만 이번에 내부 구현을 동작하며 조금은 달리 생각하게 되었다. 어쩌면 전역 변수나, 외부 의존성을 주입해주고, 내가 원하는 범위 만큼의 리액트 라이프 사이클과 동기화 시키며, 사이드 이펙트를 대신 관리해주는 역할로 생각하게 되었다. 그도 그럴게 Provider는 단순히 _currentValue라는 값을 업데이트 하고, 업데이트 히스토리를 관리하는 역할을 하며, useContext는 컴포넌트와 Context간의 의존 관계를 관리하고, 단순히 Context의 _currentValue값을 반환하는 함수이기 때문이다.
다만 valueStack을 통해 Provider의 상태에 따른 적절한 값을 읽을 수 있게 관리하며, linked-list 기반의 의존성 추적을 통해 의존하는 컴포넌트가 적절히 리렌더링 될 수 있도록 해준다. 그렇기에 의존성 주입도구에서 의존성 주입 그 이상의 무언가 라는 관점의 변화가 생긴거 같다.
이게 옳바른 방향인지 아닌지는 아직 잘 모르겠다. 여전히 고민하고 있고, 고민해 봐야 알것만 같다.
기환님의 딥다이브 시리즈 잘 읽고 있습니다👍 몇 가지 질문이 있어서 슬쩍 남겨놓겠습니다
좋은 글 감사합니다!
저도 최근에 좋은 코드를 보는 습관을 들이려고 오픈 소스를 뜯어보려고 하는데, 너무 어렵더라구요!
나중에 기회가 된다면 기환님의 깊이 있는 학습법 배우고 싶습니다!👍
혹시 context랑 Provider를 저는 외부에서 선언해서 가져와서 사용하는데, 딱히 이유가 있는 건 아니고 처음 학습하면서 접했던 아티클이 그렇게 사용 중이라서 이렇게 쓰나보다 해서 사용하게 됐는데, 컴포넌트 내부에서 사용하시는 이유가 있으실까요??
좋은 글 쓰시느라 고생하셨습니다~bb
단순하게 리코일처럼 전역 상태로 쓰는 도구로만 알고있었는데 기환님 글 읽으면서 다시 생각해보는 계기가 되었습니다 ㅎ ㅎ
최근에 const {setAuth} useContext(AuthContext)를 사용하다가 어떻게 AuthContext를 알고 값을 가져오지? 라는 궁금증이 생겨서 공식문서만 설명만 보고 "대충 Provider를 찾아 계속 부모로 올라가는구나..." 정도만 이해했었는데 마침 메인화면에 Context API 뜯어보기가 나오네요.
제가 React 패러다임을 잘 몰라 온전히 이해하지는 못했지만 코드를 뜯어보고 흐름을 파악하는게 정말 좋은 접근인 것 같습니다.
앞으로도 기환햄의 딥다이브 기대하겠습니다!
좋은 글 감사합니다 :-)
사용하는 라이브러리와 프레임워크의 API를 사용하기만 했지 깊이있게 알아보자는 생각은 못했는데, 기환님 글을 볼때마다 좋은 인사이트와 자극을 얻는 것 같아요!
너무 잘 읽었습니다!
React Context API에 대한 심층적인 분석과 내부 구현에 대한 해석이 인상적입니다.
저도 기환님처럼 주니어때부터 원리에 대해서 학습했다면 어땠을까 하고 매번 반성할때가 많아요
그래도 이런식의 접근과 학습은 기환님에게 항상 풍족한 자산으로 돌아올거같습니다 응원합니다 :)
좋은글 감사합니다!
다시 한번 context api를 보게된 글이었어요.
한가지 의견이 있습니다.!
const context = useContext(CounterContext);
이렇게 값을 바로 넣어주는 것 만으로도 의존성을 주입해준다고 생각합니다.
어떻게 보면 헬퍼처럼 보일 수도 있겠지만 props를 통해 값을 주입 받지 않고 useContext를 통해서 값을 주입 받고 있으니 이것만 해도 의존성 주입이라고 생각 합니다.