지난 시간에는 형제 컴포넌트가 데이터를 공유하기 위해 공통 부모로 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는 다음 세 단계로 사용합니다.
- Context 생성 —
createContext로 만든다 - 공급 —
<Context.Provider value={...}>로 트리의 어딘가를 감싸 데이터를 제공 - 구독 — 자식 컴포넌트에서
useContext(Context)로 값을 꺼내 사용
코드로 봅시다. 위의 user 예제를 Context로 풀어보겠습니다.
1단계 — Context 생성
import { createContext } from 'react';
export const UserContext = createContext(null);createContext에 넣는 값은 기본값입니다. Provider가 감싸지 않은 위치에서 useContext를 호출했을 때 사용되는 값입니다.
2단계 — Provider로 공급
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로 받지 않아도 됩니다.
import Sidebar from './Sidebar';
function Layout() {
return (
<div>
<Sidebar />
</div>
);
}
export default Layout;import ProfileMenu from './ProfileMenu';
function Sidebar() {
return (
<aside>
<ProfileMenu />
</aside>
);
}
export default Sidebar;Layout, Sidebar, ProfileMenu는 user에 대해 아무것도 알 필요가 없습니다. 깔끔해졌죠.
3단계 — useContext로 구독
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(또는 갱신 함수)를 같이 담는 패턴이 매우 흔합니다. 그래야 자손에서 값을 읽을 뿐 아니라 변경도 할 수 있죠.
import { createContext } from 'react';
export const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});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>
);
}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 자체를 별도 컴포넌트로 분리하는 게 깔끔합니다. 상태 관리 로직을 한곳에 모아두는 효과가 있어요.
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;import ThemeProvider from './ThemeProvider';
import Page from './Page';
function App() {
return (
<ThemeProvider>
<Page />
</ThemeProvider>
);
}App이 훨씬 단순해졌습니다. 테마 관련 모든 로직이 ThemeProvider 안에 캡슐화되어, 다른 곳에서 가져다 쓰기도 쉬워졌습니다.
커스텀 훅으로 한 번 더 감싸기
useContext(ThemeContext)처럼 매번 Context를 직접 import하는 것도 살짝 번거롭습니다. 자주 쓰는 Context는 커스텀 훅으로 감싸서 사용 편의를 올리는 패턴이 흔합니다.
import { createContext, useContext } from 'react';
export const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
export function useTheme() {
return useContext(ThemeContext);
}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:
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:
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:
import { useTheme } from './ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
현재: {theme === 'light' ? '☀ 라이트' : '🌙 다크'} (클릭해서 전환)
</button>
);
}
export default ThemeToggle;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;버튼을 누르면 두 카드의 색이 동시에 바뀝니다. Card와 ThemeToggle은 서로의 존재를 모르고, 부모도 둘 사이의 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도 사실 커스텀 훅의 한 예시였죠. 다음 글에서 본격적으로 다뤄보겠습니다.