Module Federation Shared Dependency 작동 원리와 활용 방법
Module Federation이 무엇인지 알아본 지난번 포스트에 이어, 이번에는 Module Federation의 중요한 기능 중에 하나이면서, 난해한 면이 있는 공유 의존성(Shared Dependency)에 대해 예제와 함께 이해해봅니다.
Shared Modules
별도의 청크로 분리하여 앱의 런타임에 로드해 사용하는 의존성 모듈입니다. Webpack Module Federation Plugin의 shared 옵션으로 해당 의존성의 이름과 버전, 부가적인 옵션 등을 지정 가능합니다. 다음 코드는 webpack docs에서 가져온 예제입니다.
module.exports = {
plugins: [
new ModuleFederationPlugin({
shared: {
'my-vue': {
// can be referenced by import "my-vue"
import: 'vue', // the "vue" package will be used as a provided and fallback module
shareKey: 'shared-vue', // under this name the shared module will be placed in the share scope
shareScope: 'default', // share scope with this name will be used
singleton: true, // only a single version of the shared module is allowed
strictVersion: true, // don't use shared version when version isn't valid. Singleton or modules without fallback will throw, otherwise fallback is used
version: '1.2.3', // the version of the shared module
requiredVersion: '^1.0.0', // the required version of the shared module
},
},
}),
],
};
작동 방식을 좀 더 풀어 설명하자면 이렇습니다.
- 런타임에 로드되는 Micro App의 빌드타임에 공유 의존성의 모듈(Webpack Module)을 별도 청크로 분리해 번들링합니다.
- 런타임에서 Micro App이 로드될 때, 해당 공유 의존성을 사용하면서 먼저 로드되는 앱의 런타임에 미리 분리된 청크를 로드해서 의존성을 사용하게 합니다.
- 이어 로드되는, 해당 공유 의존성을 사용하는 다른 Micro App에서 먼저 불러온 공유 의존성은 따로 로드하지 않고 이미 로드된 청크에서 의존성을 사용할 수 있게 합니다.
- 만약에 먼저 불러온 공유 의존성이 로드된 Micro App에서 사용한다고 정의한 의존성과 버전, share scope가 일치하지 않는 경우 해당 Micro App은 자신의 빌드 번들에서 공유 의존성에 대한 번들을 꺼내서 씁니다.
이 기능은 크게 2가지 관점에서 효용이 있다고 봅니다.
- 최적화 관점: 런타임에서 Micro App간 많이 쓰이는 의존성에 대해 해당 의존성을 런타임에서 한번만 로드하고 돌려쓸 수 있게 되니 모든 마이크로 앱에서 빌드타임에 해당 의존성을 가질 필요가 없어지고, 의존성을 런타임에서 여러번 로드할 필요도 없어집니다.
- 인스턴스 정합성 관점 : 각 Micro App의 런타임에 불러올 공유 의존성들은 버전과 Module Federation Plugin의 shared 옵션에 따라 앱 내에서 참조할 인스턴스를 섬세하게 지정하게 됩니다. 이 때문에 전역 앱에서 모든 런타임 의존성이 하나의 인스턴스만을 사용하게 하거나, 같은 의존성이라도 버전과 옵션에 따라 여러개 인스턴스를 갖게 하는 것도 가능합니다.
예제 오버뷰
이번에도 예제를 준비했습니다. 예제를 브라우저에 띄우면 이런 화면이 나와요
Micro App은 3개이고, Host앱이 2개의 Remote App(remote1, remote2)을 런타임에 로드하고 있는 구조입니다. 각 앱마다 조금씩 다른 shared 설정을 가지고 있는데요.
공유하는 의존성은 가짓수로 따지면 크게 4개인데, 각각의 의존성들을 통해 설정에 따라 약간씩 다른 작동 방식을 보여드리려 합니다.
React
: 모든 Micro App(host, remote1, remote2)에서 사용하는 공유 의존성으로, singleton 옵션을 켜서 전역 앱에서 인스턴스를 단일버전으로 고정했습니다. 앱 전역에서 하나의 인스턴스만 존재하게 됩니다.lodash.camelcase
: remote1, remote2 App에서 쓰이는 공유 의존성입니다. 각 앱에서 package.json에 명시된 버전이 같습니다.date-fns
: remote1, remote2 App에서 쓰이는 공유 의존성인데요, package.json에 명시된 버전이 다릅니다. (2.30,0
,2.20.0
)shared-deps-mf-package
: 모든 Micro App에서 사용하는 공유 의존성인데 런타임에서 사용하는 host와 remote1의 버전이 같고(1.0.9
), remote2만 버전이 다릅니다(1.0.11
)- 브라우저에서 인스턴스 양상을 확인하기 위해 이 패키지의 구현체에는 패키지의 버전을 확인하는 함수(
getVersion
), 패키지의 인스턴스 정합성을 확인하는 함수(getId
)가 있습니다.
- 브라우저에서 인스턴스 양상을 확인하기 위해 이 패키지의 구현체에는 패키지의 버전을 확인하는 함수(
요 마이크로 앱에서도 런타임에서의 번들 로드 양상을 한번 보고 가겠습니다. 다음 그림과 같습니다.
- 전체 앱 진입점 청크를 로드합니다. (
main.js
, localhost:3000) - host에서도 사용하는 react에 대한 모듈이 담긴 청크를 로드합니다.(
484.js
,378.js
) - host에서 사용하는 1.0.9 버전의
shared-deps-mf-package
모듈이 담긴 청크를 로드합니다.(869.js
) - host 앱 본문에 해당하는 코드를 가진 청크를 로드합니다(
568.js
) - remote1의 remoteEntry 진입점 청크를 로드합니다.(
remoteEntry.js
, localhost:3001) - remote2의 remoteEntry 진입점 청크를 로드합니다.(
remoteEntry.js
, localhost:3002) - remote2에서 사용하는 2.20.0 버전의
date-fns
모듈이 담긴 청크를 로드합니다.(637.js
) - remote2에서 사용하는 1.0.11 버전의
shared-deps-mf-package
모듈이 담긴 청크를 로드합니다.(235.js
) - remote2에서 사용하는
lodash.camelcase
모듈을 포함한 청크를 로드합니다.(763.js
)- remote2에서 사용하는
react
는 host가 로드되었을 때 먼저 로드되었으니 remote2에서는 로드하지 않습니다.
- remote2에서 사용하는
- remote2 앱 본문에 해당하는 코드를 가진 청크를 로드합니다(
327.js
, localhost:3002) - remote1에서 사용하는 2.30.0 버전의
date-fns
모듈이 담긴 청크를 로드합니다.(444.js
) - remote1 앱 본문에 해당하는 코드를 가진 청크를 로드합니다(
327.js
, localhost:3001)- remote1에서 사용하는
lodash.camelcase
는 remote2에서,react
와shared-deps-mf-package
모듈은 각각 host가 로드되었을 때 먼저 로드되었으니 remote1에서는 따로 로드하지 않습니다.
- remote1에서 사용하는
아무래도 동적 모듈 로드이다 보니 모든 로드 순서가 매번 같지는 않고, host 로드 이후 remote1과 remote2에 해당하는 앱을 동시에 부르니 각 앱에 필요한 모듈들의 로드 순서는 엎치락 뒤치락일 수도 있다는 것을 감안해주시면 좋습니다.
Shared Module 번들링
Shared 옵션에서 공유 의존성을 등록하면, 앱 빌드에 어떤 영향을 미치는지부터 보겠습니다.
Shared modules are modules that are both overridable and provided as overrides to nested containers. They usually point to the same module in each build, e.g., the same library.
Webpack에는 Module이라는 개념이 있습니다.
Module은 여러개의 파일로 구성된 웹 애플리케이션의 한 부분으로, JS뿐 아니라 이미지, CSS나 HTML도 모듈이 될 수 있습니다. Webpack은 번들링 과정에서 import문을 파싱하면서 의존성(dependency)을 먼저 리졸브하고, 해당 의존성들을 의미있는 단위로 모아다가 Module로 만듭니다. Module은 id로 식별되고, 번들링 결과물인 chunk에는 여러개의 Module이 들어갈 수 있습니다.
공유 의존성을 설정했다면, 의존성이 하나의 Module로 번들링되어서 런타임에 로드할 수 있는 Chunk안에 들어갑니다. 이때 분리되는 Module의 ID는 해당 Shared Module의 이름과 버전에 1:1로 대응됩니다. 이것 덕분에 다른 마이크로 앱에서도 특정 의존성을 런타임에서 가져올 때 어떤 Module을 봐라봐야 하는지 알 수 있게 됩니다.
공유 의존성의 버전은 해당 앱에서 package.json에 정의된 버전을 기본적으로 따라갑니다. Micro App의 진입점 번들을 보면, 어떤 공유 의존성을 어떤 버전으로 사용하려 하는지 확인할 수 있습니다.
신기한 것은, package.json에 공유 의존성에 대한 정의가 없어도 shared 설정에 명시되어 있다면 빌드 에러를 내지 않고 해당 공유 의존성을 플러그인에서 리졸브해 빌드 결과물에 포함시킨다는 것입니다.
공유 의존성 번들링에 관여하는 주요한 컨셉 중 하나는 "어떤 마이크로 앱이 먼저 로드될지 모른다. 로드 될지 안 될지도 모른다."는 것입니다. 청크를 로드하든 앱을 로드하든 모든 것이 런타임에 결정되니까요.
그래서 공유 의존성 모듈은 트리쉐이킹도 안 됩니다. 각 Micro App은 모듈 전체를 빌드 결과물에 가지고 있게 됩니다. 트리쉐이킹 지원할 계획이 없느냐는 webpack의 이슈에, 개별 분리 배포될 Micro App에서 사용할 모듈과 모듈의 구현체가 뭔지 모르는데 어떻게 트리쉐이킹 하겠냐는 취지의 메인테이너의 답이 달려있습니다.
곁다리로 알면 좋은 사실 한 가지로, Module Federation Plugin은 shared 모듈이 번들링되는 청크의 id, name과 같은 네이밍 속성에 관 여하지 않습니다. 따라서 빌드 결과물의 청크 이름은 다른 Code Splitting으로 발생한 청크들과 명명 규칙이 같아서 빌드 결과물 이름만 봐서는 구분이 안됩니다.
저는 막 개발 환경에서 shared 모듈이 들어간 chunk 이름에 vendor 이런거 붙길래 뭔가 shared 모듈이 포함된 청크들은 따로 명명되는건가? 했는데 아니었습니다.
일반적인 청크들과 똑같기 때문에 optimization.splitChunks 옵션을 설정했다면 shared module이 포함된 청크들도 영향을 받습니다.
Shared Module 런타임 로드
위에서 번들 과정에서 공유 의존성 모듈이 어떻게 빌드 결과물에 포함되는지 알아봤습니다.
Micro App에서 어떤 공유 의존성 모듈을 쓸지는 애플리케이션의 런타임에 결정됩니다. 버전과 미리 설정놓은 값에 따라 다른 앱에서 이미 로드된 모듈을 사용할 것인지, 같은 빌드 결과물에서 꺼내 쓸지를 결정합니다.
런타임에 공유 의존성 모듈을 결정하는 변수는 크게 3개입니다. 살펴보도록 하겠습니다.