[FE] Context, Store 그래서 둘이 뭔 차이임?
Lorem ipsum dolor sit amet
SERIES / FE ↗시작
3주차 발제가 이루어진 11월 1일, 팀 모임 시기 우리 팀 안에서 해당 주제가 나왔다.
되게 다양한 이야기가 오고 갔는데, 다양한 전역 상태관리 라이브러리를 사용해본 나조차도
"왜 쓰는 거지?", 그리고 "왜 Provider는 남발하지 말라는 거지?" 에 대해 잊고 있었던 것 같다.
솔직히 그동안은 이렇게 생각했다.
그냥 사람들 다들 쓰니까…
이 생각으로 되게 오랫동안 사용해왔다.
React가 가진 기본 전제: 단방향 데이터 흐름
React는 단방향 데이터 흐름을 기반으로 동작한다.
단방향 데이터 흐름에서는,
- 데이터는 부모 → 자식으로 내려간다
- 필요한 컴포넌트까지 props drilling을 한다
그런데 앱이 커질 경우 아래와 같은 문제가 발생한다.
- props가 5단계 이상 내려간다
- 어제 태영님은 6단계까지 해봤다고… 하셨다
- 중간 컴포넌트는 데이터를 쓰지도 않는데 전달만 한다
- 컴포넌트 간 데이터 공유가 필요해진다
- 이 결과, 이벤트/상태 흐름이 꼬이기 시작한다
즉, React의 기본 구조만으로는 전역적으로 필요한 상태를 관리하기 번거롭다.
문제 상황을 코드로 보자
Scenario 1: Props Drilling Hell
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} setUser={setUser} />;
}
function Layout({ user, setUser }) {
return (
<div>
<Sidebar user={user} setUser={setUser} />
</div>
);
}
function Sidebar({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} />;
}
function UserMenu({ user, setUser }) {
return <Avatar user={user} setUser={setUser} />;
}
function Avatar({ user, setUser }) {
// 겨우 여기서 사용...
return <img src={user?.avatar} alt="avatar" />;
}
문제점:
- Layout, Sidebar는
user를 쓰지도 않는데 전달만 하고 있다. setUser가 필요 없는 컴포넌트도 받아야 함user타입이 바뀌면? 5곳을 다 수정해야 함- 새로운 props가 추가되면? 다시 5곳 모두 수정
- 어제 태영님이 말씀해주신 부분
이게 바로 Props Drilling Hell이다.
Store의 등장
Store = 공용 데이터 창고
- 여러 컴포넌트가 공유해야 할 상태를 한 곳에서 관리
- 어디서든 읽고 업데이트할 수 있음
대표적인 Store 방식:
| Store 방식 | 예시 |
|---|---|
| Context 기반 | React Context |
| Flux | Redux |
| Proxy 기반 | Zustand, Jotai, Valtio |
| Atomic | Recoil, Jotai |
핵심 목적은 단순하다.
앱 전체가 접근할 수 있는 전역 저장소(Store) 만들기
Provider는 무엇인가? 왜 필요한가?
Store를 만들었다면, 이제 React에게 알려줘야 한다.
"이 아래 컴포넌트들은 이 상태(Store)에 접근할 수 있어!"
이 역할을 하는 것이 바로 Provider다.
<StoreProvider>
<App />
</StoreProvider>
요약하자면,
| 역할 | 의미 |
|---|---|
| Store | 데이터를 저장하는 공간 |
| Provider | 그 데이터를 트리에 주입해서 쓰게 해주는 장치 |
해결 방법 비교
Solution 1: Context 사용
const UserContext = createContext();
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Layout /> {/* props 제거! */}
</UserContext.Provider>
);
}
function Avatar() {
const { user } = useContext(UserContext);
return <img src={user?.avatar} alt="avatar" />;
}
장점:
- 중간 컴포넌트 수정 불필요
- 필요한 곳에서만
useContext로 접근 - React 내장 기능, 추가 라이브러리 불필요
단점:
user객체 내 어떤 값이 바뀌어도 구독한 모든 컴포넌트 리렌더- 여러 Context가 필요하면 Provider Hell
그렇다면 왜 Provider를 남발하면 안 될까?
아래와 같은 구조를 본 적 있을 것이다. 나도 정말 많이 봤던 구조이며, 솔직히 말하자면 매 프로젝트 마다 한 번 씩은 이런 모습을 봤다.
<UserProvider>
<ThemeProvider>
<ToastProvider>
<CartProvider>
<NotificationProvider>
<App />
</NotificationProvider>
</CartProvider>
</ToastProvider>
</ThemeProvider>
</UserProvider>
이른바 Provider Hell.
문제 1: 트리가 불필요하게 깊어진다
// 각 Provider마다 리렌더링 경계가 생긴다
// Context 값 변경 시 전체 하위 트리 재평가
<UserProvider>
<ThemeProvider>
<ToastProvider>
{/* 중첩될 때마다 React의 재조정(Reconciliation) 단계 추가 */}
{/* 각 Provider의 value 객체가 참조 동일성 체크됨 */}
{/* 디버깅 시 Context 추적 어려움 */}
<App />
</ToastProvider>
</ThemeProvider>
</UserProvider>
문제 2: 불필요한 렌더링
React Context는 기본적으로
value가 바뀌면 해당 Context를 구독하는 모든 컴포넌트가 재렌더링한다.
예를 들어:
// 흔한 실수: 모든 상태를 하나의 Context에
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState({
profile: { name: "John", avatar: "..." },
settings: { theme: "dark", lang: "ko" },
cart: { items: [], total: 0 },
});
// 문제: cart 하나만 바뀌어도 전체 리렌더 발생
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
// ProfileCard 컴포넌트
function ProfileCard() {
const { user } = useContext(UserContext);
return <div>{user.profile.name}</div>;
// cart가 변경되어도 리렌더된다. (profile은 안 바뀌었는데,,,)
}
특히 내가 현재 진행중인 프로젝트의 스택인 React Native에서는 성능에 직격탄이다.
현대적 해결책: Zustand
Solution 2: Zustand 사용
import { create } from "zustand";
const useUserStore = create((set) => ({
profile: { name: "John", avatar: "..." },
settings: { theme: "dark", lang: "ko" },
cart: { items: [], total: 0 },
updateCart: (cart) => set({ cart }),
updateProfile: (profile) => set({ profile }),
}));
// Avatar 컴포넌트
function Avatar() {
// selector로 avatar만 구독
const avatar = useUserStore((state) => state.profile.avatar);
return <img src={avatar} alt="avatar" />;
// profile.avatar만 변경되면 리렌더
// cart나 settings 변경은 무시
}
// UserName 컴포넌트
function UserName() {
const name = useUserStore((state) => state.profile.name);
return <span>{name}</span>;
// profile.name만 변경되면 리렌더
}
// CartBadge 컴포넌트
function CartBadge() {
const itemCount = useUserStore((state) => state.cart.items.length);
return <span>{itemCount}</span>;
// cart만 변경되면 리렌더, profile/settings 변경은 무시
}
장점:
- Provider 불필요
- 세밀한 리렌더 제어 (selector 기반)
- DevTools, Persist 등 확장 기능
- 간결한 API
- 작은 번들 사이즈 (~1KB)
- 사실 이 작은 번들 사이즈 때문에 나는,
아니 뭐 어차피 양도 얼마 안되는데.. DX적으로도 Zustand 그냥 쓰는게 편하지 않음?이라고 생각했었다🧐
- 사실 이 작은 번들 사이즈 때문에 나는,
현대적 트렌드
- "Store는 쪼개되, Provider는 적게"
- 아마 나를 포함하여 많은 이들이 원칙처럼 받아들이고 있는 부분일 것이다.
- Context만으로 해결하려 하기보단 Zustand, Jotai 같은 가벼운 상태 라이브러리 사용
- selector / subscribe 기반으로 필요한 부분만 렌더
언제 Context, 언제 Zustand(전역상태 라이브러리)?
Context는 무엇인가?
특정 도메인의 상태를 공유하기 위한 인터페이스
즉, 도메인이 명확할 때 사용한다.
예시:
| 사용 사례 | 이유 |
|---|---|
| 유저 정보 | 앱 레벨 공용 도메인 |
| 다크모드 / 테마 | UI 설정 |
| 언어(i18n) | 공용 인터페이스 정책 |
| Feature flag | 환경 설정 |
이처럼 딱 관심사가 하나인 경우 가장 적합하다.
그럼 왜 Zustand 같은 전역 상태 라이브러리를 쓰는가?
Context도 전역 상태다.
그런데도 Zustand를 쓰는 이유는 무엇일까?
간단하다.
Context는 공유 도구이고, Zustand는 상태 관리 시스템이다.
차이를 보면 명확하다.
| 관점 | Context | Zustand |
|---|---|---|
| 역할 | 값 전달(공유) | 상태 + 비즈니스 로직 관리 |
| 관심사 | 명확한 단일 도메인 | 여러 도메인, 복잡한 흐름 |
| 리렌더 성능 | 기본적으로 전체 리렌더 | selector 기반 부분 리렌더 |
| 확장성 | 직접 관리 | persist, middleware, devtools 등 |
| Provider | 필요함 | 불필요 |
| 번들 사이즈 | 0 (내장) | ~1KB (Zustand) |
즉,
앱이 복잡해질수록 Zustand 같은 라이브러리가 강력해진다.
우리 팀 에서 추가로 나온 내용 "React Query면 충분하지 않나요?"
어제 팀 회의에서 한 분이 이렇게 말씀하셨다:
나는 클라이언트 상태 | 서버 상태가 있다고 생각한다. React Query의 등장으로 상태관리가 쉬워져서 얘로만 핸들링 가능하지 않음?
이 질문을 꾸준히 곱씹어 본 뒤, 나름 생각해보고 클로드와도 진대를 나누어보았다.
상태를 구분하자: 서버 상태 vs 클라이언트 상태
// 서버 상태 예시
const { data: user } = useQuery("user", fetchUser);
const { data: posts } = useQuery("posts", fetchPosts);
// 클라이언트 상태 예시
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedTab, setSelectedTab] = useState("home");
const [filters, setFilters] = useState({ category: "all" });
| 상태 종류 | 특징 | 도구 |
|---|---|---|
| 서버 상태 | 비동기, 캐싱 필요, 동기화 필요 | React Query, SWR |
| 클라이언트 상태 | 동기적, UI 상태, 로컬 로직 | useState, Zustand, Context |
React Query만으로 충분한 경우
// 서버 데이터만 다루는 앱
function App() {
const { data: user } = useQuery("user", fetchUser);
const { data: todos } = useQuery("todos", fetchTodos);
// 간단한 UI 상태는 useState로 충분
const [filter, setFilter] = useState("all");
return <TodoList todos={todos} filter={filter} onFilterChange={setFilter} />;
}
이 정도 규모면 React Query + useState로 충분하다.
Zustand가 필요한 경우
// 복잡한 클라이언트 상태가 많은 경우
function App() {
// 여러 컴포넌트에서 공유되는 UI 상태들
const [sidebarOpen, setSidebarOpen] = useState(false);
const [modalStack, setModalStack] = useState([]);
const [notifications, setNotifications] = useState([]);
const [theme, setTheme] = useState('dark');
const [selectedItems, setSelectedItems] = useState(new Set());
const [dragState, setDragState] = useState(null);
// 이걸 다 props로 내려야 한다면?
// Context로 나누면 Provider Hell
return (/* ... */);
}
// Zustand로 해결
const useUIStore = create((set) => ({
sidebarOpen: false,
modalStack: [],
notifications: [],
theme: "dark",
selectedItems: new Set(),
dragState: null,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
pushModal: (modal) =>
set((state) => ({ modalStack: [...state.modalStack, modal] })),
addNotification: (notif) =>
set((state) => ({ notifications: [...state.notifications, notif] })),
// ...
}));
// 어디서든 사용
function Sidebar() {
const isOpen = useUIStore((state) => state.sidebarOpen);
const toggle = useUIStore((state) => state.toggleSidebar);
// ...
}
결론: React Query vs Zustand가 아니라 AND
이 둘은 상호 보완적 관계로 사용하는 것이 가장 베스트 솔루션인 것 같다.
서버 상태 → React Query
클라이언트 상태 (복잡) → Zustand
클라이언트 상태 (단순) → useState/Context
베스트 조합:
// React Query: 서버 데이터
const { data: products } = useQuery("products", fetchProducts);
// Zustand: 복잡한 클라이언트 상태
const cart = useCartStore((state) => state.items);
const addToCart = useCartStore((state) => state.addItem);
// useState: 로컬 UI 상태
const [quantity, setQuantity] = useState(1);
흔히 겪는 함정들
함정 1: 일단 전역으로 시작하기
나도 이 실수를 가끔 한다. 아니 뭐..
적게 주는 것 보단 많이 주는게 낫잖아?그리고언제 어디서 어떻게 필요할 지 어떻게 아니?같은 안일한 생각과 함께 말이다.
나쁜 예:
// 모든 UI 상태를 전역으로 하는 경우
const useGlobalStore = create((set) => ({
modalOpen: false,
tooltipText: "",
hoverState: false,
mousePosition: { x: 0, y: 0 },
// ... 온갖 UI 상태들
}));
- 문제: 이 상태들은 전역일 필요가 없다.
좋은 예:
// UI 상태는 가능하면 로컬로
function Modal() {
const [isOpen, setIsOpen] = useState(false);
// 이 모달은 다른 곳에서 제어할 필요 없음
return (
<>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && <ModalContent onClose={() => setIsOpen(false)} />}
</>
);
}
원칙: 전역 상태는 최소화하자
가능하다면,
- 로컬 state부터 시작
- props drilling이
2-3단계까지는 괜찮음 - 정말 여러 곳에서 쓰이는지 확인 후 전역으로 돌리기
함정 2: Context를 캐싱 수단으로 사용
나쁜 예:
// Context에 서버 데이터 캐싱
const DataContext = createContext();
function DataProvider({ children }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/data")
.then((res) => res.json())
.then((data) => {
setData(data);
setLoading(false);
});
}, []);
return (
<DataContext.Provider value={{ data, loading }}>
{children}
</DataContext.Provider>
);
}
- 문제:
- 캐싱 로직을 직접 구현해야 함
- 에러 처리가 복잡함
- refetch, stale time 등 고려사항이 많음
좋은 예:
// React Query/SWR 같은 전문 도구 사용
function App() {
const { data, isLoading, error, refetch } = useQuery("data", fetchData, {
staleTime: 5 * 60 * 1000, // 5분
cacheTime: 10 * 60 * 1000, // 10분
});
// 캐싱, 리페칭, 에러 처리 다 해결
}
서버 상태는 React Query, SWR을 쓰자.
함정 3: 성능 측정 없이 최적화
"리렌더 많을 것 같은데"라는 생각 => Zustand 도입한 경우, 하지만, 실제 측정해보니 문제가 없는 경우가 발생했다고 가정해보자.
-
추측으로 최적화
Context가 느릴 것 같아서 Zustand로 바꿨어요
-
측정 후 최적화
- React DevTools Profiler로 먼저 측정
- 실제 병목 지점 파악
- 필요한 곳만 최적화
교훈: Premature optimization is the root of all evil
의사결정 트리
상태가 필요하다
↓
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 이 상태를 여러 컴포넌트가 공유하나?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
↓ NO → useState (로컬 상태)
↓ YES
↓
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2. 서버 데이터인가?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
↓ YES → React Query / SWR
↓ NO (클라이언트 상태)
↓
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3. 단일 도메인(테마, 언어, 유저 등)인가?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
↓ YES → Context (단순한 경우)
↓ NO (복잡한 상태/로직)
↓
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4. 성능 최적화가 필요한가?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
↓ YES → Zustand / Jotai
↓ NO → Context로 시작
결론: 언제 무엇을 써야 할까?
| 상황 | 적합한 선택 | 이유 |
|---|---|---|
| 컴포넌트 내부 상태 | ✅ useState | 가장 간단하고 명확함 |
| 서버 데이터 (API) | ✅ React Query / SWR | 캐싱, 동기화 자동 처리 |
| 단일 도메인 (테마, 언어, 유저) | ✅ Context | 명확한 관심사 분리 |
| 복잡한 클라이언트 상태 | ✅ Zustand / Jotai | 성능 + DX |
| 빠른 UI 변동 (drag/drop, modal) | ✅ Zustand | selector 기반 최적화 |
| 소규모 앱 | Context + useState | 추가 라이브러리 불필요 |
| 중/대규모 앱 | Zustand + React Query | 확장성과 유지보수성 |
한 줄 요약
Context는 문, Zustand는 창고 시스템, React Query는 배송 서비스
둘은 대체 관계가 아니라 상호 보완 관계다.
핵심 원칙 3가지
1. 기본은 Local State
전역은 정말 필요할 때만. "나중에 필요할 것 같아서"는 좋은 이유가 아니다.
// 시작은 항상 여기서 출발
const [state, setState] = useState();
// 이렇게 시작하지 말 것
const useGlobalStore = create(/* ... */);
2. 서버 상태 ≠ 클라이언트 상태
도구를 구분하라. Context/Zustand로 서버 데이터 관리하지 말 것.
// 명확한 구분
const serverData = useQuery("key", fetcher); // React Query
const uiState = useUIStore(); // Zustand
const localState = useState(); // useState
3. 측정 후 최적화
추측보다 React DevTools Profiler를 믿어라.
// 추측
"Context가 느릴 것 같으니 Zustand 쓰자";
// 측정
// 1. Profiler로 측정
// 2. 병목 확인
// 3. 필요한 곳만 최적화
실전 조합 예시
중규모 이상 앱 추천 구성
// 1. 서버 상태: React Query
const { data: user } = useQuery("user", fetchUser);
const { data: products } = useQuery("products", fetchProducts);
// 2. 복잡한 클라이언트 상태: Zustand
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
}));
// 3. 단순 전역 상태: Context
const ThemeContext = createContext();
// 4. 로컬 상태: useState
const [isOpen, setIsOpen] = useState(false);
번들 사이즈 비교
React Query: ~13KB (gzipped)
Zustand: ~1KB (gzipped)
Jotai: ~3KB (gzipped)
Redux Toolkit: ~12KB (gzipped)
Context API: 0KB (built-in)
"Zustand 번들 크지도 않은데 걍 DX적으로 편한 거 쓰지"라는 내 생각은 딱히 틀리지는 않은 것 같기는 하다.
마무리하며
전역 상태관리는 "그냥 쓰는 기술"이 아니다.
잘 쓰면 생산성과 유지보수성이 올라가지만, 잘못 쓰면 성능과 구조가 무너진다.
그래서 항상 스스로 질문하고, 팀원들과도 논의해야 한다.
"이 상태가 정말 전역이어야 할까?"
의사결정 순서:
- 로컬 state로 시작
- Props drilling이 심해지면 Context 고려
- 성능 이슈나 복잡도가 증가하면 Zustand
- 서버 데이터는 무조건 React Query/SWR
결국 가장 중요한 건 적절한 도구를 적절한 상황에 쓰는 것이다.