Tech

Context API 원리 살피기

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

이 객체가 우리가 흔히 사용하는 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 생성 직후 _currentValue에 기본값이 담긴 상태

  • 일단 Context가 생성되면, Context의 _currentValue 에는 기본값이 저장된다.

Provider에 진입해 이전 값을 valueStack에 백업하고 새 값으로 교체하는 모습

  • 그리고 렌더링 중에 Provider를 만나게 되면, 현재 저장되어있는 값을 valueStack 에 백업하고, Provider의 value를 저장한다.

Provider가 중첩돼도 스택 덕분에 현재 값을 정확히 가리키는 모습

  • 이러한 구조를 통해, 같은 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를 빠져나오며 스택에서 이전 값을 꺼내 복원하는 모습

  • 렌더링 중에 Provider의 닫는 태그를 만나게 되면, 전역 스택에 있는 값을 pop하고 _currentValue 에 저장한다.

복원이 끝나 Provider 바깥이 원래 값을 가리키는 상태

  • 따라서, Provider의 영역안에서 정확한 값을 가리킬 수 있게 된다.

하나의 전역 스택만 쓰는 이유

React는 모든 Context가 하나의 전역 스택을 공유한다.

모든 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의 특징은 다음과 같다.

컴포넌트의 dependencies에 구독 중인 Context가 연결 리스트로 쌓인 구조

  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 중 하나라도 변경되면 전체 컴포넌트를 다시 계산한다.

그림으로 알아보기

Provider의 value가 바뀌었을 때 하위 트리로 전파되는 과정

Context의 개수와 탐색

Provider가 많을수록 같은 서브트리를 여러 번 탐색하게 되는 비용

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

정리하기

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

다른 글