Context api는 props 없이 어떻게 상태를 공유하고 전파하는 걸까

Article

들어가며

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 객체 하나가 생성됩니다.

이 객체가 우리가 흔히 사용하는 ThemeContextAuthContext 같은 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 동작을 이해하기 위해 중요한 부분은 두 가지입니다.

  1. _currentValue
    1. 실제로 Context 값이 참조되고 관리되는 곳입니다.
    2. Provider의 value 값도 결국 여기에 저장됩니다.
    3. useContext를 통해 접근하는 값은 이곳입니다
  2. Provider
    1. Provider를 통해 Context가 관리되는 영역을 지정할 수 있습니다.
    2. Provider는 Context를 참조하고 있어서, 리액트에게 어떤 Context의 Provider인지를 알려줍니다.
    3. 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;
}
  1. context._currentValue(Context 객체 내부에서 전역적으로 공유되는 현재 값)를 전역 스택(valueStack)에 백업합니다.
  2. 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);
}
  1. 전역 스택에 저장해둔 이전 값을 꺼냅니다.
  2. 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;
}

이 코드를 통해 확인해야할 정보는 두 가지입니다.

  1. Context를 읽을때, 가공없이 context._currentValue의 값을 그대로 리턴합니다.
  2. 컴포넌트마다 dependencies 가 존재하고, Context를 읽을때 dependencies에 기록합니다.

컴포넌트마다 존재하는 dependencies의 특징은 다음과 같습니다.

이미지

  1. dependencies에는 다시 렌더링 되어야하는지 정보 lanes 와 firstContext 를 시작으로 구독중인 context들이 linked list 로 저장되어있습니다.
  2. 이렇게 저장된 dependencies는, 나중에 Context의 값이 변경되었을때, 해당 context를 구독한 컴포넌트를 빠르게 찾도록 도와줍니다.
  3. memoizedValue는 이전 Context의 currentValue와 비교하여 업데이트 여부를 결정하는데 사용됩니다.

4. Context 가 변경되었을 때 전파되는 과정

Context는 단순 전역 변수처럼 값만 바꾸면 반영되지 않습니다.

가장 중요한 조건은 Provider의 props.value 변경입니다.

React가 이를 감지하고 Context를 구독하는 컴포넌트의 리렌더링을 유도합니다.

Context 변경 전파 과정

  1. React는 렌더링 과정에서 Provider의 value prop을 이전 값과 비교해 변경 여부를 감지합니다.
  2. 값이 바뀌었다면 Provider의 하위 트리를 DFS로 탐색하면서 각 컴포넌트의 dependencies를 확인합니다.
  3. dependencies변경된 Context가 포함되어 있다면 해당 컴포넌트의 lanesSyncLane라는 업데이트 플래그를 기록합니다.
  4. 렌더링을 진행하면서
    1. (Provider의 value 변경을 감지하고 lanes를 기록한) 같은 렌더 패스에서 곧바로 컴포넌트 리렌더링이 이루어집니다.
    2. childLanes가 존재하면 하위 트리 탐색을 이어가며,
    3. _lanes가 설정된 컴포넌트는 다시 계산되어 새 Context 값을 읽어옵니다.
    4. 하나의 컴포넌트가 여러 Context를 구독 중이라면, dependencies 중 하나라도 변경되면 전체 컴포넌트를 다시 계산합니다.

그림으로 알아보기

이미지

Context의 개수와 탐색

이미지

  • Provider가 많고 자주 변경될수록 각 Provider마다 변경 여부를 확인하고 하위 트리를 DFS로 탐색해야 합니다.
  • 즉, 동일한 서브트리를 여러 번 탐색하게 되어 비용이 커질 수 있습니다.
  • 그래서 Context API는 전역 상태 관리 용도로 과도하게 사용하면 성능 저하가 발생할 수 있습니다.

정리하기

  • Context API는 단순한 전역 변수처럼 보이지만, 실제로는 React의 렌더링 사이클과 최적화를 고려한 정교한 구조로 동작합니다.
  • Provider는 Consumer에 값을 직접 "전달"하기보다는, Context 객체의 현재 값을 교체하고 "복원"하는 장치 역할을 합니다.
  • useContext는 단순히 값을 읽는 것에 그치지 않고, 해당 컴포넌트가 어떤 Context에 의존하는지도 기록합니다.
  • 값이 변경되었을때는 즉시 전파되는 것이 아니라, 렌더링이 트리거되면서 Consumer에 업데이트 플래그가 표시되고 이후 렌더링 단계에서 새 값이 반영됩니다.
  • 이런 메커니즘 덕분에 React는 Context를 전역처럼 간단히 사용할 수 있게 하면서도, 실제로는 필요한 Consumer만 선별적으로 리렌더링하는 최적화된 시스템을 제공합니다.
읽어주셔서 감사합니다