Turborepo 모노레포 설정 가이드 — 공유 패키지부터 CI 파이프라인까지

프론트엔드

Turborepo모노레포패키지 관리CI/CDpnpm

이 글은 누구를 위한 것인가

  • 여러 Next.js 앱에서 같은 컴포넌트를 복사해 쓰는 팀
  • 모노레포를 도입했지만 빌드가 느리거나 태스크 순서가 맞지 않는 팀
  • Turborepo 공식 문서를 봤지만 실제 프로젝트에 어떻게 적용할지 모르는 개발자

모노레포가 필요한 신호

  • 여러 앱이 같은 컴포넌트, 타입, 유틸리티를 복사해서 사용
  • 공통 코드를 수정했을 때 모든 앱에 반영하는 것이 번거롭다
  • 여러 패키지를 별도 저장소로 분리했지만 동시 변경 시 버저닝이 복잡하다

1. 초기 설정

# pnpm 필수 (npm workspace도 가능하지만 pnpm 권장)
npx create-turbo@latest my-monorepo
cd my-monorepo

디렉토리 구조

my-monorepo/
├── apps/
│   ├── web/                    # Next.js 메인 앱
│   ├── admin/                  # Next.js 관리자 앱
│   └── docs/                   # 문서 사이트
├── packages/
│   ├── ui/                     # 공유 UI 컴포넌트
│   ├── config/                 # 공유 설정 (ESLint, TypeScript, Tailwind)
│   ├── types/                  # 공유 타입
│   └── utils/                  # 공유 유틸리티
├── package.json                # 루트 package.json
├── pnpm-workspace.yaml
└── turbo.json

pnpm-workspace.yaml

packages:
  - 'apps/*'
  - 'packages/*'

2. turbo.json 파이프라인 설정

Turborepo의 핵심은 태스크 간 의존성과 캐싱을 turbo.json에 정의하는 것이다.

{
  "$schema": "https://turbo.build/schema.json",
  "globalEnv": ["NODE_ENV", "VERCEL_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],  // 의존하는 패키지의 build가 먼저 실행됨
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "dependsOn": [],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "*.config.*"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "persistent": true,  // 장시간 실행 태스크
      "cache": false       // dev 서버는 캐시 안 함
    },
    "type-check": {
      "dependsOn": ["^build"]
    }
  }
}

^build 의미: 이 태스크를 실행하기 전에 의존하는 모든 패키지의 build를 먼저 실행.

packages/ui를 의존하는 apps/web에서 build를 실행하면:

  1. packages/uibuild 실행
  2. apps/webbuild 실행

3. 공유 패키지 설정

packages/ui - 공유 컴포넌트

// packages/ui/package.json
{
  "name": "@repo/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    "./button": {
      "types": "./src/button.tsx",
      "default": "./src/button.tsx"
    },
    "./card": "./src/card.tsx"
  },
  "scripts": {
    "lint": "eslint src/",
    "type-check": "tsc --noEmit"
  },
  "devDependencies": {
    "@repo/config": "workspace:*",
    "typescript": "^5",
    "react": "^18"
  },
  "peerDependencies": {
    "react": "^18"
  }
}
// packages/ui/src/button.tsx
import { type ButtonHTMLAttributes } from 'react';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
}

export function Button({ variant = 'primary', size = 'md', className, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  );
}

packages/config - 공유 설정

// packages/config/eslint/index.js
/** @type {import('eslint').Linter.Config} */
module.exports = {
  extends: ['next/core-web-vitals', 'prettier'],
  rules: {
    'no-unused-vars': 'error',
    'no-console': ['warn', { allow: ['warn', 'error'] }],
  },
};
// packages/config/typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "target": "es2017",
    "lib": ["es2017"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "resolveJsonModule": true,
    "incremental": true
  },
  "exclude": ["node_modules"]
}

앱에서 패키지 사용

// apps/web/package.json
{
  "name": "web",
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*"
  }
}
// apps/web/app/page.tsx
import { Button } from '@repo/ui/button';
import { formatDate } from '@repo/utils';

export default function Page() {
  return (
    <Button variant="primary">
      {formatDate(new Date())}
    </Button>
  );
}

4. Tailwind CSS 공유 설정

// packages/config/tailwind/index.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [],  // 각 앱에서 오버라이드
  theme: {
    extend: {
      colors: {
        brand: {
          50:  '#eff6ff',
          500: '#3b82f6',
          900: '#1e3a8a',
        }
      },
      fontFamily: {
        sans: ['Pretendard Variable', 'system-ui', 'sans-serif'],
      },
    },
  },
};
// apps/web/tailwind.config.js
const baseConfig = require('@repo/config/tailwind');

module.exports = {
  ...baseConfig,
  content: [
    './app/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
    '../../packages/ui/src/**/*.{ts,tsx}',  // UI 패키지 포함
  ],
};

5. Remote Cache로 CI 속도 최적화

Turborepo Remote Cache는 로컬 캐시를 팀 전체와 CI가 공유한다. 한 번 빌드한 결과를 팀원이나 CI가 재사용할 수 있다.

Vercel Remote Cache (무료)

npx turbo login
npx turbo link

.turbo/config.json에 팀 정보가 저장되고, 이후 turbo build가 원격 캐시를 활용한다.

GitHub Actions 설정

# .github/workflows/ci.yml
name: CI

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

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

      - uses: pnpm/action-setup@v3
        with:
          version: 9

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

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

      - name: Build, lint, test
        run: pnpm turbo build lint test type-check
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}  # Remote Cache 토큰
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

6. 선택적 빌드: 변경된 패키지만

# 변경된 패키지와 그에 의존하는 패키지만 빌드
pnpm turbo build --filter='...[origin/main]'

# 특정 앱만 빌드
pnpm turbo build --filter=web

# 특정 패키지와 의존성 포함
pnpm turbo build --filter=@repo/ui...

PR에서 변경된 부분만 빌드·테스트하면 CI 시간을 크게 줄일 수 있다.


맺으며

Turborepo 모노레포의 핵심 가치는 두 가지다: 공통 코드의 단일 진실, 빌드 캐싱으로 CI 속도 향상.

공통 UI 컴포넌트를 packages/ui로 분리하는 것부터 시작한다. 처음에 완벽한 구조를 만들려 하지 않아도 된다 — 모노레포의 장점은 나중에 패키지를 분리하고 이동하는 것이 쉽다는 것이다. turbo.jsondependsOn을 올바르게 설정하는 것이 가장 중요한 첫 번째 단계다.