본문으로 바로가기
<joshuayeyo />
MENU
← ALL NOTES
FE8 MIN READ

[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
FluxRedux
Proxy 기반Zustand, Jotai, Valtio
AtomicRecoil, 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는 상태 관리 시스템이다.

차이를 보면 명확하다.

관점ContextZustand
역할값 전달(공유)상태 + 비즈니스 로직 관리
관심사명확한 단일 도메인여러 도메인, 복잡한 흐름
리렌더 성능기본적으로 전체 리렌더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)} />}
    </>
  );
}

원칙: 전역 상태는 최소화하자

가능하다면,

  1. 로컬 state부터 시작
  2. props drilling이 2-3단계까지는 괜찮음
  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)✅ Zustandselector 기반 최적화
소규모 앱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적으로 편한 거 쓰지"라는 내 생각은 딱히 틀리지는 않은 것 같기는 하다.


마무리하며

전역 상태관리는 "그냥 쓰는 기술"이 아니다.

잘 쓰면 생산성과 유지보수성이 올라가지만, 잘못 쓰면 성능과 구조가 무너진다.

그래서 항상 스스로 질문하고, 팀원들과도 논의해야 한다.

"이 상태가 정말 전역이어야 할까?"

의사결정 순서:

  1. 로컬 state로 시작
  2. Props drilling이 심해지면 Context 고려
  3. 성능 이슈나 복잡도가 증가하면 Zustand
  4. 서버 데이터는 무조건 React Query/SWR

결국 가장 중요한 건 적절한 도구를 적절한 상황에 쓰는 것이다.


추가 학습 및 참고 자료

FE / 2 OF 5
  1. [FE] 새로고침 해도 이미지를 유지하는 방법
  2. [FE] Context, Store 그래서 둘이 뭔 차이임?
  3. [FE] React Native에서 Amplitude 쿠키 에러 해결하기
  4. [FE] VDOM은 언제나 실제 DOM보다 나을까?
  5. [FE] 혼돈 속에서도 지속되는 프로덕트를 위해 — 도메인 기반 구조 리빌드 기록