리액트 기초 강좌 #12 useContext

13 분 소요

지난 시간에는 형제 컴포넌트가 데이터를 공유하기 위해 공통 부모로 state를 끌어올리는 패턴을 배웠습니다. 좋은 도구지만, 컴포넌트 트리가 깊어지면 한 가지 문제가 생깁니다. 이번 시간에는 그 문제와 해결책인 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로 다루면 성능 문제가 생길 수 있습니다.

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

Context는 "데이터 전달 통로"이지, 그 자체로 정교한 상태 관리 도구는 아닙니다. 앱 전체의 복잡한 상태(전역 사용자 정보 + 알림 + 카트 + 설정 + ...)를 다룬다면 Zustand, Redux Toolkit, Jotai 같은 전용 라이브러리가 더 잘 맞습니다. Context는 작은 범위의 공유 상태나 테마/언어/사용자 같은 "거의 안 바뀌는" 전역 데이터에 적합합니다.

노트

"Context의 적절한 용도"를 가늠하는 한 줄: 앱 전반에서 자주 쓰이지만 거의 바뀌지 않는 값 (테마, 로그인된 사용자, 언어 설정, 토스트 알림 시스템 등)이 가장 잘 맞습니다.

직접 해보기

테마(라이트/다크)를 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 없이 트리 어디서든 같은 데이터에 접근할 수 있게 된 것입니다.

마무리

이번 글에서는 prop drilling 문제와 해결책인 Context API를 배웠습니다. 정리하면:

  • prop drilling — 중간 컴포넌트들이 무관한 props를 그저 전달만 하는 상황
  • Context는 트리 어딘가의 값을 그 아래 자손이 직접 꺼내 쓰게 해주는 "통로"
  • 사용 3단계: createContext<Provider value={...}>useContext
  • 값과 setter를 객체로 묶어 공급하는 패턴이 흔함
  • Provider 로직은 별도 컴포넌트로, 소비는 커스텀 훅으로 감싸면 깔끔
  • 단순 prop 전달은 그냥 props가 낫고, 자주 바뀌는 데이터에는 부적절

여기까지가 배치 2(#9~#12) 마무리입니다. 폼, useEffect, 상태 끌어올리기, Context까지 다뤘으니 이제 작은 실전 앱을 처음부터 끝까지 만드는 데 필요한 패턴은 거의 다 갖춰졌습니다.

다음 글인 "리액트 기초 강좌 #13 커스텀 훅"에서는 컴포넌트 사이에서 로직을 공유하는 가장 우아한 도구인 커스텀 훅을 다뤄보겠습니다. 이번 글에서 살짝 사용한 useTheme도 사실 커스텀 훅의 한 예시였죠. 다음 글에서 본격적으로 다뤄보겠습니다.