이 글은, https://martinfowler.com/articles/micro-frontends.html 페이지를 번역한 글 입니다.
저는 번역 경험이 거의 없습니다. 서투른 글이라 번역체의 분명하지 않은 문장 표현들이 종종 있겠습니다만.. 너그러이 보아 주셨으면 좋겠고, 얼마든지 개선점이나 조언 주시면 감사하겠습니다. 저는 이 문서의 내용이 분명 우리에게 도움이 될 것이라 생각하고, 많은 분들과 나누고 싶습니다.
(그리고 문서가 좀 깁니다. 따로 편집하거나 요약하지 않았습니다.)
미흡한 자료 보아 주셔서 감사합니다 :)
Contents
(이 페이지에서 볼 내용 끝에 * 로 표시)
• 이점 (Benefits)
⁃ 점차적인 업그레이드 (Incremental upgrades)
⁃ 단순하고 분리된 코드베이스 (Simple, decoupled codebases)
⁃ 독립적인 배포 (Independent deployment)
⁃ 자율적인 팀 (Autonomous teams)
⁃ 간단히 말해서 (In a nutshell)
• 예제 (The example)
• 통합에 대한 접근 방식 (Integration approaches)
⁃ 서버사이드 템플릿 구성 (Server-side template composition)
⁃ 빌드 타임 통합 (Build-time integration)
⁃ iframe을 통한 런타임 통합 (Run-time integration via iframes)
⁃ JavaScript를 통한 런타임 통합 (Run-time integration via JavaScript)
⁃ 컴포넌트를 통한 런타임 통합 (Run-time integration via Web Components)
• 스타일링 (Styling)
• 공유컴포넌트 라이브러리 (Shared component libraries)
• 어플리케이션 간 통신 (Cross-application communication)
• 백엔드 통신 (Backend communication)
• 테스팅 (Testing)
• 상세 예제 (The example in detail) *
⁃ 컨테이너 (the container) *
⁃ 마이크로 프론트엔드 (The micro frontends) *
⁃ 라우팅을 통한 어플리케이션 간 통신 (Cross-application communication via routing) *
⁃ 공통 컨텐츠 (Common content) *
⁃ 인프라 (Infrastructure) *
• 단점 (Downsides)
⁃ 페이로드 크기 (Payload size)
⁃ 환경의 차이 (Environment differences)
⁃ 운영 및 거버넌스 복잡성 (Operational governance complexity)
• 결론 (Conclusion)
상세 예제
이 글의 나머지 대부분은 예제 애플리케이션을 구현할 수 있는 한 가지 방법에 대한 자세한 설명입니다. 대부분 컨테이너 애플리케이션과 마이크로 프론트엔드 어플리케이션이 JavaScript를 사용하여 어떻게 통합되는지에 초점을 맞춥니다. 아마도 가장 흥미롭고, 복잡한 부분일 것입니다. https://demo.microfrontends.com에서 실제 배포된 최종 결과물을 볼 수 있으며 전체 소스 코드는 Github에서 확인할 수 있습니다 .
데모는 모두 React.js를 사용하여 제작되었지만 React가 이 아키텍처에 대해 독점권이 있는 것은 아닙니다. 마이크로 프론트엔드는 다양한 툴이나 프레임 워크로 구현될 수 있습니다. 우리는 인기가 있고 우리에게 익숙하기 때문에 React를 선택했습니다.
컨테이너 (Main)
컨슈머의 출발점이기 때문에, 컨테이너부터 시작합니다. 무엇으로 구성되었는지 봅시다. package.json
:
{
"name": "@micro-frontends-demo/container",
"description": "Entry point and container for a micro frontends demo",
"scripts": {
"start": "PORT=3000 react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test"
},
"dependencies": {
"react": "^16.4.0",
"react-dom": "^16.4.0",
"react-router-dom": "^4.2.2",
"react-scripts": "^2.1.8"
},
"devDependencies": {
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"jest-enzyme": "^6.0.2",
"react-app-rewire-micro-frontends": "^0.0.1",
"react-app-rewired": "^2.1.1"
},
"config-overrides-path": "node_modules/react-app-rewire-micro-frontends"
}
react
와 react-scripts
에 대한 의존성으로부터, 우리는 이것이 create-react-app
로 작성된 React.js 애플리케이션이라는 것을 확인할 수 있습니다. 더 흥미로운 사실은 그것이 없는 것입니다 : 최종 어플리케이션을 구성하기 위해 함께 구성되어야 할 마이크로 프론트엔드에 대한 언급이 없습니다. 라이브러리 종속성을 여기에 지정했다면, 앞서 언급했듯이 릴리즈 사이클에서 문제가 되는 커플링을 일으킬 수 있는 빌드 타임 통합(2번째 방법)의 길로 나아가게 될 것입니다.
micro app을 선택하고 배치하는 방법을 살펴보기 위해, App.js
를 봅니다. React Router를 사용하여 미리 정의된 Router 목록과 현재 URL을 일치시키고, 해당 컴포넌트를 렌더링합니다.
<Switch>
<Route exact path="/" component={Browse} />
<Route exact path="/restaurant/:id" component={Restaurant} />
<Route exact path="/random" render={Random} />
</Switch>
Random
컴포넌트는 그다지 중요하지 않습니다 - 무작위로 선택된 레스토랑 URL로 페이지를 리디렉션할 뿐입니다. Browse
및 Restaurant
컴포넌트는 다음과 같습니다 :
const Browse = ({ history }) => (
<MicroFrontend history={history} name="Browse" host={browseHost} />
);
const Restaurant = ({ history }) => (
<MicroFrontend history={history} name="Restaurant" host={restaurantHost} />
);
두 경우 모두 MicroFrontend
컴포넌트를 렌더링합니다. history object(나중에 중요하게 될)와 별도로, 애플리케이션의 고유한 이름과 번들을 다운로드할 수 있는 호스트를 지정합니다. 이 구성 기반 URL은 로컬에서 실행될 때 http://localhost:3001
, 프로덕션에서 실행될 때https://browse.demo.microfrontends.com
와 같습니다.
App.js
에서 마이크로 프론트엔드 어플리케이션을 선택하면, 이제 그것을 MicroFrontend.js
에서 렌더링할 것입니다. 이것은 또다른 React 컴포넌트입니다 :
class MicroFrontend extends React.Component {
render() {
return <main id={`${this.props.name}-container`} />;
}
}
이것은 전체 클래스가 아니므로 더 많은 방법을 곧 보게 될 것입니다.
렌더링할 때, micro app에 고유한 ID를 가진 컨테이너 요소를 페이지에 넣기만 하면 됩니다. 이곳이 micro app이 스스로 렌더링하도록 알려줘야할 곳입니다. 우리는 micro app을 다운로드하고 마운트하기위한 트리거로 React의 componentDidMount
를 사용합니다 :
class MicroFrontend …
componentDidMount() {
const { name, host } = this.props;
const scriptId = `micro-frontend-script-${name}`;if (document.getElementById(scriptId)) {
this.renderMicroFrontend();
return;
}fetch(`${host}/asset-manifest.json`)
.then(res => res.json())
.then(manifest => {
const script = document.createElement('script');
script.id = scriptId;
script.src = `${host}${manifest['main.js']}`;
script.onload = this.renderMicroFrontend;
document.head.appendChild(script);
});
}
먼저 고유 ID를 가진 관련 스크립트가 이미 다운로드 되었는지 확인합니다. 이 경우 즉시 렌더링할 수 있습니다. 그렇지 않은 경우, 메인 스크립트의 전체 URL을 조회하기 위해 해당 호스트로부터 asset-manifest.json
를 가져옵니다. 스크립트의 URL을 설정하고 나면, onload
핸들러를 이용해 Document에 마이크로 프론트엔드를 렌더링합니다.
(componentDidMount
는 React 컴포넌트의 수명주기 메소드입니다. 컴포넌트의 인스턴스가 처음 DOM에 '마운트'된 직후 프레임워크에서 호출됩니다.)
(asset manifest 파일에서 스크립트의 URL을 가져와야 합니다. react-scripts
는 캐싱을 용이하게 하기 위해 파일 이름에 해시가 있는 컴파일된 JavaScript 파일을 출력하기 때문입니다. )
class MicroFrontend …
renderMicroFrontend = () => {
const { name, history } = this.props;window[`render${name}`](`${name}-container`, history);
// E.g.: window.renderBrowse('browse-container, history');
};
위의 코드에서, 방금 다운로드한 스크립트에 의해 배치된 전역 함수 window.renderBrowse
를 호출합니다. 마이크로프론트엔드가 렌더링해야하는 <main>
요소의 ID 와 곧 설명할 history
객체를 전달합니다. 이 전역 함수의 서명은 container app과 micro app 사이의 key contract입니다. 커뮤니케이션과 통합이 이루어져야하는 곳이므로, 가볍게 유지하여 새로운 micro app을 추가할 수 있으면서 유지 보수가 쉽도록 해야 합니다. 이 코드를 변경해야 하는 작업을 수행할 때마다, 코드베이스의 결합과 계약을 유지하는 것이 무엇을 의미하는지 오랫동안 고민해야 합니다.
클린업을 핸들링하는 마지막 부분이 있습니다. MicroFrontend
컴포넌트가 언마운트될 때(DOM에서 제거 될 때), 관련 micro app도 언마운트해야 합니다. 이를 위해 각 마이크로 프론트엔드 어플리케이션이 정의한 해당 전역 함수가 있으며, 이를 적절한 React lifecycle 메소드에서 호출합니다 :
class MicroFrontend …
componentWillUnmount() {
const { name } = this.props;window[`unmount${name}`](`${name}-container`);
}
자체 콘텐트 측면에서, 컨테이너가 직접 렌더링하는 것은 사이트의 최상위 헤더와 사이트의 네비게이션 바 입니다. 이것은 모든 페이지에서 동일합니다. 이러한 엘리먼트에 대한 CSS는 헤더 내의 엘리먼트 스타일을 지정하기 위해 신중하게 작성되어 마이크로 프론트엔드 내의 모든 스타일 코드와 충돌하지 않아야 합니다.
이것이 컨테이너 어플리케이션의 끝입니다! 상당히 기초적이지만, 런타임 시 마이크로 앱을 동적으로 다운로드 할 수 있는 쉘을 제공하여 단일 페이지에서 응집력있게 결합하도록 합니다. 이러한 마이크로 프론트엔드는 다른 마이크로 프론트엔드나 컨테이너 자체의 변경 없이 생산에 이르기까지 독립적으로 배치될 수 있습니다.
마이크로 프론트엔드 (Browse)
이 이야기가 계속되는 논리적 위치는 계속 언급되고 있는 글로벌 렌더함수 입니다. 우리 앱의 홈페이지는 필터링 가능한 레스토랑 리스트이며 진입점은 다음과 같습니다.
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import registerServiceWorker from './registerServiceWorker';window.renderBrowse = (containerId, history) => {
ReactDOM.render(<App history={history} />, document.getElementById(containerId));
registerServiceWorker();
};window.unmountBrowse = containerId => {
ReactDOM.unmountComponentAtNode(document.getElementById(containerId));
};
일반적으로 React.js 어플리케이션에서 ReactDOM.render
호출은 최상위 스콥에 있게 됩니다. 즉, 이 스크립트 파일을 로드하자 마자 바로 하드코딩된 DOM 엘리먼트로 렌더링되기 시작합니다. 이 애플리케이션의 경우, 렌더링이 언제 어디서 발생하는지 제어할 수 있어야 하므로, DOM 엘리먼트의 ID를 매개변수로 받는 함수로 래핑하고, 전역 window
오브젝트에 해당 함수를 첨부합니다. 우리는 또한 클린업에 사용할 해당 unmount 함수를 볼 수 있습니다.
micro app이 전체 container app에 통합될 때 이 함수가 어떻게 호출되는지 이미 살펴 보았지만, 여기에서 성공의 가장 큰 기준 중 하나는 micro app을 독립적으로 개발하고 실행할 수 있다는 것입니다. 따라서 각 마이크로 프론트엔드 index.html
에는 어플리케이션을 컨테이너 외부에서 "standalone" 모드로 렌더링하는 인라인 스크립트가 포함되어 있습니다.
<html lang="en">
<head>
<title>Restaurant order</title>
</head>
<body>
<main id="container"></main>
<script type="text/javascript">
window.onload = () => {
window.renderRestaurant('container');
};
</script>
</body>
</html>
이 시점부터, 마이크로 프론트엔드는 평범하고 오래된 일반적 React App입니다. ‘browse’ 어플리케이션은 백엔드에서 레스토랑의 목록을 가져와, 레스토랑을 찾고, 필터링하는 <input>
엘리먼트를 제공합니다. 그리고 특정 레스토랑을 탐색하는 React Router <Link>
를 렌더링합니다. 이 시점에서 우리는 메뉴가 있는 단일 레스토랑을 렌더링하는 두 번째 'order' 마이크로 앱으로 전환할 것입니다.
마이크로 프론트엔드에 대해 언급할 가치가 있는 마지막 항목은 둘 다 모든 스타일링에 styled-components
가 사용된다는 것입니다. 이 CSS-in-JS 라이브러리를 사용하면 스타일을 특정 컴포넌트와 쉽게 연관시킬 수 있으므로 마이크로 프론트엔드 앱의 스타일이 다른 컨테이너나 다른 마이크로 앱에 영향을 미치지 않습니다.
라우팅을 통한 어플리케이션 간 통신
앞서 언급한 것처럼, application간 커뮤니케이션은 최소한으로 유지되어야 합니다. 이 예에서, 유일한 요건은 ‘Browse’ 페이지 가 어떤 레스토랑을 로드해야 하는지 ‘Restaurant’ 페이지에 알려줘야 한다는 것입니다. 여기에서는 클라이언트측 라우팅을 사용하여 이 문제를 해결하는 방법을 살펴 보겠습니다.
여기에 포함된 세 개의 React 어플리케이션은 모두 선언적 라우팅을 위해 React Router를 사용하고 있지만, 두 가지의 서로다른 방법으로 초기화되고 있습니다. 컨테이너 어플리케이션의 경우, <BrowserRouter>
를 생성하여 내부적으로 history
객체를 인스턴스화합니다. 이것은 이전부터 우리가 다뤄 왔던 history
객체입니다. 우리는 이 객체를 사용하여 클라이언트 측 히스토리를 조작하고, 또한 이를 사용하여 여러 개의 React Routers를 함께 연결할 수 있습니다. 각 마이크로 어플리케이션에서는 Router를 다음과 같이 초기화합니다.
<Router history={this.props.history}>
이 경우, React Router가 다른 history object를 인스턴스화하는 대신, 컨테이너 앱이 전달한 인스턴스를 사용합니다. 이로서 모든 <Router>
인스턴스가 연결되었으므로, 어느 곳에서든 Route 변경이 일어나면 모든 곳에 반영됩니다. 이렇게 하면 URL을 통해 하나의 마이크로 프론트엔드 앱에서 다른 앱으로 "parameters"를 쉽게 전달할 수 있습니다. 예를 들어, ‘Browse’ 마이크로 프론트엔드에서 다음과 같은 링크가 있습니다.
<Link to={`/restaurant/${restaurant.id}`}>
이 링크를 클릭하면 컨테이너에서 route가 업데이트되며, route는 변경된 URL임을 확인하고 Restaurant micro app을 마운트하고 렌더링해야 한다는 결정을 내립니다. 그런 다음 micro app 자체의 라우팅 로직은 URL에서 레스토랑 ID를 추출하여 해당 정보를 렌더링합니다.
이 예제의 흐름은 별볼일없는 URL의 유연성과 힘을 보여줍니다. 공유나 북마킹에 유용한 것을 차치하고, 이 아키텍처에서 URL은 마이크로 어플리케이션 사이에서 앱 간의 의도를 전달하는 유용한 방법이 될 수 있습니다. 이 목적으로 페이지 URL을 사용할 때 몇 가지 특징이 있습니다.
- 이 구조는 잘 정의되고 개방된 표준입니다.
- 페이지의 모든 코드에 전역적으로 접근할 수 있습니다.
- 제한된 크기로 인해 적은 양의 데이터만 전송하도록 권장됩니다.
- 이러한 사용자 시점은, 도메인을 충실하게 모델링하도록 장려합니다.
- 선언적인 것이지, 필수적인 것이 아닙니다. 즉 “이 일을 해라” 가 아니라 “우리가 있는 곳”입니다.
- 마이크로 프론트엔드 앱이 서로 간접적으로 소통하게 하고, 서로를 직접 알거나 직접 의존하지 않게 합니다.
마이크로앱 간의 커뮤니케이션으로 라우팅을 사용할 때, 우리가 선택한 routes가 계약을 구성합니다. 이 경우, 식당은 /restaurant/:restaurantId
를 통해 불러올 수 있음을 정했고, 이를 참조하는 모든 어플리케이션을 업데이트하지 않고는 해당 경로를 변경할 수 없습니다. 이 계약의 중요성을 감안할 때, 우리는 계약이 준수되고 있는지 확인하는 자동화테스트를 실시해야 합니다.
공통 콘텐츠
우리 팀과 마이크로 프론트엔드가 가능한 한 독립적이 되기를 원하지만, 몇 가지 공통 사항이 있습니다. 우리는 이전에 shared component libraries 가 마이크로 프론트엔드에서 일관성을 유지하는 데 어떻게 도움이 되는지에 대해 기술했지만, 이 작은 데모에서는 컴포넌트 라이브러리가 지나치게 많이 사용될 수 있습니다. 그래서 대신 이미지, JSON 데이터 및 CSS를 비롯한 모든 엘리먼트를 포함하는 공통 콘텐츠 레파지토리를 가지고, 네트워크를 통해 모든 마이크로 프론트엔드에 제공합니다.
Micro Frontends에서 공유할 수 있는 또 다른 사항은, 라이브러리 의존성입니다. 우리는 곧 설명하겠지만, dependency의 중복은 마이크로 프론트엔드의 공통적인 단점입니다. 어플리케이션간에 이러한 dependency를 공유하는 데는 여러 가지 어려움이 따르지만, 이 데모 어플리케이션에서는 이것이 어떻게 수행될 수 있는지에 대해 이야기할만한 좋은 사례가 있습니다.
첫 번째 단계는 공유할 dependency를 선택하는 것입니다. 컴파일된 코드를 빠르게 분석한 결과, 번들의 약 50 %가 react
및 react-dom
에 의해 채워졌음을 알 수 있었습니다. 크기와 더불어 이 두 라이브러리는 가장 핵심적인 dependency므로 모든 마이크로 프론트엔드가 압축을 풀면(패키지를 품고 있지 않는다면..의 의미인 듯) 이득을 볼 수 있다는 것을 알 수 있습니다. 마지막으로, 이들은 안정적이고 성숙한 라이브러리로, 두 개의 주요 버전에 변경 사항을 반영하기 때문에 애플리케이션 전반의 업그레이드를 위한 비용이 너무 비싸서는 안됩니다.
실제 extraction의 경우, 우리가 해야 할 일은 webpack 설정에서 외부로 라이브러리를 표시하는 것 입니다. 이전에 설명한 것과 비슷한 연결을 통해 할 수 있습니다.
module.exports = (config, env) => {
config.externals = {
react: 'React',
'react-dom': 'ReactDOM'
}
return config;
};
그런 다음 각 index.html
파일에 두 개의 script
태그를 추가하여 공유 컨텐츠 서버에서 두 개의 라이브러리를 가져옵니다.
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script src="%REACT_APP_CONTENT_HOST%/react.prod-16.8.6.min.js"></script>
<script src="%REACT_APP_CONTENT_HOST%/react-dom.prod-16.8.6.min.js"></script>
</body>
팀 간에 코드를 공유하는 것은 항상 잘하기 어려운 작업입니다. 확실히 공통점이 있는 것, 그리고 여러 곳에서 동시에 변화해야 하는 것만 공유해야 합니다. 우리가 공유해야 할 것과 그렇지 않은 것을 유의해서 구분한다면, 분명 이득이 있습니다.
하부 구조
애플리케이션은 AWS에서 호스팅되며, 핵심 인프라 (S3 버킷, CloudFront 배포판, 도메인, 인증서 등)가 Terraform 코드의 센터 레파지토리를 사용하여 한꺼번에 프로비저닝됩니다. 각각의 마이크로 프론트엔드는 Travis CI (Travis CI) 의 자체 배포 파이프 라인을 갖춘 고유의 소스 레파지토리를 가지고 있습니다. Travis CI 는 static assets을 빌드하여 테스트하고 S3 버킷에 배포합니다. 이는 중앙집중식 인프라 관리의 편리함과 독립적인 구축 가능성의 균형을 맞춥니다.
각 마이크로 프론트엔드 (및 컨테이너)는 고유한 버킷을 가져옵니다. 이것은 버킷에 들어가는것들에 대해 자유롭게 컨트롤할 수 있다는 것을 의미하며, 다른 팀이나 애플리케이션에서 객체 이름의 충돌이나 액세스 관리 규칙의 충돌에 대해 걱정할 필요가 없습니다.