1. 전역 상태 관리의 구세주, 그리고 배신
React를 처음 배울 때 Props Drilling(프롭스 드릴링)의 고통을 겪고 나면, Context API는 마치 구세주처럼 보입니다.
"와! 최상위 컴포넌트(Provider)에서 값을 던져주면, 저 밑바닥에 있는 자식 컴포넌트가 바로 받을 수 있네? 중간다리는 건너뛰어도 된다니!"
그래서 우리는 행복한 마음으로 UserContext, ThemeContext, ModalContext 등을 만들어서 앱 전체를 감싸기 시작합니다.
하지만 앱의 규모가 커지고 기능이 많아질수록 이상한 현상이 발생합니다.
인풋 창에 글자 하나를 칠 때마다 앱 전체가 버벅거리고, 전혀 상관없는 헤더 컴포넌트가 번쩍거립니다.
React DevTools Profiler를 켜보니, 상태가 하나 바뀔 때마다 거의 모든 컴포넌트가 불필요하게 렌더링(Unnecessary Re-render)되고 있습니다.
이것이 바로 Context API의 가장 큰 함정이자, 많은 팀이 결국 Redux, Zustand, Recoil 같은 외부 라이브러리로 갈아타는 이유입니다.
2. 왜 다 같이 렌더링될까? (근본 원인 분석)
원인은 React가 변경을 감지하는 방식(Object Identity Check)과 Context API의 설계 철학에 있습니다.
const MyContext = createContext();
function App() {
const [user, setUser] = useState({ name: "Kim" });
const [theme, setTheme] = useState("dark");
// user와 theme을 하나의 객체로 묶어서 전달
const value = { user, theme };
return (
<MyContext.Provider value={value}>
<UserProfile /> {/* user만 필요함 */}
<ThemeButton /> {/* theme만 필요함 */}
</MyContext.Provider>
);
}
여기서 setTheme("light")를 호출하여 테마를 다크 모드에서 라이트 모드로 바꿨다고 가정해봤다.
어떤 일이 벌어질까요?
theme 상태가 변했으므로 App 컴포넌트가 리렌더링됩니다.
App 내부의 const value = { user, theme } 코드가 다시 실행됩니다.
- 이때,
value 객체는 새로운 참조 주소(Reference)를 가진 객체로 다시 태어납니다. (내용물인 user는 그대로지만, value라는 껍데기는 새것입니다. {...} !== {...})
-
MyContext.Provider의 value prop이 바뀌었습니다.
- React는 "어? Context 값이 바뀌었네?"라고 판단합니다.
- 이 Context를
useContext로 구독하고 있는 모든 하위 컴포넌트(UserProfile, ThemeButton)에게 강제로 리렌더링 명령을 내립니다.
문제의 핵심은 UserProfile 컴포넌트입니다.
이 친구는 user 정보만 필요하고 theme에는 관심이 없습니다. user 정보는 변하지 않았습니다.
하지만 theme이 바뀌면서 value 객체가 통째로 바뀌었기 때문에, 억울하게 같이 렌더링되는 것입니다.
이것이 불필요한 렌더링(Unnecessary Rerender)의 정체입니다.
3. 해결책 1 - Context 쪼개기 (Split Context) - Best Practice
가장 정석적이고 효과적인 해결법은 "상태의 성격에 따라 Context를 분리하는 것"입니다.
서로 관련 없는 데이터는 같은 배(Context)에 태우지 마세요.
단일 책임 원칙(SRP)은 컴포넌트뿐만 아니라 Context 설계에도 적용됩니다.
const UserContext = createContext();
const ThemeContext = createContext();
function App() {
const [user, setUser] = useState({ name: "Kim" });
const [theme, setTheme] = useState("dark");
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<UserProfile /> {/* UserContext만 구독 -> theme이 바뀌어도 영향 없음! */}
<ThemeButton /> {/* ThemeContext만 구독 */}
</ThemeContext.Provider>
</UserContext.Provider>
);
}
이제 theme이 바뀌어도 ThemeContext만 업데이트되므로, ThemeButton만 리렌더링됩니다. UserContext를 구독하는 UserProfile은 안전합니다.
이 방법은 코드가 조금 길어지지만, 성능 최적화 효과가 가장 확실합니다.
4. 해결책 2 - 상태(State)와 업데이트 함수(Dispatch) 분리하기
자주 변하는 값(State)과 변하지 않는 값(Dispatch 함수)을 분리하는 것도 중요합니다.
useState가 반환하는 setState 함수는 React가 참조 안정성(Stable Identity)을 보장합니다. 즉, 렌더링이 다시 되어도 함수 주소가 바뀌지 않습니다.
// Bad Case: 같이 묶으면 count가 변할 때마다 setCount만 쓰는 애들도 리렌더링됨
const value = { count, setCount };
// Good Case: Dispatch 전용 Context 만들기
const StateContext = createContext();
const DispatchContext = createContext();
function App() {
const [count, setCount] = useState(0);
return (
<StateContext.Provider value={count}>
<DispatchContext.Provider value={setCount}>
<DisplayCount /> {/* count를 씀: 리렌더링 됨 */}
<BtnIncrease /> {/* setCount만 씀: 리렌더링 안 됨 (최적화 성공!) */}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
BtnIncrease 컴포넌트는 setCount 함수만 필요합니다. setCount 자체는 변하지 않으므로, count 값이 1, 2, 3으로 변해도 BtnIncrease는 리렌더링되지 않습니다.
5. 해결책 3 - 메모이제이션 (UseMemo)
Context를 쪼개기 귀찮거나 구조상 어렵다면, useMemo를 사용해서 value 객체의 생성을 제어할 수 있습니다.
이는 주로 App 컴포넌트(부모)가 다른 이유로 리렌더링될 때, Context 값까지 덩달아 재생성되는 것을 막기 위함입니다.
function App() {
const [user, setUser] = useState({ name: "Kim" });
// user가 변할 때만 userContextValue 객체를 새로 만듦
const userContextValue = useMemo(() => ({ user, setUser }), [user]);
return (
<UserContext.Provider value={userContextValue}>
{/* ... */}
</UserContext.Provider>
);
}
하지만 이 방법은 App이 리렌더링될 때 하위 컴포넌트를 보호해주는 역할만 할 뿐, Context 내의 일부 값만 바뀌었을 때의 문제(구독자 전체 리렌더링)는 해결하지 못합니다. Context 내부 값이 바뀌면, 그걸 쓰는 애들은 무조건 다시 그려집니다.
6. 언제 외부 라이브러리(Zustand, Recoil)를 써야 할까?
Context API 최적화를 위해 Provider를 10개씩 만들고 useMemo로 감싸다 보면 코드가 매우 지저분해집니다(Provider Hell).
다음과 같은 증상이 있다면 Context를 버리고 전역 상태 라이브러리를 도입할 시기입니다.
- 잦은 업데이트 (High Frequency Updates): 스크롤 위치, 마우스 좌표, 타이머, 애니메이션 등 초당 수십 번 변하는 데이터. Context는 이런 용도가 아닙니다.
- 부분 구독 (Partial Subscription)이 필요하다:
{ a, b, c, d }라는 거대한 스토어에서 a만 바뀌었을 때 a를 쓰는 컴포넌트만 렌더링하고 싶다.
- Context API는 기본적으로 이게 불가능합니다. (React 팀이
useContextSelector를 연구 중이지만 아직 실험적입니다.)
- Zustand, Recoil, Redux는 Selector 기능을 기본 제공하여 기가 막히게 렌더링을 최적화해줍니다.
Zustand 예시:
const useStore = create((set) => ({
count: 0,
text: 'hello',
inc: () => set((state) => ({ count: state.count + 1 })),
}))
function Component() {
// Selector 패턴: count가 바뀔 때만 리렌더링됨. text가 바뀌어도 영향 없음.
const count = useStore((state) => state.count)
return <div>{count}</div>
}
이 한 줄의 코드가 주는 쾌적함은 써본 사람만 압니다. Provider도 필요 없습니다.
7. 실전 체크리스트
Q: Context API와 Redux/Zustand의 가장 큰 차이점은 무엇인가요?
A: Context API는 주로 의존성 주입(Dependency Injection)을 위한 도구로, 값이 바뀌면 구독하는 모든 컴포넌트가 리렌더링됩니다. 반면 Redux나 Zustand 같은 상태 관리 라이브러리는 Selector를 통해 상태의 특정 부분만 구독(Subscribe)할 수 있어 불필요한 렌더링을 방지하는 성능 최적화 기능이 내장되어 있습니다. 또한 Provider 없이 훅 기반으로 전역 상태에 접근할 수 있어 구조가 단순해집니다.
Q: Context Provider 내부에서 value를 useMemo로 감싸야 하는 이유는 무엇인가요?
A: Provider를 렌더링하는 부모 컴포넌트가 리렌더링될 때마다 value 객체가 새로 생성되는 것을 막기 위함입니다. 객체가 새로 생성되면 참조값(Reference)이 바뀌므로, Context를 구독하는 모든 하위 컴포넌트가 불필요하게 리렌더링됩니다.
8. 마무리
Context API는 훌륭한 도구지만, 만능열쇠가 아닙니다.
엄밀히 말하면 "상태 관리 도구"라기보다는 "의존성 주입(Dependency Injection) 도구"에 가깝습니다.
단순한 테마 설정, 로그인 유저 정보, 언어 설정 같이 "자주 변하지 않는(Low Frequency) 전역 데이터"를 전달하는 데 적합합니다.
1밀리초마다 변하는 데이터를 Context에 넣고 "왜 React가 느리지?"라고 탓하면 안 됩니다.
망치로 나사를 돌리려고 하지 마세요. 드라이버(Zustand, Recoil 등)를 꺼내야 할 때입니다.