useContext 심화 — Provider 지옥을 탈출하는 방법
Props drilling을 피하려고 Context를 쓰기 시작했는데, Provider가 10개씩 중첩되고 성능도 나빠진다면 어디서 잘못된 걸까요?
개념 정의
useContext는 컴포넌트 트리를 관통하여 데이터를 전달하는 React의 내장 메커니즘입니다. props를 중간 컴포넌트를 거쳐 전달하지 않고, Provider와 Consumer 사이에서 직접 데이터를 공유합니다.
왜 필요한가
깊이 중첩된 컴포넌트에 데이터를 전달할 때, 중간 컴포넌트들이 자신이 사용하지 않는 props를 전달해야 하는 "props drilling" 문제를 해결합니다. 테마, 인증 정보, 언어 설정 등 ** 트리 전체에 걸쳐 필요한 데이터 **에 적합합니다.
내부 동작
기본 사용법
// 1. Context 생성
const ThemeContext = createContext('light'); // 기본값
// 2. Provider로 값 제공
function App() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext.Provider value={theme}>
<Header />
<Main />
<Footer />
</ThemeContext.Provider>
);
}
// 3. useContext로 값 소비
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>테마 버튼</button>;
}
전파 원리
Provider의 value가 변경되면 다음과 같이 동작합니다.
- React는 해당 Context를 ** 구독하는 모든 컴포넌트 **를 찾습니다
- 해당 컴포넌트들을 ** 리렌더링 **합니다
React.memo로 감싸더라도 Context 변경에 의한 리렌더링은 ** 방지되지 않습니다**
const MyContext = createContext(null);
const MemoizedChild = React.memo(function MemoizedChild() {
const value = useContext(MyContext);
console.log('렌더링됨'); // Context value가 바뀌면 React.memo에도 불구하고 렌더링
return <div>{value}</div>;
});
value 변경 시 리렌더링 문제
문제: 매 렌더링마다 새 객체
function App() {
const [user, setUser] = useState({ name: '홍길동' });
const [theme, setTheme] = useState('dark');
// ❌ 매 렌더링마다 새 객체 생성 → 모든 구독자 리렌더링
return (
<AppContext.Provider value={{ user, theme, setUser, setTheme }}>
<Main />
</AppContext.Provider>
);
}
해결 1: useMemo로 value 메모이제이션
function App() {
const [user, setUser] = useState({ name: '홍길동' });
const [theme, setTheme] = useState('dark');
const value = useMemo(
() => ({ user, theme, setUser, setTheme }),
[user, theme]
);
return (
<AppContext.Provider value={value}>
<Main />
</AppContext.Provider>
);
}
해결 2: Context 분리 (권장)
const UserContext = createContext(null);
const ThemeContext = createContext(null);
function App() {
const [user, setUser] = useState({ name: '홍길동' });
const [theme, setTheme] = useState('dark');
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<Main />
</ThemeContext.Provider>
</UserContext.Provider>
);
}
// theme만 읽는 컴포넌트는 user가 변해도 리렌더링 안 됨
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>버튼</button>;
}
해결 3: state와 dispatch 분리
const StateContext = createContext(null);
const DispatchContext = createContext(null);
function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
// dispatch만 필요한 컴포넌트는 state 변경에 리렌더링 안 됨
function AddButton() {
const dispatch = useContext(DispatchContext);
return <button onClick={() => dispatch({ type: 'ADD' })}>추가</button>;
}
Provider 지옥 탈출
문제: 중첩된 Provider
// ❌ Provider 지옥
function App() {
return (
<AuthProvider>
<ThemeProvider>
<LanguageProvider>
<RouterProvider>
<NotificationProvider>
<ModalProvider>
<Main />
</ModalProvider>
</NotificationProvider>
</RouterProvider>
</LanguageProvider>
</ThemeProvider>
</AuthProvider>
);
}
해결: compose 유틸
function ComposeProviders({ providers, children }) {
return providers.reduceRight(
(child, Provider) => <Provider>{child}</Provider>,
children
);
}
function App() {
return (
<ComposeProviders
providers={[
AuthProvider,
ThemeProvider,
LanguageProvider,
RouterProvider,
NotificationProvider,
ModalProvider,
]}
>
<Main />
</ComposeProviders>
);
}
props가 필요한 Provider를 위한 변형
function ComposeProviders({ providers, children }) {
return providers.reduceRight(
(child, [Provider, props]) => <Provider {...props}>{child}</Provider>,
children
);
}
// 사용
<ComposeProviders
providers={[
[ThemeProvider, { initialTheme: 'dark' }],
[AuthProvider, {}],
[LanguageProvider, { locale: 'ko' }],
]}
>
<Main />
</ComposeProviders>
커스텀 훅으로 Context 사용성 개선
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuth().then(user => {
setUser(user);
setLoading(false);
});
}, []);
const login = async (credentials) => {
const user = await authenticate(credentials);
setUser(user);
};
const logout = () => {
setUser(null);
clearTokens();
};
const value = useMemo(
() => ({ user, loading, login, logout }),
[user, loading]
);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// 커스텀 훅 — 에러 처리 포함
function useAuth() {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth는 AuthProvider 안에서 사용해야 합니다');
}
return context;
}
// 사용
function UserMenu() {
const { user, logout } = useAuth();
return user ? <button onClick={logout}>로그아웃</button> : <LoginButton />;
}
Context의 한계와 대안
한계
- value가 바뀌면 ** 모든 구독자가 리렌더링** (선택적 구독 불가)
- ** 빈번하게 변하는 값 **(마우스 위치, 스크롤 등)에는 부적합
- 복잡한 상태 관리에는 보일러플레이트가 많아짐
대안
- Zustand: 선택적 구독(selector)으로 필요한 값만 감지
- Jotai: 원자적 상태 관리로 세밀한 리렌더링 제어
- Redux Toolkit: 대규모 애플리케이션의 복잡한 상태 관리
// Zustand — 선택적 구독
const useStore = create((set) => ({
user: null,
theme: 'light',
setTheme: (theme) => set({ theme }),
}));
// theme만 변경 감지 — user가 바뀌어도 리렌더링 안 됨
function ThemeButton() {
const theme = useStore(state => state.theme);
return <button>{theme}</button>;
}
주의할 점
value에 매번 새 객체를 전달하면 모든 구독자가 리렌더링됨
<Provider value={{ user, theme }}>처럼 렌더링 중에 새 객체를 생성하면, Provider의 부모가 리렌더될 때마다 모든 Consumer가 리렌더링됩니다. useMemo로 value를 안정시키거나, Context를 분리해야 합니다.
Context는 선택적 구독을 지원하지 않음
Context value의 일부만 사용하는 컴포넌트도, value 전체가 변경되면 리렌더링됩니다. 빈번하게 변하는 값(마우스 좌표, 실시간 타이머 등)에는 Context보다 Zustand, Jotai 같은 선택적 구독이 가능한 라이브러리가 적합합니다.
정리
| 항목 | 설명 |
|---|---|
| Context 용도 | 트리를 관통하는 데이터 전달 (테마, 인증, 언어 등) |
| 리렌더링 | value 변경 시 ** 모든 구독자** 리렌더링 |
| Context 분리 | state/dispatch를 별도 Context로 분리하여 불필요한 리렌더링 방지 |
| Provider 지옥 | compose 유틸이나 커스텀 훅으로 해결 |
| 한계 | 선택적 구독 불가 — 빈번히 변하는 값에는 전용 상태 관리 라이브러리 고려 |
Context는 만능이 아니지만, 적절히 분리하고 커스텀 훅으로 감싸면 강력한 데이터 전달 도구가 됩니다.
댓글 로딩 중...