🎯 학습 목표
- Prop drilling 문제를 이해하고 Context로 해결할 수 있다
- createContext → Provider → useContext 세 단계 구조를 구현한다
- useReducer와 Context를 결합한 완전한 상태 관리 패턴을 구현한다
- Context 성능 이슈와 useMemo 해결책을 이해한다
- Context 분리 전략을 알고 적용할 수 있다
📖 핵심 개념 1 — Prop Drilling 문제
컴포넌트 트리가 깊어지면 중간 컴포넌트가 사용하지 않는 props를 단순히 전달하기 위해 받아야 하는 상황이 생깁니다. 이를 Prop Drilling이라고 합니다.
// ❌ Prop Drilling — user를 사용하지 않는 중간 컴포넌트도 받아서 전달해야 함
function App() {
const [user, setUser] = useState({ name: '김개발', role: 'admin' });
return <PageLayout user={user} />;
}
function PageLayout({ user }) {
// PageLayout은 user를 직접 사용하지 않음 — 단지 전달만 함
return <Header user={user} />;
}
function Header({ user }) {
// Header도 user를 직접 사용하지 않음 — 또 전달
return <UserMenu user={user} />;
}
function UserMenu({ user }) {
// 여기서 처음으로 user를 실제로 사용
return <p>{user.name}님 환영합니다</p>;
}
// 3단계 drilling이지만 실제 앱에서는 5~10단계가 되기도 합니다
📖 핵심 개념 2 — Context 3단계 구조
// contexts/UserContext.jsx
import { createContext, useContext, useState } from 'react';
// 1단계: Context 생성 (기본값은 useContext를 Provider 밖에서 쓸 때 사용됨)
const UserContext = createContext(null);
// Provider 컴포넌트 — Context 값을 트리에 제공
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
function login(userData) {
setUser(userData);
}
function logout() {
setUser(null);
}
// 2단계: Provider로 값 제공
return (
<UserContext.Provider value={{ user, login, logout }}>
{children}
</UserContext.Provider>
);
}
// 커스텀 훅으로 래핑 — Provider 밖에서 사용 시 에러를 명확하게
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser는 UserProvider 안에서만 사용할 수 있습니다');
}
return context;
}
// main.jsx — Provider 설정
import { UserProvider } from './contexts/UserContext';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<UserProvider> {/* 전체 앱을 감쌈 */}
<App />
</UserProvider>
</BrowserRouter>
</StrictMode>
);
// 3단계: 어디서든 useContext로 값 사용
function UserMenu() {
const { user, logout } = useUser(); // Props 없이 직접 접근!
if (!user) return <p>로그인해주세요</p>;
return (
<div>
<p>{user.name}님</p>
<button onClick={logout}>로그아웃</button>
</div>
);
}
📖 핵심 개념 3 — useReducer + Context 패턴
상태 업데이트 로직이 복잡해지면 useState 대신 useReducer를 사용합니다. Redux의 개념(action, reducer, dispatch)을 경량화한 버전입니다.
// contexts/CartContext.jsx
import { createContext, useContext, useReducer, useMemo } from 'react';
// 액션 타입 상수 — 오타 방지를 위해 상수로 정의
const CART_ACTIONS = {
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
UPDATE_QUANTITY: 'UPDATE_QUANTITY',
CLEAR_CART: 'CLEAR_CART',
};
// 초기 상태
const initialState = {
items: [],
totalCount: 0,
totalPrice: 0,
};
// 리듀서 — 순수 함수: (이전 상태, 액션) => 새 상태
function cartReducer(state, action) {
switch (action.type) {
case CART_ACTIONS.ADD_ITEM: {
const existing = state.items.find(i => i.id === action.payload.id);
const newItems = existing
? state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: i.quantity + 1 }
: i
)
: [...state.items, { ...action.payload, quantity: 1 }];
return {
...state,
items: newItems,
totalCount: newItems.reduce((sum, i) => sum + i.quantity, 0),
totalPrice: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0),
};
}
case CART_ACTIONS.REMOVE_ITEM: {
const newItems = state.items.filter(i => i.id !== action.payload.id);
return {
...state,
items: newItems,
totalCount: newItems.reduce((sum, i) => sum + i.quantity, 0),
totalPrice: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0),
};
}
case CART_ACTIONS.CLEAR_CART:
return initialState;
default:
return state;
}
}
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
// 성능 최적화: dispatch는 항상 동일 참조이므로 의존성에 포함 불필요
const value = useMemo(() => ({
...state,
addItem: (item) => dispatch({ type: CART_ACTIONS.ADD_ITEM, payload: item }),
removeItem: (id) => dispatch({ type: CART_ACTIONS.REMOVE_ITEM, payload: { id } }),
clearCart: () => dispatch({ type: CART_ACTIONS.CLEAR_CART }),
}), [state]); // state가 바뀔 때만 새 value 객체 생성
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export const useCart = () => {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart는 CartProvider 안에서 사용하세요');
return ctx;
};
📖 핵심 개념 4 — Context 성능 이슈와 해결
// ❌ 성능 문제 — Provider value에 매번 새 객체 생성
function BadProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
return (
// 렌더링마다 새 객체 생성 → 모든 Consumer가 리렌더링
<MyContext.Provider value={{ user, theme, setUser, setTheme }}>
{children}
</MyContext.Provider>
);
}
// ✅ useMemo로 최적화
function GoodProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const value = useMemo(
() => ({ user, theme, setUser, setTheme }),
[user, theme] // user나 theme가 실제로 변경될 때만 새 객체 생성
);
return <MyContext.Provider value={value}>{children}</MyContext.Provider>;
}
// ✅ Context 분리 전략 — 자주 변하는 값과 드물게 변하는 값을 분리
// 자주 변하는 상태와 드물게 변하는 설정을 하나의 Context에 담으면
// 설정을 읽는 컴포넌트도 상태 변경마다 리렌더링됨
const UserStateContext = createContext(null); // 자주 변하는 값
const UserActionsContext = createContext(null); // 드물게 변하는 함수
⚠️ 흔한 실수 (よくあるミス)
- 모든 상태를 Context로: Context는 전역으로 필요한 상태(테마, 사용자 정보, 언어 설정 등)에 적합합니다. 특정 컴포넌트 트리에서만 필요한 상태는 그 공통 부모의 state로 충분합니다.
- Provider value 최적화 누락: value 객체를 useMemo로 감싸지 않으면 불필요한 리렌더링이 발생합니다.
- useReducer 없이 복잡한 상태 관리: 상태 업데이트 로직이 여러 곳에 분산되거나 복잡해지면 useReducer로 중앙화하세요.
- Context vs Zustand 선택 혼란: Context는 단순한 전역 상태에 적합합니다. 복잡한 상태 로직, 성능이 중요한 상황, 빈번한 업데이트가 있다면 Zustand(11강)를 사용하세요.
💡 실무 팁
- Context 파일 구조:
src/contexts/폴더에 컨텍스트별 파일을 만들고, Provider와 커스텀 훅을 같은 파일에 export합니다. - 테마와 언어는 Context에 적합: 앱 전체에서 사용하지만 자주 변하지 않는 값(다크모드, i18n)은 Context의 최적 사용 사례입니다.
- Context DevTools: React DevTools의 컴포넌트 탭에서 Context 값을 실시간으로 확인하고 수정할 수 있습니다.