Context API는 컴포넌트 트리 깊숙한 곳까지 props를 계속 전달하지 않고도 데이터를 공유할 수 있게 해주는 React 내장 기능입니다. 실무에서는 전역 테마, 인증 정보, 사용자 설정처럼 업데이트 빈도가 낮고 앱 전역에서 필요한 값에 적합합니다.
1. Context가 필요한 순간
다음과 같은 경우 Context 사용을 고려합니다.
- 여러 레벨을 거쳐 같은 props를 전달하는 prop drilling이 발생합니다.
- 전역 테마, 로케일, 인증 사용자, 기능 플래그 등 전역 의존 값이 있습니다.
- 업데이트 빈도가 높지 않습니다. (입력값, 스크롤 위치처럼 자주 바뀌는 값은 Context에 넣지 않는 것이 좋습니다)
반대로 다음 케이스는 다른 도구가 더 적합합니다.
- 서버 상태: React Query 등 데이터 패칭 라이브러리
- 매우 잦은 업데이트/부분 구독: Zustand/Jotai/Recoil
- 타임트래블/미들웨어/디버깅 도구 필요: Redux Toolkit
2. 기본 구성: Context, Provider, useContext
테마를 전역으로 공유하는 가장 단순한 예제입니다. 실수 방지를 위해 커스텀 훅에서 Provider 누락을 검사합니다.
// theme-context.jsx
import React from 'react';
const ThemeContext = React.createContext(undefined);
export function ThemeProvider({ children }) {
const [theme, setTheme] = React.useState('light'); // 'light' | 'dark'
// value는 반드시 메모이제이션하여 불필요한 리렌더를 줄입니다.
const value = React.useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = React.useContext(ThemeContext);
if (!ctx) throw new Error('useTheme은 ThemeProvider 안에서만 사용해야 합니다');
return ctx;
}
// 사용 예
import React from 'react';
import { ThemeProvider, useTheme } from './theme-context';
function Header() {
const { theme, setTheme } = useTheme();
const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
return (
<header data-theme={theme}>
<button onClick={toggle}>Toggle Theme</button>
</header>
);
}
export default function App() {
return (
<ThemeProvider>
<Header />
{/* ... */}
</ThemeProvider>
);
}
주의: Provider의 value로 객체 리터럴을 직접 넣으면 렌더마다 새 객체가 생성되어 전체 구독 컴포넌트가 리렌더됩니다. 꼭 useMemo로 감싸주세요.
3. 전역 상태 관리 패턴: useReducer + State/Dispatch 분리
Context는 값이 바뀌면 해당 Context를 구독하는 모든 컴포넌트를 리렌더합니다. 이를 줄이려면 상태와 디스패치를 분리한 2개의 Context를 사용합니다. 상태만 필요한 컴포넌트는 dispatch 변경으로 리렌더되지 않습니다.
// auth-context.jsx
import React from 'react';
const AuthStateContext = React.createContext(undefined);
const AuthDispatchContext = React.createContext(undefined);
const initialState = { user: null, loading: false };
function authReducer(state, action) {
switch (action.type) {
case 'LOGIN_PENDING':
return { ...state, loading: true };
case 'LOGIN_SUCCESS':
return { user: action.user, loading: false };
case 'LOGOUT':
return { user: null, loading: false };
default:
return state;
}
}
export function AuthProvider({ children }) {
const [state, dispatch] = React.useReducer(authReducer, initialState);
// state는 객체지만 useReducer가 새 참조를 보장하므로 그대로 제공해도 됩니다.
// 필요 시 파생 값만 메모이제이션해서 제공하세요.
return (
<AuthStateContext.Provider value={state}>
<AuthDispatchContext.Provider value={dispatch}>
{children}
</AuthDispatchContext.Provider>
</AuthStateContext.Provider>
);
}
export function useAuthState() {
const ctx = React.useContext(AuthStateContext);
if (!ctx) throw new Error('useAuthState는 AuthProvider 안에서만 사용해야 합니다');
return ctx;
}
export function useAuthDispatch() {
const ctx = React.useContext(AuthDispatchContext);
if (!ctx) throw new Error('useAuthDispatch는 AuthProvider 안에서만 사용해야 합니다');
return ctx;
}
// 사용 예
import React from 'react';
import { AuthProvider, useAuthState, useAuthDispatch } from './auth-context';
function Avatar() {
const { user, loading } = useAuthState();
if (loading) return <p>Loading...</p>;
return user ? <img src={user.avatar} alt={'avatar'} /> : <span>Guest</span>;
}
function LoginButton() {
const dispatch = useAuthDispatch();
const onLogin = async () => {
dispatch({ type: 'LOGIN_PENDING' });
const user = await fakeLogin();
dispatch({ type: 'LOGIN_SUCCESS', user });
};
return <button onClick={onLogin}>Login</button>;
}
export default function App() {
return (
<AuthProvider>
<Avatar />
<LoginButton />
</AuthProvider>
);
}
async function fakeLogin() {
return new Promise(resolve =>
setTimeout(() => resolve({ name: 'Lee', avatar: '/me.png' }), 500)
);
}
4. 성능 최적화 포인트
- Provider value 메모이제이션: 객체/함수는 useMemo/useCallback으로 안정화합니다.
- Context 분리: state/dispatch 분리처럼 역할별 Context로 리렌더 범위를 줄입니다.
- 빈번한 업데이트는 로컬 상태: 입력값/애니메이션 상태는 각 컴포넌트에 두고, 최종 결과만 전역으로 올립니다.
- 파생 값은 소비자에서 메모이제이션: 필요한 최소한의 값을 useMemo로 계산합니다.
// 파생 값 최소 전달 예
function Header() {
const { user } = useAuthState();
const greeting = React.useMemo(() => (user ? `Hi, ${user.name}` : 'Welcome'), [user]);
return <h1>{greeting}</h1>;
}
추가로 부분 구독이 꼭 필요하면 use-context-selector 같은 라이브러리를 검토합니다. 기본 Context만으로는 "필드 단위 선택" 리렌더 제어가 제한적입니다.
5. TypeScript 팁
Context 기본값은 undefined로 두고, 커스텀 훅에서 런타임 검사로 안전하게 만듭니다. 제네릭으로 타입을 명확히 선언하세요.
// auth-context.tsx (TypeScript)
import React from 'react';
type User = { name: string; avatar: string };
type AuthState = { user: User | null; loading: boolean };
type AuthAction =
| { type: 'LOGIN_PENDING' }
| { type: 'LOGIN_SUCCESS'; user: User }
| { type: 'LOGOUT' };
const AuthStateContext = React.createContext<AuthState | undefined>(undefined);
const AuthDispatchContext = React.createContext<React.Dispatch<AuthAction> | undefined>(undefined);
// 이후 로직은 JS 예제와 동일
6. 테스트 전략
컨슈머를 테스트할 때는 Provider로 감싸고 필요한 초기 상태/디스패치를 주입합니다. 별도 TestProvider를 만들어 재사용하면 편리합니다.
// test-utils.jsx
import React from 'react';
import { AuthStateContext, AuthDispatchContext } from './auth-context';
export function TestAuthProvider({ children, state, dispatch = () => {} }) {
return (
<AuthStateContext.Provider value={state}>
<AuthDispatchContext.Provider value={dispatch}>
{children}
</AuthDispatchContext.Provider>
</AuthStateContext.Provider>
);
}
// Avatar.test.jsx
import { render, screen } from '@testing-library/react';
import { TestAuthProvider } from './test-utils';
import { Avatar } from './Avatar';
test('로그인 사용자를 표시한다', () => {
render(
<TestAuthProvider state={{ user: { name: 'Lee', avatar: '/me.png' }, loading: false }}>
<Avatar />
</TestAuthProvider>
);
expect(screen.getByRole('img')).toBeInTheDocument();
});
7. 흔한 실수 체크리스트
- Provider value에 새 객체/함수 리터럴을 직접 넣음 → useMemo/useCallback으로 고정하세요.
- createContext에 실제 기본값을 넣어 Provider 없이도 동작하게 함 → 버그를 숨깁니다. undefined로 두고 훅에서 검사하세요.
- 자주 변하는 상태를 전역 Context에 저장 → 리렌더 폭탄. 로컬로 내리고 최종 상태만 전역 공유하세요.
- 거대한 단일 Context로 모든 걸 관리 → 기능별로 Context 분리하세요.
- 비동기에서 setState/dispatch 클로저 문제 → 최신 값을 의존성에 포함하거나 함수형 업데이트를 사용하세요.
8. Next.js/SSR 실무 팁
Context를 사용하는 파일은 클라이언트 컴포넌트에서만 동작합니다. Next.js 13+에서는 Provider를 한 곳에 모아 'use client' 지시자를 추가합니다.
// app/providers.tsx (Next.js)
'use client';
import React from 'react';
import { ThemeProvider } from '@/theme-context';
import { AuthProvider } from '@/auth-context';
export function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
);
}
정리하면, Context API는 전역 의존성이 명확하고 업데이트 빈도가 낮은 상태를 공유할 때 가장 효율적입니다. useReducer와 Context 분리, value 메모이제이션만 지켜도 실무에서 충분히 가볍고 깔끔한 전역 상태 관리가 가능합니다.
'React' 카테고리의 다른 글
| React에서 Virtualized List 구현하기 (0) | 2026.04.13 |
|---|---|
| React Hooks로 커스텀 훅 설계하기 (1) | 2026.04.13 |
| React와 TypeScript를 함께 사용할 때의 베스트 프랙티스 (0) | 2026.04.10 |
| React에서 Context API로 전역 상태 관리하기 (0) | 2026.04.10 |
| React 18의 Concurrent Features 활용 가이드 (0) | 2026.04.10 |