본문으로 건너뛰기

안동민 개발노트

본문 시작

Context API 기초

React 19의 Context identity, Provider 범위, fallback, 구독 갱신과 value 안정화 계약을 테마 예제로 익힙니다.

여러 하위 컴포넌트가 같은 값을 읽을 때 모든 중간 컴포넌트에 props를 반복해서 전달하면 변경 경로가 길어질 수 있습니다.

React Context는 부모가 특정 하위 트리에 값을 제공하고, 그 아래 컴포넌트가 깊이와 관계없이 그 값을 읽게 하는 전달 채널입니다.

Context 객체 자체가 상태를 저장하거나 소유하는 것은 아닙니다. 상태와 변경 함수는 여전히 컴포넌트의 useState, useReducer 또는 별도 데이터 계층이 소유하고, Context는 그 값을 필요한 범위에 전달합니다.

Context identity를 만든 뒤 상태와 변경 함수의 소유자가 React 19 Provider로 값을 제공하고, 중간 컴포넌트를 지나 가장 가까운 Provider 아래 소비자가 값을 구독하는 구조

Identity → owner → provider → consumer

Context는 상태 저장소가 아니라 하위 트리에 값을 전달하는 채널 identity입니다. 컴포넌트가 상태와 변경 함수를 소유하고, Provider가 정한 범위 안의 소비자가 가장 가까운 값을 읽습니다.

1 · Context identity

createContext(null)

Context 객체는 제공하고 읽을 값의 종류를 식별합니다. 현재 상태를 직접 보관하거나 변경하지 않습니다.

Fallback boundary

undefined는 fallback이 아니다

기본값은 일치하는 Provider가 없을 때만 사용됩니다.

value={undefined}를 제공하면 소비자도 undefined를 받습니다.

  1. State/action owner

    ThemeProvider 컴포넌트가 themetoggleTheme을 소유합니다. Context 자체는 소유자가 아닙니다.

  2. React 19 Provider

    <ThemeContext>value={contextValue}를 전달해 필요한 가장 작은 하위 트리를 감쌉니다. ThemeContext.Provider는 이전 문법입니다.

  3. Pass-through

    중간 컴포넌트는 props를 중계하거나 Context를 직접 읽지 않아도 됩니다. 직접 구독하지 않는다는 뜻이지 부모 렌더와 무관하다는 보장은 아닙니다.

  4. Consumer

    useContext(ThemeContext)를 호출한 컴포넌트가 현재 값을 구독합니다. Provider는 소비자보다 위에 있어야 합니다.

  5. Nearest provider

    Provider가 중첩되면 가장 가까운 값이 이깁니다. Provider가 전혀 없을 때만 정적인 기본값을 읽으며, null sentinel은 custom hook에서 누락을 드러낼 수 있습니다.

Context 후보

같은 하위 트리가 읽는 UI 계약

테마, 현재 사용자, 언어처럼 여러 먼 소비자가 같은 값을 읽을 때 범위를 명시합니다.

원본 계층 유지

Local · URL · server/cache

입력 필드는 가까운 상태에, 경로·검색 조건은 router에, 서버 응답과 캐시 수명은 데이터 계층에 둡니다.

Context는 props 전달을 생략할 수 있지만 소유권을 대신하지 않습니다. 범위와 fallback은 API 계약이고, 최적화는 실제 구독과 렌더를 측정한 뒤 적용합니다.


Context가 잘 맞는 경우

Context는 다음처럼 하나의 하위 트리에서 여러 컴포넌트가 읽는 값에 잘 맞습니다.

  • 테마와 색상 모드
  • 현재 사용자와 권한 UI
  • 언어와 지역 설정
  • 특정 기능 영역의 상태와 변경 명령

앱의 규모만으로 Context 사용 여부를 정하지는 않습니다. 값의 소유자, 필요한 소비자, Provider 범위, 변경 빈도를 함께 보고 가장 작은 충분한 범위를 선택합니다.


Context API의 세 역할

1. createContext: 채널 identity와 fallback 정의

createContext(defaultValue)는 컴포넌트가 제공하고 읽을 Context identity를 만듭니다. 이 객체 자체에는 현재 상태가 들어 있지 않습니다.

src/contexts/ThemeContext.js
import { createContext, useContext } from 'react';

const ThemeContext = createContext(null);

export function useTheme() {
  const value = useContext(ThemeContext);

  if (value === null) {
    throw new Error('useTheme must be used within ThemeProvider');
  }

  return value;
}

export default ThemeContext;

기본값은 위쪽 트리에 일치하는 Provider가 전혀 없을 때만 쓰는 정적인 마지막 fallback입니다. Provider가 value={undefined}를 제공하면 기본값으로 돌아가지 않고 undefined가 전달됩니다.

이 예제는 Provider 누락을 조용히 숨기지 않도록 null을 sentinel로 두고 useTheme()에서 오류를 냅니다. 반대로 Provider 없이도 의미 있게 동작해야 하는 Context라면 'light'처럼 실제 fallback 값을 사용할 수 있습니다.

2. Provider: 상태 소유자가 현재 값을 제공

설치된 React 19에서는 Context 객체 자체를 Provider로 렌더링합니다. 이전 버전의 <ThemeContext.Provider> 문법도 동작하지만 React 19 공식 문서에서는 legacy 방식입니다.

src/contexts/ThemeProvider.js
import { useCallback, useMemo, useState } from 'react';
import ThemeContext from './ThemeContext';

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = useCallback(() => {
    setTheme(current => (current === 'light' ? 'dark' : 'light'));
  }, []);

  const value = useMemo(
    () => ({ theme, toggleTheme }),
    [theme, toggleTheme],
  );

  return (
    <ThemeContext value={value}>
      {children}
    </ThemeContext>
  );
}

ThemeProvider 컴포넌트가 theme 상태와 toggleTheme 동작을 소유합니다. ThemeContext는 이 값을 전달할 채널 identity입니다.

useCallbackuseMemo는 정확성을 위한 필수 요소가 아니라 성능 최적화입니다. 여기서는 Provider가 다른 이유로 다시 렌더될 때 같은 theme에 대해 새 함수와 새 객체를 만들지 않게 합니다. theme가 실제로 바뀌면 value도 바뀌어야 합니다.

3. useContext: 가장 가까운 Provider의 값 구독

src/components/Header.js
import { useTheme } from '../contexts/ThemeContext';

export default function Header() {
  const { theme, toggleTheme } = useTheme();

  return (
    <header className={`header header--${theme}`}>
      <p>현재 테마: {theme}</p>
      <button type="button" onClick={toggleTheme}>
        테마 전환
      </button>
    </header>
  );
}

useContext(ThemeContext)는 호출한 컴포넌트 위쪽에서 가장 가까운 ThemeContext Provider를 찾습니다. 같은 컴포넌트가 반환하는 Provider는 그 컴포넌트 안의 useContext 호출에 적용되지 않으므로 Provider는 소비자보다 위에 있어야 합니다.

ThemeContext.Consumer도 남아 있지만 새 함수 컴포넌트에서는 useContext가 권장됩니다.


props 없이 통과하는 중간 컴포넌트

Context를 직접 읽지 않는 컴포넌트는 Context 갱신의 직접 구독자가 아닙니다.

src/App.js
import ThemeProvider from './contexts/ThemeProvider';
import Header from './components/Header';
import MainContent from './components/MainContent';

export default function App() {
  return (
    <ThemeProvider>
      <Header />
      <MainContent />
    </ThemeProvider>
  );
}
src/components/MainContent.js
import ContentSection from './ContentSection';

export default function MainContent() {
  return (
    <main>
      <ContentSection />
    </main>
  );
}
src/components/ContentSection.js
import { useTheme } from '../contexts/ThemeContext';

export default function ContentSection() {
  const { theme } = useTheme();

  return <section className={`content content--${theme}`}>본문</section>;
}

MainContentThemeContext를 직접 읽지 않지만 ContentSection은 읽습니다. 따라서 MainContent가 Context 전파를 위해 props를 중계할 필요는 없습니다.

다만 “Context를 읽지 않는다”가 “절대 다시 렌더되지 않는다”는 뜻은 아닙니다. 부모가 다시 렌더되면 일반적인 부모-자식 렌더 경로로 실행될 수 있고, element identity나 memoization에 따른 bailout이 일부 작업을 건너뛸 수도 있습니다. 직접 Context 구독과 부모 렌더 경로를 구분해서 관찰해야 합니다.


가장 가까운 Provider와 override

Provider를 중첩하면 소비자는 위쪽에서 가장 가까운 Provider의 값을 받습니다.

<ThemeContext value={darkTheme}>
  <Page />

  <ThemeContext value={lightTheme}>
    <Footer />
  </ThemeContext>
</ThemeContext>

Page 아래 소비자는 darkTheme을, 안쪽 Provider 아래 Footer 소비자는 lightTheme을 읽습니다. Provider 범위는 가능한 한 값을 실제로 필요로 하는 가장 작은 하위 트리에 둡니다.


Context 갱신과 렌더 계약

React는 Provider의 이전 value와 다음 valueObject.is로 비교합니다.

  • 비교 결과가 다르면 그 Context를 읽는 소비자에게 새 값을 전달하고 다시 렌더링합니다.
  • 객체와 함수는 내용이 같아 보여도 새 identity이면 다른 값입니다.
  • memo는 props에 대한 부모 렌더 최적화이므로, 컴포넌트가 직접 읽는 Context의 갱신을 막지 않습니다.
  • Context를 읽는 바깥 컴포넌트와 필요한 값만 props로 받는 memoized 자식을 분리하는 방식은 별도의 최적화 경계가 될 수 있습니다.
Provider 범위와 상태 소유자를 먼저 정하고 Object.is 비교, value identity, 소비자 갱신, memo 한계, Context 분리를 차례로 점검하는 Context 설계 결정표

Scope → identity → subscription

Context 최적화는 memo부터 추가하는 일이 아니라 범위와 소유권을 확인하는 일에서 시작합니다. 그다음 value가 왜 달라지는지와 누가 직접 구독하는지를 순서대로 추적합니다.

Context value 변경과 소비자 갱신을 좁히는 판단 순서
순서 확인할 질문 유지할 계약
1 · Scope 어느 하위 트리가 값을 읽는가? Provider를 모든 페이지의 최상단이 아니라 소비자를 포함하는 가장 작은 충분한 범위에 둡니다.
2 · Owner 누가 상태와 변경 규칙을 소유하는가? 컴포넌트의 useStateuseReducer가 소유합니다. Context는 현재 값과 변경 명령을 전달합니다.
3 · Compare 이전 값과 다음 값이 같은가? React는 Provider의 valueObject.is로 비교합니다.
4 · Identity 객체나 함수가 이유 없이 새로 만들어지는가? 측정된 비용이 있으면 useCallbackuseMemo로 불필요한 identity 변경을 줄입니다.
5 · 구독자 누가 이 Context를 직접 읽는가? 값이 달라지면 useContext 소비자가 새 값을 받고 다시 렌더됩니다. pass-through는 직접 구독자가 아닙니다.
6 · Memo memo가 Context 갱신을 막는가? 막지 못합니다. 바깥에서 Context를 읽고 필요한 값을 memoized 자식의 props로 내리는 별도 경계는 가능합니다.
7 · Split 소비자 집합과 변경 주기가 다른가? 관련 없는 값, 또는 state와 안정적인 dispatch를 별도 Context로 나눌 수 있습니다.
  1. Scope

    값을 읽는 소비자를 포함하는 가장 작은 Provider 범위를 고릅니다.

  2. Owner

    상태와 변경 규칙은 컴포넌트가 소유하고 Context는 전달만 합니다.

  3. Object.is

    이전 value와 다음 value가 다른지 확인합니다.

  4. Value identity

    객체·함수가 불필요하게 새로 만들어질 때만 useCallbackuseMemo를 검토합니다.

  5. Consumer update

    직접 Context를 읽는 소비자가 새 값을 받습니다. pass-through는 직접 구독하지 않습니다.

  6. Memo limit

    memo는 직접 Context 갱신을 막지 않습니다. props 경계를 따로 만들 때만 역할이 달라집니다.

  7. Context split

    소비자 집합과 변경 주기가 다르면 Context를 나눕니다.

직접 구독과 부모 렌더는 다른 경로다

Context를 읽지 않는 중간 컴포넌트도 부모 렌더 때문에 실행될 수 있고, element identity나 memoization에 따른 bailout이 일부 작업을 건너뛸 수 있습니다.

Profiler로 원인을 확인한다

Provider 범위, value identity, 직접 소비자, 부모 렌더를 구분해 기록한 뒤 가장 작은 변경을 적용합니다.

value identity 안정화

Provider가 매번 value={{ theme, toggleTheme }}처럼 새 객체와 새 함수를 만들면, 상태가 그대로여도 Provider의 다른 렌더 때문에 소비자가 갱신될 수 있습니다.

측정 결과 이 비용이 의미 있을 때 useCallback으로 함수를, useMemo로 객체 identity를 안정화합니다. 메모이제이션을 무조건 추가하지 말고 React Profiler로 어떤 Provider 변경이 어떤 소비자 렌더를 만들었는지 먼저 확인합니다.

Context 분리

관련 없는 값을 한 객체에 넣으면 한 필드만 바뀌어도 그 Context를 읽는 모든 소비자가 새 값을 받습니다.

다음 기준이 다르면 Context를 나눌 수 있습니다.

  • 값을 읽는 소비자 집합
  • 변경 빈도와 변경 이유
  • 상태를 읽는 컴포넌트와 안정적인 변경 명령만 필요한 컴포넌트
  • Provider가 필요한 하위 트리 범위

statedispatch를 별도 Context로 나누는 것도 한 예입니다. 다만 작은 테마 객체처럼 비용이 문제가 되지 않는 경우에는 단순한 하나의 Context가 더 읽기 쉽습니다.


Context에 넣지 않을 상태

Context는 모든 상태의 기본 목적지가 아닙니다.

  • 한 입력 필드나 작은 컴포넌트만 쓰는 값은 가까운 local state에 둡니다.
  • URL 경로와 검색 조건은 router가 소유합니다.
  • 서버 응답, 로딩, 재검증, 캐시 수명은 server/cache 데이터 계층에 둡니다.
  • Context는 이 원본들을 UI store로 복제하는 수단이 아닙니다.

정리

Context 설계의 핵심은 “얼마나 전역적인가”보다 다음 질문에 답하는 것입니다.

  1. 누가 상태와 변경 규칙을 소유하는가?
  2. 어느 하위 트리의 소비자가 값을 읽는가?
  3. Provider value는 어떤 이유로 identity가 바뀌는가?
  4. 직접 Context 구독과 부모 렌더 경로가 어떻게 다른가?
  5. 변경 주기와 소비자 집합이 다른 값은 분리해야 하는가?

다음 절에서는 useReducer가 복잡한 업데이트 규칙을 한 소유자에 모으고, Context가 statedispatch를 하위 트리에 전달하는 구조를 살펴봅니다.