junyeokk
Blog
React·2026. 02. 15

React Context API

React 앱을 만들다 보면 여러 컴포넌트에서 같은 데이터를 사용해야 하는 상황이 자주 생긴다. 로그인한 사용자 정보, 현재 테마, 언어 설정 같은 것들이다. 이런 데이터를 컴포넌트 트리 아래로 전달하려면 중간에 있는 모든 컴포넌트를 거쳐야 한다.

tsx
// 최상위에서 theme을 쓰는 컴포넌트까지 계속 내려줘야 한다
function App() {
  const [theme, setTheme] = useState("light");
  return <Layout theme={theme} setTheme={setTheme} />;
}

function Layout({ theme, setTheme }) {
  return <Sidebar theme={theme} setTheme={setTheme} />;
}

function Sidebar({ theme, setTheme }) {
  // Sidebar 자체는 theme을 안 쓰지만 자식에게 전달해야 해서 받는다
  return <ThemeToggle theme={theme} setTheme={setTheme} />;
}

function ThemeToggle({ theme, setTheme }) {
  return <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>{theme}</button>;
}

이걸 prop drilling이라고 한다. 중간 컴포넌트들이 자신은 쓰지도 않는 props를 받아서 아래로 넘겨야 하는 문제다. 컴포넌트가 깊어질수록 관리가 힘들어지고, 중간 컴포넌트들이 불필요한 의존성을 갖게 된다. Layout이나 Sidebar는 theme과 아무 관련이 없는데도 props에 theme이 있어야 한다.

Context API는 이 문제를 해결한다. 컴포넌트 트리의 특정 범위 안에서 데이터를 직접 전달할 수 있게 해준다. 중간 컴포넌트를 거치지 않고, 데이터가 필요한 컴포넌트가 직접 가져다 쓴다.


Context의 정체: 의존성 주입

Context를 이해하려면 한 가지 핵심을 짚고 넘어가야 한다. Context는 상태 관리 도구가 아니다. Context는 의존성 주입(Dependency Injection) 메커니즘이다. 값을 "전달"하는 수단일 뿐, 값을 "관리"하는 기능은 없다.

상태 관리란 상태의 초기화, 읽기, 갱신, 그리고 갱신에 따른 부수효과 처리를 포함한다. Context가 하는 일은 이 중 "읽기"의 전달 부분뿐이다. 실제 상태의 저장과 갱신은 useState, useReducer, 또는 외부 라이브러리가 담당한다.

이 구분이 중요한 이유는, Context를 상태 관리 도구로 착각하면 Context만으로 복잡한 상태를 다루려다 성능 문제에 부딪히기 때문이다. 이 부분은 뒤에서 자세히 다룬다.


기본 사용법

Context는 세 단계로 사용한다: 생성 → 제공 → 소비.

1. Context 생성

tsx
import { createContext } from "react";

interface ThemeContextType {
  theme: "light" | "dark";
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | null>(null);

createContext에 전달하는 값은 기본값이다. Provider 없이 useContext를 호출했을 때 반환되는 값이다. 타입 안전성을 위해 null로 두고, 커스텀 훅에서 null 체크를 하는 패턴이 일반적이다.

2. Provider로 값 제공

tsx
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  const toggleTheme = () => {
    setTheme((prev) => (prev === "light" ? "dark" : "light"));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

Provider는 컴포넌트 트리에서 "이 범위 안에서는 이 값을 쓸 수 있다"라고 선언하는 역할이다. value에 넘긴 값이 하위 컴포넌트들에게 전달된다.

3. useContext로 소비

tsx
function ThemeToggle() {
  const context = useContext(ThemeContext);

  if (!context) {
    throw new Error("ThemeToggle must be used within ThemeProvider");
  }

  return (
    <button onClick={context.toggleTheme}>
      현재 테마: {context.theme}
    </button>
  );
}

useContext를 호출하면 가장 가까운 상위 Provider의 value를 가져온다. Provider가 없으면 createContext에 넘긴 기본값을 반환한다.

커스텀 훅으로 정리

매번 null 체크를 하는 건 번거롭다. 커스텀 훅으로 감싸면 깔끔해진다.

tsx
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within ThemeProvider");
  }
  return context;
}

이제 소비자 컴포넌트에서는 useTheme()만 호출하면 된다. 에러 메시지도 한 곳에서 관리된다.

tsx
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();
  return <button onClick={toggleTheme}>현재 테마: {theme}</button>;
}

Provider 패턴: 범위와 합성

Context의 강력한 점은 Provider의 범위를 자유롭게 설정할 수 있다는 것이다. 앱 전체에 걸 수도 있고, 특정 페이지나 모달에만 걸 수도 있다.

앱 전체 Provider

테마, 인증 정보처럼 어디서든 필요한 값은 최상위에 둔다.

tsx
function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <Router />
      </ThemeProvider>
    </AuthProvider>
  );
}

범위 제한 Provider

폼 상태처럼 특정 영역에서만 필요한 값은 해당 영역에만 Provider를 둔다.

tsx
function CheckoutPage() {
  return (
    <CheckoutFormProvider>
      <ShippingForm />
      <PaymentForm />
      <OrderSummary />
    </CheckoutFormProvider>
  );
}

이렇게 하면 CheckoutFormProvider의 상태가 바뀌어도 CheckoutPage 밖의 컴포넌트는 전혀 영향을 받지 않는다. 이것이 Context를 "전역 상태 도구"가 아닌 "범위 지정 전달 도구"로 봐야 하는 이유다.

같은 Context의 중첩

같은 Context의 Provider를 중첩하면, 하위 Provider가 상위 Provider를 덮어쓴다.

tsx
<ThemeContext.Provider value={{ theme: "light", toggleTheme }}>
  <Header />  {/* light 테마 */}
  <ThemeContext.Provider value={{ theme: "dark", toggleTheme }}>
    <Sidebar />  {/* dark 테마 */}
  </ThemeContext.Provider>
</ThemeContext.Provider>

useContext는 항상 가장 가까운 상위 Provider의 값을 가져오기 때문이다. 이 동작은 의도적으로 설계된 것이고, 부분적으로 다른 설정을 적용할 때 유용하다.


리렌더링 문제: Context의 가장 큰 한계

Context를 상태 관리에 쓸 때 반드시 알아야 하는 것이 있다. Provider의 value가 바뀌면, 그 Context를 구독하는 모든 컴포넌트가 리렌더링된다. 선택적 구독이 불가능하다.

tsx
interface AppState {
  user: User;
  theme: string;
  notifications: Notification[];
  cart: CartItem[];
}

const AppContext = createContext<AppState | null>(null);

이렇게 하나의 거대한 Context에 여러 상태를 넣으면, cart가 바뀔 때 theme만 읽고 있는 컴포넌트도 리렌더링된다. Context는 value 객체의 참조가 바뀌면 모든 소비자를 리렌더링하기 때문이다.

왜 이런 동작인가

React의 Context는 내부적으로 Provider의 value를 Object.is로 비교한다. 새로운 객체 참조가 전달되면 "값이 바뀌었다"고 판단하고, 해당 Context를 useContext로 읽고 있는 모든 컴포넌트를 강제 리렌더링한다.

tsx
function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  // ❌ 매 렌더링마다 새 객체가 생성되어 모든 소비자가 리렌더링
  return (
    <AppContext.Provider value={{ state, dispatch }}>
      {children}
    </AppContext.Provider>
  );
}

이 코드에서 { state, dispatch }는 매 렌더링마다 새로운 객체를 생성한다. AppProvider가 리렌더링될 때마다 Context의 모든 소비자가 함께 리렌더링된다.

useMemo로 참조 안정화

value 객체를 useMemo로 감싸면 불필요한 리렌더링을 줄일 수 있다.

tsx
function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  const value = useMemo(() => ({ state, dispatch }), [state]);

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

하지만 이것은 "Provider 자체가 불필요하게 리렌더링될 때"를 막아줄 뿐이다. state가 실제로 바뀌면 여전히 모든 소비자가 리렌더링된다. state.cart만 바뀌어도 state.theme을 읽는 컴포넌트까지 리렌더링되는 문제는 해결되지 않는다.


해결 패턴: Context 분리

이 문제의 실질적인 해결책은 Context를 관심사별로 분리하는 것이다.

tsx
const ThemeContext = createContext<ThemeContextType | null>(null);
const AuthContext = createContext<AuthContextType | null>(null);
const CartContext = createContext<CartContextType | null>(null);
tsx
function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          <Router />
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

이제 cart 상태가 바뀌면 CartContext를 구독하는 컴포넌트만 리렌더링된다. ThemeContext를 구독하는 컴포넌트는 영향을 받지 않는다.

상태와 디스패치 분리

더 세밀하게 최적화하려면, 상태를 읽는 Context와 상태를 변경하는 함수의 Context를 분리할 수 있다.

tsx
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<CartDispatch | null>(null);

function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialCart);

  return (
    <CartStateContext.Provider value={state}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

function useCartState() {
  const context = useContext(CartStateContext);
  if (!context) throw new Error("useCartState must be used within CartProvider");
  return context;
}

function useCartDispatch() {
  const context = useContext(CartDispatchContext);
  if (!context) throw new Error("useCartDispatch must be used within CartProvider");
  return context;
}

왜 이렇게 할까? dispatch 함수는 useReducer가 반환하는 안정된 참조다. 리렌더링 사이에도 동일한 참조를 유지한다. 따라서 CartDispatchContext의 value는 절대 바뀌지 않고, "장바구니에 추가" 버튼처럼 dispatch만 필요한 컴포넌트는 cart 상태가 바뀌어도 리렌더링되지 않는다.

tsx
// cart 상태가 바뀌면 리렌더링
function CartTotal() {
  const { items } = useCartState();
  const total = items.reduce((sum, item) => sum + item.price, 0);
  return <span>{total}원</span>;
}

// cart 상태가 바뀌어도 리렌더링되지 않음
function AddToCartButton({ productId }) {
  const dispatch = useCartDispatch();
  return (
    <button onClick={() => dispatch({ type: "ADD_ITEM", productId })}>
      장바구니에 추가
    </button>
  );
}

Context vs 전역 상태 관리 라이브러리

Context의 한계를 알았으니, 외부 상태 관리 라이브러리(Zustand, Redux, Jotai 등)와 비교해보자.

선택적 구독

가장 큰 차이점이다. Zustand 같은 라이브러리는 상태의 일부분만 선택적으로 구독할 수 있다.

tsx
// Zustand: count가 바뀔 때만 리렌더링
const count = useStore((state) => state.count);

// Context: state 객체가 바뀌면 무조건 리렌더링
const { count } = useAppState();

Zustand는 내부적으로 useSyncExternalStore를 사용하고, 셀렉터의 반환값이 이전과 같으면 리렌더링을 건너뛴다. Context는 이런 메커니즘이 없다. value 참조가 바뀌면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링된다.

상태 갱신 로직의 위치

Context + useReducer를 쓰면 상태 갱신 로직이 Provider 컴포넌트 안에 위치한다. React 컴포넌트 생명주기에 묶인다.

tsx
// Context + useReducer: reducer가 Provider 안에 있다
function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  // ...
}

외부 라이브러리는 React 바깥에 상태를 둔다. 컴포넌트 트리와 독립적으로 상태를 읽고 쓸 수 있다.

tsx
// Zustand: React 바깥에서도 접근 가능
const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));

// 컴포넌트 밖에서도 사용 가능
useCartStore.getState().addItem(newItem);

이 차이는 비동기 로직이나 미들웨어를 다룰 때 특히 크다. Context에서 비동기 작업을 처리하려면 useEffect와 함께 복잡한 패턴이 필요하지만, 외부 라이브러리는 미들웨어나 내장 메서드로 깔끔하게 처리한다.

디버깅

Redux DevTools는 상태 변화 히스토리, 시간 여행 디버깅, 액션 로그를 제공한다. Zustand도 devtools 미들웨어로 Redux DevTools를 사용할 수 있다. Context는 React DevTools에서 현재 value만 확인할 수 있을 뿐, 변화 히스토리나 어떤 액션이 상태를 바꿨는지 추적하기 어렵다.

Provider 보일러플레이트

Context를 여러 개 분리하면 Provider가 중첩된다.

tsx
function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          <NotificationProvider>
            <ModalProvider>
              <Router />
            </ModalProvider>
          </NotificationProvider>
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

이른바 "Provider Hell"이다. 기능적으로 문제는 없지만 코드 가독성이 떨어진다. Zustand나 Jotai 같은 라이브러리는 Provider 없이 전역 상태를 사용할 수 있어서 이 문제가 없다.


언제 Context를 쓰고, 언제 외부 라이브러리를 쓸까

Context가 적합한 경우

  • 자주 바뀌지 않는 값: 테마, 로케일, 인증 상태 등. 이런 값은 앱 생명주기에서 몇 번 바뀌지 않기 때문에 리렌더링 문제가 거의 없다.
  • 범위가 제한된 상태: 특정 폼, 특정 페이지, 특정 모달 안에서만 공유되는 상태. Provider 범위를 좁게 잡으면 영향 범위도 좁아진다.
  • 의존성 주입: 컴포넌트에 서비스 객체나 설정 값을 주입할 때. 값이 거의 바뀌지 않으므로 Context의 리렌더링 특성이 문제되지 않는다.
  • 외부 의존성 최소화: 작은 프로젝트에서 라이브러리를 추가하고 싶지 않을 때.

외부 라이브러리가 적합한 경우

  • 자주 바뀌는 상태: 실시간 데이터, 카운터, 인풋 상태 등. 선택적 구독으로 불필요한 리렌더링을 방지해야 한다.
  • 여러 컴포넌트가 상태의 다른 부분을 구독: 컴포넌트 A는 user.name을, 컴포넌트 B는 user.avatar를 필요로 하는 경우. Context는 둘 다 리렌더링하지만, 셀렉터 기반 라이브러리는 각각 필요한 부분이 바뀔 때만 리렌더링한다.
  • 복잡한 상태 갱신 로직: 여러 단계의 비동기 작업, 낙관적 업데이트, 캐싱 등이 필요할 때.
  • 컴포넌트 밖에서 상태 접근: 라우터 가드, API 인터셉터, 유틸리티 함수에서 상태를 읽거나 써야 할 때.

함께 사용

Context와 외부 라이브러리는 배타적 선택이 아니다. 실제 프로젝트에서는 함께 사용하는 경우가 많다.

text
테마, 로케일 → Context
인증 상태 → Context (변경 빈도 낮음) 또는 Zustand (토큰 갱신 로직 복잡할 때)
서버 상태 → React Query
클라이언트 전역 상태 → Zustand
폼 상태 → React Hook Form (또는 범위 제한 Context)

어떤 도구를 쓸지는 상태의 특성에 따라 결정한다. 변경 빈도, 구독 범위, 갱신 로직의 복잡도를 기준으로 판단하면 된다.


Context + useReducer 패턴

Context와 useReducer를 조합하면 Redux와 비슷한 패턴을 만들 수 있다. 작은 규모의 상태 관리에 외부 라이브러리 없이 쓸 수 있어서 유용하다.

tsx
// 액션 타입 정의
type CartAction =
  | { type: "ADD_ITEM"; payload: CartItem }
  | { type: "REMOVE_ITEM"; payload: string }
  | { type: "CLEAR" };

// 리듀서
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD_ITEM":
      return { ...state, items: [...state.items, action.payload] };
    case "REMOVE_ITEM":
      return {
        ...state,
        items: state.items.filter((item) => item.id !== action.payload),
      };
    case "CLEAR":
      return { ...state, items: [] };
    default:
      return state;
  }
}

// Provider
function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });

  const value = useMemo(() => ({ state, dispatch }), [state]);

  return (
    <CartContext.Provider value={value}>
      {children}
    </CartContext.Provider>
  );
}

이 패턴은 상태 갱신 로직을 리듀서에 모아두기 때문에 상태가 어떻게 바뀌는지 추적하기 쉽다. 하지만 앞서 설명한 Context의 리렌더링 한계는 그대로 적용된다. 상태가 복잡해지고 소비자가 많아지면 성능 최적화를 위해 Context를 분리하거나 외부 라이브러리로 전환하는 것을 고려해야 한다.


정리

특성Context전역 상태 라이브러리
목적값 전달 (DI)상태 관리
선택적 구독✅ (셀렉터)
Provider 필요라이브러리에 따라 다름
컴포넌트 외부 접근
미들웨어/DevTools
번들 사이즈0 (React 내장)추가 의존성
학습 비용낮음라이브러리에 따라 다름

Context는 "상태 관리 도구"가 아니라 "값 전달 메커니즘"이다. 이 구분을 이해하면 언제 Context만으로 충분하고, 언제 외부 라이브러리가 필요한지 자연스럽게 판단할 수 있다. 자주 바뀌지 않는 값의 전달에는 Context가 완벽하다. 자주 바뀌고 여러 컴포넌트가 부분적으로 구독하는 상태에는 Zustand 같은 라이브러리가 적합하다.


관련 문서