Micro Frontends, 글로 배워보자구요.
마이크로 프론트앤드는 마이크로 서비스의 개념을 프론트엔드 세계로 확장한 개념이다.
마이크로 서비스처럼 전체 화면을 작동할 수 있는 단위로 나누어 개발한 후 서로 조립하는 방식이다.
Main Concept
기술 독립성
- 각 작동 단위들은 기술적으로 독립적일 수 있어야 한다.
- 각 작동 단위에 사용된 프론트앤드 기술(React, Vue, Vanilla JS 등)에 상관 없이 조합이 가능해야 한다.
컨텍스트 독립성
- 각 작동 단위들이 같은 프레임워크를 사용하더라도, 컨텍스트를 공유해선 안된다.
- 독립적인 애플리케이션을 자체적으로 구축해야 하고, 상태 공유나 전역 변수에 의존해서는 안된다.
네임스페이스를 활용한 분리
- 각 작동 단위의 격리가 불가능한 경우, 네이밍 컨벤션에 따라 prefix 등으로 네임스페이스를 활용한다.
- CSS, 로컬 스토리지, 이벤트, 쿠키에 네임스페이스를 부여하여 충돌을 방지하고 명확히 분리한다.
통신 시스템에 기본 브라우저 기능 활용
- 작동 단위 간의 통신을 위한 시스템을 자체 구축하는 것보다, 브라우저 이벤트를 사용한다.
- 만약 정말로 작동 단위 간 커스텀 API가 필요한 경우, 가능한 한 간단하게 유지한다.
탄력적인 웹 디자인 구축
- 자바스크립트에서 에러가 나거나 실행할 수 없더라도, 기능은 사용 가능해야 한다.
- 범용 렌더링(Universal Rendering)과 점진적 향상(Progressive Enhancement)을 통해 성능을 향상시킬 수 있다.
- Universal Rendering
- Progressive Enhancement
장점
- 작고, 응집력 있고 유지보수에 용이한 코드베이스를 가질 수 있다. 따라서 디커플링이라는 소프트웨어 개발 목표를 달성한다.
- 각 마이크로 프런트 엔드는 고유한 기술 및 프레임 워크를 선택할 수 있다. 독립적으로 구현, 테스트, 업그레이드, 업데이트 및 배포 할 수 있어, 팀에 유연성을 제공한다.
- 프론트앤드 개발을 점진적 업그레이드 또는 재작성이 수월해진다.
- 마이크로 프런트 엔드는 수직 팀을 장려한다. 수직 팀에는 일반적으로 기능 소유자, UX 디자이너, 제품 관리자, 백엔드 개발자, 프런트 엔드 개발자 및 품질 보증 엔지니어가 포함된다.
단점
- 마이크로 프런트 엔드는 분리되거나 느슨하게 결합 된 구성 요소를 위해 설계되었다. 그들 사이에 너무 많은 종속성을 넣으려고하면 디버깅 악몽이 발생할 수 있다.
- 마이크로 프런트 엔드를 가능하게하는 파이프라인으로 인해 복잡도가 증가한다. 외부로드 문제를 해결하려면 기술적 전문 지식이 필요하며 디버깅 프로세스에는 시간이 많이 걸린다. 또한 SSO (Single Sign-On), 글로벌 CSS 등과 관련된 문제에 직면 할 수 있다.
- 각 마이크로 프런트 엔드에는 중복 된 코드 또는 기능이 있을 수 있다. 예를 들어, React 라이브러리는 각 마이크로 프런트 엔드에 포함될 수 있어, 번들 크기와 메모리 소비를 증가시킨다.
- 런타임 시 마이크로 프런트 엔드를 동적 또는 지연로드하는 데 추가 시간이 걸린다.
- 사용자 인터페이스는 여러 팀에서 설계되었으므로 UX 설계는 마이크로 프런트 엔드에서 일관되지 않을 수 있다.
Micro Frontend 통합 방법
마이크로 앱들을 개발 후, 어떻게 통합할지 고려해야 한다.
여러 방식이 있는데, 공통적으로 존재하는 아키텍처가 있다.
일반적으로 마이크로 애플리케이션들이 있고(작동 단위), 그것들을 구성하는 단일 컨테이너 애플리케이션이 있다.
단일 컨테이너 애플리케이션은 다음과 같은 것들을 한다.
- 공통 페이지 요소를 렌더링한다.
- 인증 및 탐색과 같은 공통적으로 고려되어야 하는 문제들을 해결한다.
- 다양한 마이크로 앱들을 페이지에 모으고, 각 앱들에게 언제 어디서 렌더링할지 알려준다.
서버 사이드 템플릿 통합
각 서버로 html 템플릿을 요청하고, 최종 응답서버에서 각 템플릿을 조합해서 응답을 보낸다. 서버측에서 최종 화면을 조합한다. (SSR 에 적용)
코드로 살펴보자.
아래와 같이 범용적인 페이지 요소들을 포함하는 index.html 파일을 만든다. 이 파일에는 HTML fragment 파일들이 서버사이드에서 포함되어진다(넣어진다).
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Feed me</title>
</head>
<body>
<h1>🍽 Feed me</h1>
<!--# include file="$PAGE.html" -->
</body>
</html>
Nginx를 사용하여 아래 파일을 제공하고, 요청되는 URL이 $PAGE 변수에 대입되도록 구성한다.
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
ssi on;
# Redirect / to /browse
rewrite ^/$ http://localhost:8080/browse redirect;
# Decide which HTML fragment to insert based on the URL
location /browse {
set $PAGE 'browse';
}
location /order {
set $PAGE 'order';
}
location /profile {
set $PAGE 'profile'
}
# All locations should render through index.html
error_page 404 /index.html;
}
위와 같은 방식은 상당히 표준적인 서버측 구성 방식이다.
여기엔 어떻게 여러 개의 HTML 파일들이 웹 서버에 배치되는지는 나와있지 않지만, 각 파일들은 다른 페이지들을 고려하거나 다른 페이지들에게 영향을 미치지 않는다고 가정한다. 또한, 각각이 독립적인 배포 파이프라인을 가진다고 가정한다.
독립성을 높이기 위해, 각 마이크로 앱들을 렌더링하고 서비스하는 별도의 서버가 있을 수 있으며, 한 서버는 한 마이크로 앱에 요청을 한다. 또한 응답 캐싱을 통해 지연 시간에 영향을 주지 않고 작업을 수행하도록 할 수 있다.
빌드타임 통합
마이크로 프론트엔드를 패키지로 배포하고, 컨테이너 앱이 그것들을 라이브러리 종속성(library dependencies)으로 포함하도록 하는 것이다.
코드로 살펴보면, 아래와 같은 package.json이 작성될 수 있다.
{
"name": "@feed-me/container",
"version": "1.0.0",
"description": "A food delivery web app",
"dependencies": {
"@feed-me/browse-restaurants": "^1.2.3",
"@feed-me/order-food": "^4.5.6",
"@feed-me/user-profile": "^7.8.9"
}
}
이 방식은 배포 가능한 단일 Javascript 번들을 생성한다. 그리고 다양한 공통 종속성을 제거할 수 있다.
하지만 이 접근 방식은 각각의 마이크로 앱에 변경사항이 있을 때마다, 그 변경사항을 반영한 단일 번들을 만들기 위해 다시 컴파일하고 릴리즈해야 한다.
애플리케이션을 개별 코드베이스로 나누는 많은 문제들을 해결해놓고, 다시 릴리즈 단계에서 커플링하는 방식은 제외하자. 마이크로 프론트엔드를 빌드타임이 아닌 런타임에 통합하는 방법을 찾아야 한다.
iframe을 통한 런타임 통합
전통적인 방식이면서 가장 쉬운 방식이다.
iframe을 사용하면 각 마이크로 페이지들을 독립적인 하위 페이지로 쉽게 만들 수 있다. 스타일과 글로벌 변수가 서로 간섭하지 않는다는 측면에서 상당한 수준의 고립성을 제공한다.
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<iframe id="micro-frontend-container"></iframe>
<script type="text/javascript">
const microFrontendsByRoute = {
'/': 'https://browse.example.com/index.html',
'/order-food': 'https://order.example.com/index.html',
'/user-profile': 'https://profile.example.com/index.html',
};
const iframe = document.getElementById('micro-frontend-container');
iframe.src = microFrontendsByRoute[window.location.pathname];
</script>
</body>
</html>
새로운 기술이 아니라서 흥미롭지 않을 수 있지만, 마이크로 프론트엔드의 이점을 상기해본다면, 애플리케이션 분할과 팀을 구조화하는 데에 신경쓴다면, 이 방식이 가장 적합하다.
Iframe 방식을 선택하는 것을 꺼리는 경우가 종종 있는데, 사실 그런 데에는 타당한 이유가 있다. 이 방식을 통한 분리는 쉽지만, 다른 옵션들보다 유연성이 떨어지는 경향이 있다.
이 방식은 어플리케이션의 서로 다른 부분들을 통합 구축하는 것이 어렵다. 따라서 라우팅, 히스토리, deep linking이 더욱 복잡해지고, 반응형 페이지 개발에도 추가적인 어려움들이 따른다.
UX가 iframe안에 갇히기 때문에 어색한 UI 표현을 가질 수 있다.
Javascript를 통한 런타임 통합
iframe과 달리 유연한 통합이 가능하다. 현실적으로 가장 많이 사용하는 방식이다.
각 마이크로 앱들은 <script> 태그를 통해 페이지에 포함된다.
컨테이너 애플리케이션에서는 마이크로 앱 번들을 <script> 태그를 통합 다운로드 받고, 약속된 초기화 메소드를 호출한다.
컨테이너 앱에서는 mount 되어야 할 마이크로 앱을 결정하고, 마이크로 앱들에게 언제 어디에 렌더링할지 알려주기 위한 함수를 호출한다.
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<!-- 이 스크립트는 즉시 무언가를 렌더링 하지 않는다. -->
<!-- 대신, window에 entry-point 함수를 추가한다. -->
<script src="https://browse.example.com/bundle.js"></script>
<script src="https://order.example.com/bundle.js"></script>
<script src="https://profile.example.com/bundle.js"></script>
<div id="micro-frontend-root"></div>
<script type="text/javascript">
// 이 전역 함수들은 위의 script들에서 window에 추가된 것들이다.
const microFrontendsByRoute = {
'/': window.renderBrowseRestaurants,
'/order-food': window.renderOrderFood,
'/user-profile': window.renderUserProfile,
};
const renderFunction = microFrontendsByRoute[window.location.pathname];
/*
entry-point 함수를 선언한 후, 그것들을 호출한다.
엘리먼트의 id를 넘겨서 어디에 렌더링할지를 알려준다.
*/
renderFunction('micro-frontend-root');
</script>
</body>
</html>
위 예시는 굉장히 기초적인 예시지만, 그래도 기본적인 기술을 보여준다.
빌드타임 통합과는 달리, 각 번들.js 파일들을 독립적으로 배치할 수 있고, iframe 통합과는 달리, 원하는대로 마이크로 앱들 간의 통합이 가능하도록 유연성을 제공한다. 필요한 경우에만 번들을 다운로드하거나, 마이크로 앱이 렌더링될 때 데이터를 주고받는 등 다양한 방법으로 코드를 확장할 수 있기 때문이다.
이처럼, 이 접근 방식은 유연성과 독립적 구현 가능성이 결합되어 자주 채택된다.
Web Components를 통한 런타임 통합
이 방식은 자바스크립트를 통한 런타임 통합 방식에서 한 가지만 변형이 있는 방식이다.
각 마이크로 앱이 호출할 컨테이너의 전역 함수를 정의하여, 상위에서 하위 앱들의 전역 함수를 호출하는 방식이 아니다.
대신, 컨테이너 앱에서 인스턴스화할 HTML Custom Element를 정의하는 방식이다.
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<!-- 이 스크립트는 즉시 무언가를 렌더링 하지 않는다. -->
<!-- 대신,이 스크립트를 통해 custom element들이 정의된다. -->
<script src="https://browse.example.com/bundle.js"></script>
<script src="https://order.example.com/bundle.js"></script>
<script src="https://profile.example.com/bundle.js"></script>
<div id="micro-frontend-root"></div>
<script type="text/javascript">
// 이 엘리먼트 타입들은 위의 스크립트들을 통해서 정의되었다.
const webComponentsByRoute = {
'/': 'micro-frontend-browse-restaurants',
'/order-food': 'micro-frontend-order-food',
'/user-profile': 'micro-frontend-user-profile',
};
const webComponentType = webComponentsByRoute[window.location.pathname];
/*
적합한 웹 컴포넌트 custom element 타입을 선언한 후, 인스턴스를 생성하여 document에 추가한다.
*/
const root = document.getElementById('micro-frontend-root');
const webComponent = document.createElement(webComponentType);
root.appendChild(webComponent);
</script>
</body>
</html>
최종 결과는 이전 방식과 매우 유사하다.
주요 차이점은 '웹 컴포넌트 방식'을 선택한다는 것이다.
웹 컴포넌트 스펙을 좋아하고, 브라우저가 제공하는 기능을 사용하는 것을 좋아한다면 이 옵션이 적합하다.
컨테이너 앱과 마이크로 앱들 사이에 고유한 인터페이스를 정의하여 사용하려면, 이전 방식이 적합하다.
Mirco Frontend 통합 고려사항
- 스타일링
- BEM과 같은 엄격한 네이밍 규칙 사용
- SASS와 같은 전처리기를 통해 상위 선택자 추가 등을 통해 독립성 유지
- 모든 스타일을 CSS-in-JS 방식으로 처리
- Shadow DOM: 메인 document DOM 으로부터 독립적으로 렌더링 되는 캡슐화된 Shadow DOM 트리를 엘리먼트에 추가하고, 연관된 기능을 제어하기 위한 JavaScript API 의 집합. 이 방법으로 엘리먼트의 기능을 프라이빗하게 유지할 수 있어, 다큐먼트의 다른 부분과의 충돌에 대한 걱정 없이 스크립트와 스타일을 작성할 수 있다.
- component library 공유
- 마이크로 앱들 간의 시각적 일관성을 유지하기 위해 재사용 가능한 UI 컴포넌트 라이브러리를 개발하여 공유한다. 코드 재사용성과 시각적 일관성을 유지할 수 있다.
- 너무 일찍 만드는 것은 좋지 않다. 실제로 사용하기 전에 컴포넌트의 API가 무엇이어야 하는지 추측하기는 쉽지 않다. 그러므로 많은 변동이 발생하게 되므로, 초기에는 일부 중복이 발생하더라도 각 팀이(각 마이크로 앱 내에서) 코드베이스에서 필요에 따라 자체 컴포넌트를 만들어두는 것이 좋다. 패턴이 자연스럽게 나타나도록 허용하고, 컴포넌트의 API가 명확해지면 그 때 만드는 것을 권장한다.
- 애플리케이션 간 커뮤니케이션
- 가능한 적은 소통을 하는 것이 좋다.
- Custom events를 사용하면 마이크로 앱들이 간접적으로 통신할 수 있다. 이는 직접 결합을 최소화하는 좋은 방법이지만, 마이크로 앱들 간 약속을 정하고 구현해야 하므로 더 어려울 수 있다.
- 콜백과 데이터를 아래쪽으로 전달하는 React 모델로 약속을 명확하게 정하는 것도 좋은 솔루션이다.
- URL 표시 줄을 통신 메커니즘으로 사용하는 것도 좋은 방법이다.
- 어떤 방식을 선택하든, 서로 메시지 또는 이벤트를 전송하여 통신하는 것이 좋으며, 공유 상태를 갖는 것은 좋은 선택지가 아니다. 마이크로 서비스에서 데이터베이스를 공유하는 것이 좋지 않은 것처럼, 데이터 구조와 도메인 모델을 공유하자마자 엄청난 양의 결합이 생기게 되고, 변경에 많은 제약이 따르기 때문이다.
- vuex는 일반적으로 하나의 응용 프로그램에 대해 단일 전역 공유 저장소를 기본으로 한다. 그러나 각 마이크로 앱들이 자체 저장소를 갖는 것이 더 합리적이다.
- 백엔드 통신
- BFF(Backend for Frontend Pattern) 패턴으로 프론트앤드 전용 API를 갖는다. 각 마이크로 앱들은 해당 프론트 엔드의 요구사항만 충족하는 목적을 가진 백엔드만을 가진다.
- 별도의 데이터베이스를 가질 수도 있다.
- 로그인 인증 정보는 통합하는 Container가 소유한다. 초기화 시 인증을 통해 얻은 토큰을 각 마이크로 프론트엔드에 주입되며, 마이크로 프론트엔드는 서버에 대한 요청과 함께 토큰을 보내고, 해당 마이크로 앱의 백엔드에서는 필요한 모든 유효성 검사를 수행하는 방식으로 구성할 수 있다.
통합 테스팅
모놀리틱과 마이크로 방식은 테스팅에서 모두 동일하지만 한 가지 명확한 차이는, 다양한 마이크로 프론트엔드와 컨테이너 애플리케이션의 통합 테스트이다. 이 작업은 기능 테스트나 e2e 테스트 도구(selenium, cypress 등)를 사용하여 수행할 수 있으나, 그렇게 멀리 갈 것 까진 없다.
통합 기능 테스트는 low level에서 다룰 수 없는 테스트만 다루면 된다. low level 에서는 비지니스 로직, 렌더링 로직 정도를 커버하는 수준의 단위 테스트를 수행하고, 통합 기능 테스트에서는 각 페이지들이 잘 조합되었는지만 확인하면 된다. 예를 들면, 특정 URL 에 완전히 통합된 애플리케이션이 로드되었는지 확인하기 위해, 마이크로 프론트엔드의 하드코딩 된 특정 텍스트가 페이지에 존재하는지를 assert 하는 것이 될 수 있다.
References
- https://mobicon.tistory.com/572
- https://micro-frontends.org/
- https://ichi.pro/ko/maikeulo-peuleonteu-endeu-jeobgeun-bangsig-eul-wihan-10-gaji-gyeoljeong-sahang-5410395627328
- https://www.xenonstack.com/insights/micro-frontend-architecture
- https://soobing.github.io/micro-frontends/
- https://martinfowler.com/articles/micro-frontends.html