마이크로 프론트엔드

마이크로 프론트엔드의 개념과 내가 겪은 사례들을 소개한다.

개념

일반적으로 마이크로 프론트엔드란, 전체 화면을 작동할 수 있는 단위로 나누어 개발한 후 조립하는 방식을 말한다. 보통 페이지 전체를 감싸는 호스트(Host) 앱이 있고, 그 안에 영역별로 리모트(Remote) 앱이 있는 구조로 구성된다. 그렇게 하면 호스트 앱만 리모트 앱을 불러올 수 있는 단순한 구조를 유지할 수 있다.

한 페이지 내에서 여러 개의 작은 앱으로 나누어 개발/배포하기 때문에 코드 베이스가 분리되어 관리하기 쉬워지는 점이 있고, 점진적인 업그레이드, 독립적인 배포도 장점으로 여겨진다.

단점으로는 번들 크기가 커질 수 있고, 여러 앱을 통합하는 과정이 필요하기 때문에 복잡도가 올라가는 점이 있다.

각 앱을 개발하고 한 페이지로 통합하는 과정이 중요한 부분인데, 여러 가지 방법이 있다.

  • 서버 템플릿 통합
  • 빌드타임 통합
  • 런타임 통합

사례

사례 1. 새로운 버전의 Vue 앱으로 마이그레이션

기존의 Vue2로 작성된 앱을 Vue3로 점진적으로 마이그레이션하면서 마이크로 프론트엔드를 적용했다. 메뉴가 여러 개 있고 각 메뉴마다 컨텐츠 영역만 바뀌는 서비스였기 때문에, 모든 페이지에서 공통적으로 사용되는 사이드바와 헤더 영역을 호스트 앱에서 렌더링하도록 하고 컨텐츠 영역을 리모트 앱으로 작성하였다.

런타임 통합 방식으로 개발했고, 리모트 앱의 빌드 결과물을 S3에 업로드하고 Cloudfront로 서빙하는 방식을 채택했다.

리모트 앱의 빌드 결과물에 대한 정보를 manifest 파일에 저장하여 호스트 앱에서는 manifest 파일만 읽으면 다운로드할 리소스를 알 수 있게 했다.

{
  "main.js": "https://remote.cloudfront.net/b1e83f586850a7a615e6.js",
  "650.css": "https://remote.cloudfront.net/650.28bd2c050cea3f0ec082.css",
  "js": "https://remote.cloudfront.net/1977f4864b2689cc2164.js",
  "941.css": "https://remote.cloudfront.net/941.51686b83024c9e47ab91.css",
  "index.html": "https://remote.cloudfront.net/index.html",
  "js.map": "https://remote.cloudfront.net/1977f4864b2689cc2164.js.map",
  "650.css.map": "https://remote.cloudfront.net/650.28bd2c050cea3f0ec082.css.map",
  "941.css.map": "https://remote.cloudfront.net/941.51686b83024c9e47ab91.css.map"
}
json

호스트 앱은 런타임에 리모트 앱의 리소스를 다운로드받아서 <head>에 넣어서 마운트 시킨다.

런타임 통합을 했기 때문에 배포 단위가 나눠졌고, 그래서 배포 및 롤백이 쉽다.

사례 2. 프레임워크 전환 중 공통 컴포넌트 개발

서비스를 앵귤러에서 리액트로 전환하는 중인데, 점진적으로 전환하다보니 일부 페이지는 앵귤러로 개발돼있고 다른 페이지는 리액트로 개발된 상황이었다. 전환하는 기간이 길어지다보니 앵귤러/리액트 두 개의 앱에서 공통적으로 사용하는 컴포넌트를 두 번 작성해야 하는 불편함이 커졌다.

그래서 공통 컴포넌트를 리액트로 작성하고, 마이크로 프론엔드를 적용해 해당 컴포넌트를 앵귤러 페이지에서도 사용할 수 있게 만들었다.

통합 방식은 빌드타임 통합을 선택했는데, 공통 컴포넌트를 패키지로 분리하고 github packages에 private package로 배포했다. 그리고 앵귤러 앱에서 npm으로 해당 패키지를 설치해서 사용하는 식으로 통합했다.

공용 컴포넌트를 단독으로 사용할 수 있도록 컨테이너를 추가했다. 컨테이너에서는 필요한 Provider와 로직을 추가한다. 예를 들어 <Header> 의 컨테이너의 다음과 같다.

const HeaderContainer = () => {
  const [queryClient] = useState(() => new QueryClient())

  const handleFireUserEvent = () => { ... }

  return (
    <div className="react-component">
      <QueryClientProvider client={queryClient}>
        <Header onFireUserEvent={handleFireUserEvent} />
      </QueryClientProvider>
    </div>
  )
}
tsx

그리고 앵귤러 앱에서 마운트/언마운트 할 수 있도록 API를 제공하는 객체를 추가한다.

export const header = {
  root: null as ReactDOM.Root | null,
  mount: (id: string) => {
    const rootEl = document.getElementById(id)

    if (!rootEl) {
      throw new Error(`Header - root element not found. id is ${id}`)
    }

    header.root = ReactDOM.createRoot(rootEl)
    header.root.render(<HeaderContainer />)
  },
  unmount: () => {
    header.root?.unmount()
  },
}
tsx

마지막으로, 앵귤러에서 리모트 앱을 사용할 때에는 마운트 될 DOM 요소의 id를 전달해서 렌더링 될 위치를 지정한다.

import { header } from '@my/components'

header.mount('header-container')
tsx

개발하는 시간을 아끼기 위해서 빌드타임 통합으로 구성하긴 했지만, 변경사항이 생길 때마다 배포를 같이 해야 하기 때문에 배포/롤백에 대한 부담이 비교적 더 크다.

그 외 고려 사항

앱 간 통신

서로 다른 앱 간 통신을 해야하는 경우가 많다(라우팅, 로그 등). 통신 수단으로 여러 가지를 활용할 수 있다.

  • CustomEvent
    • 이벤트를 발생시켜서 통신하는 방법. 구현하기 간단하고 양방향으로 통신할 수 있어서 자주 사용한다.
  • data-* attribute
    • 주로 호스트 앱에서 리모트 앱으로 props를 전달할 때 사용한다.
  • url
    • 필요한 정보를 url에 공유할 수 있다.

스타일링

각 앱의 전역 스타일이 다른 앱에 영향을 줄 수 있다. 이런 경우를 방지하기 위해 영향 범위를 제한하는 것이 필요하다.

shadow DOM, css layer 등의 방법을 고려해볼 수 있고, tailwindcss 의 preflight를 사용하여 base css를 제어할 수도 있다.