저번 시간에 Module Federation 글에 연장선으로 이번 시간에는 Monorepo에 대해 알아보도록 하겠습니다.
모노레포 또한 대규모 웹 애플리케이션 개발 시 자주 언급되는 방법론인거 같습니다. 그만큼 토스, 라인 등 여러 회사에서 채택되어 사용되고 있습니다. 저는 대규모 애플리케이션을 경험할 일이 없었고, 지금 알아보고자 하는건 모노레포로 설계하고 개발하는 것은 어떨지 알아보는 단계에서 작성한 글입니다. 다소 부족한 글이지만 그냥 아는대로 작성해보도록 하겠습니다.
모노레포를 설명하기 위해 모놀리식? 멀티레포? 와 같이 비교해서 알아보면 좋을 거 같습니다.
참고 : https://engineering.linecorp.com/ko/blog/monorepo-with-turborepo
모놀리식 방식 : 말 그대로 하나의 프로젝트에 다 때려박았다는 느낌입니다. 초기에는 빠르게 개발할 수 있고 좋을거 같지만, 점차 프로젝트가 커지면 쉽지 않을 구조가 될 것이 분명합니다.
멀티 레포 : 각 프로젝트 별 저장소를 만들어서 관리합니다. 이렇게 되면 완전 독립적이어서 자율성이 높고, 독립적인 빌드가 가능하다는 장점이 있습니다. 하지만, 너무 독립적이어서 프로젝트를 만들 때마다 초기 세팅을 반복해야하고, 코드 중복도 생기고, 재사용성이 어려워진다는 단점도 있습니다. 그래서 이를 해결하고자 별도 Bolierplate template를 만들기도 합니다.
모노 레포 : 모놀리식 방식과 흡사하게 단일 저장소로 관리되고 있습니다만 세부적으로는 프로젝트 별로 분리가 되어있는 형태입니다. 즉, 모노 레포는 모놀리스 레포보다는 모듈러 레포라고 할 수 있습니다. 차이점 이해 되시나요?
아직까지는 멀티 레포가 일반적으로 사용되는 방식이라고 볼 수 있고, 조금씩 모노 레포에도 관심을 가지기 시작하면서 여러가지 지원 도구(Lerna, Nx, Turborepo 등) 이 나오는 추세입니다.
아키텍처 관점에서 보면 백엔드는 MSA 라는 마이크로 서비스 아키텍처가 활발하게 이뤄지고 있는 거 같습니다. 서비스나 도메인별로 쪼개서 관리하는 형태입니다.
하지만 프론트엔드는 아직까지는 마이크로 서비스에 대해서 익숙하지는 않은 거 같습니다. 대부분의 프론트엔드는 여전히 모놀리식으로 되어있습니다. 만약 프론트엔드도 서비스 별로 나눠서 아래처럼 만들 수 있지 않을까요? 뭔가 Module Federation 개념과도 흡사합니다.
한편, 구글이라는 서비스를 봤을 때 구글 드라이브, Gmail, 캘린더 등 여러가지 서비스가 많습니다. 이런 서비스를 보면 뭔가 구글 스타일로 통일된 UI를 가지고 있다는 느낌이 있지 않나요? 서비스마다 별도로 프로젝트를 만들었다면 이렇게까지 통일성은 없었을 거 같습니다. 당연히 중복도 많아지고...
이렇게 공통된 UI 컴포넌트나 Util 함수는 따로 공통 라이브러리로 만들면 어떨까요?
이를 종합하면 서비스는 서비스별로 나누고, 공통 부분은 공통 라이브러리로 만들고... 이렇게 설계를 하도록 도와주는 방식이 모노레포라고 생각합니다.
Turbo는 Rust로 작성된 자바스크립트와 타입스크립트에 최적화된 향상된 번들러 및 빌드 시스템 - turbo 공식문서
아래는 turbo 공식 문서에 있는 글을 번역한 내용입니다. Turborepo가 무엇을 지향하는지 알거 같네요.
“Scaling your Codebase shouldn't be so difficult”
프로젝트 규모가 커질수록 느려집니다. linting, testing, and building 과 같은 작업은 엄청난 시간이 걸리기 시작합니다. 여러 애플리케이션을 제공하는 경우, 모노레포를 사용할 수 있습니다. 생산성 면에서 특히 프론트엔드의 경우 매우 우수하지만, 해야할 일이 많습니다. configs를 조정하고, scripts를 작성하고, 여러 항목을 함께 연결하는 파이프라인 작업에 며칠 또는 몇 주를 허비하는 것이 완전히 일상이 되었습니다.
우리는 다른 것이 필요합니다. 우리는 당신의 팀을 따라갈 수 있는 빌드 시스템을 구축하고 있습니다. CI 속도가 빨라지고, 중복된 작업이 중단되어 NPM 스크립트가 단순해집니다. 유지보수 부담 없이 세계 최고 수준의 개발 환경을 구축할 수 있습니다.
참고 : https://turbo.build/repo/docs/core-concepts/caching
코드베이스의 작업(ex. lint, build, test)은 최대한 빨리 실행되지 않습니다. Turbo는 캐싱을 사용하여 CI 속도를 높입니다. Turborepo는 점진적으로 채택되도록 설계되어있으므로 몇 분 안에 대부분의 코드베이스에 추가할 수 있다.
이전에 했던건 캐싱이 되고, 바뀐 부분이나 새로 추가된 부분에 대해서만 Turborepo가 작업을 진행합니다.
모노 레포 프로젝트를 turborepo를 사용해서 생성해보도록 하겠습니다. (참고)
❯ npx create-turbo@latest
Need to install the following packages:
create-turbo@1.10.2
Ok to proceed? (y) y
>>> TURBOREPO
>>> Welcome to Turborepo! Let's get you set up with a new codebase.
? Where would you like to create your turborepo? game-zone
? Which package manager do you want to use? pnpm
Downloading files. This might take a moment.
>>> Created a new Turborepo with the following:
apps
- apps/docs
- apps/web
packages
- packages/eslint-config-custom
- packages/tsconfig
- packages/ui
Installing packages. This might take a couple of minutes.
>>> Success! Created a new Turborepo at "game-zone".
초기 구조를 보니 다음과 같이 되어있습니다.
apps 폴더에 web 프로젝트를 보면 이런식으로 되어있는 것을 볼 수 있습니다. ui는 workspace라는 특이한 형태를 가지고 있는데, 이는 pakcages에 ui를 의미합니다.
"dependencies": {
"next": "^13.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ui": "workspace:*"
},
그니까... web에 node_nodules를 보면 ui 라는게 있는데.. 사실 이게 packages에 ui와 동일한 형태입니다. symlink 로 연결이 되어있기 때문에 그렇습니다. 이러한 점을 봤을때, 서비스에 공통된 부분은 따로 빼서 관리하면 되겠구나... 라는 생각이 듭니다. 👍
간단하게 Shell 이라는 공통 레이아웃 컴포넌트를 만들어보겠습니다. 이 컴포넌트는 apps 폴더에서 사용할 예정입니다. 또한, 내부적으로는 zustand를 사용해서 유저와 점수 상태를 관리해보겠습니다. zustand 사용은 이번이 처음이네요...
pnpm add zustand
zustand를 간단히 살펴보면 가볍고 사용하기 쉽고, Redux와 비슷하면서도 Boilerplate code가 거의 없다고 하네요. 유저랑 점수에 대한 상태를 만들고, 상태 변경 함수를 만들면 됩니다.
// useAppShell.tsx
import { create } from "zustand";
type Store = {
user: string | null;
score: number;
setUser: (user: string | null) => void;
addScore: (amount: number) => void;
};
// store 생성
export const useAppShell = create<Store>((set) => ({
user: null,
score: 0,
setUser: (user) => set({ user }),
addScore: (amount) => set((state) => ({ score: state.score + amount })),
}));
또한, 상태를 localStorage에 저장해서 관리하고 싶다면 persist를 사용할 수 있습니다. 이렇게 하면 새로고침을 하더라도 상태가 유지됩니다.
// useAppShell.tsx
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Store = {
user: string | null;
score: number;
setUser: (user: string | null) => void;
addScore: (amount: number) => void;
};
// store 생성
export const useAppShell = create<Store>()(
persist<Store>(
(set) => ({
user: null,
score: 0,
setUser: (user) => set({ user }),
addScore: (amount) => set((state) => ({ score: state.score + amount })),
}),
{
name: "app-shell", // localStorage 내 저장될 states 값을 관리하는 key
}
)
);
그리고 나서 Shell 컴포넌트를 구현한 코드입니다. Button도 재사용성을 위해 따로 분리하였습니다.
// Shell.tsx
import React, { useState } from "react";
import { Button } from "./Button";
import { useAppShell } from "./useAppShell";
type ShellProps = {
title: string;
children: React.ReactNode;
};
export function Shell({ title, children }: ShellProps) {
const { user, score, setUser } = useAppShell();
return (
<>
<header
style={{
...
}}
>
<h1>{title}</h1>
<div>
{user ? (
<>
<span style={{ fontSize: "18px", marginRight: "16px" }}>
{user} - 점수: {score}
</span>
<Button onClick={() => setUser(null)}>Logout</Button>
</>
) : (
<Button onClick={() => setUser("ckstn0777")}>Login</Button>
)}
</div>
</header>
<main
style={{
...
}}
>
{children}
</main>
</>
);
}
그리고 이를 실제 app 폴더에 web 프로젝트에서 사용해보니 잘 적용된 것을 알 수 있습니다.
import { Shell } from "ui";
export default function Page() {
return <Shell title="game zone">게임 존</Shell>;
}
또한, Shell 을 수정할 때마다 자동으로 바뀌므로 아주 좋은 거 같습니다.
이제 팀마다 별도의 간단한 미니 게임을 개발한다고 가정해봅시다. A 팀은 CardPicker 게임을 만들 것입니다. 간단하게 카드 5장이 랜덤으로 섞여있고, 사용자는 카드를 한장 뽑아서 높은 점수를 가진 카드를 뽑을 수록 점수를 많이 획득하게 됩니다.
기존 apps에 있는 프로젝트는 삭제해주겠습니다. 그리고나서 create-react-app으로 시작하려고 했는데 너무 무겁더군요... 그래서 그냥 직접 react + webpack + ts-loader 조합으로 설정해서 구현해주었습니다.
그리고 packages ui에 만든 Shell, Button 컴포넌트와 useAppShell을 가져와서 사용해주었습니다.
// package.json
"ui": "workspace:*"
// App.tsx
import { Shell } from "ui";
import CardPicker from "./CardPicker";
function App() {
return (
<Shell title="Card Picker">
<CardPicker />
</Shell>
);
}
export default App;
필요한 CardPicker 컴포넌트에 대해서만 개발을 하면 되니 아주 편하군요.
A팀이 CardPicker 게임을 만들동안, B팀은 up down 게임을 만들어보도록 하겠습니다. 1~100사이의 숫자 중 컴퓨터가 선택한 숫자를 맞추는 게임입니다. 컴퓨터는 up, down으로 저희에게 힌트를 알려줄 겁니다.
프로젝트 구성은 CardPicker와 동일하게 했으며, 구체적인 구현 과정은 생략하겠습니다.
각 팀별로 만든 게임을 하나의 애플리케이션으로 통합해보도록 하겠습니다. 이 때, Module Federation를 사용해볼 것입니다. 기본 설정은 다음과 같습니다.
// webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;
module.exports = {
...
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
new ModuleFederationPlugin({
name: "updown",
filename: "remoteEntry.js",
exposes: {
"./UpDown": "./src/UpDown",
},
shared: {
...deps,
ui: {
singleton: true,
},
react: {
singleton: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
},
},
}),
}
이렇게 설정을 하고 실행을 해보니... 빈 화면에 콘솔에 에러가 발생합니다.
caught Error: Shared module is not available for eager consumption:
사실 이 부분이 제일 골치가 아프더군요. 해결방법으로는 2가지가 있는 거 같습니다. (공식문서 참고)
일단, 마음에 드는 방식은 dynamic import를 사용하는 방식이었습니다. 공식문서에서도 이를 추천하고 있기 때문입니다. 그리고 보편적으로 이용되는 방식인거 같기도 합니다.
그래서 bootstrap.tsx 파일을 별도로 만들고 기존 index.tsx에 있던 코드를 옮겨줍니다.
// bootstrap.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
그리고 나서 index.tsx는 index.ts로 바꾸고, bootstrap 파일을 import 해줍니다.
import("./bootstrap");
그리고 나서 실행했더니 여전히 동일한 에러가 발생하고 있었습니다. 그래서 몇시간을 삽집을 하다가 결국 해결을 하게 되었는데, 먼저 동작원리를 파악하면 왜 이런 에러가 발생하는지 알 수 있었습니다.
먼저 잘 작동이 되는 코드를 찾아서 한번 실행해봤습니다. 이런식으로 나오더군요.
하지만, 저의 경우에는 불러오는 순서나 내용이 좀 달랐습니다. 뭔가 많이 다릅니다...
그리고 저 stackoverflow 글을 보니 bootstrap 없이 main_bundle에 하드코드된 스크립트를 실행하려고 하면 remoteEntry.js 앞에 로드되고 main_bundle이 실제로 앱을 실행하려고 하면 실패하게 된다고 합니다.
즉, bootstrap으로 나눠서 import 한 이유는 chunk(분리)하기 위함입니다. 실행 순서가 main.js -> remoteEntry.js -> 의존성 라이브러리(vendors) -> bootstrap이 되어야 합니다. 근데 저 같은 경우는 bootstrap이 제대로 분리되지 않아보입니다. 결국, 의존성 라이브러리도 없는데 실행을 하려고 하면 당연히 잘 안될 것입니다.
그러면 왜 main이랑 bootstrap이 나눠지지 않았을까 고민을 해봤습니다. ts-loader 문제인건가 싶어서 babel-loader에 presets를 사용해서 바꿔봤는데.. 오 잘 해결이 되었습니다.
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-typescript"
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"regenerator": true
}
]
]
}
역시 아래처럼 로드 되는게 맞네요. 이렇게 로드 되어야 제대로 실행이 되며, main이랑 bootstrap도 잘 나눠진걸 알 수 있습니다.
근데... ts-loader로도 잘 동작하는 예시 코드가 있길래... 그래도 뭔가 ts-loader를 사용하고 싶었습니다. 사실 babel-loader보다 ts-loader 하나로 사용하는게 편하더군요. babel-loader는 플러그인을 여러개 설치를 해야 해서 좀 불편했습니다.
그래서 원인을 찾다보니 치명적인 실수가 있었더군요. tsconfig.json은 그냥 tsc init 한 이후로 확인을 안했는데, 기존에는 module이 commonjs로 되어있더군요. 그래서 import가 제대로 처리가 안된듯 싶습니다. esm을 사용했어야 했는데... 큽...
/* Modules */
"module": "esnext" /* Specify what module code is generated. */,
"moduleResolution": "node",
이렇게 오류는 해결이 되었고, 나머지 host 부분에서 remote에서 exposes한 컴포넌트를 사용하기 위한 설정을 해줍니다.
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
new ModuleFederationPlugin({
name: "gamezone",
remotes: {
cardpicker: "cardpicker@http://localhost:5500/remoteEntry.js",
updown: "updown@http://localhost:5501/remoteEntry.js",
},
shared: {
...deps,
ui: {
singleton: true,
},
react: {
singleton: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
},
},
}),
],
React.lazy를 사용해서 Suspense를 사용해서 불러오는동안 fallback을 보여주도록 해주었습니다.
import { Shell } from "ui";
import React, { Suspense } from "react";
const CardPicker = React.lazy(() => import("cardpicker/Cardpicker"));
const UpDown = React.lazy(() => import("updown/UpDown"));
function App() {
return (
<Shell title="game zone">
<div>
<Suspense fallback={<div>Loading...</div>}>
<div style={{ display: "flex", gap: "20px" }}>
<CardPicker />
<UpDown />
</div>
</Suspense>
</div>
</Shell>
);
}
export default App;
최종 결과입니다. host에서는 각 팀에서 개발한 cardpicker와 updown을 가져와서 사용해주었습니다.
전체 코드 참고 : https://github.com/ckstn0777/game-zone
이미지 참고 : https://www.kimcoder.io/blog/micro-frontend-module-federation
제가 원하는게 저런 그림이었습니다. packages 폴더에 있는 내용이 Core Component Library가 되며 공통 컴포넌트와 디자인 시스템, 공통 설정 등이 들어가게 됩니다. 그리고 Webpack Module Federation 으로 분리하여 개발을 진행했고, host에서는 하나로 합쳐주었습니다. Shell을 통해서는 상태를 공유하고 기본 레이아웃을 잡아주는 역할을 했고요.
아무래도 요즘에 저런 설계가 많이 보이는거 같습니다. 프론트엔드 개발 방식도 점차 발전한다는게 재밌기도 하고 한편으로는 점점 어려워지는거 같기도 합니다.
그러고보니 이번시간에는 turborepo를 알아보려고 했었는데 어쩌다보니 turborepo는 제대로 알아보질 못했네요. 😂