React Context가 성능 문제를 만드는 경우

React Context가 성능 문제를 만드는 경우

한눈에 보기

Context는 깊은 prop 전달을 줄이는 도구이지 선택적 구독 기능이 있는 범용 상태 저장소는 아니다. Provider의 value가 이전 값과 달라지면 해당 Context를 읽는 소비자는 자신이 실제로 사용하는 필드와 무관하게 새 값을 받는다. 먼저 Profiler로 비용을 확인하고, 책임과 변경 빈도가 다른 값을 분리하며, Provider 범위를 좁히고, 세밀한 구독이 필요하면 external store를 검토한다.

목차

Context가 문제라기보다 업데이트 범위가 문제다

로그인 사용자, theme, 알림 수, 검색 필터를 하나의 App Context에 넣었다고 해 보자.

type AppContextValue = {
  currentUser: User | null;
  theme: "light" | "dark";
  unreadCount: number;
  searchQuery: string;
  setTheme: (theme: "light" | "dark") => void;
  setSearchQuery: (query: string) => void;
};

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

앱 어디서나 필요한 값을 쉽게 꺼낼 수 있어 처음에는 편하다.

function ThemeToggle() {
  const context = useContext(AppContext);
  if (!context) throw new Error("AppContext가 필요합니다.");

  return (
    <button
      onClick={() =>
        context.setTheme(
          context.theme === "light" ? "dark" : "light",
        )
      }
    >
      테마 변경
    </button>
  );
}

그러나 검색 입력 한 글자마다 searchQuery가 바뀌면 같은 Context를 읽는 ThemeToggle, UserMenu, NotificationBadge도 새로운 value를 받는다. 각 컴포넌트가 검색어를 전혀 사용하지 않아도 마찬가지다.

Context가 느리다는 말보다 정확한 문제는 다음과 같다.

변경 빈도와 소비 범위가 다른 값들을 하나의 구독 단위로 묶었다.

대략적인 변경 빈도 필요한 범위
theme 낮음 앱 전체
currentUser 낮음 헤더와 권한 영역
unreadCount 중간 헤더·알림 화면
searchQuery 키 입력마다 검색 화면

이 값들이 한 value에 있으면 가장 자주 바뀌는 값의 빈도로 모든 소비자가 영향을 받는다.

Context 변경은 어떻게 소비자에게 전파되는가

Provider의 value가 바뀌면 React는 해당 Context를 읽는 하위 소비자에게 최신 값을 전달한다.

function AppProvider({ children }: PropsWithChildren) {
  const [theme, setTheme] = useState<Theme>("light");

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

객체 리터럴은 Provider가 렌더될 때마다 새 객체다.

Object.is(
  { theme: "light" },
  { theme: "light" },
); // false

부모가 다른 이유로 렌더돼도 value 참조가 달라지고 Context 소비자가 업데이트될 수 있다.

flowchart TB
    A[Provider 재렌더] --> B[새 value 객체 생성]
    B --> C{이전 value와 Object.is 비교}
    C -->|다름| D[Context 소비자에 새 값 전파]
    C -->|같음| E[Context 변경 전파 없음]

소비자가 value의 일부만 구조 분해했다고 선택적 구독이 되지는 않는다.

function UserName() {
  const { currentUser } = useAppContext();
  return <span>{currentUser?.name}</span>;
}

searchQuery만 바뀌어도 전체 App Context value가 달라지므로 UserName은 새로운 Context 값을 받는다.

구독 단위

기본 useContext의 구독 단위는 객체 속성이 아니라 Context value 전체다.

큰 value 객체가 만드는 불필요한 결합

Context를 하나로 합치면 성능 외에도 설계 문제가 생긴다.

다음 Provider는 서로 다른 책임을 한곳에서 관리한다.

function AppProvider({ children }: PropsWithChildren) {
  const [currentUser, setCurrentUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>("light");
  const [query, setQuery] = useState("");
  const [draft, setDraft] = useState<Draft | null>(null);
  const [notifications, setNotifications] = useState<Notification[]>([]);

  return (
    <AppContext.Provider
      value={{
        currentUser,
        setCurrentUser,
        theme,
        setTheme,
        query,
        setQuery,
        draft,
        setDraft,
        notifications,
        setNotifications,
      }}
    >
      {children}
    </AppContext.Provider>
  );
}

Provider 파일이 커졌다는 이유보다 값의 변경 이유와 소비자가 다르다는 점이 분리 신호다.

value를 useMemo로 감싸면 해결되는 것

Provider의 부모가 렌더될 때 같은 theme state로 새 객체가 만들어지는 문제는 useMemo로 줄일 수 있다.

function ThemeProvider({ children }: PropsWithChildren) {
  const [theme, setTheme] = useState<Theme>("light");

  const value = useMemo(
    () => ({ theme, setTheme }),
    [theme],
  );

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

setTheme 함수는 안정적인 참조이므로 보통 dependency에서 생략할 수 있다. 명시해도 값이 바뀌지 않아 문제는 없다.

이 최적화가 해결하는 것은 theme이 그대로인데 Provider가 다른 이유로 렌더된 경우의 새 객체 생성이다.

하지만 theme이 실제로 바뀌면 value도 바뀌어 모든 ThemeContext 소비자가 새 값을 받는 것이 맞다. useMemo는 Context의 구독 단위를 속성별로 바꾸지 않는다.

const value = useMemo(
  () => ({
    currentUser,
    theme,
    unreadCount,
    searchQuery,
  }),
  [currentUser, theme, unreadCount, searchQuery],
);

검색어가 바뀔 때마다 이 객체는 정당하게 새로 만들어진다. 거대한 Context를 useMemo 하나로 감싸도 구조적 결합은 그대로다.

함수 참조도 안정화해야 할 수 있다

value에 일반 함수를 넣으면 Provider 렌더마다 새 참조가 된다.

function AuthProvider({ children }: PropsWithChildren) {
  const [user, setUser] = useState<User | null>(null);

  function logout() {
    setUser(null);
    clearSession();
  }

  const value = useMemo(
    () => ({ user, logout }),
    [user, logout],
  );
}

logout이 매번 달라져 memoization이 항상 무효화된다. useCallback으로 안정화하거나 함수 정의를 value 계산 안에 둘 수 있다.

const logout = useCallback(() => {
  setUser(null);
  clearSession();
}, []);

const value = useMemo(
  () => ({ user, logout }),
  [user, logout],
);

다만 최적화 코드가 실제 문제보다 복잡해지지 않도록 Profiler로 확인한다.

책임과 변경 빈도에 따라 Context 나누기

가장 직접적인 개선은 별도의 구독 단위로 나누는 것이다.

const ThemeContext = createContext<ThemeContextValue | null>(null);
const AuthContext = createContext<AuthContextValue | null>(null);
const NotificationContext =
  createContext<NotificationContextValue | null>(null);

Provider를 조합한다.

function AppProviders({ children }: PropsWithChildren) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>
          {children}
        </NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

Theme이 바뀌어도 ThemeContext를 읽지 않는 인증 소비자는 Context 변경 때문에 업데이트되지 않는다.

분리 기준은 파일 크기가 아니다.

  1. 같은 이유로 바뀌는가?
  2. 비슷한 빈도로 바뀌는가?
  3. 같은 소비자 집합이 읽는가?
  4. 생명주기와 초기화 위치가 같은가?

자주 바뀌는 mouse position과 거의 바뀌지 않는 user settings를 같은 Context에 두면 좋지 않다. 반면 themesetTheme처럼 하나의 개념을 이루는 값은 함께 두는 것이 자연스럽다.

너무 잘게 나누기도 비용이다

속성 하나마다 Context를 만들면 Provider 계층과 API가 복잡해진다. 실제 변경 빈도와 소비 범위가 다른 책임 단위로 나눈다.

state와 dispatch를 분리하기

많은 컴포넌트는 state를 읽지 않고 action만 보낸다. state와 dispatch를 한 Context에 넣으면 dispatch만 쓰는 버튼도 state 변경 때 새 value를 받는다.

type CartAction =
  | { type: "itemAdded"; productId: string }
  | { type: "itemRemoved"; productId: string };

const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext =
  createContext<Dispatch<CartAction> | null>(null);
function CartProvider({ children }: PropsWithChildren) {
  const [state, dispatch] = useReducer(cartReducer, initialCartState);

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

React가 제공하는 dispatch 참조는 안정적이다. action만 보내는 컴포넌트는 CartStateContext를 읽지 않는다.

function AddToCartButton({ productId }: { productId: string }) {
  const dispatch = useCartDispatch();

  return (
    <button
      onClick={() =>
        dispatch({ type: "itemAdded", productId })
      }
    >
      담기
    </button>
  );
}

이 패턴은 성능뿐 아니라 읽기와 변경 권한을 API 수준에서 구분한다.

Provider의 위치를 필요한 범위로 좁히기

검색 필터가 상품 검색 페이지에서만 필요하다면 앱 root에 둘 이유가 없다.

function ProductSearchRoute() {
  return (
    <SearchFilterProvider>
      <SearchToolbar />
      <SearchResult />
    </SearchFilterProvider>
  );
}

다른 route는 이 Context와 state를 만들지 않는다. 페이지를 벗어나면 state도 자연스럽게 폐기된다.

Provider 위치는 state 수명을 정한다.

flowchart TB
    A[App Root] --> B[AuthProvider
앱 수명] B --> C[Router] C --> D[Product Route] D --> E[SearchFilterProvider
페이지 수명] E --> F[Search UI]

modal을 열 때만 필요한 state는 modal subtree 안에, wizard 단계 상태는 wizard route 안에 둔다. “나중에 다른 곳에서도 쓸 수 있다”는 가능성만으로 root에 올리지 않는다.

Provider를 너무 아래로 내리면 형제 소비자와 공유할 수 없으므로 실제로 공유하는 가장 가까운 공통 조상을 찾는다. 이는 상태를 가까이 두는 것이 좋은 이유와 연결된다.

memo가 Context 업데이트를 막지 못하는 이유

memo는 부모가 전달한 props가 같을 때 재렌더를 건너뛸 수 있게 한다. 하지만 컴포넌트가 직접 읽는 Context가 바뀌면 최신 값을 받아야 하므로 다시 렌더된다.

const Greeting = memo(function Greeting() {
  const theme = useContext(ThemeContext);
  return <p className={theme}>안녕하세요.</p>;
});

props가 없어도 theme 변경에는 렌더되어야 한다. 그렇지 않으면 화면이 이전 theme에 머문다.

Context 읽는 부분과 무거운 자식을 분리할 수 있다.

function ThemedProductList() {
  const theme = useTheme();

  return (
    <ProductList
      colorScheme={theme.colorScheme}
    />
  );
}

const ProductList = memo(function ProductList({
  colorScheme,
}: {
  colorScheme: string;
}) {
  return <ExpensiveList colorScheme={colorScheme} />;
});

Context 전체가 바뀌더라도 실제 전달 prop인 colorScheme이 같으면 무거운 자식이 건너뛸 수 있다. 이 패턴도 실제 render 비용이 큰 경우에만 사용한다.

서버 상태를 Context에 직접 넣을 때의 한계

API에서 가져온 데이터를 Context state에 넣는 것은 가능하다.

function ProductProvider({ children }: PropsWithChildren) {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    fetchProducts()
      .then(setProducts)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  return (
    <ProductContext.Provider
      value={{ products, loading, error }}
    >
      {children}
    </ProductContext.Provider>
  );
}

그러나 실제 서버 상태에는 더 많은 문제가 있다.

Context는 값을 전달할 뿐 이 문제들을 해결하지 않는다. TanStack Query, SWR, Relay 또는 프레임워크 데이터 API 같은 서버 상태 도구를 사용하고, Context에는 API client나 설정처럼 안정적인 dependency만 전달하는 편이 나을 수 있다.

function ProductScreen() {
  const query = useQuery({
    queryKey: ["products", filters],
    queryFn: ({ signal }) => fetchProducts(filters, signal),
  });

  return <ProductList products={query.data ?? []} />;
}

Context를 서버 cache의 복제품으로 만들면 source of truth가 둘이 되어 동기화 문제가 생긴다.

세밀한 구독이 필요하면 external store를 검토한다

수백 개 행이 있는 편집기에서 각 행이 서로 다른 state slice를 읽는다면 Context 분리만으로 부족할 수 있다. 소비자별 selector와 변경 구독을 제공하는 external store가 적합하다.

React의 useSyncExternalStore는 외부 저장소 snapshot을 안전하게 구독하는 API다.

function useStoreSelector<T>(
  store: Store,
  selector: (state: StoreState) => T,
): T {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getServerSnapshot()),
  );
}

실제 구현에서는 selector 결과의 참조 안정성, equality 비교, SSR snapshot을 세심하게 다뤄야 한다. 검증된 라이브러리를 사용하는 편이 안전하다.

외부 store를 선택할 조건은 다음과 같다.

작은 theme이나 locale Context를 성능 걱정만으로 외부 상태 라이브러리로 옮길 필요는 없다.

Profiler로 최적화 전후 측정하기

Context 최적화는 코드만 보고 추측하지 않는다.

가상의 검색 화면에서 다음 시나리오를 측정한다.

  1. 검색창에 10자를 입력한다.
  2. 각 키 입력 commit에서 렌더된 컴포넌트를 확인한다.
  3. 검색과 무관한 Header, UserMenu, ThemeToggle이 렌더되는지 본다.
  4. AppContext를 SearchFilterContext로 분리한다.
  5. 같은 입력 시나리오를 다시 기록한다.

프로그램 측정도 가능하다.

function onRender(
  id: string,
  phase: "mount" | "update" | "nested-update",
  actualDuration: number,
) {
  performanceMetrics.record({
    id,
    phase,
    actualDuration,
  });
}

<Profiler id="application-header" onRender={onRender}>
  <ApplicationHeader />
</Profiler>

확인할 지표는 단순 렌더 횟수만이 아니다.

Context 소비자가 다시 렌더되어도 컴포넌트가 가볍고 사용자 지연이 없다면 최적화 우선순위가 낮다.

최적화 순서

상태 위치 좁히기 → Context 책임 분리 → value 참조 안정화 → 무거운 자식 분리와 memo → 세밀한 external store 순서로 복잡성을 점진적으로 늘린다.

Context 설계 체크리스트

Context를 만들기 전에 다음을 확인한다.

  1. 실제로 여러 깊은 컴포넌트가 이 값을 필요로 하는가?
  2. component composition이나 명시적 props가 더 읽기 쉬운가?
  3. 값의 소유 범위와 수명은 어디까지인가?
  4. 같은 Context의 값들이 같은 이유와 빈도로 바뀌는가?
  5. value 객체와 함수 참조가 불필요하게 매번 달라지는가?
  6. 읽기 소비자와 dispatch 전용 소비자를 나눌 가치가 있는가?
  7. 서버 상태 문제를 Context로 다시 구현하고 있지 않은가?
  8. Profiler에서 실제 전파 비용이 확인됐는가?

테스트에서는 Provider 없이 hook을 호출했을 때 명확히 실패하도록 custom hook을 둔다.

function useTheme(): ThemeContextValue {
  const value = useContext(ThemeContext);

  if (value === null) {
    throw new Error("useTheme은 ThemeProvider 안에서 사용해야 합니다.");
  }

  return value;
}

기본값으로 조용히 동작하게 하면 Provider 누락을 알아차리기 어렵다. 다만 library context처럼 의미 있는 기본값이 있는 경우에는 정책이 다를 수 있다.

정리

Context는 앱 전체 상태를 한 객체에 넣는 저장소가 아니라 트리 아래로 dependency를 전달하는 도구다. Provider value가 바뀌면 해당 Context 소비자는 최신 값을 받는다. 기본 useContext는 객체 속성별 선택 구독을 제공하지 않는다.

Context 성능 문제는 Context 개수보다 하나의 value가 묶고 있는 변경 빈도와 소비 범위가 지나치게 넓을 때 발생한다.

관련 노트와 참고 자료