본문 바로가기

Context API 기초

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

여러 하위 컴포넌트가 같은 값을 읽을 때 모든 중간 컴포넌트에 props를 되풀이해 넘기면, 값이 바뀌는 경로가 길어질 수 있습니다. React의 Context는 부모가 특정 하위 트리에 값을 제공하고, 그 아래의 컴포넌트가 깊이와 상관없이 그 값을 읽게 해 주는 전달 통로입니다. 다만 Context 객체 자체가 상태를 저장하거나 가지는 것은 아닙니다. 상태와 변경 함수는 여전히 컴포넌트의 useState나 useReducer, 또는 별도의 데이터 계층이 가지고, Context는 그 값을 필요한 범위에 전달할 뿐입니다. 4장에서 Context를 처음 다뤘다면, 이 글에서는 React 19의 Provider 문법과 기본값, 갱신의 규칙을 더 정확히 짚어 봅니다.

Context는 소유자의 값을 가장 가까운 Provider에서 전달합니다

REACT STATE · OWNERSHIP & TRANSITION

Context는 소유자의 값을 가장 가까운 Provider에서 전달합니다

ThemeProvider가 theme과 toggleTheme을 소유하고 ThemeContext는 전달할 identity를 정합니다. Provider가 중첩되면 각 소비자는 바로 위의 일치하는 값을 읽습니다.

가장 가까운 Provider의 값과 두 fallback 조건ThemeProvider가 state와 변경 함수를 소유합니다. 같은 ThemeContext의 바깥 darkTheme 범위 안에서 Page는 dark를 읽지만, 중첩된 lightTheme 범위의 Footer는 light를 읽습니다. 범위 밖에 표시한 별도 조건에서는 Provider가 없을 때만 null fallback을 받고 custom hook이 오류를 내며, undefined를 제공한 Provider에서는 undefined를 받습니다.바깥 Provider 범위안쪽 Provider 범위ThemeProvider · ownertheme · toggleThemeThemeContext valuedarkThemePage 아래 소비자theme = dark안쪽 ThemeContextlightThemeFooter 소비자theme = lightProvider 없음fallback: nulluseTheme → 오류value = undefinedundefined 전달default로 안 돌아감darkThemelightTheme범위 밖의 별도 조건
가장 가까운 Provider의 값과 두 fallback 조건ThemeProvider가 state와 변경 함수를 소유합니다. 같은 ThemeContext의 바깥 darkTheme 범위 안에서 Page는 dark를 읽지만, 중첩된 lightTheme 범위의 Footer는 light를 읽습니다. 범위 밖에 표시한 별도 조건에서는 Provider가 없을 때만 null fallback을 받고 custom hook이 오류를 내며, undefined를 제공한 Provider에서는 undefined를 받습니다.바깥 Provider안쪽 ProviderThemeProviderstate · action ownertheme · toggleThemeThemeContext valuedarkThemePage 아래 소비자theme = dark안쪽 ThemeContextlightThemeFooter 소비자theme = lightProvider 없음fallback: nulluseTheme → 오류value = undefinedundefined 전달default로안 돌아감darklight별도 조건

본문의 override · fallback과 서로 다른 경우

바깥 Provider: darkTheme → Page
안쪽 Provider: lightTheme → Footer
Provider 없음 → null → useTheme 오류
value = undefined → undefined

null sentinel의 오류는 예제 custom hook의 정책입니다. undefined를 제공한 Provider는 defaultValue로 되돌아가지 않습니다.

1 · Context identity

createContext(null)

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

Fallback boundary

undefined는 fallback이 아니다

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

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

  1. State/action owner

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

  2. React 19 Provider

    <ThemeContext>에 value={value}를 전달해 필요한 가장 작은 하위 트리를 감쌉니다. 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가 소유자의 값을 가장 가까운 Provider에서 전달하는 범위를 보여 줍니다. ThemeProvider가 소유자로서 theme과 toggleTheme을 가지고, ThemeContext는 그 값을 전달할 식별자(identity) 역할을 합니다. 같은 ThemeContext로 바깥에서 darkTheme을 제공하면 그 범위 안의 Page는 dark를 읽고, 안쪽에서 다시 lightTheme을 제공하면 그 범위의 Footer는 light를 읽습니다. Provider가 중첩되면 각 소비자는 바로 위에 있는 일치하는 Provider의 값을 받습니다.

그림의 오른쪽은 범위 밖의 두 조건을 따로 보여 줍니다. Provider가 아예 없으면 소비자는 기본값 null을 받고, 이 글의 커스텀 훅 useTheme이 오류를 냅니다. 반면 Provider가 value={undefined}를 제공하면 소비자는 기본값으로 돌아가지 않고 undefined를 그대로 받습니다. 그림 아래 칸들이 정리하듯 Context 객체는 값의 종류를 식별할 뿐 상태를 보관하거나 바꾸지 않고, React 19에서는 <ThemeContext value={value}>로 필요한 가장 작은 하위 트리를 감싸며, 중간의 컴포넌트는 props를 넘기거나 Context를 직접 읽지 않아도 됩니다. 다만 직접 구독하지 않는다는 것이 부모의 렌더링과 상관없다는 뜻은 아닙니다. 또 입력 필드의 값은 가까운 상태에, 경로와 검색 조건은 라우터에, 서버의 응답과 캐시의 수명은 데이터 계층에 그대로 두며, Context는 props를 생략하게 해 줄 뿐 소유를 대신하지 않습니다.


Context가 잘 맞는 경우

Context는 하나의 하위 트리에서 여러 컴포넌트가 읽는 값에 잘 맞습니다. 테마와 색상 모드, 현재 사용자와 권한에 따른 UI, 언어와 지역 설정, 특정 기능 영역의 상태와 변경 명령이 그 예입니다. 다만 앱의 규모만으로 Context를 쓸지 정하지는 않습니다. 값의 소유자와 필요한 소비자, Provider의 범위, 값이 바뀌는 빈도를 함께 보고 필요를 충족하는 가장 작은 범위를 고릅니다.


Context API의 세 역할

1. createContext: 통로의 식별자와 기본값 정의

createContext(defaultValue)는 컴포넌트가 제공하고 읽을 Context의 식별자를 만듭니다. 이 객체 자체에는 현재 상태가 들어 있지 않습니다. 이 파일에는 JSX가 없으므로 .js로 저장합니다.

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가 전혀 없을 때만 쓰는 정적인 마지막 대체값입니다. Provider가 value={undefined}를 제공하면 기본값으로 돌아가지 않고 undefined가 전달됩니다. 이 예제는 Provider가 빠진 것을 조용히 감추지 않도록 null을 표시용 값(sentinel)으로 두고, useTheme()에서 오류를 냅니다. 반대로 Provider 없이도 의미 있게 동작해야 하는 Context라면 'light'처럼 실제로 쓸 수 있는 기본값을 둘 수 있습니다.

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

React 19에서는 Context 객체 자체를 Provider로 렌더링합니다. 이전 버전의 <ThemeContext.Provider> 문법도 동작하지만, React 19 공식 문서에서는 예전 방식으로 소개합니다(React 문서의 createContext).

src/contexts/ThemeProvider.jsx
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는 이 값을 전달할 통로의 식별자입니다. useCallback과 useMemo는 결과의 정확성에 꼭 필요한 요소가 아니라 성능 최적화입니다. 여기서는 Provider가 다른 이유로 다시 렌더링될 때, 같은 theme에 대해 새 함수와 새 객체를 만들지 않게 합니다. theme이 실제로 바뀌면 value도 바뀌어야 합니다.

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

src/components/Header.jsx
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를 권합니다(React 문서의 useContext).


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

Context를 직접 읽지 않는 컴포넌트는 Context 갱신의 직접적인 구독자가 아닙니다. 아래 세 파일에서 App은 Provider 안에 두 컴포넌트를 배치하고, MainContent는 props 없이 ContentSection을 렌더링하며, 맨 안쪽의 ContentSection만 useTheme()으로 Context를 읽습니다. 중간에 있는 MainContent의 코드에는 테마와 관련된 이름이 하나도 나오지 않는다는 점을 눈여겨봅니다.

src/App.jsx
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.jsx
import ContentSection from './ContentSection';

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

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

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

MainContent는 ThemeContext를 직접 읽지 않지만 ContentSection은 읽습니다. 그래서 MainContent가 Context를 전달하려고 props를 중계할 필요는 없습니다. 다만 Context를 읽지 않는다는 것이 절대 다시 렌더링되지 않는다는 뜻은 아닙니다. 부모가 다시 렌더링되면 일반적인 부모와 자식의 렌더링 경로로 실행될 수 있고, element의 식별자나 메모이제이션에 따른 건너뛰기가 일부 작업을 생략할 수도 있습니다. 직접적인 Context 구독과 부모의 렌더링 경로를 구분해서 살펴봐야 합니다.

이 테마 파일들을 앱에 연결하면 처음 Header의 문구는 “현재 테마: light”이고, 전환한 뒤에는 “dark”가 됩니다. MainContent에 테마 props를 더하지 않아도 ContentSection은 같은 Context의 값을 읽습니다. 다만 이 예제에는 .header--dark와 .content--dark의 CSS가 정의되어 있지 않으므로, 클래스 이름이 바뀌는 것과 실제 색이 바뀌는 것은 따로 확인해야 합니다. 색까지 잇는 완전한 실습은 테마 변경 기능 구현에서 진행합니다.


가장 가까운 Provider와 override

Provider를 중첩하면 소비자는 위쪽에서 가장 가까운 Provider의 값을 받습니다. 아래 코드는 React 19부터 쓸 수 있는 <ThemeContext value={...}> 형태로 Provider를 렌더링합니다. 바깥 Provider는 darkTheme을 주고, 그 안에서 Footer만 다시 lightTheme을 주는 Provider로 감쌌습니다.

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

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

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


Context 갱신과 렌더 규칙

React는 Provider의 이전 value와 다음 value를 Object.is로 비교합니다. 비교 결과가 다르면 그 Context를 읽는 소비자에게 새 값을 전달하고 다시 렌더링합니다. 객체와 함수는 내용이 같아 보여도 새로 만든 것이면 다른 값입니다. memo는 props에 대해 부모의 렌더링을 줄이는 최적화이므로, 컴포넌트가 직접 읽는 Context의 갱신을 막지 못합니다. 대신 Context를 읽는 바깥 컴포넌트와, 필요한 값만 props로 받는 메모이제이션된 자식을 나누는 방식은 별도의 최적화 지점이 될 수 있습니다.

Context value와 소비자 갱신 판단 순서 — 상태 추적

같은 light 내용의 a/b 객체가 Object.is false이므로 직접 소비자 갱신과 같은 참조 a/a 보존이 다르다. Provider value identity/직접 구독/부모 렌더를 분리한 연속 렌더 사례가 유용하다.

같은 내용의 새 객체도 Context value의 변경입니다

REACT STATE · OWNERSHIP & TRANSITION

같은 내용의 새 객체도 Context value의 변경입니다

Provider 범위와 상태 소유자를 먼저 확인한 뒤 value identity와 직접 구독자를 추적합니다. memo는 직접 Context 구독의 새 값 전달을 막지 않습니다.

value identity와 직접 Context 구독

value identity와 직접 Context 구독Object.is(A,A)는 true, 같은 내용의 새 객체 B는 false입니다. useContext를 직접 구독하는 일반 소비자와 memo 소비자는 새 value를 각각 전달받습니다. pass-through는 직접 구독자가 아니며 부모 렌더는 별도 경로입니다. Context split·참조 최적화 판단은 원설명에 보존합니다.truefalse상태 소유 컴포넌트Provider value A/BObject.is같은 참조?A → A같은identityA → Blight같아도 변경소비자새 값렌더memo 소비자직접 갱신
value identity와 직접 Context 구독Object.is(A,A)는 true, 같은 내용의 새 객체 B는 false입니다. useContext를 직접 구독하는 일반 소비자와 memo 소비자는 새 value를 각각 전달받습니다. pass-through는 직접 구독자가 아니며 부모 렌더는 별도 경로입니다. Context split·참조 최적화 판단은 원설명에 보존합니다.truefalse상태 소유컴포넌트Provider valueA/BObject.is같은 참조?A → A같은identityA → Blight같아도변경소비자새 값렌더memo소비자직접갱신

Object.is(A,A)는 true, 같은 내용의 새 객체 B는 false입니다. useContext를 직접 구독하는 일반 소비자와 memo 소비자는 새 value를 각각 전달받습니다. pass-through는 직접 구독자가 아니며 부모 렌더는 별도 경로입니다. Context split·참조 최적화 판단은 원설명에 보존합니다.

원래 설명과 상세 기준

value identity · 최적화가 필요한 원인부터 확인

a = { theme: "light" }
b = { theme: "light" }
Object.is(a, b) → false
Object.is(a, a) → true

실제 theme 변경에는 새 값이 필요합니다. useMemo/useCallback은 불필요한 identity 변경의 측정된 비용을 줄이는 최적화입니다.

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

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

  2. Owner

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

  3. Object.is

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

  4. Value identity

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

  5. Consumer update

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

  6. Memo limit

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

  7. Context split

    소비자 집합과 변경 주기가 다른 값, 또는 state와 안정적인 dispatch를 별도 Context로 나눌 수 있습니다.

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

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

Profiler로 원인을 확인한다

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

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 표 행순서 | 확인할 질문 | 유지할 계약
원도표 표 행1 · 범위 | 어느 하위 트리가 값을 읽는가? | Provider를 모든 페이지의 최상단이 아니라 소비자를 포함하는 가장 작은 충분한 범위에 둡니다.
원도표 표 행2 · 소유 | 누가 상태와 변경 규칙을 소유하는가? | 컴포넌트의 useState나 useReducer가 소유합니다. Context는 현재 값과 변경 명령을 전달합니다.
원도표 표 행3 · 비교 | 이전 값과 다음 값이 같은가? | React는 Provider의 value를 Object.is로 비교합니다.
원도표 표 행4 · 참조 | 객체나 함수가 이유 없이 새로 만들어지는가? | 측정된 비용이 있으면 useCallback과 useMemo로 불필요한 identity 변경을 줄입니다.
원도표 표 행5 · 구독 | 누가 이 Context를 직접 읽는가? | 값이 달라지면 useContext 소비자가 새 값을 받고 다시 렌더됩니다. pass-through는 직접 구독자가 아닙니다.
원도표 표 행6 · 메모 | memo가 Context 갱신을 막는가? | 막지 못합니다. 바깥에서 Context를 읽고 필요한 값을 memoized 자식의 props로 내리는 별도 경계는 가능합니다.
원도표 표 행7 · 분리 | 소비자 집합과 변경 주기가 다른가? | 관련 없는 값, 또는 state와 안정적인 dispatch를 별도 Context로 나눌 수 있습니다.
원도표 조건·주석Scope: 값을 읽는 소비자를 포함하는 가장 작은 Provider 범위를 고릅니다.
원도표 조건·주석Owner: 상태와 변경 규칙은 컴포넌트가 소유하고 Context는 전달만 합니다.
원도표 설명이전 value와 다음 value가 다른지 확인합니다.
원도표 조건·주석Object.is: 이전 value와 다음 value가 다른지 확인합니다.
원도표 설명객체·함수가 불필요하게 새로 만들어질 때만 useCallback과 useMemo를 검토합니다.
원도표 조건·주석Value identity: 객체·함수가 불필요하게 새로 만들어질 때만 useCallback과 useMemo를 검토합니다.
원도표 조건·주석Consumer update: 직접 Context를 읽는 소비자가 새 값을 받습니다. pass-through는 직접 구독하지 않습니다.
원도표 설명memo는 직접 Context 갱신을 막지 않습니다. props 경계를 따로 만들 때만 역할이 달라집니다.
원도표 조건·주석Memo limit: memo는 직접 Context 갱신을 막지 않습니다. props 경계를 따로 만들 때만 역할이 달라집니다.
원도표 조건·주석Context split: 소비자 집합과 변경 주기가 다른 값, 또는 state와 안정적인 dispatch를 별도 Context로 나눌 수 있습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
이전 value Avalue: A light / consumer: A의 현재 값provider가 theme:light인 객체 A를 제공한다값의 내용과 객체의 정체성을 나눈다
새 value B내용: light 유지 / identity: A≠B같은 light 내용을 가진 새 B는 Object.is(A,B)=false다직접 소비자는 새로운 Context 값의 갱신을 받는다
같은 A 비교Object.is(A,A): true / memo: 직접 Context 갱신을 막지 못함A/A는 true이며 props memo와 별도의 Context·부모 경로를 구별한다참조 최적화는 측정한 필요에 적용한다

Context value와 소비자 갱신 판단 순서 — 상태 추적

같은 light 내용의 a/b 객체가 Object.is false이므로 직접 소비자 갱신과 같은 참조 a/a 보존이 다르다. Provider value identity/직접 구독/부모 렌더를 분리한 연속 렌더 사례가 유용하다.

같은 내용의 새 객체도 Context value의 변경입니다

REACT STATE · OWNERSHIP & TRANSITION

같은 내용의 새 객체도 Context value의 변경입니다

Provider 범위와 상태 소유자를 먼저 확인한 뒤 value identity와 직접 구독자를 추적합니다. memo는 직접 Context 구독의 새 값 전달을 막지 않습니다.

value identity와 직접 Context 구독

value identity와 직접 Context 구독Object.is(A,A)는 true, 같은 내용의 새 객체 B는 false입니다. useContext를 직접 구독하는 일반 소비자와 memo 소비자는 새 value를 각각 전달받습니다. pass-through는 직접 구독자가 아니며 부모 렌더는 별도 경로입니다. Context split·참조 최적화 판단은 원설명에 보존합니다.truefalse상태 소유 컴포넌트Provider value A/BObject.is같은 참조?A → A같은identityA → Blight같아도 변경소비자새 값렌더memo 소비자직접 갱신
value identity와 직접 Context 구독Object.is(A,A)는 true, 같은 내용의 새 객체 B는 false입니다. useContext를 직접 구독하는 일반 소비자와 memo 소비자는 새 value를 각각 전달받습니다. pass-through는 직접 구독자가 아니며 부모 렌더는 별도 경로입니다. Context split·참조 최적화 판단은 원설명에 보존합니다.truefalse상태 소유컴포넌트Provider valueA/BObject.is같은 참조?A → A같은identityA → Blight같아도변경소비자새 값렌더memo소비자직접갱신

Object.is(A,A)는 true, 같은 내용의 새 객체 B는 false입니다. useContext를 직접 구독하는 일반 소비자와 memo 소비자는 새 value를 각각 전달받습니다. pass-through는 직접 구독자가 아니며 부모 렌더는 별도 경로입니다. Context split·참조 최적화 판단은 원설명에 보존합니다.

원래 설명과 상세 기준

value identity · 최적화가 필요한 원인부터 확인

a = { theme: "light" }
b = { theme: "light" }
Object.is(a, b) → false
Object.is(a, a) → true

실제 theme 변경에는 새 값이 필요합니다. useMemo/useCallback은 불필요한 identity 변경의 측정된 비용을 줄이는 최적화입니다.

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

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

  2. Owner

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

  3. Object.is

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

  4. Value identity

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

  5. Consumer update

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

  6. Memo limit

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

  7. Context split

    소비자 집합과 변경 주기가 다른 값, 또는 state와 안정적인 dispatch를 별도 Context로 나눌 수 있습니다.

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

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

Profiler로 원인을 확인한다

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

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 표 행순서 | 확인할 질문 | 유지할 계약
원도표 표 행1 · 범위 | 어느 하위 트리가 값을 읽는가? | Provider를 모든 페이지의 최상단이 아니라 소비자를 포함하는 가장 작은 충분한 범위에 둡니다.
원도표 표 행2 · 소유 | 누가 상태와 변경 규칙을 소유하는가? | 컴포넌트의 useState나 useReducer가 소유합니다. Context는 현재 값과 변경 명령을 전달합니다.
원도표 표 행3 · 비교 | 이전 값과 다음 값이 같은가? | React는 Provider의 value를 Object.is로 비교합니다.
원도표 표 행4 · 참조 | 객체나 함수가 이유 없이 새로 만들어지는가? | 측정된 비용이 있으면 useCallback과 useMemo로 불필요한 identity 변경을 줄입니다.
원도표 표 행5 · 구독 | 누가 이 Context를 직접 읽는가? | 값이 달라지면 useContext 소비자가 새 값을 받고 다시 렌더됩니다. pass-through는 직접 구독자가 아닙니다.
원도표 표 행6 · 메모 | memo가 Context 갱신을 막는가? | 막지 못합니다. 바깥에서 Context를 읽고 필요한 값을 memoized 자식의 props로 내리는 별도 경계는 가능합니다.
원도표 표 행7 · 분리 | 소비자 집합과 변경 주기가 다른가? | 관련 없는 값, 또는 state와 안정적인 dispatch를 별도 Context로 나눌 수 있습니다.
원도표 조건·주석Scope: 값을 읽는 소비자를 포함하는 가장 작은 Provider 범위를 고릅니다.
원도표 조건·주석Owner: 상태와 변경 규칙은 컴포넌트가 소유하고 Context는 전달만 합니다.
원도표 설명이전 value와 다음 value가 다른지 확인합니다.
원도표 조건·주석Object.is: 이전 value와 다음 value가 다른지 확인합니다.
원도표 설명객체·함수가 불필요하게 새로 만들어질 때만 useCallback과 useMemo를 검토합니다.
원도표 조건·주석Value identity: 객체·함수가 불필요하게 새로 만들어질 때만 useCallback과 useMemo를 검토합니다.
원도표 조건·주석Consumer update: 직접 Context를 읽는 소비자가 새 값을 받습니다. pass-through는 직접 구독하지 않습니다.
원도표 설명memo는 직접 Context 갱신을 막지 않습니다. props 경계를 따로 만들 때만 역할이 달라집니다.
원도표 조건·주석Memo limit: memo는 직접 Context 갱신을 막지 않습니다. props 경계를 따로 만들 때만 역할이 달라집니다.
원도표 조건·주석Context split: 소비자 집합과 변경 주기가 다른 값, 또는 state와 안정적인 dispatch를 별도 Context로 나눌 수 있습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
이전 value Avalue: A light / consumer: A의 현재 값provider가 theme:light인 객체 A를 제공한다값의 내용과 객체의 정체성을 나눈다
새 value B내용: light 유지 / identity: A≠B같은 light 내용을 가진 새 B는 Object.is(A,B)=false다직접 소비자는 새로운 Context 값의 갱신을 받는다
같은 A 비교Object.is(A,A): true / memo: 직접 Context 갱신을 막지 못함A/A는 true이며 props memo와 별도의 Context·부모 경로를 구별한다참조 최적화는 측정한 필요에 적용한다

위 그림은 같은 내용의 새 객체도 Context value의 변경이라는 점과, 소비자의 갱신을 살펴보는 순서를 보여 줍니다. 위쪽의 흐름처럼 상태를 가진 컴포넌트가 Provider에 value A를 넘겼다가 다시 A를 넘기면 Object.is가 같은 참조로 보지만, 내용이 같은 light이더라도 새 객체 B를 넘기면 변경으로 봅니다. 그러면 useContext로 직접 구독하는 일반 소비자와 memo로 감싼 소비자가 모두 새 값을 받아 다시 렌더링됩니다.

그림 아래의 표는 갱신을 줄이는 일곱 단계의 질문입니다. 어느 하위 트리가 값을 읽는지 보고 Provider를 그 소비자를 품는 가장 작은 범위에 두며, 상태와 바꾸는 규칙은 컴포넌트의 useState나 useReducer가 가지게 합니다. 이전 값과 다음 값을 Object.is로 비교해 같은지 확인하고, 객체나 함수가 이유 없이 새로 만들어진다면 측정한 비용이 있을 때 useCallback과 useMemo로 줄입니다. 그다음 누가 이 Context를 직접 읽는지 확인하며, memo가 Context의 갱신을 막지 못한다는 점과, 소비자의 집합과 바뀌는 주기가 다른 값이나 state와 안정적인 dispatch는 Context를 나눌 수 있다는 점을 고려합니다.

그림 위쪽의 예처럼 a = { theme: "light" }와 b = { theme: "light" }에서 Object.is(a, b)는 false, Object.is(a, a)는 true입니다. 그림의 설명처럼 실제로 테마가 바뀌었다면 새 값이 필요하고, useMemo와 useCallback은 불필요한 식별자 변경의 측정된 비용을 줄이는 최적화일 뿐입니다. 마지막 두 칸이 덧붙이듯, Context를 읽지 않는 중간 컴포넌트도 부모의 렌더링 때문에 실행될 수 있으므로, Profiler로 Provider의 범위와 value의 식별자, 직접 소비자, 부모의 렌더링을 구분해 기록한 뒤 가장 작은 변경을 적용합니다.

객체 비교는 React 화면 없이도 자바스크립트 Console에서 먼저 확인할 수 있습니다.

const a = { theme: 'light' };
const b = { theme: 'light' };
const same = a;

console.log(Object.is(a, b));    // false: 새 객체
console.log(Object.is(a, same)); // true: 같은 객체

내용과 식별자를 구별한 다음에야 어떤 Provider의 렌더링이 새 value를 만들었는지 추적할 수 있습니다. 이 비교만으로 실제 소비자의 렌더링 횟수를 측정한 것은 아닙니다.

value의 식별자 고정하기

Provider가 매번 value={{ theme, toggleTheme }}처럼 새 객체와 새 함수를 만들면, 상태가 그대로여도 Provider의 다른 렌더링 때문에 소비자가 갱신될 수 있습니다. 측정해 보니 이 비용이 의미 있다면 useCallback으로 함수를, useMemo로 객체의 식별자를 고정합니다. 메모이제이션을 무조건 더하지 말고, React Profiler로 어떤 Provider의 변경이 어떤 소비자의 렌더링을 일으켰는지 먼저 확인합니다.

Context 나누기

관련 없는 값을 한 객체에 넣으면 한 필드만 바뀌어도 그 Context를 읽는 모든 소비자가 새 값을 받습니다. 그 값을 읽는 소비자의 집합이 다르거나, 바뀌는 빈도와 이유가 다르거나, 상태를 읽는 컴포넌트와 안정적인 변경 명령만 필요한 컴포넌트가 나뉘거나, Provider가 필요한 하위 트리의 범위가 다르다면 Context를 나눌 수 있습니다. state와 dispatch를 별도의 Context로 나누는 것도 한 예입니다. 다만 작은 테마 객체처럼 비용이 문제가 되지 않는 경우에는 하나의 단순한 Context가 더 읽기 쉽습니다.


Context에 넣지 않을 상태

Context가 모든 상태의 기본 목적지는 아닙니다. 입력 필드 하나나 작은 컴포넌트만 쓰는 값은 가까운 지역 상태에 두고, URL의 경로와 검색 조건은 라우터가, 서버의 응답과 로딩, 재검증, 캐시의 수명은 서버·캐시 데이터 계층이 가집니다. Context는 이런 원래의 값을 UI용 저장소로 복제하는 수단이 아닙니다.


정리

  • Context는 소유자가 가진 값을 하위 트리에 전달하는 통로이며, 객체 자체가 상태를 가지지는 않습니다.
  • createContext(defaultValue)의 기본값은 Provider가 아예 없을 때만 쓰이고, value={undefined}를 제공하면 undefined가 그대로 전달됩니다.
  • React 19에서는 <ThemeContext value={value}>로 Context 자체를 Provider로 렌더링하며, 소비자는 useContext로 가장 가까운 Provider의 값을 읽습니다.
  • React는 value를 Object.is로 비교하므로 같은 내용의 새 객체도 변경이며, memo는 직접 읽는 Context의 갱신을 막지 못합니다.
  • 측정해서 비용이 확인되면 useMemo와 useCallback으로 value를 고정하고, 소비자와 바뀌는 주기가 다른 값은 Context를 나눕니다.
  • 지역 상태와 URL, 서버·캐시의 값은 Context로 복제하지 않고 원래의 자리에 둡니다.

연습 문제

모든 소비자는 앞의 useTheme을 쓰고, Provider에 주는 정상적인 값은 { theme, toggleTheme } 객체라고 가정합니다. 다음 상황을 각각 따로 판단해 보세요.

1. 중첩된 두 Provider

바깥 Provider의 theme은 dark, 안쪽 Provider의 theme은 light입니다. 안쪽에 있는 Footer와 바깥에만 있는 Header는 각각 무엇을 읽을까요?

해설 보기

Footer는 가장 가까운 안쪽의 light를, Header는 바깥의 dark를 읽습니다. 두 Provider가 같은 Context 객체를 쓴다는 조건이 중요합니다. 서로 다른 Context 객체라면 서로의 값을 덮어쓰지 않습니다.

2. Provider 밖의 Header

Header를 모든 Provider 밖으로 옮기면 useTheme의 어떤 분기가 실행될까요?

해설 보기

Provider가 없으므로 useContext가 정적인 기본값 null을 돌려주고, value === null 분기가 실행되어 useTheme must be used within ThemeProvider 오류를 던집니다. Provider가 빠진 실수를 조용히 넘어가지 않고 바로 드러내는 것이 이 표시용 값의 목적입니다.

3. value가 undefined인 Provider

Provider는 있지만 value={undefined}라면 기본값 null이 대신 전달될까요? useTheme의 value === null 검사와 Header의 구조 분해는 어떻게 될까요?

해설 보기

기본값으로 돌아가지 않고, 존재하는 Provider가 제공한 undefined를 그대로 읽습니다. undefined === null은 거짓이므로 이 커스텀 오류 검사는 통과하지만, Header가 undefined에서 theme과 toggleTheme을 구조 분해하는 순간 TypeError가 납니다.

Provider가 빠진 것을 알아차리려고 둔 null 표시용 값이, 잘못된 value의 타입까지 검증해 주는 것은 아닙니다.

다음 글 useReducer 훅 소개에서는 useReducer가 복잡한 갱신 규칙을 한 소유자에 모으고, Context가 state와 dispatch를 하위 트리에 전달하는 구조를 살펴봅니다.