연합 상태를 제대로 구현하기: Zustand, TanStack Query, 그리고 실제로 작동하는 패턴들

발행: (2025년 12월 17일 오전 05:53 GMT+9)
17 min read
원문: Dev.to

Source: Dev.to

위에 제공된 소스 링크 외에 번역할 텍스트가 포함되어 있지 않습니다. 번역을 원하는 전체 기사 내용(마크다운 형식 포함)을 제공해 주시면 한국어로 번역해 드리겠습니다.

문제

우리 모두 겪어본 적이 있습니다: Module Federation을 설정하고 앱을 마이크로‑프론트엔드로 분할했는데, Zustand 스토어가 한 모듈에서는 업데이트되고 다른 모듈에서는 업데이트되지 않거나, 더 심각하게는 TanStack Query 캐시가 같은 사용자 프로필을 세 번이나 가져오는 상황—각 원격이 자신만의 세계에 있다고 생각하기 때문이죠.

단일 React 애플리케이션에서 아름답게 동작하던 패턴들이 연합된 아키텍처에서는 무너지게 됩니다.

  • Context provider는 모듈 경계를 넘지 못합니다.
  • 스토어가 두 번 인스턴스화됩니다.
  • 캐시 무효화가 여러분이 원하지 않았던 분산 시스템 문제로 변합니다.

이 가이드는 실제 프로덕션에서 효과가 입증된 패턴들을 다룹니다—중복 인스턴스를 방지하는 싱글톤 구성, 결합도를 높이지 않는 캐시 공유 전략, 그리고 연합 애플리케이션을 유지보수 가능하게 만드는 클라이언트 상태(Zustand)와 서버 상태(TanStack Query) 사이의 중요한 분리. 이는 이론적인 권고가 아니라 Zalando, PayPal 및 기타 대규모 Module Federation을 운영하는 조직들의 실제 교훈입니다.

왜 이런 일이 발생하는가

모놀리식 SPA에서는 메모리가 연속적인 공유 자원입니다. 루트에서 인스턴스화된 Redux 스토어나 React Context 제공자는 전역적으로 접근할 수 있습니다.

페더레이션 시스템에서는 애플리케이션이 서로 다른 JavaScript 번들로 구성됩니다—종종 다른 팀이 개발하고, 서로 다른 시점에 배포되며, 런타임에 비동기적으로 로드됩니다. 이러한 번들은 같은 브라우저 탭에서 실행되지만, 서로 다른 클로저 스코프와 의존성 트리로 분리됩니다.

Root cause: 명시적인 싱글톤 설정이 없으면 각 페더레이션 모듈이 React, Redux, 혹은 Zustand의 자체 인스턴스를 갖게 됩니다. 사용자는 이를 다음과 같이 경험합니다:

  • 한 섹션에서는 인증이 작동하지만 다른 섹션에서는 작동하지 않음,
  • 테마 토글이 인터페이스의 일부에만 적용됨,
  • 마이크로‑프론트엔드 간 이동 시 장바구니 항목이 사라짐.

Source:

Webpack이 모듈을 공유하는 방법

상태 공유를 구동하는 엔진은 __webpack_share_scopes__ 전역 객체이며, 이는 브라우저 세션 내 모든 공유 모듈을 위한 레지스트리 역할을 하는 내부 Webpack API입니다.

  1. Host 부트스트랩__webpack_share_scopes__.default 에서 공유로 표시된 각 라이브러리에 대한 엔트리를 초기화합니다. 각 엔트리에는 버전 번호와 모듈을 로드하는 팩토리 함수가 포함됩니다.
  2. Remote 부트스트랩 – 핸드셰이크를 수행합니다: 공유 스코프를 검사하고, 사용 가능한 버전을 요구 사항과 비교하며, 의미론적 버전 해결을 사용해 호환성을 판단합니다.

Host가 React 18.2.0을 제공하고 Remote가 ^18.0.0을 요구하면, 런타임은 호환성을 판단하고 Remote는 Host의 레퍼런스를 사용합니다. 이 Reference Sharing은 Remote가 React.useContext를 호출할 때 Host와 정확히 동일한 Context Registry에 접근하도록 보장합니다.

핸드셰이크가 실패하면 Remote는 자체 React 사본을 로드하게 되며, 이 경우 Host의 프로바이더가 존재하지 않는 별도의 세계가 생성됩니다.

필수 Webpack 설정

// webpack.config.js – Every federated module needs this
const deps = require('./package.json').dependencies;

module.exports = {
  // …other config
  plugins: [
    new ModuleFederationPlugin({
      // …name, remotes, exposes, etc.
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react,
          strictVersion: true,
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
          strictVersion: true,
        },
        zustand: {
          singleton: true,
          requiredVersion: deps.zustand,
        },
        '@tanstack/react-query': {
          singleton: true,
          requiredVersion: deps['@tanstack/react-query'],
        },
      },
    }),
  ],
};

가장 중요한 세 가지 속성

속성동작 설명
singleton: true모든 연합 모듈에서 단 하나의 인스턴스만 존재하도록 보장합니다.
strictVersion버전 충돌 시 오류를 발생시키며, 중복 로드를 조용히 수행하지 않습니다.
requiredVersionsemver 범위를 강제하여 실수로 인한 버전 불일치를 방지합니다.

package.json에서 버전을 동적으로 로드하면 설정이 설치된 패키지와 동기화됩니다.

“Shared module is not available for eager consumption” 문제 해결

오류는 새로운 Module Federation 설정에서 자주 나타납니다. 표준 엔트리 포인트는 React를 동기적으로 import하지만, 공유 모듈은 비동기적으로 로드됩니다. 런타임이 import가 실행되기 전에 공유 스코프를 초기화하지 못한 것입니다.

두 가지 가능한 해결책

  1. Eager loading (빠른 해결책, 번들 크기 증가)

    // In the shared config
    react: { singleton: true, eager: true, requiredVersion: deps.react },

    이 설정은 React를 초기 번들에 강제로 포함시켜, gzip 기준으로 약 100‑150 KB 정도 Time‑to‑Interactive에 추가됩니다.

  2. Asynchronous entry point (권장)

    // index.js – Simple wrapper enabling async loading
    import('./bootstrap');
    // bootstrap.js – Your actual application entry
    import React from 'react';
    import ReactDOM from 'react-dom';
    import App from './App';
    
    ReactDOM.render(<App />, document.getElementById('root'));

    Webpack이 import('./bootstrap')을 만나면 프라미스를 생성합니다. 해결 과정에서 Federation 런타임은 __webpack_share_scopes__를 초기화하고, 원격 엔트리 포인트를 확인하며, 공유 의존성이 준비되었는지 검사합니다. bootstrap.js가 실행될 때쯤에는 React가 공유 스코프에 이미 존재하게 됩니다.

왜 Zustand가 마이크로‑프론트엔드에서 잘 작동하는가

  • 작은 번들 크기 – 압축 시 약 1 KB.
  • 싱글톤 친화적 아키텍처 – 프로바이더 계층 구조가 필요 없음.
  • React Context가 필요 없음 – 스토어는 순수 JavaScript 객체.

일반적인 버그: Remote가 Host 업데이트에 반응하지 않음

Remote는 초기 상태를 올바르게 렌더링하지만, Host가 상태를 업데이트하면 Remote는 초기 값에 “갇힌” 채로 남아 있습니다.

무슨 일이 일어나고 있나요?

Host와 Remote 모두 빌드‑타임 별칭을 통해 같은 스토어를 import하므로, 번들러가 스토어 코드를 두 번에 걸쳐 포함합니다. 런타임에서는:

  • Host가 Store_Instance_A를 생성합니다.
  • Remote가 Store_Instance_B를 생성합니다.

Host가 Instance A를 업데이트하지만, Remote는 Instance B를 구독하고 있습니다. 따라서 업데이트가 전파되지 않습니다.

해결책: 정확히 같은 JavaScript 객체를 공유하기

// Remote 모듈이 스토어를 노출합니다 (예: src/store.js)
import create from 'zustand';

export const useSharedStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));
// Host가 Remote 스토어를 사용합니다
import { useSharedStore } from 'remoteApp/store';

function Counter() {
  const { count, increment } = useSharedStore();
  return (
    <div>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

스토어 모듈이 공유(shared 설정을 통해)되기 때문에, Host와 Remote 모두 같은 useSharedStore 참조를 받게 되어 상태 업데이트가 모든 곳에서 관찰됩니다.

TL;DR

  • 공유 라이브러리를 싱글톤으로 구성 (singleton: true).
  • 엄격한 버전 검사를 활성화 (strictVersion: true).
  • 엔트리 포인트를 비동기로 로드하여 공유 스코프가 초기화될 시간을 줍니다.
  • 연합 앱의 클라이언트‑사이드 상태 관리에는 Zustand를 선호 – 프로바이더 없이도 동작합니다.
  • 공유 모듈에서 동일한 스토어 인스턴스를 노출하여 Host와 Remote가 실제로 상태를 공유하도록 합니다.

이러한 패턴을 적용하면, 연합 마이크로‑프런트엔드가 고립된 섬들의 집합이 아니라 하나의 일관된 애플리케이션처럼 동작합니다.

Module Federation – 스토어 공유 예시

1. 호스트에서 스토어 노출하기

// cart-remote/webpack.config.js
module.exports = {
  // …
  exposes: {
    './CartStore': './src/stores/cartStore',
    './CartComponent': './src/components/Cart',
  },
};

2. 호스트 스토어 구현 (Zustand)

// libs/shared/data-access/src/lib/cart.store.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';

export const useCartStore = create(
  devtools(
    persist(
      (set) => ({
        items: [] as any[],
        addItem: (item) =>
          set((state) => ({
            items: [...state.items, item],
          })),
        clearCart: () => set({ items: [] }),
      }),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
);

// 원자적 셀렉터를 내보냅니다 (불필요한 리렌더 방지)
export const useCartItems = () => useCartStore((state) => state.items);
export const useCartActions = () => useCartStore((state) => state.actions);

3. 리모트가 호스트 스토어를 사용

// remote/src/App.tsx
import { useCartStore } from 'host/CartStore';

export const RemoteApp = () => {
  const items = useCartStore((state) => state.items);
  return <div>{items.length} items in cart</div>;
};

Note: remote/App.tsxhost/CartStore를 import 하면, Webpack이 요청을 Module Federation 런타임에 위임합니다. 런타임은 호스트가 이미 생성한 동일한 스토어 인스턴스를 반환하므로 양쪽 모두 같은 클로저와 상태를 공유하게 됩니다.

Redux‑Based Architecture – Avoid Nested Providers

문제

각 Remote를 자체 <Provider> 로 감싸면 위험한 중첩 Provider가 생성됩니다.

더 깔끔한 패턴 – 의존성 주입

// Remote component contract
interface Props {
  store: StoreType;
}

const RemoteWidget = ({ store }: Props) => {
  const state = useStore(store);
  return <div>{state.value}</div>;
};

export default RemoteWidget;
// Host side
const RemoteWidget = React.lazy(() => import('remote/Widget'));

const App = () => (
  <React.Suspense fallback="Loading…">
    <RemoteWidget store={hostStore} />
  </React.Suspense>
);
  • Remote와 스토어 위치를 분리합니다.
  • 모의 스토어를 사용한 테스트가 가능합니다.
  • 스토어 소유권을 Host가 확실히 유지합니다.

TanStack Query v5 – QueryClient 공유

5.1 Shared QueryClient (내부 MFE에 권장)

// host/src/queryClient.ts   (exposed via Module Federation)
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,
      gcTime: 300_000,
      refetchOnWindowFocus: false,
    },
  },
});
// Remote side
const { queryClient } = await import('host/QueryClient');

function RemoteApp() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Remote components */}
    </QueryClientProvider>
  );
}

Advantages

Benefit
즉시 캐시 중복 제거 – 원격이 호스트가 이미 가져온 데이터를 네트워크 요청 없이 사용합니다.
전역 무효화 – 어느 모듈에서든 발생한 mutation이 모든 소비자에게 적용됩니다.

Risk

  • 호스트와 원격이 같은 React 인스턴스를 사용해야 합니다. 인스턴스가 다르면 “No QueryClient set” 오류가 발생합니다.

5.2 Isolated QueryClient (타사 MFE에 유용)

// Remote creates its own client
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient(/* …options… */);

function RemoteApp() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Remote components */}
    </QueryClientProvider>
  );
}

Advantages

Benefit
원격이 서로 다른 버전의 React Query를 사용할 수 있습니다.
호스트 캐시가 크래시 나더라도 원격에 영향을 주지 않습니다.

Disadvantages

Drawback
중복 네트워크 트래픽 – 호스트와 원격이 동일한 데이터를 각각 가져올 수 있습니다.
원격에서 발생한 mutation이 자동으로 호스트 캐시를 업데이트하지 않으며(수동 동기화 필요).

비교: 공유된 QueryClient vs. 격리된 QueryClient

특징공유된 QueryClient격리된 QueryClient
데이터 재사용높음 (즉시 캐시 적중)낮음 (HTTP 캐시에 의존)
결합도높음 (React를 공유해야 함)낮음 (독립 인스턴스)
무효화전역 (하나의 변이가 모두 업데이트)로컬 (수동 동기화 필요)
견고성취약 (컨텍스트 문제 시 치명적)견고 (실패 안전)
사용 시점내부, 신뢰할 수 있는 MFE제3자 또는 별도 도메인

Distributed Cache Coordination – BroadcastChannel

// 캐시 무효화를 위한 BroadcastChannel
const channel = new BroadcastChannel('query-cache-sync');

function useCreateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createProduct,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
      channel.postMessage({ type: 'INVALIDATE', queryKey: ['products'] });
    },
  });
}

// 각 MFE에서 구독자
channel.onmessage = (event) => {
  if (event.data.type === 'INVALIDATE') {
    queryClient.invalidateQueries({ queryKey: event.data.queryKey });
  }
};

TanStack DB (Beta) – “Sync a Database Replica”

  • Concept: API 응답을 캐시하는 대신, TanStack DB는 원격 데이터베이스의 로컬 복제본을 동기화합니다.
  • Data Model: 타입이 지정된 Collections는 엄격한 계약 역할을 하며, MFE들은 서로 fetch를 조율하지 않고도 다른 필터를 사용해 동일한 컬렉션을 조회할 수 있습니다.
  • Benefit: 복제본이 모든 MFE에 대한 단일 진실 원천이 됩니다.

멀티‑탭 동기화 – broadcastQueryClient 플러그인

import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental';

broadcastQueryClient({
  queryClient,
  broadcastChannel: 'my-app-session',
});

같은 앱의 모든 탭을 캐시 업데이트를 브로드캐스트하여 동기화합니다.

Event‑Based Communication (Fire‑and‑Forget)

10.1 Shared Event Bus (BroadcastChannel)

// authChannel.ts
export const authChannel = new BroadcastChannel('auth_events');

export const sendLogout = () => {
  authChannel.postMessage({ type: 'LOGOUT' });
};

10.2 Same‑Document Signals (CustomEvent)

// UI signal – open cart drawer
window.dispatchEvent(
  new CustomEvent('cart:open', { detail: { productId: 123 } })
);
  • Pros: Zero library dependencies, leverages the browser’s native event loop.
  • Use Cases: UI‑only interactions like “Buy Now” → open cart drawer.

모듈 연합 2.0 – RetryPlugin

import { RetryPlugin } from '...'; // add appropriate import path
// Configuration example
Back to Blog

관련 글

더 보기 »

LLM 채팅 UI에서 240 FPS를 추구하기

요약: 나는 React UI에서 스트리밍 LLM 응답을 위한 다양한 최적화를 테스트하기 위해 벤치마크 스위트를 구축했다. 주요 요점: 1. 먼저 적절한 상태를 구축하고, 그 다음에 최적화를 적용한다…