사툰사툰

쌩 npm으로 MFE 구축하기 (1) : 개념
FRONTEND

쌩 npm으로 MFE 구축하기 (1) : 개념

모노레포? MFE? MFA? 그게 도대체 뭘까요? 모던 프론트엔드 프로젝트 구성 방법을 차근차근 짚어봤습니다.

2023. 7. 5.

1210

현재 인턴을 통해 실제 서비스를 다루며 뜻깊은 경험을 쌓고 있다. 확실히 학생 때 진행한 프로젝트보다 규모가 커지면서 다룰 사항이 많아졌다. 최근 여러 컨퍼런스를 보면 왜 아키텍쳐 주제를 자주 다루었는지 이제야 좀 체감이 된다 😂

현재 다니고 있는 회사는 어드민 페이지, 실제 서비스 페이지 그리고 테스트 페이지 각각 개별 레포지토리로 다르게 두어 배포하고 있다. 아마 이게 흔히 보던 멀티레포 방식일 것이다.

image

이번 방학 때 학교 친구들과 토이 프로젝트를 개발하면서 이 멀티레포를 개선하고 싶었다. 그래서 요새 화두되고 있는 모노레포Micro-Frontend 방식을 도입해보기로 했다.

모노레포? Micro-Frontend? 그게 뭐죠?

처음에 이 키워드를 찾아봤을 땐 두 키워드가 같은 용어라고 생각했다. 단순히 멀티레포의 단점을 보완하기 위한 아키텍쳐라고 인식했기 때문이다. 차근차근 관련 개념을 파헤쳐보자.

image

모노리스

가장 처음에 접하기 쉬운 단일 아키텍쳐 방식이다. 각 어플리케이션은 팀마다 별도의 레포지토리를 가지며, 각 레포지토리는 단일 빌드 파이프라인을 구축한다. 레포지토리 내에선 View 제작, Controller 비즈니스 로직 설계, 데이터 다루기 등 모든 것을 해결한다.

이 방식은 관심사 분리가 어렵기 때문에, 다양한 서비스를 개발할 시 모든 서비스에게 서로 의존성이 생긴다. 서비스가 커질수록 개발과 배포 단위는 점점 복잡해진다. 하나의 서비스를 개발하더라도 그 안의 독자적인 기능들이 관리되지 않는다면 무척 번거로워진다.

image

멀티레포(폴리레포)

각 어플리케이션마다 고유한 레포지토리를 나누는 방식이다. 개별적으로 개발할 수 있으며 CI/CD, 테스트, Lint 등의 작업을 독립적으로 수행한다. 이렇게 개발한다면, 모듈화를 통해 개별로 관리할 수 있으므로 모노리스 방식의 단점을 보완할 수 있다.

하지만, 이 방식엔 몇 가지 문제점이 존재한다.

번거로운 코드 공유

모노리스 방식에선 쉽게 할 수 있는 코드 공유는 이제 어려워졌다. 멀티레포에서 코드를 공유하려면 코드 공유용 레포지토리를 따로 생성하고 이를 공유할 수 있도록 비즈니스 로직을 설계해야 한다.

공유 레포지토리 관리 비용 증가

단순하게 생각해서 코드 공유용 레포지토리를 하나 만들고 관리하면 되지 않나 싶었다. 하지만 오만한 생각이었다. 우선 개별적으로 레포지토리를 구성한다면, 테스트, 빌드 및 배포를 개별적으로 관리하므로 관리 방법을 다 다르게 익히고 있어야 한다. 또한 공통으로 사용되는 유틸 함수나 기능이 있을 경우 서로 공유되지 않는다면 중복해서 구현해야 하고 유지보수하기가 무척 힘들다.

그리고 특정 레포지토리에서 버그나 호환문제가 발생할 경우 어느 부분에서 에러가 발생하는지 개별적으로 다른 레포지토리의 히스토리와 배포된 버전을 조정하여 (상상도 하기 싫을 정도로) 샅샅이 살펴보아야 한다.

모노리스와 모노레포의 장점을 다 갖고 싶다!

관심사 분리모듈화를 적절하게 조합한 설계는 정말 바람직하다. 그래서 요새 모노레포 방식이 디자인 패턴 중에서 뜨거운 감자다.

image

모노레포

이 방식은 하나의 root app 내에서 여러 어플리케이션이 단일 레포지토리에서 관리되는 방식이다. 단, 모노리스와 달리 각 App 별로 모듈화가 되어 있으며 공유 라이브러리를 제외하면 서로 관심사 분리가 되어 독자적으로 기능 개발을 한다.

단일화된 개발 환경

멀티레포와 달리 단일 레포지토리 내에서 각 기능별로 함께 동작하며 어플리케이션이 추가되어도 기존 배포 및 빌드 과정을 따르므로 크게 수정할 필요가 없다. 간단한 예로, 프로젝트를 nginx로 배포한다면 각 App을 포트포워딩하여 쉽게 기능별로 배포할 수 있다.

또한, 단일화된 레포지토리 내에서 변경 사항을 쉽게 알 수 있다. 서로 다른 기능이 변경 사항으로 영향을 받을 때 쉽게 업데이트가 가능하다.

쉬운 공유 모듈 관리

또한, 각 기능별로 독립적인 개발 및 빌드 환경을 구성하여 의존성 관리를 쉽게 한다. 따로 관리하기 때문에 리팩토링도 쉽다. 수백 개의 프로젝트에서 몇 개의 공통 컴포넌트를 사용한다면 변경 사항이 있을 때 훨씬 반영이 빠르다.

그렇다면 모노레포가 갑이다?

전혀 아니다. 당연히 모노레포를 도입하기 위해선 적지 않은 비용이 소요되고, 아무리 모듈화를 할지라도 코드베이스가 증가하며 복잡성이 훨씬 높아진다. 어플리케이션마다 일관된 프로세스가 필요 없다면, 모노레포는 오히려 독이다. 변경 사항이 있을 때마다 PR과 Merge의 과정을 거쳐야 하는데 이는 App마다 다른 배포 주기를 전혀 고려하지 못한다.

제일 중요한 문제가 바로 통합된 CI/CD이다.

image

Micro-frontend, UI 단위로 쪼개자!

그렇다면 Micro Frontend는 무엇일까? 이는 MSA 패턴을 프론트엔드로 확장한 개념이라고 보면 된다. 마이크로 서비스처럼 단위로 화면을 쪼개어 개발한 후 서로 조립하는 방식인데 보통 ‘UI 컴포넌트’를 기준으로 잡는다.

처음 정보를 찾아봤을 땐 MFA, MFE 두 단어로 나누어 언급하길래 서로 다른가 싶었다. 하지만 전혀 그렇지 않았다 😂. 아마 MSA와 비슷하게 출발해서 MFA가 나왔다고 생각한다. 필자는 근래 더 많이 쓰는 MFE로 칭하겠다.

이 방식은 모노레포와 멀티레포 환경 양쪽에서 적용할 수 있는 방법이다. 다만 멀티레포 환경에서 진행할 경우 eslintdependencies가 공유되지 않기 때문에 통합하는 과정에서 많은 비용이 발생한다. 그러므로 모노레포 환경에서 진행하는 걸 추천한다.

MFE를 설계하는 여러 방법들

Micro-Frontend는 정형화된 방식은 아니지만, 몇 가지 제안이 있다. 다음 5가지 방식을 살펴보자.

  1. Build-time integration
  2. Server-side template composition
  3. Run-time integration via iframes
  4. Run-time integration via Web Components
  5. Run-time integration via JavaScript

npm을 통한 Build-time 통합

npm Build-time 통합 예시
{
  "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"
  }
}

npm을 통한 Build-time 통합은 Build 단계에서 dependency를 통해 컴포넌트를 모듈화하고 관리한다. 코드 분리와 상태 관리는 쉽지만 서비스 간 커뮤니케이션 비용이 절대적으로 크다.

npm publish를 통해 따로 CI/CD를 구축하며 dependency의 변동 사항을 쉽게 알아차려야 한다. npm-check-updates 패키지를 통해 dependency를 최신화 할 수 있으나 문제는 에러 발생했을 때다. 서로 패키지가 어디서 에러가 나는지 찾으려면 각 모듈의 버전을 일일이 확인해야 한다.

가장 중요한 건 모든 기능을 세분화해도 다시 dependency를 체크하고 build해야 한다. 이 방법을 제안한 글쓴이도 권장하지 않는 방법이라고 단언한다.

Server-side-Template Routing

Server-side-Template Routing 예시

index.html

<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

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;
}

이 방법은 index.html 에서 만들어진 템플릿을 따로 불러와 nginx 에서 라우팅하는 방법이다. 생각보다 마이크로서비스 단위로 잘게 쪼개는 건 그리 어렵지 않음을 보여준다. 그러나 기존 멀티레포에서 기능을 라우팅 시켜주는 것과 별반 다르지 않다. 결정적으로 페이지만 라우팅할 뿐 UI 컴포넌트로 잘게 쪼갤 수 없는 방법이다.

iframe

iframe 방식이 가장 깔끔하고 무난하지만, iframe injection과 같은 보안 문제와 전역 상태 관리에서 이슈가 발생한다. iframe 내부에서 변경된 상태를 host 페이지와 동기화하려면 별도의 메커니즘을 구현해야 한다. 일반적으로 Web API를 활용하는 것이 좋다. 주로 customEvent를 만들어 EventListener 혹은 dispatch로 state를 관리하면 좋으나 실제 DOM을 조작하므로 바람직하진 않다.

단, 상태 관리를 제외하곤 단순히 View 역할만 한다면 충분히 사용할 수 있는 방법이라고 생각한다.

Web-Component Run-time 통합

Web-Component 통합 예시
<html>
  <head>
    <title>Feed me!</title>
  </head>
  <body>
    <h1>Welcome to Feed me!</h1>

<!-- These scripts don't render anything immediately --><!-- Instead they each define a custom element type --><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">
// These element types are defined by the above scriptsconst webComponentsByRoute = {
        '/': 'micro-frontend-browse-restaurants',
        '/order-food': 'micro-frontend-order-food',
        '/user-profile': 'micro-frontend-user-profile',
      };
const webComponentType = webComponentsByRoute[window.location.pathname];

// Having determined the right web component custom element type,// we now create an instance of it and attach it to the documentconst root = document.getElementById('micro-frontend-root');
const webComponent = document.createElement(webComponentType);
      root.appendChild(webComponent);
    </script>
  </body>
</html>

Web API로 이미 예전에 나온 Web Component를 도입하여 Run-time 때 통합하는 방식이다. 이 또한 마찬가지로 iframe 과 같은 문제에 직면한다. 상태 관리는 여전히 쉽지 않고, Web Component는 여전히 DOM에 의존해야 한다.

가장 효과적인 JS, CSS Run-time 통합

JS, CSS Run-time Rendering 방식이 가장 널리 사용되며 유용하다. 앞서 지적했던 상태 관리 문제를 해결하며 이 방식의 핵심은 코드를 유연하게 통합하는 원리다. 호스트 모듈에서 원격 모듈의 코드를 동적으로 불러온다.

이 방안은 Webpack5의 Module Federation로 해결하는데 Vite에서도 가능하다. 호스트 모듈에서 remoteEntry.js 라는 build된 파일을 원격 모듈에서 읽어온다. 이 파일은 원격 모듈에서 Expose한 특정 컴포넌트나 페이지의 코드를 불러온다.

image

Module Federation ≠ Micro-frontend

흔한 오해는 Micro-FrontendModule Federation이라고 생각하지만 아니다. 앞서 살펴봤던 방법으로 알 수 있듯이 Micro-frontend는 하나의 아키텍처이다. 반면에 Module Federation은 코드를 옮겨주는 작업을 수행하는 도구이다. 즉, Micro-frontend이란 UI 관리 패턴에서 Module Federation이란 도구를 주로 사용하는 것뿐이다.

MFE도 역시 갑은 아니다

우선 (당연히) 일반적인 방법보다 복잡하다. 호스트 모듈에서 remoteEntry.js를 이용하여 각 서비스 별로 참조해야 하는데 그러면 결국 로컬에서 테스트할 때 모든 서비스를 Serving해야 한다. 또한, 하위 Service에서 상위 Service를 참조할 때 확인할 방법이 없다.

서비스 단위로 쪼개어 배포하므로 주로 Docker를 이용하여 배포하면 간편하다. 이럴 때 CORS 이슈를 맞닥뜨릴 수도 있어 주의해야 한다. 게다가 remoteEntry.js는 build된 파일이다. react-developement-server처럼 즉각적으로 반응을 확인할 수 없다. 해당 서비스를 다시 배포 및 빌드하여 확인해야 한다. 만약 특정 Service가 단순한 컴포넌트라면 일반적인 컴포넌트보다 훨씬 무겁고 복잡할 수 있다.

캐싱 문제도 생각해야 하는데 HTML 캐시를 각 모듈별로 어떻게 관리해야 할지 방법을 고려해야 한다. 요청을 할 때 동시성 제어 문제가 발생할 수 있다.

도르마무! 이건 아니야! 도르마무! 번거로워!

아키텍처는 상황과 목적에 맞게

지금까지 모던 프론트엔드를 위한 프로젝트 구성 방법을 살펴보았다. 정말 꼬리에 꼬리를 물고 단점을 보완해나가지만 아직까진 완벽한 아키텍처는 없다.

결론적으로 어떤 프로젝트를 구성하고, 무엇이 필요한 지에 따라 적절한 아키텍처를 선택하면 된다. 단순 프로젝트에선 크게 고민할 것까진 없지만, 확실히 서비스가 커질 때 마주칠 수 있는 문제다. 이에 대해 여러 해결 방식을 알고 있으면 정말 좋다고 생각한다. 특히, 서비스 개발을 할 때 프로젝트마다 자유자재로 배포를 한다면 개발 속도가 훨씬 상승한다고 생각한다.

이제 개념도 짚어 봤으니 실제로 프로젝트를 구성해보자.

다음 포스팅에선 npm workspacemonorepo 구축 삽질을 담아보겠다 😁

참고

모던 프론트엔드 프로젝트 구성 기법 - 모노레포 개념 편 (naver.com)

https://if.kakao.com/2022/session/74

SSR환경에서의 Micro-Frontend 구현과 퍼포먼스 향상을 위한 캐시전략 (naver.com)

[소프트웨어 아키텍처 패턴] 모놀리스(Monolithic)와 마이크로서비스 아키텍처(Microservice Architecture) | 6mini.log

런타임 통합 Micro Frontends 아키텍처가 주는 가치 (maxkim-j.github.io)

Monolithic application - Wikipedia

Monorepo Explained

Micro Frontend Architecture and Best Practices (xenonstack.com)

Monorepos and its flavors. Package-based VS Integrated | by Harsh Verma | Medium

Micro Frontends (martinfowler.com)

웹 컴포넌트가 프론트엔드 프레임워크를 대신할까? | 요즘IT (wishket.com)

#모노레포#npm#yarn#workspace#MFA#MFE