이전 편에서 소개한 dynamic-module-federation-example 프로젝트는 네비게이션을 제외하면 직접 제작한 react-native-dynamic-module-federation 라이브러리를 사용하는 코드밖에 없습니다.
따라서 react-native-dynamic-module-federation 라이브러리 코드만 설명하면 될 것 같습니다.
목차
- 주요 기능
- 아이디어
- ReactNativeDynamicModuleFederationPlugin
3-1. 코드를 추가하는 방법
3-2. 기존 앱을 삭제하는 코드
3-2-1. 엔트리 객체
3-2-2. webpackJsonpCallback - DynamicImportProvider
- DynamicImportManager
5-1. 앱 업데이트 시점
5-2. 앱 버전을 가져오는 방법 - useImportLazy/Module
- 마치며
1. 주요 기능
- 로드된 앱을 삭제하는 코드를 Module Federation이 만들어낸 파일에 추가합니다.
- 기존 앱을 삭제하고, 새로운 앱을 로드하는 기능을 제공합니다.
2. 아이디어
처음엔 repack, Module Fedration 동작 방식이 궁금해서 플러그인 내부와 번들링된 코드를 분석하다가 이런 생각을 하게 되었습니다.
Federated.importModule 호출 위치를 리액트 컴포넌트 안으로 가져와서 앱의 uri를 의존성으로 하여 메모이제이션하면 어떨까?
그리고 uri와 Script Manager를 필요에 따라 변경하면, 런타임에 특정 특정 모듈만 업데이트하는 것이 가능할 것 같았습니다.
3. ReactNativeDynamicModuleFederationPlugin
Module Federation 시스템에서 한번 로드된 앱과 해당 앱에서 로드한 모듈은 캐싱되기 때문에, 컴포넌트 안에서 Federated.importModule를 실행한다고 해서 스크립트를 새로 받아오지 않습니다.
따라서 Federated.importModule를 실행하기 전에 앱의 캐시를 제거해야 합니다.
ReactNativeDynamicModuleFederationPlugin은 Module Federation이 만들어낸 파일에 기존에 로드된 앱을 삭제하는 함수를 추가합니다.
3-1. 코드를 추가하는 방법
이 섹션을 읽기 전에 webpack의 동작 원리에 대해 알면 좋을 것 같습니다.
Webpack 내부 동작 원리 이해 - Eunsu Kim
ReactNativeDynamicModuleFederationPlugin 플러그인에서 원하는 위치에 코드를 추가하는 방법은 여러가지가 있지만, {앱이름}.container.bundle 엔트리 파일(청크)에 엔트리 모듈을 추가하는 방식을 사용했습니다.
파일에 모듈을 추가하는 방법은 독특합니다.
Compilation 객체의 dependencyFactories에 Dependency 클래스와 팩토리 객체 쌍을 저장해 놓습니다.
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
compilation.dependencyFactories.set(
ReactNativeDynamicModuleFederationDependency,
new ReactNativeDynamicModuleFederationModuleFactory()
);
});
이 상태로는 아무런 동작을 하지 않습니다.
사용 예정인 데이터를 미리 저장해 놓은 것뿐입니다.
그리고 make 타임에 Compilation 객체의 addEntry 메서드에 Dependency 객체와 타겟 파일(청크) 이름을 넣어서 실행합니다.
const dep = new ReactNativeDynamicModuleFederationDependency(name); // Dependency 객체
dep.loc = { name };
compilation.addEntry(
compilation.options.context,
dep, // Dependency 객체
{ filename: `${name}.container.bundle` }, // 타겟 청크 이름
(error) => {
if (error) return callback(error);
callback();
}
);
참고로 여기서 사용된 {name}.container.bundle 엔트리 파일은 repack의 Module Federation 플러그인이 이미 생성한 파일입니다.
그리고 webpack은 하나의 청크에 다수의 엔트리 모듈이 추가되는 것을 허용합니다.
여기서 Dependency의 클래스와 객체를 구분해야 합니다.
compilation.dependencyFactories에 Dependency 클래스를 저장해 놓았고, 어떤 청크 또는 모듈의 하위 의존성으로 Dependency 객체를 추가하면, compilation.dependencyFactories에서 해당 객체의 생성자로 모듈 팩토리 객체를 찾습니다.
const Dep = /** @type {DepConstructor} */ (dependency.constructor);
const moduleFactory = this.dependencyFactories.get(Dep);
https://github.com/webpack/webpack/blob/v5.92.0/lib/Compilation.js#L2148-L2149
찾은 팩토리 객체의 create 메서드를 사용하여 모듈 객체를 생성하여 모듈 그래프에 삽입하고, 코드를 생성할 때, 모듈 객체의 codeGeneration 메서드를 사용해서 코드 문자열을 추출하는 방식입니다.
추출된 코드는 모듈 Resolver 함수의 컨텐츠가 됩니다.
addEntry 메서드는 팩토리 객체를 _addEntryIetm → addModuleTree → handleModuleCreation → factorizeModule을 거쳐서 factorizeQueue 객체에 추가합니다.
factorizeQueue 객체는 생성될 때, 추가되는 아이템에 대해 create 메서드를 호출하는 작업이 미리 지정되어 있습니다.
결론적으로 Dependency가 모듈 팩토리를 통해 Module 객체로 변환되는 것입니다.
그리고 어떤 모듈을 의존성에 추가한다고 자동으로 해당 모듈의 Resolver가 실행되는 것은 아닙니다.
상위 모듈이 해당 모듈을 어떻게 처리할지에 달려있습니다.
하지만 기본 내장 플러그인인 JavascriptModulesPlugin은 엔트리 청크에 addEntry 메서드로 추가된 엔트리 모듈 Resolver를 실행시켜주는 코드가 삽입해 줍니다.
buf2.push(
`${i === 0 ? `var ${RuntimeGlobals.exports} = ` : ""}${
RuntimeGlobals.onChunksLoaded
}(undefined, ${JSON.stringify(
chunks.map(c => c.id)
)}, ${runtimeTemplate.returningFunction(
`${RuntimeGlobals.require}(${moduleIdExpr})`
)})`
);
예를 들어 dynamic-module-federation-example 프로젝트의 alphabet 앱 경로에서 npm run bundle:android를 실행하면 build/generated/android 경로에 alphabet.container.bundle(remoteEntry.js) 파일이 생성되는데, 해당 파일의 맨 아래 부분에 엔트리 모듈 Resolver를 자동으로 실행시켜 주는 코드가 삽입되어 있는 것을 확인할 수 있습니다.
var moduleMap = { // module resolvers
"webpack/container/entry/alphabet?307d": () => {/* ... */}
"webpack/container/entry/alphabet?a8b0": () => {/* ... */}
}
// ↓↓ 요 부분 생성
__webpack_require__("webpack/container/entry/alphabet?307d");
var __webpack_exports__ = __webpack_require__("webpack/container/entry/alphabet?a8b0");
그래 저는 청크 이름({name}.container.bundle)에 Dependency 객체를 추가해 주는 것만으로 간단하게 원하는 모듈과 해당 모듈이 가지고 있는 코드 조각을 원하는 위치에 삽입할 수 있었습니다.
원래 사용하려고 했던 방식
처음엔 다음과 같이 기존 Module Federation에서 엔트리 객체를 노출시키는 ContainerEtnryModule을 수정하려고 했습니다.
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.optimizeModules.tap(PLUGIN_NAME, (modules) => {
for (const module of modules) {
if (
typeof module === 'object' &&
module.constructor?.name === 'ContainerEntryModule' // 요 부분 불안!
) {
const codeGeneration = module.codeGeneration.bind(module);
// codeGeneration 메서드 래핑
module.codeGeneration = function ({
moduleGraph,
chunkGraph,
runtimeTemplate,
}) {
// 원본 codeGeneration 메서드 호출 결과
const result = codeGeneration({
moduleGraph,
chunkGraph,
runtimeTemplate,
});
// result.sources 조작 ... 요 부분도 불안
return result;
};
}
}
});
});
이 방식을 사용하면 각 앱의 엔트리 객체에 자신을 삭제하는 메서드를 노출시킴으로써, 특정 앱에 대한 작업(init, get, dispose)을 한 군데로 모을 수 있습니다. (응집도↑)
제가 사용한 방법은 독립적인 엔트리 모듈로 추가한 방법이기 때문에, ContainerEntryModule에 접근할 수 없어, 앱을 삭제하는 함수를 global에 따로 추가할 수밖에 없었습니다. (응집도↓)
const source = Template.indent([
// ...
`${RuntimeGlobals.global}.${DISPOSE_CONTAINER_KEY}['${this.name}'] = function() {`,
Template.indent([
`var webpackChunkKey = "webpackChunk${this.name}";`,
'if (Array.isArray(self[webpackChunkKey]) && self[webpackChunkKey].length > 0) {',
Template.indent(['self[webpackChunkKey] = [];']),
'}',
`if (${RuntimeGlobals.hasOwnProperty}(${RuntimeGlobals.global}, "${this.name}")) {`,
Template.indent([`delete ${RuntimeGlobals.global}.${this.name};`]),
'}',
]),
'}',
// ...
]);
sources.set('javascript', new RawSource(source));
return {
sources,
runtimeRequirements,
};
그럼에도 불구하고, 독립적인 엔트리 모듈을 사용한 이유는 위의 코드는 ContainerEntryModule에 지나치게 의존적이라는 것과 원본 코드가 훼손되는 것이 마음에 걸렸기 때문입니다.
3-2. 기존 앱을 삭제하는 코드
3-2-1. 엔트리 객체
Module Federation 분석에서 어떤 앱의 remoteEntry.js가 실행되면, 해당 앱의 엔트리 객체를 global에 추가한다고 했었습니다.
repack의 Federated.importModule은 global에서 원하는 엔트리 객체가 있는지 검사하고, 없을 때만 해당 앱의 remoteEntry.js 파일을 로드합니다.
이것은 오리지널 Module Federation에서 사용되는 import 표현식도 동일합니다.
따라서 새로운 remoteEntry.js를 다운받아 global에 새로운 엔트리 객체를 등록하기 위해 기존 엔트리 객체를 지워야 합니다.
3-2-2. webpackJsonpCallback
Module Federation 분석에서 global의 webpackChunk{앱이름} 속성(이하 chunkLoadingGlobal)에 빈 배열을 만들고 push 메서드를 webpackJsonpCallback 함수로 오버라이드한다고 했었습니다.
그런데 webpackJsonpCallback 함수는 분할된 파일이 실행되면서 push 메서드가 실행될 때뿐만 아니라, remoteEntry.js 스크립트가 실행될 때도 chunkLoadingGlobal 배열에 저장된 모든 아이템에 대해 실행됩니다.
즉, 이전 앱에 의해 채워진 chunkLoadingGlobal 배열의 모듈 Resolver들이 새로운 remoteEntry.js가 실행될 때 다시 __webpack_modules__에 추가되는 것입니다.
이를 방지하기 위해 global에서 지워야 할 앱의 chunkLoadingGlobal배열도 지워야 합니다.
엔트리 객체와 chunkLoadingGlobal 배열 두 객체를 지우는 코드를 ReactNativeDynamicModuleFederationModule webpack 모듈의 codeGeneration 메서드에서 생성합니다.
module.exports = class ReactNativeDynamicModuleFederationModule extends Module {
// ...
codeGeneration({ moduleGraph, chunkGraph, runtimeTemplate }) {
// 해당 앱이 사용하는 모든 청크 리스트 모음 (앱을 삭제할 때 관련 청크도 같이이 삭제하기 위해서)
const chunkIncludingThisModule = Array.from(
chunkGraph.getModuleChunksIterable(this)
).find((chunk) => chunk.id === this.name);
const chunkIds = [];
if (chunkIncludingThisModule) {
// ...
}
const sources = new Map();
const source = Template.indent([
// 앱 삭제 코드 추가
// 관련 청크 삭제 코드 추가
]);
sources.set('javascript', new RawSource(source));
return {
sources,
};
}
};
4. DynamicImportProvider
앱 uri 상태를 Context를 사용하여 children에 공유합니다.
그리고 앱 uri를 업데이트하기 전에 Script Resolver를 업데이트합니다.
export const DynamicImportProvider = ({ manager }: { manager: DynamicImportManager }) => {
const [containers, setContainers] = useState<Containers>();
const refreshContainers = () => {
// 1. 앱 버전 가져오기
// 2. ReactNativeDynamicModuleFederationPlugin에 의해 생성된 앱 삭제 함수로 기존 앱 삭제
// 3. Script Resolver
// 4. 앱 uri 업데이트
};
useEffect(() => {
refreshContainers();
}, []);
// manager에 refreshContainers 주입 → 외부에서 호출
return (
<Context.Provider value={{ containers, manager }}>
<>{children}</>
</Context.Provider>
);
}
여기서 "1번 앱 버전 가져오기" 동작은 DynamicImportManager 객체(manager)에 위임합니다.
5. DynamicImportManager
react-native-dynamic-module-federation구성요소는 앱마다 다양한 방법으로 작동할 수 있도록 동작 과정에서 중요한 부분을 외부에서 입력되는 DynamicImportManager 객체에 위임합니다.
5-1. 앱 업데이트 시점
export const DynamicImportProvider = ({ manager }: { manager: DynamicImportManager }) => {
const [containers, setContainers] = useState<Containers>();
const refreshContainers = () => {
// ...
};
const refreshContainer = () => {
// ...
};
manager[SettersSymbol].setRefreshContainers(refreshContainers);
manager[SettersSymbol].setRefreshContainer(refreshContainer);
return (/* ... */);
}
그리고 앱에선 다음과 같이 컴포넌트 내외부에 어디에서든지 refresh 함수를 호출할 수 있습니다.
const manager = new DynamicImportManager({
fetchContainers,
fetchContainer,
errorManager: ErrorManager,
});
let appState = AppState.currentState;
// 앱 상태가 백그라운드 → 포그라운드로 바뀔 때 refresh
AppState.addEventListener('change', nextAppState => {
if (appState === 'background' && nextAppState === 'active') {
manager.refreshContainers(); // !!
}
appState = nextAppState;
});
const App = () => {
return (
<DynamicImportProvider manager={manager}>
<Main />
</DynamicImportProvider>
);
};
https://github.com/JoonDong2/dynamic-module-federation-example/blob/main/apps/host/App.tsx#L23-L43
5-2. 앱 버전을 가져오는 방법
export const DynamicImportProvider = ({ manager }: { manager: DynamicImportManager }) => {
const [containers, setContainers] = useState<Containers>();
const refreshContainers = () => {
const containersOrPromise = manager[OptionsSymbol].fetchContainers();
// ...
};
useEffect(() => {
refreshContainers();
}, []);
return (/* ... */);
}
외부에서 DynamicImportManager 객체(manager)의 fetchContainers를 정의해 주어야 합니다.
// apps/host/App.tsx
const manager = new DynamicImportManager({
fetchContainers,
fetchContainer,
errorManager: ErrorManager,
});
const App = () => {
return (
<DynamicImportProvider manager={manager}>
<Main />
</DynamicImportProvider>
);
};
// shared/src/containers.ts
const SERVER_URI = `http://${getLocalhost()}:4000/`;
const CONTAINERS_SERVER_URI = SERVER_URI + 'containers';
export const fetchContainers = async (): Promise<any> => {
const params: any = {
env: Config.ENV,
os: Platform.OS,
native_version: Config.NATIVE_VERSION,
};
const queryString = new URLSearchParams(params).toString();
const res = await fetch(`${CONTAINERS_SERVER_URI}?${queryString}`);
const containers = Object.fromEntries(
Object.entries(await res.json()).map(([containerName, version]) => {
return [
containerName,
`http://${getLocalhost()}:4000/${Config.NATIVE_VERSION}/${Platform.OS}/${Config.ENV}/${containerName}/${version}/[name][ext]`,
];
})
);
return containers;
};
5-3. 오류 처리
아래 설명할 useDynamicLazy는 미니 앱에서 노출하는 컴포넌트를 가져오는 훅입니다.
Lazy 컴포넌트를 만들 때 ErrorBoundary로 래핑할 수 있는데, 래핑할 때 원본 onError 콜백 함수에 DynamicImportManager 객체에 오류를 전달하는 코드를 끼워 넣어 줍니다.
export function useDynamicLazy<P = any>(
containerName: string,
moduleName: string,
options?: {
error?: ErrorBoundaryProps;
// ...
}
): (props: P) => JSX.Element | null {
const containers = useContainers();
const manager = useDynamicImportManager();
const Lazy = useMemo(() => {
const uri = containers?.[containerName];
if (uri) {
const NewLazy = (props: P) => {
// let component = ...
if (options?.error) {
const onError = (error: Error, info: ErrorInfo) => {
const dynamicModuleError = new DynamicModuleError(
containerName,
uri,
error
);
// ErrorBoundary에서 캐치한 오류를 DynamicImportManager 객체에 위임 !!
manager[ErrorManagerSymbol]?.onError(dynamicModuleError, info);
options?.error?.onError?.(dynamicModuleError, info);
};
component = (
<ErrorBoundary {...options.error} onError={onError}>
{component}
</ErrorBoundary>
);
}
return component;
};
return NewLazy;
}
return Null;
}, [containers?.[containerName], moduleName]);
return Lazy;
}
DynamicImportManager는 오류를 다시 DynamicImportErrorManager 객체에 위임합니다.
dynamic-module-federation-example에선 2번까진 앱 refresh 요청을 보내는 ErrorManager 객체를 만들어 사용했습니다.
// host/App.tsx
const manager = new DynamicImportManager({
fetchContainers,
fetchContainer,
errorManager: ErrorManager, // !!
});
const App = () => {
return (
<DynamicImportProvider manager={manager}>
<Main />
</DynamicImportProvider>
);
};
// shared/src/containers.ts
export class ErrorManager {
static MAX_FAILURE_COUNT = 2;
countOf = {};
manager;
// DynimicImportManager 생성자에 클래스를 입력해 주면, DynimicImportManager 생성자에서 해당 객체를 인스턴스화할 때 자기 자신을 넘겨준다.
constructor(manager: { refreshContainer: (containerName: string) => void }) {
this.manager = manager;
}
async onError(error: any) {
const key = `${error.containerName}@${error.uri}`;
const count = this.countOf[key] ?? 0;
if (count >= ErrorManager.MAX_FAILURE_COUNT) {
return;
}
// 한 개의 container의 여러 개의 모듈에서 다발적으로 오류가 발생하는 경우, 복구 처리중엔 잠시 막아둔다.
this.countOf[key] = Infinity;
// 오류가 MAX_FAILURE_COUNT에 도달하기 전엔 앱 refresh 시도
await this.manager.refreshContainer(error.containerName);
this.countOf[key] = count + 1;
}
}
6. useImportLazy/Module
repack의 Module Federation에서 외부 모듈을 로드하기 위해 사용하는 Federated.importModule은 기본적으로 컴포넌트 안에서 사용하지 않습니다.
기본적인 Module Federation에선 위에서 언급한 미니 앱이 불변한다고 가정하기 때문에, Federated.importModule로 모듈을 몇 번이고 요청해도 동일한 모듈을 캐시에서 꺼내오게 됩니다.
컴포넌트(함수) 안에서 사용하면 리랜더링될 때마다 이러한 불필요한 동작이 발생하기 때문에, 공식 문서나 예제에서는 모듈 스코프에 정의되어 있습니다.
// eslint-disable-next-line import/no-extraneous-dependencies
import { Federated } from '@callstack/repack/client';
import React from 'react';
import { Text, SafeAreaView } from 'react-native';
const App1 = React.lazy(() => Federated.importModule('app1', './App'));
const App2 = React.lazy(() => Federated.importModule('app2', './App'));
export default function App() {
return (
<SafeAreaView>
<Text>Host App</Text>
<React.Suspense fallback={<Text>Loading app1...</Text>}>
<App1 />
</React.Suspense>
<React.Suspense fallback={<Text>Loading app2...</Text>}>
<App2 />
</React.Suspense>
</SafeAreaView>
);
}
repack 공식 예제: https://github.com/callstack/repack-examples/blob/main/module-federation/host/App.tsx
하지만 이번 프로젝트에선 미니 앱이 언제든지 변경될 수 있다고 가정했기 때문에, 변화가 발생했다면 Federated.importModule 함수를 다시 실행시켜 주어야 합니다.
이것을 컴포넌트(함수) 안에서 커스텀 훅으로 미니 앱의 uri가 변경됐을 때마다 수행합니다.
export function useDynamic...(containerName, moduleName, options) {
const containers = useContainers();
const manager = useDynamicImportManager();
// containers?.[containerName] = 앱 uri
// 앱의 uri가 변경되었다는 것은 새로운 버전의 앱 코드가 업로드되었다는 의미 !!
return useMemo(() => {
const promise = () => Federated.importModule(containerName, moduleName)
// promise 조작 및 반환
// ...
}, [containers?.[containerName], moduleName])
}
containers 객체는 키가 앱 이름이고, 값이 앱의 uri인 객체입니다.
그리고 위에서 설명한 DynamicImportProvider의 Context에 포함된 상태이고, 상태가 변경되기 전에 이전 앱 정보 (정확히는 미니앱의 remoteEntry.js 스크립트에서 로드한 엔트리 모듈, chunkLoadingGlobal 배열)가 삭제되는 것을 보장합니다.
export const DynamicImportProvider = ({ manager }: { manager: DynamicImportManager }) => {
const [containers, setContainers] = useState<Containers>();
// refreshContainers를 성공했을 때 실행되는 콜백 함수
const onSuccess = async (newContainers: Containers) => {
const difference = getSymetricDifference(containers, newContainers);
if (difference.length === 0) return;
// 컨테이너를 업데이트하기 전에 resolver를 미리 변경시켜 놓는다. (동기함수)
updateResolver(newContainers);
const deleteTasks: Promise<void>[] = [];
difference.forEach((containerName) => {
const deleteTask = disposeContainer(containerName);
if (deleteTask) {
deleteTasks.push(deleteTask);
}
});
// 관련 청크 삭제가 필요하다면, Promise가 완료될 때까지 대기
if (deleteTasks.length > 0) {
await Promise.all(deleteTasks);
}
status.current = 'success';
// 이후 상태 변경
setContainers(newContainers);
return true;
};
// Context로 children에 공유
return (
<Context.Provider value={{ containers, manager }}>
<>{children}</>
</Context.Provider>
);
}
useDynamicLazy와 useDynamicModule은 기본 흐름은 동일합니다.
다만 useDynamicLazy의 경우 Federated.importModule로 반환된 모듈의 default가 컴포넌트(함수)라고 가정하고, Lazy 컴포넌트로 래핑하는 것만 다릅니다.
export function useDynamicLazy<P = any>(
containerName: string,
moduleName: string,
options?: {
error?: ErrorBoundaryProps;
suspenes?: {
fallback?: ReactNode;
timeout?: number;
onTimeout?: () => void;
};
}
): (props: P) => JSX.Element | null {
const containers = useContainers();
const manager = useDynamicImportManager();
const Lazy = useMemo(() => {
const uri = containers?.[containerName];
if (uri) {
// Lazy 컴포넌트로 래핑
const NewOriginLazy = React.lazy(
// 익명 컴포넌트
() => {
return Federated.importModule(containerName, moduleName);
}
);
const NewLazy = (props: P) => {
let component = <NewOriginLazy {...props} />;
// ErrorBoundery, Suspense 설정이 있다면 래핑
return component;
};
return NewLazy;
}
return Null;
}, [containers?.[containerName], moduleName]);
return Lazy;
}
단순히 익명 컴포넌트를 반환했다면 부모 컴포넌트가 리랜더링될 때마다 fiber까지 새로 만들어 졌겠지만, 메모이제이션됐으므로, 부모 컴포넌트가 리랜더링되더라도, key만 변경되지 않았다면, 기존 fiber를 재사용합니다.
7. 마치며
실험적인 코드이기 때문에, npm에 배포하지는 않고, modules에 로컬 라이브러리로 만들어 놓았습니다.
해당 라이브러리에 대한 사용법은 해당 라이브러리의 README.md에 설명해 놓았습니다.
'프로젝트 > Module Federation' 카테고리의 다른 글
리액트 네이티브 앱을 재시작하지 않고 일부분만 업데이트해 보자 (1. 프로젝트 소개) (0) | 2024.08.27 |
---|---|
리액트 네이티용 Module Federation (0) | 2024.08.27 |
Module Federation (0) | 2024.08.26 |