목차
12 장

useContext — 적절한 경우와 부적절한 경우

prop drilling을 풀어 주는 Context API. 사용 3단계 · value 분리 패턴 · 외부 상태 라이브러리와의 경계까지 다룹니다.

11장에서 형제 컴포넌트가 데이터를 공유하기 위해 공통 부모로 state를 끌어올리는 패턴을 다뤘습니다. 좋은 도구지만, 컴포넌트 트리가 깊어지면 한 가지 문제가 생깁니다. 본 챕터에서는 그 문제와 해결책인 Context를 다루고, Context의 한계 너머에 있는 외부 상태 라이브러리들과의 경계도 함께 짚습니다.

Prop Drilling 문제 #

다음과 같은 컴포넌트 트리를 상상해 봅시다.

컴포넌트 트리
App (state: user)
└── Layout
    └── Sidebar
        └── ProfileMenu
            └── UserAvatar (user 정보가 여기서 필요)

user state는 App에 있는데 정작 그 값을 쓰는 건 깊숙이 있는 UserAvatar입니다. 사이에 있는 Layout, Sidebar, ProfileMenu는 user에 관심이 없지만, 단지 아래로 전달하기 위해 props를 받아야 합니다.

<Layout user={user}>
  <Sidebar user={user}>
    <ProfileMenu user={user}>
      <UserAvatar user={user} />
    </ProfileMenu>
  </Sidebar>
</Layout>

이렇게 중간에 있는 컴포넌트들이 자기와 무관한 props를 받아 그저 아래로 내려보내기만 하는 상황을 **prop drilling (프롭 드릴링)**이라고 부릅니다. 깊이가 깊어지거나 전달해야 할 값이 많아지면 코드가 빠르게 지저분해집니다.

리액트는 이 문제를 풀기 위해 Context API라는 도구를 제공합니다.

Context의 아이디어 #

Context의 핵심 아이디어는 단순합니다.

컴포넌트 트리 어딘가에 데이터를 “공급” 해 두면, 그 아래 어떤 깊이의 자식이든 직접 “구독” 해서 가져다 쓸 수 있다.

중간 컴포넌트들을 거치지 않고 위에서 아래로 데이터가 순간 이동 하는 셈입니다.

Context 사용 3단계 #

Context는 다음 세 단계로 씁니다.

  1. Context 생성createContext로 만든다
  2. 공급<Context.Provider value={...}>로 트리의 어딘가를 감싸 데이터를 제공
  3. 구독 — 자식 컴포넌트에서 useContext(Context)로 값을 꺼내 사용

위의 user 예제를 Context로 풀어 보겠습니다.

1단계 — Context 생성 #

src/UserContext.js
import { createContext } from 'react';

export const UserContext = createContext(null);

createContext에 넣는 값은 기본값입니다. Provider가 감싸지 않은 위치에서 useContext를 호출했을 때 쓰이는 값입니다.

2단계 — Provider로 공급 #

src/App.jsx
import { useState } from 'react';
import { UserContext } from './UserContext';
import Layout from './Layout';

function App() {
  const [user, setUser] = useState({ name: '철수', email: 'cheolsu@example.com' });

  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  );
}

export default App;

UserContext.Provider로 감싼 영역 안의 모든 자손 컴포넌트value 값을 꺼내 쓸 수 있게 됩니다. 중간 컴포넌트들은 더 이상 user를 props로 받지 않아도 됩니다.

src/Layout.jsx
import Sidebar from './Sidebar';

function Layout() {
  return (
    <div>
      <Sidebar />
    </div>
  );
}

export default Layout;
src/Sidebar.jsx
import ProfileMenu from './ProfileMenu';

function Sidebar() {
  return (
    <aside>
      <ProfileMenu />
    </aside>
  );
}

export default Sidebar;

Layout, Sidebar, ProfileMenu는 user에 대해 아무것도 알 필요가 없습니다. 깔끔해졌습니다.

3단계 — useContext로 구독 #

src/UserAvatar.jsx
import { useContext } from 'react';
import { UserContext } from './UserContext';

function UserAvatar() {
  const user = useContext(UserContext);

  if (!user) return <p>로그인이 필요합니다.</p>;

  return (
    <div>
      <p>{user.name}</p>
      <p>{user.email}</p>
    </div>
  );
}

export default UserAvatar;

useContext(UserContext)를 호출하면 가장 가까운 UserContext.Provider가 제공한 값을 그대로 받습니다. 중간을 거치지 않고 한 번에 가져온 것입니다.

값과 함수를 같이 공급하기 #

Context 값은 객체로 만들어 state와 그 setter (또는 갱신 함수)를 같이 담는 패턴이 매우 흔합니다. 그래야 자손에서 값을 읽을 뿐 아니라 변경도 할 수 있습니다.

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

export const ThemeContext = createContext({
  theme: 'light',
  toggleTheme: () => {},
});
src/App.jsx
import { useState, useCallback } from 'react';
import { ThemeContext } from './ThemeContext';
import Page from './Page';

function App() {
  const [theme, setTheme] = useState('light');

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

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      <Page />
    </ThemeContext.Provider>
  );
}
src/ThemeToggle.jsx
import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';

function ThemeToggle() {
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <button onClick={toggleTheme}>
      현재 테마: {theme} (클릭해서 전환)
    </button>
  );
}

export default ThemeToggle;

자손 컴포넌트는 theme (현재 값)과 toggleTheme (변경 함수)을 함께 꺼내 씁니다. 이 패턴 덕에 Context 하나로 “공유 상태와 그 조작 방법"을 한꺼번에 노출할 수 있습니다.

Provider를 컴포넌트로 감싸기 #

Context 사용이 늘어나면 Provider 자체를 별도 컴포넌트로 분리 하는 게 깔끔합니다. 상태 관리 로직을 한곳에 모아 두는 효과가 있습니다.

src/ThemeProvider.jsx
import { useState, useCallback } from 'react';
import { ThemeContext } from './ThemeContext';

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

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

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export default ThemeProvider;
src/App.jsx
import ThemeProvider from './ThemeProvider';
import Page from './Page';

function App() {
  return (
    <ThemeProvider>
      <Page />
    </ThemeProvider>
  );
}

App이 훨씬 단순해졌습니다. 테마 관련 모든 로직이 ThemeProvider 안에 캡슐화되어, 다른 곳에서 가져다 쓰기도 쉬워졌습니다.

커스텀 훅으로 한 번 더 감싸기 #

useContext(ThemeContext)처럼 매번 Context를 직접 import 하는 것도 살짝 번거롭습니다. 자주 쓰는 Context는 커스텀 훅으로 감싸 사용 편의를 올리는 패턴이 흔합니다.

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

export const ThemeContext = createContext({
  theme: 'light',
  toggleTheme: () => {},
});

export function useTheme() {
  return useContext(ThemeContext);
}
src/ThemeToggle.jsx
import { useTheme } from './ThemeContext';

function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();
  // ...
}

소비하는 쪽 코드가 한결 짧아집니다. 커스텀 훅의 본격적인 이야기는 다음 13장에서 다루겠습니다.

Context를 남용하지 마세요 #

Context는 강력하지만 모든 곳에 쓰는 도구는 아닙니다. 다음 점들을 기억해 두세요.

1. 단순한 prop 전달이라면 그냥 props가 낫다 #

부모-자식 한두 단계라면 props가 훨씬 명시적이고 추적하기 쉽습니다. 깊이가 진짜 깊거나 (3~4단계 이상) 여러 갈래에서 같이 쓰는 데이터일 때 Context가 빛납니다.

2. Provider의 value가 바뀌면 그 아래 모든 구독자가 다시 렌더링됨 #

Context는 자손 모두를 묶어 버리는 만큼, value가 자주 바뀌면 광범위한 리렌더링이 일어납니다. 변경 빈도가 높은 데이터 (예: 마우스 좌표)를 Context로 다루면 성능 문제가 생길 수 있습니다.

value 분리 패턴으로 일부 완화할 수 있습니다. 자주 바뀌는 값과 거의 안 바뀌는 값을 두 개의 Context로 나누는 것입니다.

자주 바뀌는 값 vs 거의 안 바뀌는 값을 분리
// 거의 안 바뀜 — 모든 자손이 구독해도 부담 적음
<UserContext.Provider value={user}>
  {/* 자주 바뀜 — 구독 범위를 좁히는 게 좋음 */}
  <CursorContext.Provider value={cursorPosition}>
    <App />
  </CursorContext.Provider>
</UserContext.Provider>

3. Context는 전역 상태 라이브러리가 아니다 #

Context는 “데이터 전달 통로” 이지, 그 자체로 정교한 상태 관리 도구는 아닙니다. 앱 전체의 복잡한 상태 (전역 사용자 정보 + 알림 + 카트 + 설정 등)를 다룬다면 Zustand, Jotai, Redux Toolkit 같은 전용 라이브러리가 더 잘 맞습니다.

세 도구의 용도를 짧게 짚으면:

  • Zustand — 가장 가볍고 보일러플레이트가 적음. 작은~중간 규모 앱에서 first pick
  • Jotai — atom 단위로 잘게 쪼개진 상태. 부분 구독에 강하고 성능 친화적
  • Redux Toolkit — 명시적인 action / reducer 구조와 devtools. 큰 팀과 복잡한 도메인에 적합

이 책의 1~2부에서는 외부 도구를 도입하지 않습니다. Context와 lifting state up만으로 풀 수 있는 만큼이 1~2부의 범위입니다. 외부 도구의 도입은 이 책에서 직접 다루지 않지만, 부록 A (옛 리액트 마이그레이션)에서 Redux-only → RSC + Server Actions + 소형 client store로의 전환 절차를 다루겠습니다.

노트
“Context의 적절한 용도"를 가늠하는 한 줄. 앱 전반에서 자주 쓰이지만 거의 바뀌지 않는 값(테마, 로그인된 사용자, 언어 설정, 토스트 알림 시스템 등)이 가장 잘 맞습니다. 자주 바뀌고 일부에서만 쓰이는 데이터는 lifting state up이나 외부 라이브러리를 쓰는 경우가 더 낫습니다.

직접 해보기 #

테마 (라이트 / 다크)를 Context로 관리하고, 두 개의 자식 컴포넌트가 같은 테마 상태를 공유하는 예제를 만들어 봅시다.

src/ThemeContext.js:

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

const ThemeContext = createContext({
  theme: 'light',
  toggleTheme: () => {},
});

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

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

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

src/Card.jsx:

src/Card.jsx
import { useTheme } from './ThemeContext';

function Card({ children }) {
  const { theme } = useTheme();

  const styles = {
    background: theme === 'light' ? '#fff' : '#222',
    color: theme === 'light' ? '#000' : '#fff',
    padding: '16px',
    border: '1px solid #999',
    borderRadius: '8px',
    margin: '8px 0',
  };

  return <div style={styles}>{children}</div>;
}

export default Card;

src/ThemeToggle.jsx:

src/ThemeToggle.jsx
import { useTheme } from './ThemeContext';

function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      현재: {theme === 'light' ? '☀ 라이트' : '🌙 다크'} (클릭해서 전환)
    </button>
  );
}

export default ThemeToggle;

src/App.jsx:

src/App.jsx
import { ThemeProvider } from './ThemeContext';
import Card from './Card';
import ThemeToggle from './ThemeToggle';

function App() {
  return (
    <ThemeProvider>
      <ThemeToggle />
      <Card>
        <h2> 번째 카드</h2>
        <p>테마를 바꾸면 색이 변합니다.</p>
      </Card>
      <Card>
        <h2> 번째 카드</h2>
        <p> 카드가 같은 테마를 공유합니다.</p>
      </Card>
    </ThemeProvider>
  );
}

export default App;

버튼을 누르면 두 카드의 색이 동시에 바뀝니다. CardThemeToggle은 서로의 존재를 모르고, 부모도 둘 사이의 props를 중개하지 않았는데도 같은 테마 상태를 공유하고 있습니다. prop drilling 없이 트리 어디서든 같은 데이터에 접근할 수 있게 된 것입니다.

연습문제 #

  1. 위 테마 예제에 다크 / 라이트 외에 “high-contrast” 세 번째 테마를 추가해 보세요. toggleTheme 대신 setTheme(value)를 노출해 명시적으로 선택할 수 있게 만듭니다. Card의 스타일에도 세 분기 모두 처리합니다.
  2. Context value 변경 시 리렌더링 범위를 직접 관찰. 자손 컴포넌트에 console.log('rendered')를 심고, value를 자주 바꿔 보세요. Provider 아래 모든 자손이 같이 다시 렌더링되는 것을 확인합니다. 그 뒤 자주 바뀌는 값과 거의 안 바뀌는 값을 두 Context로 분리하면 자손의 일부만 리렌더링되는 것을 확인해 봅니다.
  3. 인증 Context 패턴. AuthContextAuthProvider를 만들고 user / login(email, password) / logout() 세 가지를 노출하세요. 로그인 시 mock으로 { name: '철수' }를 setUser 하고, 로그아웃은 setUser(null)입니다. LoginFormUserBadge 두 자식 컴포넌트가 같은 Context를 구독해 한쪽이 로그인하면 다른 쪽이 즉시 갱신되는지 확인합니다. 32장 (인증과 세션)의 토대가 됩니다.

한 줄 요약: Context는 트리 어딘가의 값을 그 아래 자손이 직접 꺼내 쓰게 해 주는 통로다. 사용은 3단계 — createContext<Provider value={...}>useContext. 값과 setter를 객체로 묶어 공급하는 패턴이 흔하고, Provider 로직은 별도 컴포넌트로, 소비는 커스텀 훅으로 감싸면 깔끔하다. 단순 prop 전달은 그냥 props가 낫고, 자주 바뀌는 데이터에는 부적절하다. 복잡한 전역 상태에는 Zustand / Jotai / Redux Toolkit 같은 외부 도구를 쓰는 편이 낫다.

다음 챕터 #

본 챕터에서 useTheme 같은 작은 커스텀 훅을 슬쩍 봤습니다. 다음 13장 커스텀 훅에서는 컴포넌트 사이에서 로직을 공유하는 가장 우아한 도구인 커스텀 훅을 본격적으로 다루겠습니다. 좋은 훅의 인터페이스 형태와, 반대로 “훅으로 빼지 말아야 할 경우"의 기준까지 함께 짚겠습니다.

X