Context API 하나에 다 때려 넣었다가 지옥을 봤습니다
1. "Redux 복잡하니까 그냥 Context 쓰자!"
프로젝트 초기에 Redux의 보일러플레이트가 싫어서 React 내장 Context API를 쓰기로 했습니다.
AppStateContext 하나를 만들고, 거기에 유저 정보, 테마, 모달 상태, 알림 리스트까지 다 넣었습니다.
const AppStateContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [modal, setModal] = useState(false);
// 😱 최악의 코드: 모든 상태를 하나의 객체로 묶음
const value = { user, setUser, theme, setTheme, modal, setModal };
return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>;
}
편했습니다. useContext(AppStateContext)만 하면 어디서든 데이터를 꺼낼 수 있었으니까요.
그런데 앱이 커지자 문제가 터졌습니다.
"다크 모드로 전환했는데(setTheme), 왜 회원가입 폼 입력한 게 날아가죠?"
"모달 하나 띄웠는데 왜 전체 페이지가 깜빡거리죠?"
2. 처음엔 뭐가 이해가 안 갔나? (구독의 비밀)
저는 Context가 "필요한 데이터만 구독(Subscribe)한다"고 착각했습니다.
const { theme } = useContext(AppState)를 하면, theme이 바뀔 때만 리렌더링 될 줄 알았습니다.
하지만 React Context의 메커니즘은 단순 무식합니다. "Provider의 value가 바뀌면, 이걸 구독하는 모든 컴포넌트를 강제 리렌더링 한다."
제가 value 객체를 매번 새로 만들어서({...}) 넘겨줬기 때문에,
theme 하나만 바뀌어도 value 객체의 참조값(Reference)이 바뀝니다.
그러면 value를 구독하던 LoginForm, Header, Sidebar는 "어? 데이터가 바뀌었네?" 하고 전부 리렌더링 됩니다.
3. 어떤 포인트에서 이해가 됐나? (라디오 방송국 비유)
이걸 "마을 안내 방송"에 비유하니 이해가 됐습니다.
- Redux/Zustand: 스마트폰 알림입니다. 내가 "스포츠 뉴스"만 구독하면, 스포츠 소식 때만 알림이 옵니다. (Selector 지원)
- Context API: 마을 스피커입니다. 이장님이 "자, 아까 말한 그 소식 정정합니다!"라고 방송을 키면, 온 마을 사람(구독 컴포넌트)이 하던 일을 멈추고 들어야 합니다. "저기, 저는 스포츠 뉴스만 궁금한데요?" -> 소용없습니다. 스피커는 채널 분리가 안 됩니다.
하나의 Context에 모든 걸 담는 건, "모든 잡다한 소식을 스피커 하나로 24시간 떠드는 것"과 같습니다.
4. 해결 과정 - 쪼개고 또 쪼개라 (Context Splitting)
Context API 최적화의 핵심은 "관심사의 분리"입니다. 서로 관련 없는 상태는 다른 Context에 담아야 합니다.
1단계 - State와 Dispatch 분리
가장 흔한 패턴은 "값(Value)"과 "함수(Setter)"를 분리하는 겁니다.
값은 자주 바뀌지만, 함수(setUser)는 컴포넌트 생명주기 내내 안 바뀝니다.
export const UserStateContext = createContext(null);
export const UserDispatchContext = createContext(null);
function UserProvider({ children }) {
const [user, setUser] = useState(null);
return (
// 값이 바뀌면 여기만 리렌더
<UserStateContext.Provider value={user}>
{/* 함수는 안 바뀌므로 여기는 안전 */}
<UserDispatchContext.Provider value={setUser}>
{children}
</UserDispatchContext.Provider>
</UserStateContext.Provider>
);
}
이제 setUser만 필요한 하위 컴포넌트(로그인 버튼 등)는 UserDispatchContext만 구독합니다.
그러면 user 상태가 바뀌어도(로그인 성공), 버튼 컴포넌트는 리렌더링 되지 않습니다!
2단계 - 도메인별 분리
ThemeContext, ModalContext, UserContext...
귀찮더라도 파일을 나누세요.
ThemeContext: 라이트/다크 모드 (전체 영향)FormContext: 특정 페이지 안에서만 쓰이는 상태 (지역적)
"Context가 너무 많아지면 Provider Hell(지옥)이 생기지 않나요?" 네, 보기 흉합니다. 하지만 성능을 위해서라면 이게 맞습니다.
5. Custom Hook과 Selector 패턴 뜯어보기
Context를 직접 쓰는 것보다, Custom Hook을 만들어서 내부를 캡슐화하는 게 좋습니다.
// Hooks/useUser.ts
export function useUser() {
const state = useContext(UserStateContext);
if (!state) throw new Error('Cannot find UserProvider');
return state;
}
export function useUserDispatch() {
const dispatch = useContext(UserDispatchContext);
if (!dispatch) throw new Error('Cannot find UserProvider');
return dispatch;
}
이렇게 하면 컴포넌트에서는 useContext를 몰라도 됩니다.
또한, 나중에 Context 대신 Redux나 Recoil로 교체하더라도, 컴포넌트 코드는 수정할 필요가 없습니다. hook 내부만 바꾸면 되니까요.
6. 심화: Memoization (React.memo)
Context가 분리되어 있어도, Provider의 부모가 리렌더링 되면 하위 컴포넌트들도 리렌더링 될 수 있습니다.
이땐 React.memo로 컴포넌트를 감싸야 합니다.
"Context 업데이트로 인한 리렌더링"은 React.memo를 뚫고 들어오지만,
"부모 컴포넌트의 리렌더링"은 React.memo가 막아줍니다.
Redux/Zustand와의 차이 더 알아보기
"그래서 언제 Context를 쓰고 언제 라이브러리를 써요?"
-
Context API:
- Low Frequency Update: 테마, 언어, 로그인 유저 정보 등 자주 안 바뀌는 값.
- 장점: 설치 불필요. React 내장.
- 단점: 렌더링 최적화가 어렵다. Selector 기능 없음.
-
Zustand / Recoil / Redux:
- High Frequency Update: 마우스 좌표, 텍스트 입력, 애니메이션, 복잡한 대시보드 데이터.
- 장점: Selector를 지원함.(
useStore(state => state.bears)). 내가 원하는 데이터가 바뀔 때만 리렌더링 됨.
입력 폼이나 실시간 차트 데이터를 Context에 넣는 건 자살행위입니다. 그땐 무조건 Zustand나 Recoil을 쓰세요.
8. Case Study: 실시간 채팅 앱 대참사
이 문제는 제가 채팅 앱을 만들 때 극적으로 드러났습니다.
ChatContext에 messages(채팅 목록)와 typingUsers(입력 중인 사람)를 같이 넣었습니다.
누군가 타이핑을 칠 때마다 typingUsers가 바뀝니다. (1초에 5번)
그때마다 ChatContext를 구독하는 메시지 리스트 전체(수천 개)가 리렌더링 되었습니다.
결과적으로 타이핑을 할 때마다 화면이 버벅거리는 끔찍한 렉이 발생했습니다.
해결책:
- Zustand 도입:
useChatStore(state => state.typingUsers)로 컴포넌트를 쪼갰습니다. - Context 분리: 타이핑 상태만 따로
TypingContext로 분리했습니다.
이후 타이핑 렉이 완전히 사라졌습니다. 교훈: "자주 바뀌는 값"과 "안 바뀌는 값"을 절대 섞지 마세요.
9. Pro Tip: React DevTools Profiler
"어디서 리렌더링이 일어나는지 눈으로 보고 싶어요." Chrome 확장 프로그램 React DevTools의 Profiler 탭을 켜세요.
- 녹화 버튼 클릭.
- 액션 수행 (다크 모드 토글).
- 녹화 중지.
그러면 어떤 컴포넌트가 렌더링 되었는지, "Why did this render?" (이유: Hook 1 changed)까지 다 알려줍니다. 범인을 잡는 최고의 도구입니다.
10. 용어 정리 (Glossary)
- Prop Drilling: 데이터를 전달하기 위해 부모 -> 자식 -> 손자로 계속 넘겨주는 현상. Context로 해결 가능.
- Memoization (메모이제이션): 결과를 저장해두고 재사용하는 것.
useMemo는 값을,useCallback은 함수를 저장. - Selector: 스토어 전체 상태에서 필요한 부분만 선택하는 함수. Redux, Zustand의 최적화 핵심.
- Provider Hell: Context Provider가 너무 많이 중첩되어 코드가 깊어지는 현상.
11. FAQ: 자주 묻는 질문
Q: 한 컴포넌트에서 여러 Context를 써도 되나요?
A: 네. UserContext와 ThemeContext를 동시에 구독해도 됩니다. 둘 중 하나라도 바뀌면 리렌더링 됩니다.
Q: 모든 걸 useMemo로 감싸야 하나요?
A: Provider 내부의 value는 무조건 감싸야 합니다. 하위 컴포넌트(Consumer)에서는 연산이 무거운 경우에만 감싸면 됩니다.
Q: Context는 느린가요?
A: Context 자체는 빠릅니다. 진짜 문제는 불필요한 리렌더링입니다. 최상위(App.js)에 Context를 두고 자주 업데이트하면, 앱 전체가 계속 다시 그려지므로 느려집니다.
Q: useReducer는요?
A: useReducer + Context는 Redux의 훌륭한 대체재입니다. 하지만 이것도 상태 분리를 안 하면 리렌더링 문제는 똑같이 발생합니다.
12. 정리 - Context를 뷔페처럼 쓰지 마세요
- Context에 모든 것을 담지 마세요. 뷔페 접시처럼 섞이면 맛없습니다.
- 업데이트는 모든 구독자를 깨웁니다.
- State(값)와 Dispatch(함수)를 분리하세요.
- 도메인(기능) 별로 Context를 나누세요.
- 자주 바뀌는 값은 Zustand/Recoil을 쓰세요.
Context는 의존성 주입(DI)을 위한 도구지, 고성능 상태 관리 도구가 아닙니다.