NextFederation 플러그인 톺아보기

문득 궁금해졌다,,해당 플러그인에서 무슨 처리를 해주고 있길래 Next.js 에서 MF 를 사용할 때 일반 웹팩 내장 MF 플러그인을 사용하면 안되는건지..그래서 알아봤다.

특히나 SSR 처리 / 라우팅 관련 처리 / Next.js 의 기본 다이나믹 임포트와의 충돌 처리 등등..😧 이것저것 많이 해주고 있었다.

알아보고 나니, 일반 웹팩 플러그인 만으로는 Next.js 에서 MF 구성하기에는 해야할일이 너무 많아서 요 플러그인을 쓰는게 맞는 것 같다. 만약에 마음에 안드는 동작(?)이 있다면 이 플러그인을 기반으로 일부만 커스텀해서 써야할듯

그나저나 Next.js 라는 레이어가 중간에 껴버리면 번들러 구성이 배로 복잡해지는구나 ㅠㅠ

원본코드 - https://github.com/module-federation/core/blob/main/packages/nextjs-mf/src/plugins/NextFederationPlugin/index.ts#L233

1. constructor : 플러그인 인스턴스 초기화

  constructor(options: NextFederationPluginOptions) {
    const { mainOptions, extraOptions } = setOptions(options);
    this._options = mainOptions;
    this._extraOptions = extraOptions;
    this.name = 'ModuleFederationPlugin';
  }

2. apply : Webpack 컴파일러에 플러그인 적용

  apply(compiler: Compiler) {
  // 🔥 웹팩 경로를 환경변수에 설정한다.
    process.env['FEDERATION_WEBPACK_PATH'] =
      process.env['FEDERATION_WEBPACK_PATH'] ||
      getWebpackPath(compiler, { framework: 'nextjs' });
      // 🔥 옵션에 대한 유효성 검사
    if (!this.validateOptions(compiler)) return;
    // 🔥 서버 / 클라이언트 컴파일러 구분
    const isServer = this.isServerCompiler(compiler);
    // 🔥 CopyFederationPlugin 적용
    new CopyFederationPlugin(isServer).apply(compiler);
    // 🔥 MF 옵션 구성 및 적용
    const normalFederationPluginOptions = this.getNormalFederationPluginOptions(
      compiler,
      isServer,
    );
    this._options = normalFederationPluginOptions;
    this.applyConditionalPlugins(compiler, isServer);

    new ModuleFederationPlugin(normalFederationPluginOptions).apply(compiler);
	// 🔥 엔트리 포인트 수정 
	// Next.js 의 API 라우트와 관련된 엔트리포인트 수정
	// MF 와 Next.js 의 API 라우트가 충돌하는 것을 방지하기 위해서
	// .federation/entry 로 시작하는 엔트리를 제거하여 MF 런타임이 API 라우트에 영향없도록!
    modifyEntry({
      compiler,
      prependEntry: (entry) => {
        Object.keys(entry).forEach((entryName) => {
          const entryItem = entry[entryName];
          if (!entryName.startsWith('pages/api')) return;
          if (!entryItem.import) return;
          // unpatch entry of webpack api runtime
          entryItem.import = entryItem.import.filter((i) => {
            return !i.includes('.federation/entry');
          });
        });
      },
    });
    const runtimeESMPath = require.resolve(
      '@module-federation/runtime/dist/index.esm.js',
    );
    if (!compiler.options.ignoreWarnings) {
      compiler.options.ignoreWarnings = [
        //@ts-ignore
        (message) => /your target environment does not appear/.test(message),
      ];
    }

    // 🔥 웹팩의 afterPlugin 훅에 탭된다.
    // 즉, 모든 플러그인이 적용된 직후에 실행된다.
    // 코드의 목적은 MF 런타임의 별칭(alias)를 설정하는 것이지만,
    // 현재는 주석처리되어있어서 아무런 동작을 하지 않는다.
    // 만약 주석을 해제한다면 @module-fderation/runtime 에 대한 요청을
    // runtimeESMPath 로 리다이렉트함.
    // 이는, 특정 버전의 런타임을 강제로 사용하거나 커스텀 런타임을 적용할 때 유용.
    compiler.hooks.afterPlugins.tap('PatchAliasWebpackPlugin', () => {
      compiler.options.resolve.alias = {
        ...compiler.options.resolve.alias,
        //useing embedded runtime
        // '@module-federation/runtime

- 🔥 MF 와 Next.js 의 API 라우트가 충돌하는 것을 방지하기 위해서
	- 모든 엔트리 포인트 순회 후, `pages/api`로 시작하는엔트리 포인트의 `import`배열에서 `.federation/etnry`를 포함하는 항목 제거 
	- 왜?
		- MF의 런타임코드 `.federation/entry`가 API 라우트에 포함되면 불필요한 오버헤드가 발생하거나, 예상치 못한 동작이 일어날 수 있다.
	

## 3. validateOptions : 컴파일러 옵션과 플러그인 옵션의 유효성 검증

```js
private validateOptions(compiler: Compiler): boolean {
    const manifestPlugin = compiler.options.plugins.find(
      (p: WebpackPluginInstance) =>
        p?.constructor.name === 'BuildManifestPlugin',
    );

    if (manifestPlugin) {
      //@ts-ignore
      // 🔥 App directory 사용 여부 판별
      // 사용시 에러 발생 (지원안함!)
      if (manifestPlugin?.appDirEnabled) {
        throw new Error(
          'App Directory is not supported by nextjs-mf. Use only pages directory, do not open git issues about this',
        );
      }
    }

    // 🔥 각 옵션 검증
    const compilerValid = validateCompilerOptions(compiler);
    const pluginValid = validatePluginOptions(this._options);
    const envValid = process.env['NEXT_PRIVATE_LOCAL_WEBPACK'];
    if (compilerValid === undefined)
      console.error('Compiler validation failed');
    if (pluginValid === undefined) console.error('Plugin validation failed');
    const validCompilerTarget =
      compiler.options.name === 'server' || compiler.options.name === 'client';
    if (!envValid)
      throw new Error(
        'process.env.NEXT_PRIVATE_LOCAL_WEBPACK is not set to true, please set it to true, and "npm install webpack"',
      );
    return (
      compilerValid !== undefined &&
      pluginValid !== undefined &&
      validCompilerTarget
    );
  }

4. applyConidtionalPlugins: 서버/클라에 따라서 다른 플러그인과 설정 적용


  private applyConditionalPlugins(compiler: Compiler, isServer: boolean) {
    // 🔥 컴파일러 출력 설정 조정
    compiler.options.output.uniqueName = this._options.name;
    compiler.options.output.environment = {
      ...compiler.options.output.environment,
      asyncFunction: true,
    };
    // 🔥 경로 수정 적용 
    // Next.js 와 MF 간의 경로 충돌 해결 
    applyPathFixes(compiler, this._options, this._extraOptions);
    // 🔥 디버그 모드 설정 (소스맵 설정)
    if (this._extraOptions.debug) {
      compiler.options.devtool = false;
    }

    // 🔥 서버 관련 설정 
    if (isServer) {
      // 서버 컴파일러 설정
      configureServerCompilerOptions(compiler); 
      // 서버 라이브러리와 파일 이름 설정
      configureServerLibraryAndFilename(this._options);
      // 서버 관련 플러그인 적용 
      applyServerPlugins(compiler, this._options);
      // 서버의 외부 모듈 처리 
      handleServerExternals(compiler, {
        ...this._options,
        shared: { ...retrieveDefaultShared(isServer), ...this._options.shared },
      });
    } else {
    // 🔥 클라이언트일 때 설정 
      applyClientPlugins(compiler, this._options, this._extraOptions);
    }
  }

5. getNormalFederationPluginOptions: MF 에 사용할 옵션들을 생성한다.


  private getNormalFederationPluginOptions(
    compiler: Compiler,
    isServer: boolean,
  ): moduleFederationPlugin.ModuleFederationPluginOptions {
    // 🔥 기본 공유 의존성을 설정한다
    const defaultShared = this._extraOptions.skipSharingNextInternals
      ? {}
      : retrieveDefaultShared(isServer);
    const noop = this.getNoopPath();

   // 🔥  react & react-dom & next.router 는 기본적으로 설정된다. 
    const defaultExpose = this._extraOptions.skipSharingNextInternals
      ? {}
      : {
          './noop': noop,
          './react': require.resolve('react'),
          './react-dom': require.resolve('react-dom'),
          './next/router': require.resolve('next/router'),
        };
  // 🔥 모듈 페더레이션 플러그인에 대한 옵션 구성
    return {
    // 🔥 기존 설정 옵션들을 포함시키고 
      ...this._options,
      runtime: false, // 🔥 런타임 모듈을 비활성화 시킨다.
      remoteType: 'script', // 🔥 원격 모듈을 스크립트 형태로 로드하도록 설정
      runtimePlugins: [ // 서버일 경우 `runtimePlugin` 포함 
        ...(isServer
          ? [require.resolve('@module-federation/node/runtimePlugin')]
          : []),
        require.resolve(path.join(__dirname, '../container/runtimePlugin')),
        ...(this._options.runtimePlugins || []),
      ].map((plugin) => plugin + '?runtimePlugin'), // 🔥 각 플러그인 경로 뒤에 설정
      //@ts-ignore
      // 🔥 기본 expose 설정
      exposes: {
        ...defaultExpose,
        ...this._options.exposes,
        ...(this._extraOptions.exposePages
        // 🔥 exposePages 가 true 일 경우, Next.js 페이지 자동 expose
          ? exposeNextjsPages(compiler.options.context as string)
          : {}),
      },
      // 🔥 사용자가 설정한 원격 모듈 포함 
      remotes: {
        ...this._options.remotes,
      },
      // 🔥 기본 공유 모듈 설정 포함 및 사용자가 설정한것도 포함  
      shared: {
        ...defaultShared,
        ...this._options.shared,
      },
      // 🔥 서버일 경우 빈 문자열을, 클라이언트일 경우 /static/chunks를'
      // 매니페스트 파일 경로로 설정
      ...(isServer
        ? { manifest: { filePath: '' } }
        : { manifest: { filePath: '/static/chunks' } }),
      // nextjs project needs to add config.watchOptions = ['**/node_modules/**', '**/@mf-types/**'] to prevent loop types update
      // d.ts 생성
      dts: this._options.dts ?? false,
      // 🔥 공유 모듈 전략 설정. 기본은 loaded-first
      shareStrategy: this._options.shareStrategy ?? 'loaded-first',
      experiments: {
        federationRuntime: 'hoisted',
      },
    };
  }

6. MF 와 Next.js 를 어떻게 연동하는가?

// 플러그인 내부 로직 (의사 코드)
compiler.hooks.normalModuleFactory.tap('NextFederationPlugin', (nmf) => {
  nmf.hooks.resolve.tapAsync('NextFederationPlugin', (request, context, callback) => {
    if (isRemoteModule(request)) {
      // Module Federation 로직을 적용하여 원격 모듈 해석
      resolveFederatedModule(request, context, callback);
    } else {
      // 일반적인 모듈 해석 프로세스 진행
      callback();
    }
  });
});
	exposes: {
  ...defaultExpose,
  ...this._options.exposes,
  ...(this._extraOptions.exposePages
    ? exposeNextjsPages(compiler.options.context as string)
    : {}),
},
	import dynamic from 'next/dynamic' 
		const RemoteComponent = dynamic(() => import('remote-app/Component'), { ssr: false // SSR이 필요한 경우 true로 설정 
		})

: runtimeESMPath,
};
});
}


- 🔥 MF 와 Next.js 의 API 라우트가 충돌하는 것을 방지하기 위해서
	- 모든 엔트리 포인트 순회 후, `pages/api`로 시작하는엔트리 포인트의 `import`배열에서 `.federation/etnry`를 포함하는 항목 제거 
	- 왜?
		- MF의 런타임코드 `.federation/entry`가 API 라우트에 포함되면 불필요한 오버헤드가 발생하거나, 예상치 못한 동작이 일어날 수 있다.
	

## 3. validateOptions : 컴파일러 옵션과 플러그인 옵션의 유효성 검증

{{CODE_BLOCK_2}}


## 4. applyConidtionalPlugins: 서버/클라에 따라서 다른 플러그인과 설정 적용
- Next.sj 의 CSR/ SSR 에 따라서 적용
- Next.js 의 빌드 프로세스가 서버와 클라이언트 번들을 별도로 생성하며, 이 플러그인은 각 컨텍스트에 맞는 설정을 자동으로 적용해준다.

{{CODE_BLOCK_3}}


## 5. getNormalFederationPluginOptions: MF 에 사용할 옵션들을 생성한다.

{{CODE_BLOCK_4}}

- 🔥 런타임 모듈이란 뭐고 왜 비활성화 처리할까?
	- 런타임 모듈은 MF 의 dynamic import 처리
	- `runtime:false`로 처리하는 이유는, Next.js 가 자체적인 코드 스플릿팅과 dynamic import 매커니즘을 가지고 있어서.
	- 즉, Next.js 기본 매커니즘과의 충돌을 피하고, Next.js의 최적화를 활용하기 위해 비활성화 한다.
- 🔥 서버의 runtimePlugin
	- 서버 환경에서는 `@module-federation/node/runtimePlugin` 을 사용한다.
	- 해당 플러그인은 Node.js 환경에서 MF 이 제대로 작동하도록 도와준다.
	- 예를들어, 서버사이드 렌더링 시 원격 모듈을 올바르게 로드하고 실행 할 수 있도록.
- 🔥 매니페스트파일이란?
	- Module Federation 의 구성정보를 담고 있다.
	- 어떤 모듈이 expose 되었는지, 어떤 원격모듈을 사용하는지 등의 정보가 포함된다.
	- 런타임에 이 정보를 사용하여 필요한 모듈을 동적으로 로드한다.
- 🔥  서버/클라의 매니페스트 파일 경로 차이
	- 클라이언트: `/static/chunks`에 매니페스트 파일을 저장한다.
		- 이는 브라우저에서 접근 가능한 공개 경로.
	- 서버: `빈문자열('')`사용
		- 서버에서는 매니페스트 파일이 필요없거나, 다른 방식으로 처리 가능
- 🔥  실험적인 hoisted 기능
	- MF 의 해당 기능은 런타임 코드를 애플리케이션의 엔트리포인트로 호이스팅하는 실험적인 기능.
	- 이를 통해 MF 의 초기화를 더 빠르게 하고, 성능을 개선 할 수 있음.
	- 또한, 다른 코드보다 먼저 Federation 런타임이 로드 되므로, 원격 모듈을 더 안정적으로 로드 가능.

- 🔥  hoisted 기능과 eager loading 의 차이는?
	- 호이스팅
		 - Federation 런타임 코드란, MF 의 핵심기능 코드로, 원격 모듈을 동적으로 로드하고 관리하는 역할.
		 - 즉 특정 모듈을 호이스팅하는게 아니라, MF 의 실행환경을 미리 로드해두는것.
		- MF 의 런타임 코드를 애프리케이션 진입점으로 호이스팅.
		- Federation 런타임이 애플리케이션의 다른 코드보다 먼저 로드되고 실행된다.
		- Federation 런타임만 호이스팅되며 실제 원격 모듈의 콘텐츠는 여전히 필요할 때 로드된다.
		
	- Eager Loading
		- 특정 모듈이나 청크를 애플리케이션 시작 시점에 즉시 로드하는 것
		- 지정된 모듈이나 청크가 애플리케이션 초기 로드 시점에 함께 다운로드 되고, 실행된다.



## 6. MF 와 Next.js 를 어떻게 연동하는가?
- 🔥 dynamic import 랩핑
	- Next.js의 dynamic import 기능을 확장하여 원격 모듈 지원
	- Next.js 의 dynamic import 를 직접적으로 수정하지는 않지만, 웹팩의 모듈 해석 과정을 수정하여 원격 모듈을 처리한다.
{{CODE_BLOCK_5}}
	
- 🔥 라우트 인터셉터
	- Next.js 의 라우팅 시스템을 후킹하여, 원격 페이지에 대한 요청을 가로챈다.
	- 즉, Next.js 의 dynamic import 매커니즘과 MF 를 연동한다.
{{CODE_BLOCK_6}}
	

- 위의 부분에서 원격 페이지들을 노출시킨다.
- `exposeNextjsPages` 함수를 통해서 Next.js 페이지들을 자동으로 노출시킴.
- 실제 '인터셉션'은 플러그인 자체가 아닌, Next.js의 dynamic import 매커니즘을 통해 이루어진다.
{{CODE_BLOCK_7}}

- 요 방식을 사용하면 Next.js 의 라우팅 시스템이 그대로 유지되면서 필요한 시점에 원격 컴포넌트를 로드 가능.