포스트

Monorepo 구축 완벽 가이드 - pnpm + Turborepo로 대규모 프로젝트 관리하기

pnpm workspace와 Turborepo를 활용한 효율적인 Monorepo 구축 방법을 실전 예제와 함께 상세히 알아봅니다.

Monorepo 구축 완벽 가이드 - pnpm + Turborepo로 대규모 프로젝트 관리하기

대규모 프로젝트를 관리하다 보면 여러 패키지 간의 의존성, 중복 코드, 일관성 없는 빌드 프로세스로 인해 어려움을 겪게 됩니다. Monorepo는 이러한 문제를 해결하기 위한 강력한 솔루션입니다. 이 글에서는 pnpm과 Turborepo를 활용하여 효율적인 Monorepo를 구축하는 방법을 상세히 알아보겠습니다.

Monorepo란?

Monorepo(Monolithic Repository)는 여러 프로젝트나 패키지를 하나의 저장소에서 관리하는 소프트웨어 개발 전략입니다. Google, Facebook, Microsoft 등 많은 대형 기업들이 채택하고 있는 방식입니다.

Monorepo vs Polyrepo

Polyrepo (전통적 방식)

1
2
3
4
5
my-company/
├── frontend-app/       # 별도 저장소
├── backend-api/        # 별도 저장소
├── shared-ui/          # 별도 저장소
└── shared-utils/       # 별도 저장소

Monorepo

1
2
3
4
5
6
7
8
my-company/
├── apps/
│   ├── frontend/
│   └── backend/
├── packages/
│   ├── ui/
│   └── utils/
└── package.json

Monorepo의 장점

1. 코드 공유 용이성

  • 공통 컴포넌트, 유틸리티, 타입 정의를 쉽게 공유
  • import 경로 간소화: import { Button } from '@company/ui'
  • 버전 불일치 문제 해결

2. 원자적 커밋 (Atomic Commits)

  • 여러 패키지의 변경사항을 하나의 커밋으로 관리
  • API 변경 시 모든 관련 코드를 동시에 업데이트
  • 코드 리뷰가 더 명확해짐

3. 개발 환경 일관성

  • 통일된 린트, 포맷터, 테스트 설정
  • 단일 CI/CD 파이프라인
  • 개발자 온보딩 시간 단축

4. 대규모 리팩토링 용이

  • 전체 코드베이스를 한 번에 검색 및 수정
  • 자동화된 마이그레이션 스크립트 실행 가능
  • 영향 범위 파악 용이

Monorepo의 단점

1. 저장소 크기

  • 클론 시간 증가 (Git shallow clone으로 완화 가능)
  • 디스크 공간 필요

2. 접근 권한 관리

  • 세밀한 권한 설정 어려움
  • CODEOWNERS 파일로 부분적 해결 가능

3. 빌드 시간

  • 전체 빌드 시간 증가 (캐싱으로 해결 가능)
  • 적절한 도구 없이는 비효율적

언제 Monorepo를 선택해야 하는가?

Monorepo가 적합한 경우:

  • 여러 프로젝트가 코드를 공유하는 경우
  • 팀 간 협업이 빈번한 경우
  • 일관된 개발 환경이 중요한 경우
  • 마이크로 프론트엔드 아키텍처
  • 디자인 시스템과 여러 앱을 함께 관리

Polyrepo가 적합한 경우:

  • 완전히 독립적인 프로젝트
  • 각 팀이 완전히 다른 기술 스택 사용
  • 외부 공개가 필요한 오픈소스 프로젝트
  • 매우 작은 팀 (2-3명)

도구 선택: pnpm + Turborepo

pnpm이 npm/yarn보다 좋은 이유

1. 디스크 공간 효율성

pnpm은 content-addressable storage를 사용하여 의존성을 전역 저장소에 한 번만 저장합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 일반적인 npm/yarn
~/projects/
├── project1/node_modules/react (100MB)
├── project2/node_modules/react (100MB)
└── project3/node_modules/react (100MB)
# 총 300MB 사용

# pnpm
~/.pnpm-store/
└── react@18.2.0 (100MB)
~/projects/
├── project1/node_modules/react -> symlink
├── project2/node_modules/react -> symlink
└── project3/node_modules/react -> symlink
# 총 100MB 사용 (70% 절약)

2. 엄격한 의존성 관리

pnpm은 package.json에 명시되지 않은 패키지에 접근할 수 없도록 합니다.

1
2
3
4
5
// npm/yarn: 동작함 (잘못된 동작)
import _ from 'lodash'; // package.json에 없어도 부모가 설치했다면 동작

// pnpm: 에러 발생 (올바른 동작)
// Error: Cannot find module 'lodash'

3. workspace 프로토콜

1
2
3
4
5
6
{
  "dependencies": {
    "@company/ui": "workspace:*",
    "@company/utils": "workspace:^"
  }
}

4. 성능 비교

실제 벤치마크 (React 프로젝트 기준):

작업npmyarnpnpm
첫 설치51초39초24초
캐시 있는 설치27초13초7초
디스크 공간178MB165MB105MB

Turborepo의 핵심 기능

1. 스마트 캐싱

Turborepo는 태스크 입력(소스 코드, 의존성)을 기반으로 해시를 생성하고, 결과를 캐시합니다.

1
2
3
4
5
6
7
# 첫 번째 실행
turbo run build
# >>> FULL TURBO (43.2초)

# 변경사항 없이 다시 실행
turbo run build
# >>> CACHED (0.1초)

2. 병렬 실행

1
2
3
4
5
6
7
8
9
10
11
# 일반적인 실행 (순차적)
npm run build  # packages/ui: 10초
npm run build  # packages/utils: 8초
npm run build  # apps/web: 15초
# 총 33초

# Turborepo (병렬)
turbo run build
# packages/ui + packages/utils 동시 실행: 10초
# apps/web (의존성 대기 후 실행): 15초
# 총 25초

3. 의존성 기반 실행 순서

1
2
3
4
5
6
7
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

^build는 “이 패키지를 빌드하기 전에 의존하는 모든 패키지를 먼저 빌드하라”는 의미입니다.

Nx vs Turborepo 비교

항목TurborepoNx
학습 곡선낮음중간
설정 복잡도간단복잡
캐싱로컬/리모트로컬/리모트
플러그인 생태계제한적풍부
프레임워크 통합프레임워크 독립적Angular, React 등 내장
마이그레이션쉬움어려움
성능매우 빠름빠름
적합한 경우단순하고 빠른 설정복잡한 엔터프라이즈

선택 가이드:

  • 빠르게 시작하고 싶다면: Turborepo
  • Angular 프로젝트: Nx
  • 복잡한 빌드 파이프라인: Nx
  • Vercel 에코시스템: Turborepo

실전 프로젝트 구축

실제 동작하는 Monorepo를 처음부터 구축해보겠습니다.

프로젝트 초기 설정

1. 프로젝트 생성

1
2
3
4
5
6
7
8
9
10
11
12
# 디렉토리 생성
mkdir my-monorepo
cd my-monorepo

# pnpm 초기화
pnpm init

# Turborepo 설치
pnpm add -Dw turbo

# Git 초기화
git init

2. 디렉토리 구조 생성

1
2
mkdir -p apps/web apps/docs
mkdir -p packages/ui packages/utils packages/tsconfig

최종 구조:

1
2
3
4
5
6
7
8
9
10
11
my-monorepo/
├── apps/
│   ├── web/              # Next.js 앱
│   └── docs/             # 문서 사이트
├── packages/
│   ├── ui/               # 공통 UI 컴포넌트
│   ├── utils/            # 유틸리티 함수
│   └── tsconfig/         # 공유 TypeScript 설정
├── turbo.json
├── package.json
└── pnpm-workspace.yaml

pnpm workspace 구성

pnpm-workspace.yaml 생성

1
2
3
packages:
  - 'apps/*'
  - 'packages/*'

루트 package.json 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "name": "my-monorepo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "format": "prettier --write \"**/*.{ts,tsx,md}\""
  },
  "devDependencies": {
    "turbo": "^2.3.1",
    "prettier": "^3.3.3",
    "typescript": "^5.6.3"
  },
  "packageManager": "pnpm@9.14.2"
}

Turborepo 설정

turbo.json 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  }
}

주요 설정 설명:

  • dependsOn: ["^build"]: 의존하는 패키지를 먼저 빌드
  • outputs: 캐시할 결과물 지정
  • cache: false: 개발 서버는 캐시하지 않음
  • persistent: true: 백그라운드에서 계속 실행

패키지 구조 설계

1. Web 앱 (apps/web)

1
2
cd apps/web
pnpm create next-app@latest . --typescript --eslint --tailwind --app

apps/web/package.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "name": "@my-monorepo/web",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "@my-monorepo/ui": "workspace:*",
    "@my-monorepo/utils": "workspace:*"
  },
  "devDependencies": {
    "@my-monorepo/tsconfig": "workspace:*",
    "typescript": "^5.6.3"
  }
}

2. UI 패키지 (packages/ui)

packages/ui/package.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
  "name": "@my-monorepo/ui",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "lint": "eslint src/"
  },
  "devDependencies": {
    "@my-monorepo/tsconfig": "workspace:*",
    "tsup": "^8.3.5",
    "typescript": "^5.6.3",
    "react": "^19.0.0"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  }
}

packages/ui/tsconfig.json:

1
2
3
4
5
6
7
8
{
  "extends": "@my-monorepo/tsconfig/react-library.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

3. Utils 패키지 (packages/utils)

packages/utils/package.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "name": "@my-monorepo/utils",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "test": "vitest",
    "lint": "eslint src/"
  },
  "devDependencies": {
    "@my-monorepo/tsconfig": "workspace:*",
    "tsup": "^8.3.5",
    "typescript": "^5.6.3",
    "vitest": "^2.1.8"
  }
}

공유 패키지 만들기

공통 UI 컴포넌트 패키지

packages/ui/src/Button.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import React from 'react';

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
}

const variantStyles = {
  primary: 'bg-blue-600 hover:bg-blue-700 text-white',
  secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900',
  danger: 'bg-red-600 hover:bg-red-700 text-white',
};

const sizeStyles = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg',
};

export function Button({
  variant = 'primary',
  size = 'md',
  children,
  className = '',
  ...props
}: ButtonProps) {
  return (
    <button
      className={`
        rounded-md font-medium transition-colors
        ${variantStyles[variant]}
        ${sizeStyles[size]}
        ${className}
      `}
      {...props}
    >
      {children}
    </button>
  );
}

packages/ui/src/index.ts:

1
export { Button, type ButtonProps } from './Button';

실제 사용 (apps/web/app/page.tsx):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Button } from '@my-monorepo/ui';

export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-4xl font-bold mb-8">My Monorepo</h1>
      <div className="flex gap-4">
        <Button variant="primary">Primary Button</Button>
        <Button variant="secondary">Secondary Button</Button>
        <Button variant="danger" size="lg">
          Large Danger Button
        </Button>
      </div>
    </main>
  );
}

공통 설정 패키지

1. TypeScript 설정 (packages/tsconfig)

packages/tsconfig/package.json:

1
2
3
4
5
6
{
  "name": "@my-monorepo/tsconfig",
  "version": "1.0.0",
  "private": true,
  "files": ["base.json", "nextjs.json", "react-library.json"]
}

packages/tsconfig/base.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true
  }
}

packages/tsconfig/react-library.json:

1
2
3
4
5
6
7
8
9
10
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "target": "ES2022",
    "module": "ESNext",
    "jsx": "react-jsx"
  }
}

packages/tsconfig/nextjs.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "target": "ES2022",
    "module": "ESNext",
    "jsx": "preserve",
    "plugins": [
      {
        "name": "next"
      }
    ],
    "incremental": true
  },
  "include": ["src", "next-env.d.ts"],
  "exclude": ["node_modules"]
}

2. ESLint 설정 (packages/eslint-config)

packages/eslint-config/package.json:

1
2
3
4
5
6
7
8
9
10
11
{
  "name": "@my-monorepo/eslint-config",
  "version": "1.0.0",
  "private": true,
  "main": "index.js",
  "files": ["index.js", "next.js", "react.js"],
  "dependencies": {
    "eslint-config-next": "^15.0.0",
    "eslint-config-prettier": "^9.1.0"
  }
}

packages/eslint-config/next.js:

1
2
3
4
5
6
7
module.exports = {
  extends: ['next/core-web-vitals', 'prettier'],
  rules: {
    '@next/next/no-html-link-for-pages': 'off',
    'react/jsx-key': 'warn',
  },
};

공통 유틸리티 패키지

packages/utils/src/format.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * 숫자를 천 단위 구분 기호로 포맷팅합니다.
 * @example
 * formatNumber(1234567) // "1,234,567"
 */
export function formatNumber(num: number): string {
  return new Intl.NumberFormat('ko-KR').format(num);
}

/**
 * 날짜를 한국 형식으로 포맷팅합니다.
 * @example
 * formatDate(new Date()) // "2025년 11월 27일"
 */
export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat('ko-KR', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date);
}

packages/utils/src/validation.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * 이메일 주소 유효성을 검증합니다.
 */
export function isValidEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

/**
 * 한국 휴대폰 번호 유효성을 검증합니다.
 * @example
 * isValidPhoneNumber("010-1234-5678") // true
 */
export function isValidPhoneNumber(phone: string): boolean {
  const phoneRegex = /^01[0-9]-\d{3,4}-\d{4}$/;
  return phoneRegex.test(phone);
}

packages/utils/src/index.ts:

1
2
export { formatNumber, formatDate } from './format';
export { isValidEmail, isValidPhoneNumber } from './validation';

테스트 작성 (packages/utils/src/format.test.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { describe, it, expect } from 'vitest';
import { formatNumber, formatDate } from './format';

describe('formatNumber', () => {
  it('천 단위 구분 기호를 추가해야 함', () => {
    expect(formatNumber(1234567)).toBe('1,234,567');
  });

  it('0을 올바르게 포맷팅해야 함', () => {
    expect(formatNumber(0)).toBe('0');
  });
});

describe('formatDate', () => {
  it('한국 형식으로 날짜를 포맷팅해야 함', () => {
    const date = new Date('2025-11-27');
    const formatted = formatDate(date);
    expect(formatted).toContain('2025년');
    expect(formatted).toContain('11월');
    expect(formatted).toContain('27일');
  });
});

빌드 & 개발 최적화

Turborepo 캐싱 전략

캐싱 작동 원리

Turborepo는 다음을 기반으로 해시를 생성합니다:

  1. 태스크 입력
    • 소스 코드 파일 내용
    • 의존하는 패키지의 출력물
    • 환경 변수 (globalDependencies에 지정된 것)
    • turbo.json의 태스크 설정
  2. 캐시 키 생성
1
2
3
4
5
6
7
# 해시 계산 예시
hash(
  "packages/ui/src/**/*.{ts,tsx}" +
  "packages/ui/package.json" +
  "packages/tsconfig/**/*" +
  "turbo.json[pipeline.build]"
) = "a1b2c3d4"
  1. 캐시 적중/미스
1
2
3
4
5
6
7
8
9
# 첫 번째 빌드
turbo run build
# packages/ui:build: cache miss, executing...
# packages/ui:build: 10.2s

# 변경 없이 재실행
turbo run build
# packages/ui:build: cache hit, replaying...
# packages/ui:build: 0.1s (cached)

캐싱 최적화 전략

1. 입력 범위 좁히기

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": [
        "src/**/*.{ts,tsx}",
        "!src/**/*.test.{ts,tsx}",
        "!src/**/*.stories.{ts,tsx}"
      ],
      "outputs": ["dist/**"]
    }
  }
}

2. 환경 변수 관리

1
2
3
4
5
6
7
8
9
{
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "env": ["NODE_ENV", "API_URL"],
      "outputs": ["dist/**"]
    }
  }
}

3. 캐시 무효화 트리거

1
2
3
4
5
6
7
8
9
10
11
# 특정 파일 변경 시에만 캐시 무효화
{
  "pipeline": {
    "build": {
      "inputs": [
        "$TURBO_DEFAULT$",
        ".env.production"
      ]
    }
  }
}

Remote Caching (Vercel)

로컬 캐시는 개발자 개인에게만 유용합니다. Remote Caching을 통해 팀 전체가 캐시를 공유할 수 있습니다.

1. Vercel Remote Cache 설정

1
2
3
4
5
# Turborepo 계정 연결
npx turbo login

# 프로젝트 링크
npx turbo link

2. 환경 변수 설정

1
2
3
# .env.local
TURBO_TOKEN=your_token_here
TURBO_TEAM=your_team_slug

3. CI/CD에서 Remote Cache 활용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v2
        with:
          version: 9.14.2

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm turbo build
        env:
          TURBO_TOKEN: $
          TURBO_TEAM: $

      - name: Test
        run: pnpm turbo test

Remote Cache 효과

상황로컬 캐시만Remote Cache
본인 재빌드✅ 0.1초✅ 0.1초
다른 개발자 빌드❌ 43초✅ 2초 (다운로드)
CI 빌드❌ 43초✅ 2초 (다운로드)

의존성 그래프 이해

그래프 시각화

1
2
# 의존성 그래프 생성
npx turbo run build --graph=graph.html

예시 그래프:

1
2
3
4
5
packages/tsconfig
       ↓
packages/utils ─────→ packages/ui
       ↓                    ↓
       └────────────────────┴───→ apps/web

병렬 실행 분석

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# --dry 플래그로 실행 계획 확인
npx turbo run build --dry=json

# 출력:
{
  "tasks": [
    {
      "task": "packages/tsconfig#build",
      "dependencies": [],
      "duration": 0
    },
    {
      "task": "packages/utils#build",
      "dependencies": ["packages/tsconfig#build"],
      "duration": 8234
    },
    {
      "task": "packages/ui#build",
      "dependencies": ["packages/tsconfig#build"],
      "duration": 10142
    },
    {
      "task": "apps/web#build",
      "dependencies": ["packages/utils#build", "packages/ui#build"],
      "duration": 15890
    }
  ]
}

최적화 포인트:

  1. 불필요한 의존성 제거: packages/uipackages/utils를 사용하지 않는다면 의존성 제거
  2. 태스크 분리: 무거운 태스크를 여러 단계로 분리하여 병렬화
  3. Lazy Loading: 모든 패키지를 한 번에 빌드하지 말고 필요한 것만 빌드

CI/CD 파이프라인

GitHub Actions와 Turborepo 연동

변경된 패키지만 빌드/테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            ui:
              - 'packages/ui/**'
            utils:
              - 'packages/utils/**'
            web:
              - 'apps/web/**'

  build-and-test:
    needs: changes
    runs-on: ubuntu-latest
    if: ${{ needs.changes.outputs.packages != '[]' }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v2
        with:
          version: 9.14.2

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build changed packages
        run: |
          pnpm turbo build --filter=...[origin/main]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

      - name: Test changed packages
        run: |
          pnpm turbo test --filter=...[origin/main]

      - name: Lint changed packages
        run: |
          pnpm turbo lint --filter=...[origin/main]

Turbo 필터 설명:

  • --filter=...[origin/main]: main 브랜치 이후 변경된 패키지와 그에 의존하는 모든 패키지
  • --filter=...@my-monorepo/ui: ui 패키지와 그에 의존하는 모든 패키지
  • --filter=@my-monorepo/web^...: web 패키지가 의존하는 모든 패키지 (web 자신 제외)

배포 전략

1. 개별 앱 배포 (Vercel)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# .github/workflows/deploy-web.yml
name: Deploy Web

on:
  push:
    branches: [main]
    paths:
      - 'apps/web/**'
      - 'packages/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v2
        with:
          version: 9.14.2

      - name: Build
        run: |
          pnpm install --frozen-lockfile
          pnpm turbo build --filter=@my-monorepo/web
        env:
          TURBO_TOKEN: $
          TURBO_TEAM: $

      - uses: amondnet/vercel-action@v25
        with:
          vercel-token: $
          vercel-org-id: $
          vercel-project-id: $
          working-directory: apps/web
          production: true

2. 공유 패키지 npm 배포

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# .github/workflows/publish.yml
name: Publish Packages

on:
  push:
    branches: [main]
    paths:
      - 'packages/**'

jobs:
  version-check:
    runs-on: ubuntu-latest
    outputs:
      changed-packages: $
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - id: check
        run: |
          CHANGED=$(git diff HEAD^ HEAD --name-only | grep 'packages/.*/package.json' | xargs -I {} dirname {})
          echo "packages=${CHANGED}" >> $GITHUB_OUTPUT

  publish:
    needs: version-check
    if: needs.version-check.outputs.changed-packages != ''
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v2
        with:
          version: 9.14.2

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'

      - name: Install and Build
        run: |
          pnpm install --frozen-lockfile
          pnpm turbo build --filter=./packages/*

      - name: Publish to npm
        run: |
          for package in $; do
            cd $package
            pnpm publish --no-git-checks --access public
            cd -
          done
        env:
          NODE_AUTH_TOKEN: $

실무 팁과 주의사항

버전 관리 전략

1. workspace 프로토콜 사용

1
2
3
4
5
6
{
  "dependencies": {
    "@my-monorepo/ui": "workspace:*",
    "@my-monorepo/utils": "workspace:^"
  }
}
  • workspace:*: 현재 workspace의 정확한 버전 사용
  • workspace:^: 현재 workspace의 호환 가능한 버전 사용

배포 시 자동 변환:

1
2
3
4
5
# 개발 시
"@my-monorepo/ui": "workspace:*"

# pnpm publish 후
"@my-monorepo/ui": "1.2.3"

2. Changesets를 사용한 버전 관리

1
2
3
# Changesets 설치
pnpm add -Dw @changesets/cli
pnpm changeset init

.changeset/config.json:

1
2
3
4
5
6
7
8
9
10
11
{
  "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@my-monorepo/web"]
}

버전 업데이트 워크플로우:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 변경사항 추가
pnpm changeset
# ✔ 어떤 패키지가 변경되었나요? · @my-monorepo/ui
# ✔ 변경 유형은? · minor
# ✔ 변경사항 요약: Added new Button variant

# 2. 버전 업데이트
pnpm changeset version
# packages/ui/CHANGELOG.md 생성
# package.json 버전 업데이트: 1.0.0 -> 1.1.0

# 3. 배포
pnpm changeset publish

패키지 간 의존성 관리

1. 순환 의존성 방지

1
2
3
4
5
6
7
8
9
10
# ❌ 나쁜 예: 순환 의존성
packages/ui -> packages/utils
packages/utils -> packages/ui

# ✅ 좋은 예: 단방향 의존성
packages/core (공통 타입, 상수)
    ↓
packages/utils
    ↓
packages/ui

순환 의존성 감지:

1
2
3
4
5
# madge 설치
pnpm add -Dw madge

# 순환 의존성 검사
npx madge --circular --extensions ts,tsx packages/

package.json에 스크립트 추가:

1
2
3
4
5
{
  "scripts": {
    "check-circular": "madge --circular --extensions ts,tsx packages/"
  }
}

2. Peer Dependencies 올바르게 사용

1
2
3
4
5
6
7
8
9
10
11
{
  "name": "@my-monorepo/ui",
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

이유:

  • peerDependencies: 사용자가 설치해야 하는 의존성
  • devDependencies: 개발/테스트 시 필요한 의존성
  • 버전 중복 설치 방지

흔한 실수와 해결법

1. TypeScript 타입 찾기 실패

문제:

1
2
// apps/web에서
import { Button } from '@my-monorepo/ui'; // TS2307: Cannot find module

해결:

1
2
3
4
5
6
7
8
9
10
11
// apps/web/tsconfig.json
{
  "extends": "@my-monorepo/tsconfig/nextjs.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@my-monorepo/ui": ["../../packages/ui/src"],
      "@my-monorepo/utils": ["../../packages/utils/src"]
    }
  }
}

또는 packages/ui에서 타입 파일 제대로 빌드:

1
2
3
4
5
6
7
8
9
10
11
// packages/ui/package.json
{
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  }
}

2. 개발 서버에서 변경사항 반영 안 됨

문제: packages/ui를 수정했는데 apps/web에 반영 안 됨

해결책 1: Watch 모드 활용

1
2
3
4
5
6
7
8
9
10
// turbo.json
{
  "pipeline": {
    "dev": {
      "dependsOn": ["^dev"],
      "cache": false,
      "persistent": true
    }
  }
}
1
2
3
4
5
6
// packages/ui/package.json
{
  "scripts": {
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
  }
}

해결책 2: Next.js transpilePackages

1
2
3
4
5
6
7
// apps/web/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ['@my-monorepo/ui', '@my-monorepo/utils'],
};

module.exports = nextConfig;

3. 빌드 캐시 문제

문제: 파일을 변경했는데 캐시가 적용되어 빌드가 스킵됨

해결:

1
2
3
4
5
# 캐시 삭제 후 재빌드
pnpm turbo build --force

# 특정 패키지만 캐시 무효화
pnpm turbo build --filter=@my-monorepo/ui --force

캐시 디렉토리 정리:

1
2
3
4
5
6
7
8
9
# Turborepo 캐시 삭제
rm -rf .turbo

# pnpm 스토어 정리
pnpm store prune

# node_modules 재설치
rm -rf node_modules
pnpm install

4. 의존성 설치 위치 혼동

1
2
3
4
5
6
7
8
9
10
11
12
# ❌ 잘못된 방법
cd packages/ui
pnpm add react

# ✅ 올바른 방법 (루트에서)
pnpm add react --filter @my-monorepo/ui

# ✅ 모든 패키지에 동일한 dev 의존성 추가
pnpm add -Dw typescript

# ✅ workspace 프로토콜로 로컬 패키지 추가
pnpm add @my-monorepo/ui --filter @my-monorepo/web --workspace

5. 환경 변수 공유 실수

문제: 각 앱의 .env가 다른 앱에 영향을 줌

해결:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// turbo.json
{
  "globalDependencies": [
    "**/.env.*local"
  ],
  "pipeline": {
    "build": {
      "env": [
        "NODE_ENV",
        "NEXT_PUBLIC_API_URL"
      ],
      "outputs": [".next/**", "!.next/cache/**"]
    }
  }
}

.env 파일 구조:

1
2
3
4
5
# 루트 .env (공통 설정)
NODE_ENV=development

# apps/web/.env.local (앱별 설정)
NEXT_PUBLIC_API_URL=https://api.example.com

성능 최적화 체크리스트

빌드 속도 개선:

  • Turborepo Remote Caching 활성화
  • 불필요한 dependsOn 제거하여 병렬화 극대화
  • 개발 시 필요한 패키지만 빌드 (--filter 사용)
  • TypeScript incremental 옵션 활성화
  • ESLint 캐시 활성화 (--cache 플래그)

개발 경험 개선:

  • 모든 패키지에 dev 스크립트 추가
  • Hot Module Replacement 설정
  • TypeScript paths 설정으로 빠른 타입 체크
  • transpilePackages로 실시간 반영

CI/CD 최적화:

  • 변경된 패키지만 테스트 (--filter=...[HEAD^1])
  • GitHub Actions 캐시 활용
  • 병렬 작업 활용 (build, test, lint 동시 실행)
  • Docker 레이어 캐싱

마치며

Monorepo는 초기 설정 비용이 들지만, 다음과 같은 상황에서 큰 가치를 제공합니다:

Monorepo의 진정한 가치:

  1. 코드 재사용성: 공통 컴포넌트, 유틸리티를 쉽게 공유
  2. 일관성: 단일 설정 파일로 모든 프로젝트 관리
  3. 개발 속도: 캐싱과 병렬 실행으로 빠른 빌드
  4. 협업 효율: 원자적 커밋으로 안전한 대규모 리팩토링
  5. 유지보수성: 의존성 버전 통일로 업그레이드 간소화

시작하기 전 고려사항:

  • 팀 크기: 3명 이상의 팀에서 효과적
  • 프로젝트 복잡도: 공유할 코드가 충분한가?
  • 학습 곡선: 팀원들이 새로운 도구를 배울 준비가 되었는가?
  • 마이그레이션 비용: 기존 프로젝트 이전에 드는 시간

다음 단계:

  1. 작은 프로젝트로 시작 (2-3개 패키지)
  2. 팀과 컨벤션 합의 (네이밍, 버전 관리 등)
  3. 문서화 (README, CONTRIBUTING 가이드)
  4. CI/CD 파이프라인 구축
  5. 점진적으로 패키지 추가

pnpm과 Turborepo의 조합은 현대적인 Monorepo 구축에 최적화된 도구입니다. 이 가이드를 바탕으로 팀에 맞는 Monorepo를 구축하고, 생산성을 크게 향상시킬 수 있기를 바랍니다.

참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.