Monorepo란?
Monorepo는 여러 개의 프로젝트를 하나의 저장소(Repository)에서 관리하는 방식을 의미합니다. 이 접근 방식은 코드베이스의 일관성을 유지하고, 코드 재사용성을 높이며, 종속성 관리를 용이하게 하는 데 도움이 됩니다.
Monorepo의 특징
- 일관된 코드베이스: 모든 프로젝트가 동일한 저장소에 있기 때문에 코드 스타일과 규칙을 일관되게 적용할 수 있습니다.
- 코드 재사용성: 공통 모듈이나 라이브러리를 쉽게 공유하고 재사용할 수 있습니다.
- 종속성 관리: 프로젝트 간의 종속성을 쉽게 관리할 수 있습니다.
- 통합된 빌드 및 배포: 전체 프로젝트를 한 번에 빌드하고 배포할 수 있어 효율적입니다.
만약 Monorepo를 사용하지 않는다면?
저장소 하나에 프로젝트가 하나씩 존재하는 방식은 폴리레포(Polyrepo)라고 불립니다. 단순한 프로젝트에서는 폴리레포가 관리가 더 쉬울 수 있지만, 프로젝트가 커지고 복잡해질수록 다음과 같은 문제점이 발생할 수 있습니다.
- 코드 중복: 공통 모듈이 여러 저장소에 중복되어 관리되기 쉽습니다.
- 종속성 충돌: 서로 다른 저장소에서 동일한 라이브러리의 다른 버전을 사용할 경우 충돌이 발생할 수 있습니다.
- 일관성 부족: 코드 스타일과 규칙이 저장소마다 다를 수 있습니다.
- 복잡한 빌드 및 배포: 여러 저장소를 개별적으로 빌드하고 배포해야 하므로 관리가 복잡해집니다.
코드 중복의 경우, npm이나 github package로 공통 모듈을 배포하여 해결할 수 있지만, 이는 버전 관리와 배포 프로세스를 추가로 관리해야 한다는 단점이 있습니다.
또한, Polyrepo 방식의 프로젝트가 커질수록 monorepo로 전환하는 작업이 점점 더 어려워질 수 있습니다. 그래서 개인적으로 처음부터 monorepo 방식을 채택하는 것을 선호합니다.
pnpm workspace로 monorepo로 구성하는 방법
Monorepo는 npm, yarn, pnpm같은 패키지 매니저의 워크스페이스 기능을 활용하여 구성할 수 있습니다.
패키지 매니저는 각 프로젝트를 독립적인 패키지로 관리하고, 패키지 간의 연결을 쉽게 할 수 있도록 도와줍니다.
패키지 매니저가 제공하는 워크스페이스 기능
워크스페이스란?
워크스페이스는 여러개의 package.json 파일을 가진 패키지를 하나의 저장소에서 관리할 수 있도록 해줍니다.
이를 통해 모노레포 안의 패키지를 쉽게 연결할 수 있습니다.
pnpm에서 워크스페이스 구성하기
직접 모노레포를 구성하면서 진행하겠습니다. 패키지 매니저는 pnpm을 기준으로 설명드리겠습니다.
먼저, pnpm-workspace.yaml 파일을 생성하여 워크스페이스에 포함될 패키지를 정의합니다.
1packages:
2 - 'apps/*'
3 - 'packages/*'
4- packages 필드는 워크스페이스에 포함될 패키지의 경로 패턴을 지정합니다.
- 보통 apps, packages라는 디렉토리를 만들어 각각 애플리케이션과 공통 모듈을 관리합니다.
- apps 패키지에서는 실제로 배포되는 애플리케이션을 관리합니다.
- packages 패키지에서는 여러 애플리케이션에서 공통으로 사용하는 모듈이나 라이브러리를 관리합니다.
- 반드시 apps와 packages 디렉토리를 사용할 필요는 없으며, 프로젝트 구조에 맞게 자유롭게 구성할 수 있습니다.
패키지 간 의존성 연결하기 1: 패키지 설정하기
워크스페이스 내의 패키지들은 서로 의존성을 가질 수 있습니다.
예를 들어, apps/blog 패키지가 packages/ui 패키지를 의존한다고 가정해보겠습니다.
먼저, packages/ui 패키지의 package.json 파일을 다음과 같이 작성합니다.
1// packages/ui/package.json
2{
3 "name": "@repo/ui", // 패키지 이름 설정
4 "private": true, // 중요! 실수로 npm에 publish되지 않도록 막아주는 설정
5 "version": "0.0.0",
6 "type": "module",
7 "main": "./src/entry.ts", // 패키지의 진입점 설정
8 "types": "./src/entry.ts" // 타입 정의 파일 설정
9 // ...
10}
11- name 필드는 패키지의 이름을 지정합니다. 워크스페이스 내에서 고유해야 합니다.
- 패키지 이름에 @repo와 같은 스코프를 붙이는 것을 권장합니다.
- 해당 패키지가 모노레포에서 사용하는 패키지임을 명시하려는 목적입니다.
- private 필드를 true로 설정하여 이 패키지가 npm에 게시되지 않도록 합니다
중요: 해당 패키지에서 다른 패키지에 노출할 파일을 설정해야 합니다.
- npm 패키지와 동일하게 설정하면 됩니다. 주로 main, types 필드를 사용하거나 exports를 설정합니다.
- 해당 예제에서는 main 필드로 패키지의 진입점 파일을 지정했고, types 필드로 타입 정의 파일을 지정했습니다.
- 본 예제의 특이한 점으로는 패키지에서 ts파일을 그대로 노출한다는 점입니다.
- 이는 ts파일을 컴파일하지 않고, 사용하는 쪽에서 컴파일하도록 하기 위함입니다.
- 이렇게 하면 패키지를 따로따로 빌드할 필요가 없어서 편리합니다.
- 만약, 사용하는 쪽에서 ts를 컴파일 하지 않거나 node_modules의 패키지에 대한 ts 컴파일이 잘 되지 않는다면, 패키지에서 js파일을 노출하도록 설정하면 됩니다.
패키지 간 의존성 연결하기 2: 의존성 추가하기
이제 apps/blog 패키지에서 @repo/ui 패키지를 의존성으로 추가해보겠습니다.
dependencies 또는 devDependencies에 다음과 같이 추가한 다음 pnpm install 명령어를 실행합니다.
1// apps/blog/package.json
2{
3 "name": "blog",
4 "version": "1.0.0",
5 // ...
6 "dependencies": {
7 // 모노레포 내의 패키지를 의존성으로 추가할 때 workspace:* 를 사용한다.
8 "@repo/ui": "workspace:*"
9 }
10}
11| 이름 | 설명 |
|---|---|
@repo/ui | 의존성으로 추가할 모노레포 내의 패키지 이름 |
workspace:* | 워크스페이스 내의 패키지를 의존성으로 추가할 때 사용하는 버전 표기법 |
- 버전 이름에
workspace:*를 붙이는 이유는 패키지 매니저가 해당 패키지를 워크스페이스 내에서 찾도록 하기 위함입니다.
이렇게 안하면 패키지 매니져는 npm registry에서 해당 패키지를 찾으려고 시도합니다.
패키지 매니저가 패키지를 연결하는 방법
그런데 패키지 매니저는 워크스페이스 내의 패키지를 어떻게 연결할까요? 자바스크립트 생태계에서 패키지 매니저를 사용할 때, 설치한 모듈은 node_modules 폴더에 저장됩니다.
그리고 번들러는 node_modules 폴더에서 패키지를 찾아 사용합니다. 그렇다면 워크스페이스 내의 패키지는 node_modules 폴더에 어떻게 연결될까요?
위에서 설치한 @repo/ui 패키지가 apps/blog 패키지의 node_modules 폴더에 어떻게 연결되는지 살펴보겠습니다.
"apps/blog의 node_modules에 추가된 @repo/ui"
apps/blog 폴더의 node_modules를 확인해보면 @repo/ui 패키지가 존재하는 것을 알 수 있습니다. 그런데, 자세히 보면 @repo/ui 폴더 옆에 ↳ 아이콘이 붙어있는 것을 볼 수 있습니다.
이는 해당 폴더가 심볼릭 링크(Symbolic Link)로 연결되었음을 나타냅니다. 즉, @repo/ui 패키지는 실제로 apps/blog 패키지의 node_modules 폴더에 복사된 것이 아니라, 심볼릭 링크로 연결된 것입니다.
덕분에 아래와 같은 장점을 가집니다.
- 디스크 공간 절약: 패키지가 복사되지 않고 링크로 연결되기 때문에 디스크 공간을 절약할 수 있습니다.
- 실시간 변경 반영: 패키지의 소스 코드가 변경되면, 해당 변경 사항이 즉시 반영됩니다.
의존성 모듈 사용하기
이제 apps/blog 패키지에서 @repo/ui 패키지를 사용할 수 있습니다.
다음과 같이 import 문을 사용하여 @repo/ui 패키지의 모듈을 불러올 수 있습니다.
1import './style.css';
2import { setupCounter } from '@repo/ui';
3
4setupCounter(document.querySelector<HTMLButtonElement>('#counter'));
5이 때 @repo/ui에서 import할 수 있는 파일은 packages/ui/package.json 파일에서 설정한 main, types, exports 필드에 따라 결정됩니다.
만약 import 하려는 파일이 노출되지 않았다면, 두가지를 의심해볼 수 있습니다.
- package.json의 dependencies 또는 devDependencies에 패키지가 추가되지 않았거나 install하지 않았음.
- 의존성 패키지의 package.json에서 해당 파일이 노출되지 않음.(main, types, exports 필드 확인 필요)
본 예제는 pnpm monorepo 예제 저장소에서 확인할 수 있습니다.
turbo repo
turbo repo는 next.js로 유명한 Vercel에서 만든 모노레포 관리 도구입니다.
최근 가장 인기있는 모노레포 관리 도구 중 하나입니다.
그런데 사실 turbo repo는 패키지 매니저의 워크스페이스 기능을 기반으로 동작합니다. 그래서 앞서 설명한 pnpm 워크스페이스 설정과 거의 동일하게 구성합니다.
그렇다면 turbo repo를 사용하는 이유는 무엇일까요?
turbo의 장점
| 장점 | 설명 |
|---|---|
| 고성능 빌드 시스템 | turbo는 변경된 파일만 빌드하는 변경 감지 기반 캐싱을 사용하여 빌드 속도를 크게 향상시킵니다. |
| 캐싱 및 병렬 처리 | 빌드 결과를 캐싱하여 동일한 작업을 반복할 때 시간을 절약하고, 병렬 처리를 통해 빌드 시간을 단축합니다. |
| 통합된 워크플로우 | turbo는 테스트, 린트, 배포 등 다양한 작업을 통합하여 일관된 워크플로우를 제공합니다. |
pnpm 워크스페이스에서 못하는 부분을 turbo가 보완해준다고 생각하면 됩니다.
turbo로 모노레포 구성하기
앞서 생성한 pnpm 워크스페이스 프로젝트에서 turbo를 추가해보겠습니다.
먼저, 저장소 루트 경로에 turbo를 devDependency로 추가합니다.
1# --workspace-root 옵션을 사용하여 워크스페이스 루트에 설치합니다.
2pnpm add turbo --save-dev --workspace-root
3turbo를 통해 빌드 파이프라인 구성하기
이제 turbo를 사용하여 빌드 파이프라인을 구성해보겠습니다. 빌드 파이프라인이란 프로젝트의 빌드, 테스트, 린트 등의 작업을 정의하고 실행하는 과정을 의미합니다.
먼저 turbo.json 파일을 생성하여 turbo의 설정을 추가합니다. 해당 설정을 통해 빌드 파이프라인을 구성할 수 있습니다.
1{
2 "$schema": "https://turborepo.com/schema.json",
3 // turbo 명령어로 실행할 작업들을 정의합니다.
4 "tasks": {
5 "build": {
6 // 다른 작업에 의존성을 가질 수 있습니다.
7 "dependsOn": ["^build"],
8 // 해당 작업에 대한 입력 파일을 지정합니다. 여기에 명시된 파일이 수정되면 캐시를 무효화합니다.
9 "inputs": [],
10 // 빌드 결과물을 캐싱합니다.
11 "outputs": ["dist/**", ".next/**"]
12 },
13 "lint": {},
14 "test": {}
15 }
16}
17| 이름 | 설명 |
|---|---|
$schema | turbo 설정 파일의 스키마를 지정합니다. |
tasks | turbo 명령어로 실행할 작업들을 정의합니다. |
dependsOn | 다른 작업에 대한 의존성을 정의합니다. |
inputs | 작업의 입력 파일을 지정하여 캐싱을 제어합니다. |
outputs | 작업의 빌드 결과물을 지정하여 캐싱합니다. |
dependsOn 설정으로 인해 build 작업은 다른 패키지의 build 작업에 의존성을 가집니다. 즉, 어떤 패키지의 build 작업을 실행하기 전에 해당 패키지가 의존하는 다른 패키지들의 build 작업이 먼저 실행됩니다.
우리 프로젝트에선 apps/blog 패키지가 packages/ui 패키지를 의존하므로, apps/blog의 build 작업을 실행할 때 packages/ui의 build 작업이 먼저 실행됩니다.
이를 통해 패키지 간의 의존성 관계를 고려한 빌드 순서를 자동으로 관리할 수 있습니다.
turbo 명령어 실행하기
이제 turbo 명령어를 실행하여 빌드 파이프라인을 테스트해보겠습니다. 프로젝트 루트 경로에서 다음 명령어를 실행합니다.
1pnpm turbo run build
2이와 같이 실행하면 turbo가 설정된 빌드 파이프라인에 따라 각 패키지의 build 작업을 순차적으로 실행합니다.
명령어가 너무 길어서 불편하다면, package.json 파일에 스크립트로 추가할 수 있습니다.
1// package.json
2{
3 "scripts": {
4 "build": "turbo run build"
5 }
6}
7이제 다음과 같이 간단하게 빌드 명령어를 실행할 수 있습니다.
1pnpm run build
2turbo repo 예제 코드는 turbo repo 예제 저장소에서 확인할 수 있습니다.
마치며
이상으로 Monorepo의 개념과 패키지 매니저의 워크스페이스 기능을 활용하여 Monorepo를 구성하는 방법에 대해 알아보았습니다.
Monorepo는 프로젝트의 규모가 커질수록 그 진가를 발휘하며, 코드베이스의 일관성과 관리 효율성을 크게 향상시킬 수 있습니다.
앞으로의 프론트엔드 프로젝트에서 Monorepo 방식을 적극적으로 고려해보시길 권장드립니다.