2개 이상의 프로젝트를 1개의 레포지토리에서 사용하는 것을 모노레포라고 부른다. 모노레포는 공통된 패키지를 통해 여러 개의 프로젝트에 동일한 코드를 공유할 수 있다는 장점이 있다. 내 서비스에서는 웹과 익스텐션 두 개의 프로젝트가 있으며 서로가 상호작용하며 하나의 서비스를 이루기에 공통된 코드가 많다. 이러한 공통된 코드를 하나의 프로젝트로 관리하기 위해 나는 모노레포를 사용하기로 결심했다. 공통된 코드는 shared 패키지에서 관리하고 있다. 이번 글에서는 이 shared패키지를 어떻게 여러 개의 프로젝트에서 공유해 사용하고, 사용하며 불편했던 점과 막혔던 점, 어려웠던 점에 대해 공유하고자 한다.
모노레포 구성
모노레포
모노레포는 pnpm workspace를 사용했다.
// pnpm-workspace.yaml
packages:
- "web"
- "chrome-extension"
- "pages/*"
- "packages/*"
pnpm workspace는 pnpm-workspace.yaml에서 모노레포 내 패키지를 관리할 수 있다. 위와 같이 web과 chrome-extension 프로젝트, pages 폴더 내 프로젝트, packages 폴더 내 프로젝트를 명시할 수 있다.
pnpm workapce는 가장 인기 있는 오픈소스 프로젝트 중 하나이다. Next.js, Material UI, Vite, Nuxt, Vue, Astro 등에서 사용 중이다. pnpm의 pnpm store를 통해 여러 프로젝트에서 사용하는 패키지를 한 곳에서 관리해 효율적으로, 그리고 빠르게 의존성을 설치할 수 있다는 장점이 있다. 또한 의존성 트리가 완벽한 중첩 구조로 이뤄져 (기본 설정에서는) 유령 의존성 문제가 없다는 장점이 있다. 다만 '완전히 자유롭다'고 말하긴 조심스럽다. node-linker를 hoisted로 바꾸거나 peerDependencies가 호이스팅되는 상황에서는 pnpm도 유령 의존성에서 완전히 벗어나진 못한다. 어디까지나 pnpm의 기본값(격리된 node_modules)을 쓸 때의 이야기다. 그래서 pnpm을 사용하기로 결심했고, 따라서 pnpm workspace까지 함께 사용했다.
pnpm이 npm, yarn보다 좋은 점
디스크 공간 효율성 : pnpm의 가장 큰 차별점은 내용 주소화 저장소를 사용한다.
모든 패키지 버전을 전역 저장소 pnpm store에 한 번만 저장하고 프로젝트에서는 이를 하드 링크로 참조한다.
의존성 관리의 정확성 : pnpm은 엄격한 의존성 트리 구조를 강제한다.
이는 npm/yarn에서 발생하는 유령 의존성 문제를 해결한다.
유령 의존성 문제
: npm/yarn에서 중복 설치 설치를 피하기 위해 루트 node_modules로 호이스팅을 하며 발생하는 현상
설치 속도 : pnpm은 하드링크를 사용해 설치 속도가 빠르다.
모노레포 관리 도구는 turborepo를 사용했다.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"ready": {
"dependsOn": ["^ready"],
"outputs": ["dist", "build"],
"cache": false
},
// ..
}
}
turborepo는 모노레포에서 작업을 병렬로 실행하고 캐싱하여 빌드 시간을 최적화해주는 도구이다. 또한 프로젝트 간의 의존성을 관리하고, 변경된 프로젝트만 선택적으로 빌드할 수 있는 등 다양한 기능을 제공한다. turbo.json 파일에서 각 태스크의 의존성과 출력, 캐시 여부 등을 설정할 수 있다.
turborepo는 설정 파일 하나로 끝난다. 다른 도구들처럼 복잡한 세팅이 필요 없었다. 또한 turbo.json 파일 하나로 모든 설정을 관리할 수 있어 유지보수가 용이하다.
폴더 구조
대략적인 폴더 구조는 아래와 같다.
위 pnpm-workspace.yaml에서 살펴본 폴더 구조와 동일하다.
web은 말그대로 웹사이트 패키지이다. chrome-extension은 확장 프로그램의 코어 로직들(manifest, service worker)을 관리하는 패키지이고, pages는 확장 프로그램에 필요한 페이지(content-ui, popup, side-panel), 그리고 packages는 확장 프로그램 혹은 웹사이트에 필요한 패키지로 구성된 폴더이다.
여기서 packages폴더 내 패키지는 pages에 필요한 패키지, pages와 chrome-extension은 확장 프로그램을 빌드하는데 필요한 패키지, web은 웹사이트 패키지라 볼 수 있다.
빌드 파이프라인
turborepo는 turbo.json파일에서 모노레포의 빌드 파이프라인을 구성할 수 있다. turbo.json에서는 각 태스크의 의존성과 출력, 캐시 여부를 설정할 수 있다. turbo.json에서 명시한 테스크는 turbo (task명)을 통해 실행할 수 있으며 이는 주로 package.json의 scripts에 명시해 사용한다.
tasks의 각 키는 turbo run CLI를 통해 실행하는 것과 같다. 또한 이를 실행하면 Turborepo는 워크스페이스 구성(pnpm-workspace.yaml)에 따라 각 패키지를 순회하며 package.json의 scripts에서 테스크명과 동일한 이름을 찾아 이를 실행한다.
예를들어 ready테스크가 있다고 해보자. 모노레포 내 각 패키지를 순회하다가 shared패키지의 package.json에서 ready스크립트를 발견해 이를 실행할 것이다.
```typescript // packages/shared/package.json { "scripts":{ "ready": "node build.mjs", // .. } // .. } ```
빌드를 준비하는 ready테스크부터 살펴보자. ready테스크는 위 폴더 구조에서 살펴본 packages폴더 내의 패키지에서 수행하는 스크립트로, 확장 프로그램 및 웹사이트에 필요한 패키지를 빌드하는 과정이다.
// turbo.json
{
"tasks": {
"ready": {
"dependsOn": ["^ready"],
"outputs": ["dist", "build"],
"cache": false
},
// ..
}
"dependsOn": ["^ready"]: ready 태스크를 실행하기 전에 의존하고 있는 다른 패키지들의 ready 태스크가 먼저 완료되어야 함을 의미한다. 이는 패키지 간의 의존성을 고려해 빌드 순서를 자동으로 결정하는데 도움을 준다.
의존성 관계
^dependsOn옵션에서
^를 앞에 붙여주면 turbo에게 해당 태스크가 의존하는 다른 패키지들의 태스크가 먼저 실행되어야 한다는 것을 알려준다.
"outputs": ["dist", "build"]: ready 태스크가 실행되면 dist 혹은 build 폴더가 생성되며, 이는 빌드된 결과물이 저장되는 위치를 나타낸다."cache": false: ready 태스크의 결과물을 캐시하지 않도록 설정한다. 매번 새로 빌드해서 최신 상태를 유지하려는 것이다.
그 다음으로는 build 테스크를 살펴보자. build 테스크에서 주의깊게 살펴볼 것은 위 ready테스크를 어떻게 먼저 수행하고 build테스크를 수행할 수 있을까이다.
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["ready", "^build"],
"env": [
"OPENAI_API_KEY",
"SENTRY_AUTH_TOKEN",
// ..
],
"outputs": ["dist", "build"],
"inputs": [".env"]
},
// ..
}
"dependsOn": ["ready", "^build"]: build 태스크를 실행하기 전 ready 태스크와 의존하는 다른 패키지의 build 태스크가 완료되어야 함을 의미한다. 이를 통해 공통 패키지가 먼저 빌드되고, 그 다음 해당 패키지를 사용하는 프로젝트들이 빌드된다."env": build 태스크 실행 시 필요한 환경 변수를 명시한다. 이 환경 변수들은 .env 파일에서 가져오며, inputs에 명시된 것처럼 .env 파일이 변경되면 빌드가 다시 실행된다."outputs"와"inputs": build 태스크의 결과물이 저장되는 위치와 빌드에 영향을 주는 입력 파일들을 지정한다.
이제 turbo build를 실행할 경우 위 turbo.json의 빌드 파이프라인에 따라 각 패키지의 ready 스크립트를 먼저 실행하고, build스크립트를 실행한다.
공통 패키지 빌드
빌드 파일 생성
자 이제 공통 패키지인 shared패키지를 빌드해보자. 우리 서비스에서는 esbuild를 사용했다. esbuild는 Go로 만들어 10~100배 빠르고, 설정도 간단해서 쓰기 편했다.
코드를 하나씩 뜯어보며 살펴보자.
- 환경 변수 설정
import dotenv from 'dotenv';
dotenv.config();
const define = {};
for (const k in process.env) {
define[`process.env.${k}`] = JSON.stringify(process.env[k]);
}
dotenv는 .env파일로부터 환경변수를 불러올 수 있는 라이브러리이다. dotenv.config()를 통해 .env파일의 내용을 파싱해 process.env에 할당한다.
그리고 process.env의 모든 키를 순회하며 define 객체를 만든다. define은 esbuild의 빌드 시점에서 환경 변수를 번들에 포함시키기 위한 객체이다. 이는 후에 esbuild options에서 define 옵션으로 전달되어 빌드 과정에서 환경 변수를 정적으로 대체할 것이다.
다른 서비스에서 사용하는 패키지라면 환경 변수를 파일 내에서 설정하지 않고, 인자를 통해 전달받는 방식 등을 통해 필요한 변수를 주입하는 방법이 적합할 것이다. 하지만 이 shared 공통 패키지는 web memo라는 하나의 서비스에서만 사용하기 위해 만들어졌고 환경 변수를 shared 공통 패키지에서 중앙 관리하는 것이 사용하는 측에서 편리할 것이라 판단했다. 덕분에 패키지마다 환경 변수를 따로 챙길 필요가 없어졌다.
- 빌드 옵션
/**
* @type { import("esbuild").BuildOptions }
*/
const libBuildOptions = {
entryPoints: ['./src/**/*'],
outdir: 'dist',
tsconfig: './tsconfig.json',
target: 'es6',
plugins: [nodeExternalsPlugin()],
define,
bundle: true,
alias: {
'@src': resolve(__dirname, './src'),
},
format: 'esm',
outExtension: { '.js': '.js' },
sourcemap: true,
};
이는 esbuild.build내 인자로 전달될 객체이다.
옵션을 간략하게 살펴보면 다음과 같다.
1. `entryPoints` : 빌드할 소스 파일들의 경로를 지정한다. src 폴더 내의 모든 파일을 빌드 대상으로 설정했다.
2. `tsconfig` : TypeScript 설정 파일의 경로를 지정한다. 이를 통해 TypeScript 컴파일 옵션을 적용한다.
3. `target` : 컴파일된 JavaScript 코드의 target 버전을 ES6로 설정한다. ES6로 잡은 건 이 패키지를 쓰는 web과 익스텐션이 모두 최신 브라우저 위에서 도니까, 굳이 그 아래로 낮춰 코드 양을 늘릴 이유가 없어서다. 반대로 너무 최신으로 올리면 지원 범위가 좁아질 수 있어 무난한 ES6를 택했다.
4. `plugins` : nodeExternalsPlugin을 사용해 node_modules의 외부 의존성을 번들에서 제외한다.
5. `define` : 앞서 설정한 환경 변수 객체를 전달하여 빌드 시점에 환경 변수를 코드에 포함시킨다.
6. `bundle` : true로 설정하여 모든 의존성을 하나의 파일로 번들링한다.
7. `alias` : 절대 경로 별칭을 설정하여 @src가 src 폴더를 가리키도록 한다.
8. `format` : ESM(ECMAScript Module) 형식으로 출력한다.
9. `sourcemap` : 디버깅을 위한 소스맵을 생성한다.
3. 빌드
이제 본격적으로 빌드를 해보자.
import { exec } from 'child_process';
import { promisify } from 'util';
// ..
await esbuild.build(libBuildOptions);
const execAsync = promisify(exec);
await execAsync('tsc --emitDeclarationOnly --outDir dist');
esbuild를 통해 빌드한 결과물은 빌드 옵션에서 지정한 것(outdir:'dist')처럼 dist 폴더에 생성이 되며, 이는 JavaScript 파일과 소스맵 파일을 포함(sourcemap:true)한다. 그리고 tsc(TypeScript Compiler)를 이용해 코드의 타입만을 추출해 .d.td파일로 생성한다. 이는 공통 패키지를 사용하는 다른 프로젝트에서 타입 정보를 활용할 수 있게 해준다.
exec는 Node.js 내장 모듈인 child_process의 메서드로 인자로 전달한 명령어를 shell에서 실행한다.
위 코드만 제공하면 뭔가 허전하다. 커맨드창을 조금 더 다채롭고 풍부하게 꾸며보자.
const build = async () => {
const startTime = performance.now();
console.log('🚀 빌드 프로세스 시작...');
try {
await esbuild.build(libBuildOptions);
console.log('✅ esbuild 빌드 완료');
const execAsync = promisify(exec);
await execAsync('tsc --emitDeclarationOnly --outDir dist');
console.log('✅ TypeScript 선언 파일 생성 완료');
const endTime = performance.now();
const buildTime = (endTime - startTime) / 1000;
console.log(`🕒 총 빌드 시간: ${buildTime.toFixed(2)}초`);
} catch (error) {
console.error('❌ 빌드 중 오류 발생:', error);
throw error;
}
};
build().catch(() => {
process.exit(1);
});
현재 빌드 상황을 콘솔을 이용해 출력한다. 또한 performance.now()를 이용해 빌드 시작부터 빌드가 종료될 때까지의 시간을 측정해 이 또한 출력한다. 이를 통해 사용자는 커맨드창에서 현재 빌드 상황을 한 눈에 파악할 수 있다.
외부에서 접근할 수 있도록 설정
이제 빌드한 위 패키지를 외부 패키지에서 접근할 수 있도록 해야한다. 기존에는 package.json의 main과 type을 통해 진입점을 하나로만 설정할 수 있었다면, 이제는 package.json에서 exports필드를 통해 더 세밀한 제어가 가능하다. export를 사용하면 패키지의 public API를 명시적으로 정의하고, 하위 경로 별로 진입점을 설정할 수 있다. 더 나아가 esm과 cjs 각 모듈 시스템 방식에 따라서도 정의할 수도 있다.
// packages/shared/package.json
{
"exports": {
"./utils": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.js"
},
"./utils/extension": {
"types": "./dist/utils/extension/index.d.ts",
"default": "./dist/utils/extension/index.js"
},
"./utils/web": {
"types": "./dist/utils/web/index.d.ts",
"default": "./dist/utils/web/index.js"
},
"./hooks": {
"types": "./dist/hooks/index.d.ts",
"default": "./dist/hooks/index.js"
},
"./hooks/extension": {
"types": "./dist/hooks/extension/index.d.ts",
"default": "./dist/hooks/extension/index.js"
},
// ..
}
만약 utils/extension내의 모듈을 불러오고 싶다면, @extension/shared/utils/extension에서 import하면 된다. 위와 같이 세분화해 모듈을 import함으로써 트리쉐이킹에 유리하며 이 모듈이 어디에 속해있는지 명확하게 파악할 수 있다. 예를들어, 처음에는 index.ts파일 하나에서 모두 export를 했다. 그래서 훅, 유틸 , 상수 모두 @extension/ui로부터 import를 했다. import경로를 볼 때 뭐가 훅이고, 유틸 함수인지 구분이 되지 않았다. 하지만 위처럼 import경로를 세분화함으로써 훅, 유틸, 상수를 분리된 경로에서 import할 수 있게 되었다.
위처럼 정의할 경우 import예시는 다음과 같다.
// web/src/utils/example.ts
import { formatDate } from '@extension/shared/utils';
@extension/shared는 현재 공통 패키지를 의미한다. 그리고 /utils이기 때문에 위 package.json의 exports에 따라 /dist/utils/index.js경로를 찾아가게 된다. 이 파일에는 아래와 같이 빌드된 파일이 존재할 것이다.
// packages/shared/dist/utils/index.js
// ..
// src/utils/Date.ts
function formatDate(inputDate, format) {
if (typeof inputDate === "string") inputDate = new Date(inputDate);
const year = inputDate.getFullYear();
const month = inputDate.getMonth() + 1;
const date = inputDate.getDate();
if (format === "yyyy.mm.dd") return `${year}.${String(month).padStart(2, "0")}.${String(date).padStart(2, "0")}`;
if (format === "mm/dd") return `${month}/${date}`;
throw new Error("Invalid date format");
}
왜 빌드된 파일이 다른 파일이 아니라 index.js에 있을까?
빌드 전 index.ts는 다음과 같다.
```typescript // packages/shared/utils/index.ts export * from './Date'; // .. ```그리고 빌드전 Date.ts는 다음과 같다.
```typescript // packages/shared/utils/Date.ts type DateFormat = 'yyyy.mm.dd' | 'mm/dd'; export function formatDate(inputDate: Date, format: DateFormat): string; export function formatDate(inputDate: string, format: DateFormat): string; export function formatDate(inputDate: Date | string, format: DateFormat): string { if (typeof inputDate === 'string') inputDate = new Date(inputDate); const year = inputDate.getFullYear(); const month = inputDate.getMonth() + 1; const date = inputDate.getDate(); if (format === 'yyyy.mm.dd') return `${year}.${String(month).padStart(2, '0')}.${String(date).padStart(2, '0')}`; if (format === 'mm/dd') return `${month}/${date}`; throw new Error('Invalid date format'); } ```왜 이 파일이 전부 index.js로 합쳐졌느냐. 이는 esbuild의 번들링 동작 방식 때문이다. 빌드 옵션에서 bundle을 true로 설정했기 때문에 esbuild는 모든 의존성을 추적해 하나의 파일로 번들링한다. 따라서 index.ts에서 re-export한 모든 모듈들이 최종적으로 index.js 파일 하나로 합쳐지게 된 것이다.
공통 패키지 사용
설치
설치는 간단하다.
// web/package.json
{
dependencies": {
"@extension/shared": "workspace:*",
// ..
}
}
위와 같이 package.json의 dependencies에서 key에는 해당 패키지의 package.json의 name, value에는 workspace:*를 작성하면 된다.
사용
이제 shared 패키지를 사용할 때는 여느 패키지를 사용할 때와 동일하게 사용하면 된다.
// web/src/utils/example.ts
import { formatDate } from '@extension/shared/utils';
const date = formatDate(new Date());
이렇게 import문을 통해 shared 패키지의 함수나 컴포넌트를 가져와서 사용할 수 있다. 이 때 세부 경로는 외부에서 접근할 수 있도록 설정에서 설정한, package.json의 exports를 참고하게 된다.
트러블 슈팅
공통 패키지에서 절대 경로를 인식하지 못한다.
문제 상황
shared 패키지 안에서 @src 같은 절대 경로 별칭으로 다른 모듈을 import하면, 정작 이 패키지를 가져다 쓰는 web·익스텐션 쪽에서 빌드하거나 타입을 해석할 때 그 경로를 찾지 못하고 에러가 난다.
// packages/shared/src/utils/Date.ts
import { SOME_CONST } from '@src/constants'; // tsconfig의 paths로 잡은 절대 경로
// ...
Cannot find module '@src/constants' or its corresponding type declarations.
자세한 내용은 모노레포 공통 패키지에서 절대 경로가 에러를 일으키는 이유에 있으니 참고 바란다.
공통 패키지에서 import 경로 분리하기
무려 10일, 누적 시간으로 10시간동안 붙잡은 끝에 해결한 이슈이다. 알고보니 너무 간단한 이슈여서 해결하고나서 무지 허탈하면서도 한 편으로는 덕분에 package.json의 export, esbuild의 다양한 옵션 등 모듈 시스템과 빌드 방식에 대해 깊이 있게 공부할 수 있는 좋은 기회가 되었다. 그리고 문제를 해결하는데 있어서 더 넓은 시야에서, 그리고 관점을 달리해서 보는 것의 중요성을 또 한 번 느낀 이슈였다. 마지막으로, 아무리 해결이 안될 거 같은 문제도 계속해서 붙잡으면 결국 해결이 된다는 것을 배울 수 있었다.
이제 본론으로 돌아가보자.
해결 방법
레퍼런스를 찾아보고 package.json의 exports, 구글링 등 온갖 시도를 해보았다. 그리고 마지막으로 직접 수동으로 import를 해보고 보이는 에러 메시지를 읽어보았다.
@extension/shared/constants 혹은 이에 상응하는 타입 선언을 찾을 수 없습니다.
하지만 이 결과는 moduleResolution 설정을 변경하면 해결할 수 있습니다. 이를 node16, nodenext, 혹은 bundler로 설정해주세요.
shared패키지를 사용하는 측의 tsconfig의 moduleResolution을 기존 node에서 nodeNext/node16 혹은 bundler로 변경하면 되는 아주 간단한 이슈였다. 다른 것도 아니라 직접 import문을 작성해보니 IDE에서 친절하게 에러 원인과 해결 방법을 알려주고 있었다.
여기서 bundler는 tsc에게 다른 툴에 의해 번들링될 것이라고 알려주는 옵션이다. bundler로 설정함으로써 IDE에서 import한 모듈을 찾아갈 때 빌드 후에 tsc에 의해 생성된 선언 파일(.d.ts)파일이 아니라, 빌드 전 파일( .ts)을 찾아가도록 설정할 수 있다.
원인 분석
그렇다면 이 moduleResolution이 왜 문제였느냐. 이는 공식문서를 살펴보면 확인할 수 있다.
node16, nodenext : 현대 Node.js를 위한 옵션. Node.js v12 이상의 버전은 다른 알고리즘을 사용하는 ESM imports와 와 CJS require를 모두 지원한다.
node16은 말그대로 Node.js버전 16을 의미하고, nodenext는 최신 버전의 Node.js를 의미한다.
node10 : Node.js v10보다 오래된 Node.js 버전. CommonJS require만을 지원한다. 현대 코드에서는 node10을 사용하지 않을 것을 권장한다.
bundler : 번들러와 함께 사용할 때 사용한다. node16, nodenext과 같이 package.json의 imports와 export를 지원한다. 하지만 bundler는 imports의 상대 경로를 필요로 하지 않는다.
classic : TypeScript 버전 1.6 이전에서 사용되었으며, 이제는 사용하지 않는다.
moduleResolution은 모듈 해석 전략을 결정하는 옵션이다. package.json의 imports와 exports를 사용하기 위해서는 이를 지원하는 node16, nodenext 혹은 bundler를 moduleResolution으로 설정해야한다. 그런데 이전에는 node로 설정했기 때문에 shared패키지의 packages.json의 exports에서 설정한 옵션을 해석하지 못한 것이었다.
또한 자동완성에서 보여주지 못한 것도 같은 맥락인데 VSCode는 TS Server를 통해 자동 완성을 보여주며, 이는 typescript설정인 tsconfig.json의 옵션에 따라 보여준다. 이 때 moduleResolution을 exports를 지원하지 않는 옵션으로 설정을 하니 자동완성으로 보여주지 못한 것이 당연한 현상이다.
맺으며
이번 글에서는 모노레포 환경에서 공통 패키지를 구성하고 사용한 방법을 살펴봤다.
트러블 슈팅 과정에서 마주친 절대 경로 인식 문제와 import 경로 분리 문제는 처음에는 막막해 보였지만 이를 해결하는 과정에서 모듈 시스템과 빌드 프로세스에 대해 더 깊이 있게 이해할 수 있었다. 특히 import 경로 분리 문제는 10일이라는 긴 시간이 걸렸지만 package.json의 exports, esbuild의 다양한 옵션, TypeScript의 모듈 해석 전략 등 새로운 지식을 얻을 수 있는 좋은 기회였다.
모노레포는 이미 많은 회사와 프로젝트에서 쓰고 있다. 모노레포를 한 번 사용해본 이후, 모노레포로 다시 돌아가지 못할 정도로 모노레포의 편리함을 몸소 체감하고 있기 때문이다. 내가 느낀 모노레포의 가장 큰 장점은 코드 재사용성, 편한 의존성 관리, 그리고 무엇보다도 프로젝트들을 자유롭게 넘나들기가 훨씬 수월해진다.
물론 모노레포에도 단점은 있다. 모노레포 내 프로젝트가 많아질수록 비례해서 레포지토리가 점점 무거워지고, 많은 개발자가 함께 사용할수록 버전 관리, 병합간 충돌을 고려해야한다. 아직 나는 혼자, 혹은 소규모의 팀 프로젝트에서 모노레포를 사용해봤기에 이러한 단점은 체감하지 못하고 있다. 그럼에도 큰 규모의 회사라면 위의 한계점을 고려하며 모노레포를 유연하게 도입하는 것이 좋을 듯하다.
공통 패키지를 처음 잡을 때 나도 한참 헤맸는데, 이 글이 그 시간을 조금이라도 줄여주면 좋겠다.
위에서 설명한 모노레포는 아래 레포지토리입니다.