본문 바로가기

useContext

Context Provider가 하위 컴포넌트에 값을 공급하는 흐름을 구현하고 useContext의 재렌더링 범위와 한계를 이해합니다.

앞 글에서는 useEffect의 심화 개념을 다루며 컴포넌트의 생명주기 동안 일어나는 부수 효과를 관리하는 방법을 배웠습니다. 이번에는 컴포넌트 트리에서 데이터를 공유할 때 쓰는 Context API와 useContext 훅을 살펴봅니다.

React에서 데이터는 기본적으로 부모에서 자식으로 props를 통해 전달됩니다. 하지만 컴포넌트 트리가 깊어지면, 멀리 떨어진 자식에게 데이터를 전달하려고 중간의 컴포넌트들을 거쳐 props를 계속 넘겨야 하는 상황이 생깁니다. 이를 props drilling(프롭스 드릴링)이라고 부르며, 코드를 읽기 어렵게 하고 유지보수를 힘들게 만듭니다. useContext는 이 문제를 해결해, 트리 안의 특정 데이터에 하위 컴포넌트가 직접 접근하게 해 줍니다. 테마(다크·라이트 모드), 사용자 인증 정보, 언어 설정처럼 앱 전반에서 필요한 데이터를 공유할 때 특히 유용합니다.


Context API의 개념

useContext는 가장 가까운 상위 Provider의 값을 읽습니다

CONTEXT: NEAREST PROVIDER

useContext는 가장 가까운 상위 Provider의 값을 읽습니다

App이 theme와 toggleTheme을 value로 공급하고 ThemeToggle·ThemedComponent가 읽습니다. Layout·Footer·OtherRoot는 소비 범위 비교를 위한 확장 예이며, Context 변경과 일반 부모 렌더를 구별합니다.

Context는 Provider의 값을 읽는 consumer 경계에만 전파한다Theme owner와 Provider 아래에 값을 읽지 않는 Layout과 두 consumer가 있는 트리. Provider 밖의 컴포넌트는 공유 범위에 포함되지 않는다. 공유 영역Providertheme · toggleThemeLayout트리의 중간 컴포넌트FooterContext를 읽지 않음ThemeTogglevalue를 직접 읽음ThemedComponentvalue를 직접 읽음OtherRootProvider 밖기본값만 읽음
Context는 Provider의 값을 읽는 consumer 경계에만 전파한다Theme owner와 Provider 아래에 값을 읽지 않는 Layout과 두 consumer가 있는 트리. Provider 밖의 컴포넌트는 공유 범위에 포함되지 않는다. 공유 영역Providertheme · toggleThemeLayout트리의 중간 컴포넌트FooterContext를 읽지 않음ThemeTogglevalue를 직접 읽음ThemedComponentvalue를 직접 읽음OtherRootProvider 밖기본값만 읽음

값과 변경 함수의 경로

App state: light → dark
value: {theme, toggleTheme}
ThemeToggle: toggleTheme 호출
ThemedComponent: theme로 스타일

콜백은 App의 setter를 호출하고, 다음 value를 소비자에게 전달합니다. 본문은 ThemedComponent를 두 위치에서 사용합니다.

전달 범위 · 통과와 외부

일반 선은 부모·자식 배치, 강조 선은 Context 값을 읽는 경로입니다. Provider의 내부 영역과 값을 실제 읽는 소비자를 구별합니다.

Layout·Footer는 Context 값을 props로 중계할 필요가 없습니다. Provider 밖은 그 값을 읽지 않으며 가장 가까운 다른 Provider 또는 createContext 기본값을 읽습니다.

value 변경과 렌더의 구별

Object.is로 다른 value이면 이 Context를 읽는 하위 소비자가 새 값을 받습니다. memo만으로 새 Context 반영을 막지 못합니다. 미사용 자식도 부모 렌더 같은 다른 이유로 렌더될 수 있습니다.

기본값과 객체 안정성

본문의 기본값은 문자열 light, 소비자는 객체의 theme/toggleTheme을 기대하므로 Provider 밖 사용은 같은 대안이 아닙니다. 객체 value와 함수가 매번 새로 만들어지면 useMemo만 추가해도 재생성이 계속될 수 있습니다.

위 그림은 useContext가 가장 가까운 상위 Provider의 값을 읽는다는 점을 이 글의 테마 예제로 보여 줍니다. App이 theme과 toggleTheme을 value로 공급하고, ThemeToggle과 ThemedComponent가 그 값을 읽습니다. 그림의 Layout, Footer, OtherRoot는 본문의 코드에는 없고, 값이 어디까지 전달되는지 비교하려고 더한 확장 예입니다. 일반 선은 부모와 자식의 배치를, 강조된 선은 Context의 값을 읽는 경로를 나타냅니다.

App의 state가 light에서 dark로 바뀌면, value인 {theme, toggleTheme}이 새로 만들어져 소비자에게 전달됩니다. ThemeToggle이 toggleTheme을 호출하면 App의 setter가 실행되고, ThemedComponent는 받은 theme으로 스타일을 정합니다. 중간의 Layout과 Footer는 Context의 값을 props로 중계할 필요가 없고, Provider 밖에 있는 OtherRoot는 그 값을 읽지 못하며 더 가까운 다른 Provider나 createContext의 기본값을 읽습니다.

그림의 아래 칸은 이 글의 뒤에서 다룰 주의점을 미리 보여 줍니다. Object.is로 비교해 value가 달라지면 이 Context를 읽는 하위 소비자가 새 값을 받으며, memo만으로는 새 Context 값의 반영을 막을 수 없습니다. Context를 읽지 않는 자식도 부모가 다시 렌더링되는 것 같은 다른 이유로 렌더링될 수 있습니다. 또 이 글의 기본값은 문자열 light인데 소비자는 객체의 theme과 toggleTheme을 기대하므로, Provider 밖에서 쓰는 것은 같은 동작의 대안이 아닙니다.

Context API는 세 가지 요소로 동작합니다. 먼저 React.createContext()로 Context 객체를 만듭니다. 이 객체는 데이터를 제공하는 Provider와 데이터를 쓰는 Consumer를 가지며, 함수 컴포넌트에서는 useContext 훅이 Consumer의 역할을 대신합니다. 다음으로 Context 객체의 Provider 컴포넌트가 value prop으로 하위 컴포넌트들에게 공유할 데이터를 줍니다. Provider 아래에 있는 컴포넌트는 아무리 깊이 있어도 이 value에 접근할 수 있습니다. 마지막으로 useContext(ContextObject)는 함수 컴포넌트에서 Context 객체를 인자로 받아 Provider가 주는 value를 읽어 오는 훅입니다.

React 19부터는 <ThemeContext.Provider value={...}> 대신 Context 객체 자체를 <ThemeContext value={...}>처럼 Provider로 렌더링할 수도 있습니다(React 문서의 createContext). 이 글의 코드는 두 방식 모두에서 동작하는 .Provider 형태를 씁니다.


useContext를 이용한 테마(Theme) 변경 예제

앱의 테마(다크 모드와 라이트 모드)를 전체적으로 바꾸는 기능을 useContext로 구현해 봅니다. 파일은 세 종류로 나눕니다. Context 객체를 만드는 ThemeContext.js, 현재 테마 상태를 가지고 Provider로 값을 내려 주는 App.jsx, 그 값을 읽는 ThemeToggle과 ThemedComponent입니다. 상태는 App 한곳에만 있으므로, 버튼이 테마를 바꾸면 같은 Context를 읽는 컴포넌트가 모두 새 값으로 다시 그려집니다.

Context 생성 (ThemeContext.js)

먼저 Context 객체를 만드는 파일을 작성합니다. 이 파일에는 JSX가 없으므로 .js 확장자로 저장해도 됩니다.

src/contexts/ThemeContext.js
import React from 'react';

// (1) ThemeContext 객체 생성 및 기본값 설정
// 기본값은 Provider가 없을 때 사용되거나, Context의 형태를 유추하는 데 도움을 줍니다.
const ThemeContext = React.createContext('light'); // 기본 테마는 'light'

export default ThemeContext;

createContext의 인자는 위쪽에 Provider가 없을 때만 쓰이는 기본값이며, Provider의 value가 바뀌어도 이 기본값 자체는 바뀌지 않습니다. 이 파일은 Context 객체를 하나 만들어 내보내기만 하므로, 제공하는 쪽과 읽는 쪽이 모두 이 파일을 가져와 같은 객체를 써야 합니다.

Provider를 이용한 데이터 제공 (App.jsx)

이제 App.jsx에서 ThemeContext.Provider로 App의 모든 하위 컴포넌트에 테마 데이터를 제공합니다. 테마 상태와 바꾸는 함수는 App의 useState에 있고, 바깥 div의 배경색과 글자색도 같은 theme으로 정하므로, 버튼을 누르면 앱 전체의 색이 함께 바뀝니다.

src/App.jsx
import React, { useState } from 'react';
import './App.css';
import ThemeContext from './contexts/ThemeContext'; // ThemeContext 불러오기
import ThemeToggle from './components/ThemeToggle'; // 테마 토글 컴포넌트
import ThemedComponent from './components/ThemedComponent'; // 테마 적용 컴포넌트

function App() {
  const [theme, setTheme] = useState('light'); // 현재 테마 상태

  const toggleTheme = () => {
    setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
  };

  return (
    // (1) ThemeContext.Provider로 하위 컴포넌트를 감싸고, value prop으로 데이터 전달
    <ThemeContext.Provider value={{ theme: theme, toggleTheme: toggleTheme }}>
      <div className="App" style={{
        backgroundColor: theme === 'dark' ? '#333' : '#f0f2f5',
        color: theme === 'dark' ? 'white' : '#333',
        minHeight: '100vh',
        padding: '20px',
        transition: 'background-color 0.3s, color 0.3s'
      }}>
        <h1>Context API를 이용한 테마 변경</h1>
        <ThemeToggle /> {/* 테마를 변경하는 버튼 컴포넌트 */}
        <ThemedComponent /> {/* 테마가 적용될 컴포넌트 */}
        <ThemedComponent /> {/* 여러 개의 컴포넌트가 동일한 Context에 접근 가능 */}
      </div>
    </ThemeContext.Provider>
  );
}

export default App;

ThemeContext.Provider로 앱 전체(div.App)를 감쌌고, value prop에는 공유할 객체 { theme: theme, toggleTheme: toggleTheme }을 넘겼습니다. 이 객체에는 현재 테마(theme)와 테마를 바꾸는 함수(toggleTheme)가 들어 있습니다.

useContext를 이용한 데이터 사용

이제 Provider 아래의 컴포넌트들이 useContext로 Provider가 주는 데이터에 접근합니다. 먼저 src/components/ThemeToggle.jsx를 만듭니다.

src/components/ThemeToggle.jsx
import React, { useContext } from 'react';
import ThemeContext from '../contexts/ThemeContext'; // ThemeContext 불러오기

function ThemeToggle() {
  // (1) useContext 훅을 사용하여 ThemeContext의 value를 가져옴
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <button
      onClick={toggleTheme}
      style={{
        padding: '10px 20px',
        fontSize: '16px',
        backgroundColor: theme === 'dark' ? '#666' : '#007bff',
        color: 'white',
        border: 'none',
        borderRadius: '5px',
        cursor: 'pointer',
        marginBottom: '20px'
      }}
    >
      현재 테마: {theme === 'light' ? '밝은 모드 ☀️' : '다크 모드 🌙'} (클릭하여 토글)
    </button>
  );
}

export default ThemeToggle;

useContext(ThemeContext)를 호출해 ThemeContext.Provider가 주는 value 객체를 바로 가져오고, 그 객체에서 theme과 toggleTheme을 구조 분해해 씁니다. 이어서 src/components/ThemedComponent.jsx를 만듭니다.

src/components/ThemedComponent.jsx
import React, { useContext } from 'react';
import ThemeContext from '../contexts/ThemeContext'; // ThemeContext 불러오기

function ThemedComponent() {
  const { theme } = useContext(ThemeContext); // theme 값만 가져옴

  const cardStyle = {
    padding: '20px',
    margin: '10px 0',
    borderRadius: '8px',
    border: theme === 'dark' ? '1px solid #777' : '1px solid #ddd',
    backgroundColor: theme === 'dark' ? '#555' : 'white',
    color: theme === 'dark' ? 'white' : '#333',
    transition: 'background-color 0.3s, color 0.3s, border-color 0.3s'
  };

  return (
    <div style={cardStyle}>
      <h3>테마 적용된 컴포넌트</h3>
      <p>현재 테마: {theme === 'light' ? '밝은 모드' : '다크 모드'}</p>
      <p>이 컴포넌트는 Context로부터 테마 정보를 받았습니다.</p>
    </div>
  );
}

export default ThemedComponent;

이 컴포넌트도 useContext(ThemeContext)로 theme 값만 가져와 스타일에 적용합니다. props drilling 없이도 원하는 데이터에 바로 접근할 수 있습니다.

테마 전환 동작 점검

앱을 실행하거나 파일을 저장해 화면이 바뀌면, “현재 테마: 밝은 모드 ☀️ (클릭하여 토글)” 버튼을 눌러 보세요. 앱 전체의 배경색과 글자색, 그리고 두 ThemedComponent의 스타일이 한 번에 바뀌고, 버튼과 카드의 문구가 “다크 모드”로 바뀝니다.


가장 가까운 Provider

Context는 페이지 전체에서 하나의 값만 쓰도록 강제하는 저장소가 아닙니다. 소비자는 같은 Context의 Provider를 자기 조상에서 찾고, 그 가운데 가장 가까운 Provider의 값을 읽습니다. 그래서 트리의 일부에만 다른 값을 주고 싶다면 그 영역을 다른 value의 Provider로 한 번 더 감싸면 됩니다. 다만 소비자가 자기가 돌려주는 JSX에 Provider를 넣는다고 해서, 그 Provider가 소비자의 현재 훅 호출을 감싸는 조상이 되지는 않습니다. 조상 검색과 가장 가까운 Provider, 정적인 기본값의 규칙은 React 문서의 useContext에서 함께 설명합니다.

더 깊이: 조건문 안에서 Context를 읽는 use

React 19에는 렌더하는 동안 Promise나 Context 같은 자원을 읽는 use API가 들어왔습니다. use(ThemeContext)는 useContext(ThemeContext)와 비슷하게 동작하지만, 컴포넌트의 최상위에서만 불러야 하는 useContext와 달리 if나 for 안에서도 부를 수 있습니다. 그래서 show가 참일 때만 테마를 읽어 구분선을 그리는 컴포넌트처럼, 특정 분기에서만 Context가 필요한 경우를 그 분기 안에서 바로 쓸 수 있습니다(React 문서의 use).

이름이 비슷해도 use는 훅이 아니어서 이런 호출이 허용되지만, 나머지 규칙은 거의 같습니다. 컴포넌트나 훅 안에서만 불러야 하고, 호출한 컴포넌트의 위쪽에서 가장 가까운 Provider를 찾으며, Server Components에서는 use로 Context를 읽을 수 없습니다. 같은 use에 Promise를 넘기면 데이터를 기다리는 데도 쓸 수 있는데, 이때는 렌더마다 같은 Promise 객체를 넘겨야 하고 try/catch 안에서 부를 수 없다는 별도의 규칙이 더해집니다.


useContext 사용 시 주의사항

Context의 기본값과 value의 변경이 소비자에게 어떤 영향을 주는지 구분해야 합니다. 먼저 성능입니다. Context는 편리하지만, value가 바뀌면 그 Provider의 값을 실제로 읽는 useContext 소비자들이 모두 다시 렌더링됩니다. value가 자주 바뀌어 불필요한 렌더링이 많아지면 성능 문제가 될 수 있으므로, Context를 바뀌는 주기와 쓰는 범위에 맞게 나눕니다. React.memo만으로는 그 Context를 읽는 컴포넌트에 새 value가 반영되는 것을 막을 수 없습니다.

다음은 value 객체의 안정성입니다. value={{ theme, toggleTheme }}처럼 객체 리터럴을 바로 쓰면 렌더할 때마다 새 참조가 만들어져, 그 Provider의 값을 읽는 소비자에게 매번 새 값이 전달됩니다. 이때는 useMemo 훅으로, value 객체가 기대는 값이 바뀔 때만 새 객체를 만들도록 할 수 있습니다.

src/App.jsx
import React, { useState, useMemo } from 'react';
// ...
function App() {
  const [theme, setTheme] = useState('light');
  const toggleTheme = () => { /* ... */ };

  // useMemo를 사용하여 value 객체가 theme이나 toggleTheme이 변경될 때만 재생성되도록 함
  const themeContextValue = useMemo(() => ({
    theme: theme,
    toggleTheme: toggleTheme
  }), [theme, toggleTheme]); // theme, toggleTheme이 변경될 때만 객체 재생성

  return (
    <ThemeContext.Provider value={themeContextValue}>
      {/* ... */}
    </ThemeContext.Provider>
  );
}

다만 이 예제에서 toggleTheme 함수도 useCallback으로 고정하지 않으면 렌더마다 새 함수가 만들어지므로, 그 함수를 의존성으로 가진 themeContextValue도 계속 다시 만들어집니다. useCallback과 useMemo는 10장의 성능 최적화에서 자세히 다룹니다.

마지막으로 Provider가 없는 곳에서 useContext를 호출하는 경우입니다. useContext를 호출한 컴포넌트가 그 Context의 Provider 아래에 있지 않다면 createContext()에 준 기본값이 돌아옵니다. 이 글의 기본값은 문자열 'light'이지만 소비자는 객체의 theme과 toggleTheme을 읽으므로, 이 예제는 반드시 Provider 안에서 써야 하며 Provider 없이 호출하는 것을 같은 동작의 대안으로 쓸 수 없습니다.


Context의 한계와 상태 관리 라이브러리

Context API는 props drilling을 해결하고 간단한 전역 데이터를 공유하는 데 매우 유용합니다. 하지만 여러 액션과 리듀서로 복잡한 상태의 변화를 관리해야 할 때, value가 자주 바뀌어 많은 하위 컴포넌트가 영향을 받아 렌더링을 줄이기 어려울 때, 그리고 Redux DevTools 같은 상태 추적 도구가 필요할 때는 Context API만으로 상태를 관리하는 데 한계가 있습니다.

이런 문제를 해결하려고 Redux Toolkit, Zustand, Jotai 같은 상태 관리 라이브러리가 나왔습니다. 이 라이브러리들은 Context API를 바탕으로 하거나 다른 최적화 기법을 써서, 큰 애플리케이션의 복잡한 상태 관리 문제를 더 체계적으로 풀도록 돕습니다. Context API는 이런 라이브러리의 동작 원리를 이해하는 좋은 바탕이 됩니다. Context를 더하기 전에는 그 값이 미치는 범위와 값의 안정성, 다시 렌더링되는 범위를 함께 점검하세요.


정리

  • Context는 props drilling 없이 트리의 하위 컴포넌트에 값을 공유하며, createContext()로 만들고 Provider의 value로 공급하며 useContext()로 읽습니다.
  • 소비자는 조상 가운데 가장 가까운 같은 Context의 Provider 값을 읽고, Provider가 없으면 createContext()의 기본값을 읽습니다.
  • React 19부터는 <ThemeContext value={...}>처럼 Context 객체 자체를 Provider로 렌더링할 수도 있습니다.
  • value가 바뀌면 그 값을 읽는 소비자가 모두 다시 렌더링되며, React.memo로는 이를 막을 수 없습니다.
  • 객체 value는 렌더마다 새 참조가 되므로 useMemo로 고정하고, 그 안의 함수도 useCallback으로 고정해야 효과가 있습니다.
  • 복잡한 전역 상태와 세밀한 렌더링 제어가 필요하면 상태 관리 라이브러리를 검토합니다.

연습 문제

1. 가장 가까운 Provider 찾기

별도의 트리에서 바깥 Provider는 객체의 theme을 dark로, 그 안의 작은 영역에 있는 Provider는 light로 줍니다. 바깥 영역의 소비자 A, 안쪽 영역의 소비자 B, Context를 읽지 않는 중간의 Layout, 그리고 Provider 밖에서 이 글의 ThemeContext 기본값만 읽는 소비자 C는 각각 무엇을 읽을까요?

해설 보기

A는 dark를, B는 가장 가까운 안쪽 Provider의 light를 읽습니다. C가 읽는 기본값은 객체가 아니라 문자열 'light'이므로, 원래의 객체 소비자가 기대하는 theme과 toggleTheme을 갖추지 못합니다.

Layout은 값을 읽지 않으므로 Context의 변화 때문에 다시 렌더링되는 소비자로 세지 않습니다. 부모가 다시 렌더링되는 것 같은 다른 이유로 렌더링될 수는 있지만, 그것은 별개의 일입니다. 바깥 값이 바뀌면 그 값을 실제로 읽는 A에 전달되며, 안쪽 Provider의 값을 읽는 B는 바깥 값의 직접 소비자가 아닙니다.

2. Provider 밖의 ThemeToggle

원래의 ThemeToggle을 ThemeContext.Provider 밖에서 렌더링하면 버튼에는 어떤 글자가 보이고, 버튼을 누르면 어떻게 될까요?

해설 보기

useContext(ThemeContext)가 기본값인 문자열 'light'를 돌려주고, 문자열에서 theme과 toggleTheme을 구조 분해하면 둘 다 undefined가 됩니다. 그래서 theme === 'light'가 거짓이 되어 버튼에는 “현재 테마: 다크 모드 🌙 (클릭하여 토글)”이 보입니다.

onClick에 undefined가 넘어가므로 버튼을 눌러도 아무 일도 일어나지 않습니다. 기본값과 소비자가 기대하는 모양이 다르기 때문에 생기는 문제이며, 이 예제는 Provider 안에서만 써야 합니다.

3. useMemo만 더하면

App에 useMemo로 themeContextValue를 만들었지만 toggleTheme은 원래처럼 렌더마다 새로 정의합니다. 테마와 상관없는 다른 state가 바뀌어 App이 다시 렌더링될 때, themeContextValue는 새로 만들어질까요?

해설 보기

새로 만들어집니다. toggleTheme이 렌더마다 새 함수가 되므로 useMemo의 의존성 [theme, toggleTheme] 가운데 toggleTheme이 매번 바뀌고, 그래서 themeContextValue도 매번 새 객체가 됩니다. 결국 소비자들은 테마가 그대로인데도 새 값을 받습니다.

toggleTheme을 useCallback으로 고정해야 useMemo가 의도대로 동작합니다. 이 예제의 toggleTheme은 이전 값을 받는 업데이터만 쓰므로, 의존성이 없는 useCallback(..., [])로 고정할 수 있습니다.

다음 글 useReducer에서는 useReducer로 복잡한 상태 변화를 구조화하는 방법을 다룹니다.