이번 글에서는 Pnpm을 사용해 모노레포를 구축하는 구체적인 과정을 다룹니다. 모노레포의 기본 구조를 설계하고, 필요한 설정 파일을 작성하며, 프로젝트의 초기 셋업을 완료하는 방법을 단계별로 정리하려고 합니다.
해당 글은 Pnpm을 사용해 Vite 기반의 React와 TypeScript 프로젝트를 모노레포로 구축하는 방법을 다룹니다. (모노레포 관리를 도와주는 Lerna, Turborepo와 같은 도구는 사용하지 않습니다.)
npm i -g pnpm
mkdir pnpm-monorepo
cd pnpm-monorepo
pnpm init
{
"name": "pnpm-monorepo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
저는 아래와 같은 프로젝트 구조를 사용했습니다. 해당 구조에 맞게 필요한 디렉토리를 생성해줍니다.
pnpm-monorepo/
├── apps/ # 여러 애플리케이션을 포함하는 디렉토리
│ ├── project-A/ # A 프로젝트
├── packages/ # 공통 패키지 및 라이브러리를 포함하는 디렉토리
├── scripts/
│ ├── scaffolding/ # 프로젝트 생성을 자동화하는 스크립트 디렉토리
├── pnpm-workspace.yaml # pnpm workspace 설정 파일
├── package.json
1) 단일 레포지토리에서 여러 패키지 관리
2) 의존성 공유
3) 로컬 패키지 간 의존성 관리
위와 같은 workspace는 모노레포 Root에 pnpm-workspace.yaml 파일을 생성하여 설정합니다. 이 파일에서는 어떤 디렉토리들을 workspace에 포함시킬지를 지정합니다.
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
pnpm workspace에서는 tsconfig.json을 다음 두 가지 방식 중 하나로 작성할 수 있습니다.
1) 멀티레포 구조처럼 각 패키지별로 별도의 tsconfig.json
작성하기
2) 공통 설정사항을 Root의 tsconfig.json
에 두고, 개별 설정 사항을 각 패키지의 tsconfig.json
에 추가하는 법
1번은 가장 간단하고 쉬운 방법입니다. 멀티레포에서와 동일하게 설정하면 되고, root에 별도의 tsconfig 파일이 존재하지 않아도 됩니다.
2번은 1번에 비해 설정이 복잡합니다. 하지만 이 방법을 사용하면, 공통 설정을 Root에 관리할 수 있다는 장점이 있습니다. 여기서는 2번 방법을 사용하는 방법을 설명합니다.
pnpm add -D typescript @types/node -w
-w
: Pnpm에게 패키지를 Workspace 루트에 설치하도록 하는 flag입니다.# tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"sourceMap": true,
"jsx": "react-jsx",
"target": "es5"
}
}
모노레포 내의 개별 프로젝트에서는 각자의 tsconfig.json 파일을 원하는 설정에 맞게 조정할 수 있습니다. 그러나, 공통 설정을 유지하기 위해서는 반드시 tsconfig.base.json을 extends해야 합니다.
# ex) apps/sample-project/package.json
{
extends : "../../tsconfig.base.json",
...
}
Prettier의 경우 대부분 같은 포맷팅 설정을 공유하는 경우가 많기 때문에 Root에 한 번만 설정해서 사용합니다.
pnpm add -D prettier -w
.prettierignore
파일 생성coverage
public
dist
pnpm-lock.yaml
pnpm-workspace.yaml
.prettierrc
파일 생성개별 프로젝트에 동일한 파일이 존재한다면, 중복이 발생할 수 있기 때문에 개별 프로젝트의 prettier 관련 설정 파일을 삭제해주는 것이 좋습니다.
{
"semi": true,
"singleQuote": true
# 원하는 설정 추가
}
Prettier - Code formatter
플러그인 설치 mkdir .vscode && touch .vscode/settings.json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
ESLint를 작성하는 방법은 크게 3가지가 있습니다.
1) 개별 프로젝트에 ESLint를 설정하고, 개별 프로젝트에서 관리한다.
2) Root에 ESLint를 설정하고, Root에서 관리한다.
3) Root에 ESLint를 설정하고, 개별 사항은 개별 프로젝트에서 관리한다.
이번 글에서는 2번 방법을 사용합니다. ESLint와 Prettier 설정이 기본적으로 포함되어 있는 CRA와는 달리, Vite의 템플릿은 ESLint와 Prettier 설정이 포함되어있지 않습니다. 그래서 Vite 프로젝트에서는 매번 ESLint와 Prettier 설정을 추가해줘야하는 불편함이 있기 때문에, Root에서 관리하는 방법을 선택했습니다.
다음 명령어를 통해 ESLint와 관련 패키지를 설치합니다.
pnpm add -D eslint@8.x eslint-plugin-react @typescript-eslint/parser @typescript-eslint/eslint-plugin -w
⚠️ Note!
최신 버전인 ESLint v9 버전이 일부 React 플러그인과 호환되지 않기 때문에, ESLint v8 버전을 사용하고 있습니다.
# .eslintignore
coverage
public
dist
pnpm-lock.yaml
pnpm-workspace.yaml
# .eslintrc.cjs
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
rules: {},
};
ESLint에는 코드 퀄리티 관련 규칙과 스타일 관련 규칙이 있습니다. Prettier를 사용하는 경우, ESLint 스타일 규칙과 충돌해 문제가 생길 수 있기 때문에 이를 방지하기 위한 플러그인들을 추가적으로 설치합니다.
pnpm add -D eslint-config-prettier eslint-plugin-prettier -w
module.exports = {
extends: [..., 'plugin:prettier/recommended'],
}
Linter와 Prettier를 실행할 script를 package.json에 추가합니다.
pnpm pkg set scripts.lint="eslint ."
pnpm pkg set scripts.format="prettier --write ."
명령어 실행 시, pacakge.json에 아래와 같이 추가됩니다.
"scripts": {
"lint": "eslint .",
"format": "prettier --write .",
},
pnpm add -D husky -w
pnpm add -D lint-staged -w
pnpm exec husky init
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
"scripts": {
"postinstall": "npx mrm lint-staged",
"prepare": "husky i"
},
"lint-staged": {
"**/*.{js,ts,tsx}": [
"eslint --fix"
],
},
유틸 함수를 공유하는 패키지를 만들어보며 모노레포에서 어떻게 내부 패키지를 공유할 수 있는지 알아보겠습니다.
먼저 Vite의 vanilla-ts 템플릿을 사용해 프로젝트를 만들어줍니다.
cd packages
pnpm create vite common --template vanilla-ts
cd common
pnpm install
Root에서 설정한 공통 tsconfig.base.json을 extends합니다.
{
"extends": "../../tsconfig.base.json"
}
매번 프로젝트를 실행할 때마다, 직접 프로젝트 경로로 이동해 실행하지 않고 Root에서 바로 실행할 수 있도록 Root에 아래와 같은 스크립트를 추가해줍니다.
# Root의 package.json
"scripts": {
"common": "pnpm --filter @common/utils",
}
이 스크립트를 추가하면, Root에서 pnpm common dev
명령어 실행 시 common 프로젝트가 실행됩니다.
pnpm --filter @common/utils
이 명령어에서 "common"은 특정 프로젝트의 이름을 나타냅니다. 이 부분은 사용자가 작업하려는 개별 프로젝트의 이름으로 수정할 수 있으며, 해당 이름은 각 프로젝트의 package.json 파일 내의 "name" 필드로 구분됩니다.
common/src/mains.ts
에 아래와 같은 함수를 작성합니다.
export const getCommonText = () => 'common 패키지에서 왔습니다.';
기본적으로 Vite는 App 모드로 에셋(JS, CSS, 이미지 파일 등)을 빌드하고, index.html을 엔트리 파일로 사용합니다. 하지만 위와 같은 유틸 함수 패키지를 빌드하는 경우에는 index.html 대신 main.ts 파일이 엔트리 파일로 노출되어야 합니다. 이러한 설정을 vite.config.ts
에서 수정할 수 있습니다.
먼저 라이브러리에서 타입 정의를 자동 생성하는 Vite 패키지를 설치합니다.
pnpm add -D vite-plugin-dts
그 다음, vite.config.ts 파일 생성하고 아래와 같이 작성합니다.
touch vite.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'path'
import dts from 'vite-plugin-dts'
// https://vitejs.dev/config/
export default defineConfig({
build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'] } },
resolve: { alias: { src: resolve('src/') } },
plugins: [dts()],
})
common 프로젝트의 pakcage.json에 아래 코드를 추가합니다.
main과 types 필드는 라이브러리나 패키지의 진입점(entry point)과 타입 정의 파일의 위치를 지정하는 데 사용됩니다. 이를 통해 다른 프로젝트가 해당 패키지를 사용할 때 적절한 파일을 참조할 수 있도록 합니다.
"main": "./dist/utils.js",
"types": "./dist/main.d.ts",
이번에는 React 프로젝트를 만들어, 위에서 만든 유틸 함수 라이브러리를 사용하는 방법에 대해 알아보겠습니다.
cd apps
pnpm create vite sample-app --template react-ts
cd sample-app
pnpm install
위의 패키지에서 했던 것과 동일하게, tsconfig.json과 Root의 packages.json을 수정해줍니다.
이전 단계에서 만든 내부 라이브러리를 가져와 프로젝트에 사용하는 방법은 아래와 같습니다.
pnpm add @common/utils
이 명령어는 @common/utils 패키지를 현재 프로젝트의 의존성으로 추가합니다. pnpm은 패키지를 모노레포의 루트에서 workspace의 종속성을 인식하고 설치합니다.
그리고 sample-app/package.json을 아래와 같이 수정합니다.
# sample-app/pacakge.json
"dependencies": {
"@common/utils": "workspace:*",
...,
}
여기서 "workspace:*"는 pnpm에게 @common/utils 패키지를 로컬의 workspace 내에서 직접 참조하도록 지시합니다.
App.tsx에서 common 패키지의 유틸 함수를 사용하려면 먼저 @common/utils 패키지를 빌드해야 합니다.
❓ @common/utils 패키지 빌드가 필요한 이유?
@common/utils 패키지를 라이브러리로 사용하기 위해서는, package.json의 main과 types 필드가 빌드된 파일을 가리키도록 설정했습니다. 이 설정은 라이브러리의 최적화된 번들 파일과 타입 정의 파일을 지정하여, 다른 프로젝트에서 안정적이고 일관된 방식으로 사용할 수 있도록 보장합니다. 따라서, 빌드 과정 없이 최신 변경 사항이 반영되지 않을 수 있으며, 빌드를 통해 최종 결과물인 번들 파일과 타입 정의 파일이 생성되어야만 정상적으로 라이브러리를 활용할 수 있습니다.
# packages/common 경로에서 @common/utils 패키지 빌드
pnpm build
빌드 후, 아래와 같이 import하여 사용하면 됩니다.
import { getCommonText } from '@common/utils';
import './App.css';
function App() {
return (
<>
<div>{getCommonText()}</div>
</>
);
}
export default App;
이렇게 내부 라이브러리가 변경될 때마다 매번 빌드하는 것은 매우 귀찮은 일입니다.
하지만 pnpm과 Vite를 사용하는 경우, 내부 라이브러리의 변경 사항을 매번 빌드하지 않고도 효율적으로 반영할 수 있습니다.
1. pnpm의 심볼릭 링크
pnpm은 패키지를 심볼릭 링크로 연결합니다. 이는 패키지를 실제로 복사하는 대신, 해당 패키지의 소스가 있는 경로를 참조하는 링크를 생성합니다. 예를 들어, sample-app은 실제로 @common/utils 패키지 코드 전체를 복사하지 않고, 그 패키지의 소스 코드가 있는 위치를 링크로 참조합니다.
이 덕분에 @common/utils의 소스 코드가 변경되면, sample-app에서 해당 코드의 변경 사항이 즉시 반영됩니다.
하지만 현재 @common/utils 패키지의 package.json의 main과 types 필드가 빌드된 파일을 가리키도록 설정되어 있기 때문에, 실제로는 빌드된 결과물만을 참조하게 됩니다. 이로 인해 패키지를 빌드하지 않으면 sample-app에서는 변경된 소스 코드가 자동으로 반영되지 않습니다.
2. Vite의 HMR (Hot Module Replacement)
이 때 필요한 것이 Vite의 HMR 기능입니다.
Vite는 HMR을 통해 코드 변경 시 페이지를 새로 고치지 않고도 브라우저에서 즉시 변경 사항을 반영합니다. 이 기능은 개발 중에 파일이 수정될 때마다 전체 페이지를 다시 로드할 필요 없이, 변경된 모듈만 업데이트하여 빠르게 결과를 확인할 수 있게 합니다.
Vite 설정을 조정하여 로컬 패키지의 소스 파일을 직접 참조하게 함으로써, 빌드된 결과물이 아니라 원본 소스 파일을 사용하도록 합니다. 이렇게 하면 다른 패키지의 변경 사항이 실시간으로 반영되게 할 수 있습니다.
① resolve.alias 설정
Vite 설정 파일(vite.config.ts)에서 resolve.alias를 사용하여 패키지의 소스 파일을 직접 참조하도록 설정합니다.
# sample-app/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import * as path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@common/utils': path.resolve(
__dirname,
'../../packages/common/src/main.ts',
),
},
},
});
이 설정을 통해, @common/utils를 import할 때, '../../packages/common/src/main.ts'소스 파일을 직접 참조하게 됩니다. 이로써 패키지의 빌드된 결과물 대신 소스 파일이 사용되며, HMR(핫 모듈 교체) 기능이 활성화됩니다.
② TypeScript 설정 업데이트
VSCode에서 타입 변경 사항을 감지하고 자동 완성을 지원하도록 TypeScript 설정을 업데이트합니다.
{
"compilerOptions": {
"rootDir": "../../",
"baseUrl": ".",
"paths": {
"@common/utils": ["../../packages/common/src/main.ts"]
}
},
"include": ["src", "../../packages/common/src/**/*"]
}
rootDir
: 은 TypeScript 컴파일러가 소스 파일들을 어디에서 찾을지를 지정합니다. 기본적으로는 rootDir이 apps/sample-app으로 설정되어 있는데, packages/common에 있는 파일을 찾지 못하여 에러가 발생합니다. rootDir을 sample-app 디렉토리로 설정하지 않고, 공통적으로 사용하는 디렉토리를 모두 포함하도록 설정할 수 모노레포의 Root로 설정해줍니다. 이렇게 하면 아래와 같이 라이브러리의 변경 사항이 실시간으로 반영되는 것을 확인할 수 있습니다.
이 글에서는 pnpm을 사용하여 Vite 기반의 React와 TypeScript 프로젝트를 모노레포로 구축하는 방법을 단계별로 다루었습니다. 모노레포 환경에서 여러 프로젝트와 패키지를 효율적으로 관리하고, 개발 중 실시간으로 코드 변경 사항을 반영하는 방법을 살펴보았습니다.
혼자 모노레포를 구성하고 실제로 프로젝트를 개발하고 배포하는 과정에서 많은 시행착오를 겪고 많은 것을 배웠습니다. 이 글을 통해 모노레포를 도입하고 운영하는 데 있어 실질적인 가이드가 되기를 바랍니다.
언제든 궁금한 점이 있다면 편하게 질문해주세요.
감사합니다!
참조
Create a monorepo using PNPM workspace
How to fire HMR update when local dependency changes in monorepo
pnpm과 함께하는 Frontend 모노레포 세팅
이렇게 자세할수가... 잘 보고 갑니다..!