Pnpm으로 Monorepo 구축하기 - 2. 프로젝트 구축(Vite, React, TypeScript)

YI·2024년 8월 1일
5
post-thumbnail

이번 글에서는 Pnpm을 사용해 모노레포를 구축하는 구체적인 과정을 다룹니다. 모노레포의 기본 구조를 설계하고, 필요한 설정 파일을 작성하며, 프로젝트의 초기 셋업을 완료하는 방법을 단계별로 정리하려고 합니다.

해당 글은 Pnpm을 사용해 Vite 기반의 React와 TypeScript 프로젝트를 모노레포로 구축하는 방법을 다룹니다. (모노레포 관리를 도와주는 Lerna, Turborepo와 같은 도구는 사용하지 않습니다.)

1️⃣ pnpm 설치

  • Node v16.14 이상 필요
npm i -g pnpm

2️⃣ 프로젝트 생성

mkdir pnpm-monorepo
cd pnpm-monorepo

3️⃣ 프로젝트 초기화

pnpm init
  • pnpm init 실행 시, 아래와 같은 package.json 파일이 생성됩니다.
{
  "name": "pnpm-monorepo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

4️⃣ 프로젝트 구조

저는 아래와 같은 프로젝트 구조를 사용했습니다. 해당 구조에 맞게 필요한 디렉토리를 생성해줍니다.

pnpm-monorepo/
├── apps/                          # 여러 애플리케이션을 포함하는 디렉토리
│   ├── project-A/                 # A 프로젝트
├── packages/                      # 공통 패키지 및 라이브러리를 포함하는 디렉토리
├── scripts/                       
│   ├── scaffolding/               # 프로젝트 생성을 자동화하는 스크립트 디렉토리
├── pnpm-workspace.yaml            # pnpm workspace 설정 파일
├── package.json

5️⃣ Workspace 설정

  • Pnpm의 Workspace는 여러 개의 패키지를 하나의 모노레포에서 관리할 수 있게 해주는 기능입니다.

Workspace의 주요 기능

1) 단일 레포지토리에서 여러 패키지 관리

  • 모노레포 구조를 통해 여러 프로젝트나 라이브러리를 하나의 레포지토리에서 관리할 수 있습니다.

2) 의존성 공유

  • 공통으로 사용되는 의존성들을 Root의 node_modules에 설치하여, 모든 패키지에서 이를 사용하여 중복 설치를 줄이고 디스크 공간을 절약합니다.

3) 로컬 패키지 간 의존성 관리

  • Workspace 내의 다른 패키지를 의존성을 추가할 때, 해당 패키지를 다운로드하지 않고 로컬 파일 시스템을 통해 해당 패키지를 참조합니다.
  • 예를 들어, package-b에서 package-a를 의존성으로 추가하는 경우,package-b는 package-a의 최신 버전을 로컬에서 직접 참조하게 되며, 코드 변경 사항이 즉시 반영됩니다.

pnpm-workspace.yaml

위와 같은 workspace는 모노레포 Root에 pnpm-workspace.yaml 파일을 생성하여 설정합니다. 이 파일에서는 어떤 디렉토리들을 workspace에 포함시킬지를 지정합니다.

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

6️⃣ TypeScript 설정

pnpm workspace에서는 tsconfig.json을 다음 두 가지 방식 중 하나로 작성할 수 있습니다.

1) 멀티레포 구조처럼 각 패키지별로 별도의 tsconfig.json 작성하기
2) 공통 설정사항을 Root의 tsconfig.json에 두고, 개별 설정 사항을 각 패키지의 tsconfig.json에 추가하는 법

1번은 가장 간단하고 쉬운 방법입니다. 멀티레포에서와 동일하게 설정하면 되고, root에 별도의 tsconfig 파일이 존재하지 않아도 됩니다.

2번은 1번에 비해 설정이 복잡합니다. 하지만 이 방법을 사용하면, 공통 설정을 Root에 관리할 수 있다는 장점이 있습니다. 여기서는 2번 방법을 사용하는 방법을 설명합니다.

① TypeScript 설치

pnpm add -D typescript @types/node -w
  • -w: Pnpm에게 패키지를 Workspace 루트에 설치하도록 하는 flag입니다.

② tsconfig.base.json 작성

  • Root에 tsconfig.base.json 파일을 생성한 뒤, 아래의 내용을 추가합니다. (개별 설정에 따라 적절히 수정하면 됩니다.)
# 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.json 파일을 원하는 설정에 맞게 조정할 수 있습니다. 그러나, 공통 설정을 유지하기 위해서는 반드시 tsconfig.base.json을 extends해야 합니다.

# ex) apps/sample-project/package.json
{
	extends : "../../tsconfig.base.json", 
  	...
}

7️⃣ ESLint, Prettier, Husky, lint-staged 설정 (선택)

Prettier 설정

Prettier의 경우 대부분 같은 포맷팅 설정을 공유하는 경우가 많기 때문에 Root에 한 번만 설정해서 사용합니다.

① Prettier 설치

pnpm add -D prettier -w

.prettierignore 파일 생성

coverage
public
dist
pnpm-lock.yaml
pnpm-workspace.yaml

.prettierrc 파일 생성

개별 프로젝트에 동일한 파일이 존재한다면, 중복이 발생할 수 있기 때문에 개별 프로젝트의 prettier 관련 설정 파일을 삭제해주는 것이 좋습니다.

{
  "semi": true,
  "singleQuote": true
  
  # 원하는 설정 추가
}

④ VSCode 설정

  1. VSCode의 Prettier - Code formatter 플러그인 설치
  2. 파일 저장 시, 자동으로 Workspace 내의 코드가 포멧팅 되도록 설정
mkdir .vscode && touch .vscode/settings.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode"
}

ESLint 설정

ESLint를 작성하는 방법은 크게 3가지가 있습니다.

1) 개별 프로젝트에 ESLint를 설정하고, 개별 프로젝트에서 관리한다.
2) Root에 ESLint를 설정하고, Root에서 관리한다.
3) Root에 ESLint를 설정하고, 개별 사항은 개별 프로젝트에서 관리한다.

이번 글에서는 2번 방법을 사용합니다. ESLint와 Prettier 설정이 기본적으로 포함되어 있는 CRA와는 달리, Vite의 템플릿은 ESLint와 Prettier 설정이 포함되어있지 않습니다. 그래서 Vite 프로젝트에서는 매번 ESLint와 Prettier 설정을 추가해줘야하는 불편함이 있기 때문에, Root에서 관리하는 방법을 선택했습니다.

① ESLint 설치

다음 명령어를 통해 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 파일

# .eslintignore
coverage
public
dist
pnpm-lock.yaml
pnpm-workspace.yaml

③ .eslintrc.cjs 파일

# .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: {},
};

Prettier와 ESLint 통합

ESLint에는 코드 퀄리티 관련 규칙과 스타일 관련 규칙이 있습니다. Prettier를 사용하는 경우, ESLint 스타일 규칙과 충돌해 문제가 생길 수 있기 때문에 이를 방지하기 위한 플러그인들을 추가적으로 설치합니다.

① 플러그인 설치

pnpm add -D eslint-config-prettier eslint-plugin-prettier -w

② .eslintrc.cjs에 플러그인 추가

module.exports = {
  extends: [..., 'plugin:prettier/recommended'],
}

③ package.json 수정

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 .",
  },

Husky & lint-staged 설정

① Husky, lint-staged 설치

pnpm add -D husky -w
pnpm add -D lint-staged -w
pnpm exec husky init

② .husky/pre-commit 파일 생성

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpm lint-staged

③ package.json에 실행 스크립트 추가

"scripts": {
	"postinstall": "npx mrm lint-staged",
	"prepare": "husky i"
},
"lint-staged": {
    "**/*.{js,ts,tsx}": [
      "eslint --fix"
    ],
},

8️⃣ 예시 : 패키지 만들기(with. Vite)

유틸 함수를 공유하는 패키지를 만들어보며 모노레포에서 어떻게 내부 패키지를 공유할 수 있는지 알아보겠습니다.

① 프로젝트 생성

먼저 Vite의 vanilla-ts 템플릿을 사용해 프로젝트를 만들어줍니다.

cd packages
pnpm create vite common --template vanilla-ts

cd common
pnpm install

② tsconfig.json 수정

Root에서 설정한 공통 tsconfig.base.json을 extends합니다.

{
  "extends": "../../tsconfig.base.json"
}

③ Root의 package.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.config.ts 설정 수정

기본적으로 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()],
})

⑥ package.json 수정

common 프로젝트의 pakcage.json에 아래 코드를 추가합니다.

main과 types 필드는 라이브러리나 패키지의 진입점(entry point)과 타입 정의 파일의 위치를 지정하는 데 사용됩니다. 이를 통해 다른 프로젝트가 해당 패키지를 사용할 때 적절한 파일을 참조할 수 있도록 합니다.

 "main": "./dist/utils.js",
 "types": "./dist/main.d.ts",

9️⃣ 예시 : app 만들기 (with. Vite)

이번에는 React 프로젝트를 만들어, 위에서 만든 유틸 함수 라이브러리를 사용하는 방법에 대해 알아보겠습니다.

① 프로젝트 생성

cd apps
pnpm create vite sample-app --template react-ts

cd sample-app
pnpm install

② tsconfig.json 수정 & Root의 package.json에 프로젝트 실행 스크립트 추가 (선택)

위의 패키지에서 했던 것과 동일하게, tsconfig.json과 Root의 packages.json을 수정해줍니다.

③ 유틸 함수 라이브러리 설치(@common/utils)

이전 단계에서 만든 내부 라이브러리를 가져와 프로젝트에 사용하는 방법은 아래와 같습니다.

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 모노레포 세팅

profile
Junior Frontend Developer

2개의 댓글

comment-user-thumbnail
2025년 2월 27일

이렇게 자세할수가... 잘 보고 갑니다..!

답글 달기
comment-user-thumbnail
2025년 5월 30일

오! 잘 봤습니다 매우 유익했습니다!

답글 달기