Recent Posts
Recent Comments
반응형
«   2025/11   »
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
Archives
Today
Total
관리 메뉴

오늘도 공부

Turborepo로 Next.js 16 모노레포 구축하기 본문

스터디/NextJs

Turborepo로 Next.js 16 모노레포 구축하기

행복한 수지아빠 2025. 11. 8. 14:16
반응형

목차

  • 모노레포란 무엇인가? 
  • 왜 Turborepo인가? 
  • 실전: Turborepo 프로젝트 구축 
  • 여러 Next.js 앱 동시 관리하기 
  • 공유 UI 컴포넌트 라이브러리 만들기 
  • Shadcn UI와 통합하기 
  • 성능 최적화 팁 
  • 실제 프로덕션 배포 전략

모노레포란 무엇인가?

여러분이 다음과 같은 프로젝트를 운영한다고 상상해보세요:

  • 고객용 웹 앱
  • 관리자 대시보드
  • 모바일 앱용 API
  • 마케팅 랜딩 페이지

전통적인 방식(Polyrepo)에서는 각 프로젝트마다 별도의 Git 저장소를 만듭니다. 하지만 공통 UI 컴포넌트를 수정하려면 어떻게 될까요?

❌ 기존 방식 (Polyrepo)
1. UI 라이브러리 저장소에서 Button 컴포넌트 수정
2. npm에 새 버전 배포 (v1.2.3)
3. 웹 앱 저장소에서 package.json 버전 업데이트
4. 관리자 대시보드 저장소에서도 버전 업데이트
5. 각각 테스트, 배포...

🎉 모노레포 방식 (Turborepo)
1. 공유 UI 패키지에서 Button 컴포넌트 수정
2. 끝! 모든 앱에 즉시 반영됨

**모노레포(Monorepo)**는 여러 프로젝트를 하나의 저장소에서 관리하는 코드 구성 방식입니다. Google, Microsoft, Meta 같은 거대 기업들이 이미 수년간 사용해온 검증된 방법입니다.

모노레포의 장점

원자적 커밋(Atomic Commits): 여러 프로젝트에 걸친 변경사항을 하나의 커밋으로 처리
의존성 지옥 탈출: 버전 충돌과 의존성 문제 해결
코드 재사용: 공통 로직을 쉽게 공유
일관된 개발 환경: 모든 프로젝트에서 동일한 ESLint, TypeScript 설정 사용
전체 시스템 가시성: 팀 전체가 모든 코드에 접근 가능

모노레포 ≠ 모놀리식

⚠️ 주의: 모노레포는 코드 구성 방식일 뿐, 모놀리식 아키텍처와는 다릅니다!

  • 모노레포: 여러 독립적인 프로젝트를 한 저장소에서 관리
  • 모놀리식: 모든 기능이 하나로 결합된 단일 애플리케이션

왜 Turborepo인가?

2025년 현재, 모노레포 도구 시장에는 여러 선택지가 있습니다:

도구 장점 단점

Turborepo 설정 간단, Next.js와 완벽 통합, 빠른 빌드 복잡한 프로젝트에서는 커스터마이징 제한적
Nx 강력한 기능, 엔터프라이즈급 학습 곡선 높음, 설정 복잡
Lerna 레거시 지원 우수 개발 속도 느림

Turborepo의 핵심 강점

1. 🚀 속도 (40-85% 빌드 시간 단축)

Turborepo는 지능적인 캐싱과 병렬 처리를 통해 빌드 시간을 40-85% 단축합니다.

# 첫 번째 빌드
$ turbo build
>>> Building...
✓ Built in 45s

# 아무것도 변경하지 않고 다시 빌드
$ turbo build
>>> FULL TURBO (cached)
✓ Built in 100ms  # 🎉 450배 빠름!

어떻게 가능한가?

Turborepo는 빌드마다 해시를 생성하고, 캐시에서 해시를 찾지 못하면 빌드를 실행한 후 결과를 저장합니다. 다음에 동일한 소스로 빌드할 때는 빌드 과정을 건너뛰고 캐시에서 결과를 복원합니다.

2. 🔄 병렬 실행

Turborepo는 작업을 병렬로 스케줄링하여 CPU 코어를 최대한 활용해 빌드를 최적화합니다.

전통적인 방식:
build ui → build web → build admin → build api
(순차 실행, 총 4분)

Turborepo:
build ui
   ↓
build web + build admin + build api
(병렬 실행, 총 1.5분)

3. 💾 원격 캐싱

Turborepo는 원격 캐싱을 통해 팀 전체와 CI/CD 파이프라인에서 캐시를 공유할 수 있어, CI가 동일한 작업을 두 번 수행할 필요가 없습니다.

# 팀원 A가 빌드
$ turbo build
✓ Built and cached

# 팀원 B가 같은 코드로 빌드
$ turbo build
>>> Fetching from remote cache...
✓ Built in 500ms  # 원격 캐시에서 복원!

4. 🔍 증분 빌드

변경된 패키지만 재빌드합니다. UI 컴포넌트를 수정했다면, 그것을 사용하는 앱만 다시 빌드됩니다.


실전: Turborepo 프로젝트 구축

1단계: 프로젝트 생성

pnpm을 사용하여 새로운 Turborepo 프로젝트를 생성합니다.

# pnpm 사용 (권장)
pnpm create turbo@latest

# 또는 npx
npx create-turbo@latest

# 프로젝트 이름 입력
>>> What is your project named? my-awesome-project
>>> Which package manager? pnpm

2단계: 폴더 구조 이해하기

생성된 모노레포는 apps(배포 가능한 애플리케이션)와 packages(공유 패키지)로 나뉩니다.

my-awesome-project/
├── apps/                    # 배포 가능한 애플리케이션들
│   ├── web/                # 고객용 웹사이트 (포트 3000)
│   │   ├── app/
│   │   ├── package.json
│   │   └── next.config.js
│   ├── admin/              # 관리자 대시보드 (포트 3001)
│   │   ├── app/
│   │   ├── package.json
│   │   └── next.config.js
│   └── docs/               # 문서 사이트 (포트 3002)
│       ├── app/
│       ├── package.json
│       └── next.config.js
│
├── packages/               # 공유 패키지들
│   ├── ui/                 # 공유 UI 컴포넌트
│   │   ├── src/
│   │   │   ├── button.tsx
│   │   │   ├── card.tsx
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── database/           # 공유 데이터베이스 로직
│   │   ├── src/
│   │   └── package.json
│   ├── auth/               # 공유 인증 로직
│   │   ├── src/
│   │   └── package.json
│   ├── tsconfig/           # 공유 TypeScript 설정
│   │   ├── base.json
│   │   ├── nextjs.json
│   │   └── react-library.json
│   └── eslint-config/      # 공유 ESLint 설정
│       └── index.js
│
├── turbo.json              # Turborepo 설정
├── package.json            # 루트 package.json
├── pnpm-workspace.yaml     # pnpm 워크스페이스 설정
└── .gitignore

3단계: turbo.json 설정

turbo.json 파일은 작업 파이프라인을 정의하며, 각 작업의 의존성과 캐싱 방법을 지정합니다.

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "type-check": {
      "dependsOn": ["^type-check"]
    }
  }
}

설정 상세 설명:

  • dependsOn: ["^build"]: 의존하는 패키지를 먼저 빌드
  • outputs: 캐싱할 출력 디렉토리
  • cache: false: dev 서버는 캐싱하지 않음
  • persistent: true: dev 서버를 백그라운드에서 계속 실행

4단계: 루트 package.json 설정

{
  "name": "my-awesome-project",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "test": "turbo test",
    "type-check": "turbo type-check",
    "clean": "turbo clean && rm -rf node_modules"
  },
  "devDependencies": {
    "turbo": "^2.3.0",
    "typescript": "^5.6.0"
  },
  "packageManager": "pnpm@9.0.0",
  "engines": {
    "node": ">=18.0.0"
  }
}

5단계: pnpm 워크스페이스 설정

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

여러 Next.js 앱 동시 관리하기

Turborepo를 사용하면 하나의 저장소에서 여러 Next.js 앱을 효율적으로 관리할 수 있습니다.

실전 예제: 이커머스 플랫폼

my-ecommerce/
├── apps/
│   ├── storefront/        # 고객용 쇼핑몰 (포트 3000)
│   ├── admin/             # 판매자 관리 시스템 (포트 3001)
│   ├── mobile-api/        # 모바일 앱용 API (포트 3002)
│   └── blog/              # 마케팅 블로그 (포트 3003)
└── packages/
    ├── ui/                # 공통 디자인 시스템
    ├── database/          # Prisma 스키마 및 클라이언트
    ├── auth/              # NextAuth 설정
    └── utils/             # 공통 유틸리티 함수

각 앱 설정하기

apps/storefront/package.json

{
  "name": "storefront",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3000",
    "build": "next build",
    "start": "next start -p 3000",
    "lint": "next lint"
  },
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/database": "workspace:*",
    "@repo/auth": "workspace:*",
    "next": "^16.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "@repo/eslint-config": "workspace:*",
    "typescript": "^5.6.0"
  }
}

apps/admin/package.json

{
  "name": "admin",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3001",
    "build": "next build",
    "start": "next start -p 3001",
    "lint": "next lint"
  },
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/database": "workspace:*",
    "@repo/auth": "workspace:*",
    "next": "^16.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

Next.js 16 특화 설정

apps/storefront/next.config.js

/** @type {import('next').NextConfig} */
module.exports = {
  // 공유 패키지를 트랜스파일
  transpilePackages: ['@repo/ui', '@repo/auth'],
  
  // Next.js 16의 새로운 기능들
  experimental: {
    // Turbopack 파일시스템 캐싱 (Next.js 16 베타 기능)
    turbopack: {
      resolveAlias: {
        '@': './src',
      },
    },
    // 부분적 사전 렌더링
    ppr: true,
    // React 19 기능 활성화
    reactCompiler: true,
  },
  
  // 이미지 최적화
  images: {
    domains: ['images.example.com'],
  },
}

모든 앱 동시 실행하기

# 모든 앱을 동시에 dev 모드로 실행
$ turbo dev

# 출력:
# • storefront:dev: ready - started server on 0.0.0.0:3000
# • admin:dev: ready - started server on 0.0.0.0:3001
# • mobile-api:dev: ready - started server on 0.0.0.0:3002
# • blog:dev: ready - started server on 0.0.0.0:3003

필터를 사용한 선택적 실행

필터 플래그를 사용하여 특정 스크립트만 실행할 수 있습니다.

# storefront 앱만 실행
$ turbo dev --filter=storefront

# storefront와 admin만 실행
$ turbo dev --filter=storefront --filter=admin

# storefront와 그 의존성만 실행
$ turbo dev --filter=storefront...

# 변경된 파일이 있는 앱만 빌드
$ turbo build --filter=[HEAD^1]

# 특정 패키지에 의존하는 모든 앱 빌드
$ turbo build --filter=...@repo/ui

공유 UI 컴포넌트 라이브러리 만들기

UI 패키지 구조

packages/ui/
├── src/
│   ├── components/
│   │   ├── button.tsx
│   │   ├── card.tsx
│   │   ├── input.tsx
│   │   └── modal.tsx
│   ├── hooks/
│   │   ├── use-toast.ts
│   │   └── use-media-query.ts
│   ├── utils/
│   │   └── cn.ts
│   └── index.ts
├── package.json
├── tsconfig.json
└── tailwind.config.ts

packages/ui/package.json

{
  "name": "@repo/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    "./button": "./src/components/button.tsx",
    "./card": "./src/components/card.tsx",
    "./input": "./src/components/input.tsx",
    "./hooks": "./src/hooks/index.ts",
    "./utils": "./src/utils/index.ts",
    "./styles": "./src/styles/globals.css"
  },
  "scripts": {
    "lint": "eslint . --max-warnings 0",
    "type-check": "tsc --noEmit"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "@repo/eslint-config": "workspace:*",
    "@types/react": "^19.0.0",
    "typescript": "^5.6.0",
    "tailwindcss": "^3.4.0"
  }
}

버튼 컴포넌트 예제

packages/ui/src/components/button.tsx

import * as React from 'react';
import { cn } from '../utils/cn';

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ 
    className, 
    variant = 'primary', 
    size = 'md',
    isLoading = false,
    disabled,
    children,
    ...props 
  }, ref) => {
    const variants = {
      primary: 'bg-blue-600 text-white hover:bg-blue-700',
      secondary: 'bg-gray-600 text-white hover:bg-gray-700',
      outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50',
      ghost: 'text-blue-600 hover:bg-blue-50',
    };

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

    return (
      <button
        ref={ref}
        className={cn(
          'rounded-lg font-medium transition-colors',
          'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
          'disabled:opacity-50 disabled:cursor-not-allowed',
          variants[variant],
          sizes[size],
          className
        )}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading ? (
          <span className="flex items-center gap-2">
            <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
              <circle
                className="opacity-25"
                cx="12"
                cy="12"
                r="10"
                stroke="currentColor"
                strokeWidth="4"
                fill="none"
              />
              <path
                className="opacity-75"
                fill="currentColor"
                d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
              />
            </svg>
            로딩 중...
          </span>
        ) : (
          children
        )}
      </button>
    );
  }
);

Button.displayName = 'Button';

앱에서 공유 컴포넌트 사용하기

apps/storefront/app/page.tsx

import { Button } from '@repo/ui/button';
import { Card } from '@repo/ui/card';
import { useToast } from '@repo/ui/hooks';

export default function HomePage() {
  const { toast } = useToast();

  const handleClick = () => {
    toast({
      title: '성공!',
      description: '버튼이 클릭되었습니다.',
    });
  };

  return (
    <div className="container mx-auto p-8">
      <Card>
        <h1 className="text-3xl font-bold mb-4">
          환영합니다!
        </h1>
        <p className="text-gray-600 mb-6">
          Turborepo와 Next.js 16으로 만든 모노레포 앱입니다.
        </p>
        <div className="flex gap-3">
          <Button onClick={handleClick}>
            기본 버튼
          </Button>
          <Button variant="secondary">
            보조 버튼
          </Button>
          <Button variant="outline">
            아웃라인 버튼
          </Button>
          <Button isLoading>
            로딩 버튼
          </Button>
        </div>
      </Card>
    </div>
  );
}

apps/admin/app/dashboard/page.tsx

import { Button } from '@repo/ui/button';
import { Card } from '@repo/ui/card';

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-3 gap-4">
      <Card>
        <h3 className="font-semibold mb-2">총 매출</h3>
        <p className="text-3xl font-bold">₩12,345,678</p>
        <Button size="sm" className="mt-4">
          상세 보기
        </Button>
      </Card>
      <Card>
        <h3 className="font-semibold mb-2">주문 수</h3>
        <p className="text-3xl font-bold">1,234</p>
        <Button size="sm" className="mt-4">
          주문 관리
        </Button>
      </Card>
      <Card>
        <h3 className="font-semibold mb-2">고객 수</h3>
        <p className="text-3xl font-bold">5,678</p>
        <Button size="sm" className="mt-4">
          고객 관리
        </Button>
      </Card>
    </div>
  );
}

타입 안정성

TypeScript 타입이 모든 앱에서 자동으로 공유됩니다:

// 타입 자동 완성과 검증
<Button 
  variant="primary"  // ✅ 자동 완성
  size="md"          // ✅ 자동 완성
  onClick={...}      // ✅ 타입 검증
  invalidProp="x"    // ❌ 타입 오류!
/>

Shadcn UI와 통합하기

Shadcn/ui는 Tailwind CSS로 만든 아름다운 디자인의 오픈소스 컴포넌트 세트로, Turborepo 모노레포에서 사용할 수 있습니다.

Shadcn UI 설치하기

# 프로젝트 루트에서 실행
$ npx shadcn@latest init

# 모노레포 옵션 선택
>>> Would you like to use a monorepo? Yes
>>> Which workspace would you like to use? packages/ui

UI 패키지 구조 (Shadcn 포함)

packages/ui/
├── src/
│   ├── components/
│   │   ├── ui/              # Shadcn 컴포넌트들
│   │   │   ├── button.tsx
│   │   │   ├── dialog.tsx
│   │   │   ├── dropdown-menu.tsx
│   │   │   └── toast.tsx
│   │   └── custom/          # 커스텀 컴포넌트들
│   │       ├── product-card.tsx
│   │       └── user-avatar.tsx
│   ├── lib/
│   │   └── utils.ts
│   └── index.ts
├── components.json          # Shadcn 설정
├── package.json
└── tailwind.config.ts

Shadcn 컴포넌트 추가하기

# UI 패키지에 컴포넌트 추가
$ cd packages/ui
$ npx shadcn@latest add button dialog toast

# 또는 루트에서 실행
$ npx shadcn@latest add button dialog toast --path packages/ui

components.json 설정

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "src/styles/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  },
  "aliases": {
    "components": "./src/components",
    "utils": "./src/lib/utils"
  }
}

Tailwind 설정 공유하기

packages/ui/tailwind.config.ts

import type { Config } from 'tailwindcss';

const config: Config = {
  content: [
    './src/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      colors: {
        border: 'hsl(var(--border))',
        input: 'hsl(var(--input))',
        ring: 'hsl(var(--ring))',
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
        secondary: {
          DEFAULT: 'hsl(var(--secondary))',
          foreground: 'hsl(var(--secondary-foreground))',
        },
        destructive: {
          DEFAULT: 'hsl(var(--destructive))',
          foreground: 'hsl(var(--destructive-foreground))',
        },
        muted: {
          DEFAULT: 'hsl(var(--muted))',
          foreground: 'hsl(var(--muted-foreground))',
        },
        accent: {
          DEFAULT: 'hsl(var(--accent))',
          foreground: 'hsl(var(--accent-foreground))',
        },
        popover: {
          DEFAULT: 'hsl(var(--popover))',
          foreground: 'hsl(var(--popover-foreground))',
        },
        card: {
          DEFAULT: 'hsl(var(--card))',
          foreground: 'hsl(var(--card-foreground))',
        },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
    },
  },
  plugins: [require('tailwindcss-animate')],
};

export default config;

앱에서 Shadcn 컴포넌트 사용하기

apps/storefront/app/products/page.tsx

import { 
  Button, 
  Dialog, 
  DialogContent, 
  DialogDescription, 
  DialogHeader, 
  DialogTitle, 
  DialogTrigger,
  Toast,
  ToastProvider
} from '@repo/ui';
import { useState } from 'react';

export default function ProductsPage() {
  const [open, setOpen] = useState(false);

  return (
    <ToastProvider>
      <div className="container mx-auto p-8">
        <h1 className="text-4xl font-bold mb-8">상품 목록</h1>
        
        <Dialog open={open} onOpenChange={setOpen}>
          <DialogTrigger asChild>
            <Button>
              상품 추가하기
            </Button>
          </DialogTrigger>
          <DialogContent className="sm:max-w-[425px]">
            <DialogHeader>
              <DialogTitle>새 상품 추가</DialogTitle>
              <DialogDescription>
                새로운 상품 정보를 입력하세요.
              </DialogDescription>
            </DialogHeader>
            <div className="grid gap-4 py-4">
              <div className="grid grid-cols-4 items-center gap-4">
                <label htmlFor="name" className="text-right">
                  상품명
                </label>
                <input
                  id="name"
                  className="col-span-3"
                  placeholder="상품명을 입력하세요"
                />
              </div>
              <div className="grid grid-cols-4 items-center gap-4">
                <label htmlFor="price" className="text-right">
                  가격
                </label>
                <input
                  id="price"
                  type="number"
                  className="col-span-3"
                  placeholder="가격을 입력하세요"
                />
              </div>
            </div>
            <div className="flex justify-end gap-2">
              <Button variant="outline" onClick={() => setOpen(false)}>
                취소
              </Button>
              <Button onClick={() => {
                // 상품 저장 로직
                setOpen(false);
              }}>
                저장
              </Button>
            </div>
          </DialogContent>
        </Dialog>

        {/* 상품 목록 */}
        <div className="grid grid-cols-3 gap-6 mt-8">
          {/* 상품 카드들 */}
        </div>
      </div>
    </ToastProvider>
  );
}

커스텀 컴포넌트 만들기

Shadcn 컴포넌트를 기반으로 도메인 특화 컴포넌트를 만들 수 있습니다:

packages/ui/src/components/custom/product-card.tsx

import { Button } from '../ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '../ui/card';

interface ProductCardProps {
  title: string;
  price: number;
  image: string;
  onAddToCart: () => void;
}

export function ProductCard({ title, price, image, onAddToCart }: ProductCardProps) {
  return (
    <Card className="overflow-hidden">
      <img 
        src={image} 
        alt={title}
        className="w-full h-48 object-cover"
      />
      <CardHeader>
        <CardTitle>{title}</CardTitle>
      </CardHeader>
      <CardContent>
        <p className="text-2xl font-bold">
          ₩{price.toLocaleString()}
        </p>
      </CardContent>
      <CardFooter>
        <Button 
          className="w-full" 
          onClick={onAddToCart}
        >
          장바구니에 추가
        </Button>
      </CardFooter>
    </Card>
  );
}

성능 최적화 팁

1. 의존성 그래프 최적화

Turborepo는 의존성 그래프를 기반으로 최적화된 병렬 실행을 수행합니다.

// turbo.json
{
  "tasks": {
    "build": {
      // ^build: 의존하는 패키지의 build가 먼저 완료되어야 함
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "test": {
      // build와 lint가 먼저 완료되어야 함
      "dependsOn": ["build", "lint"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      // 의존성 없음, 즉시 실행 가능
      "outputs": []
    }
  }
}

2. 캐싱 전략

Turborepo의 캐싱은 매우 효과적입니다. 실제 프로젝트에서 측정된 성능 지표를 보면 그 효과를 확인할 수 있습니다.

# 첫 번째 빌드
$ turbo build
✓ Built in 2m 15s

# 변경 없이 재빌드
$ turbo build
>>> FULL TURBO
✓ Built in 847ms

# UI 패키지만 수정 후 재빌드
$ turbo build
>>> Cache miss: @repo/ui
>>> Cache hit: @repo/database, @repo/auth
✓ Built in 12s

캐싱 전략 최적화:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**"],
      // 환경 변수가 변경되면 캐시 무효화
      "env": ["NODE_ENV", "API_URL"]
    },
    "test": {
      // 테스트 결과 캐싱
      "outputs": ["coverage/**"],
      "cache": true
    },
    "lint": {
      // 빠른 명령은 캐싱 불필요
      "cache": false
    }
  }
}

3. 원격 캐싱 설정

원격 캐싱을 사용하면 팀원들과 CI/CD 파이프라인 간에 빌드 캐시를 공유할 수 있습니다.

# Vercel 계정으로 로그인
$ npx turbo login

# 프로젝트 연결
$ npx turbo link

# 이제 원격 캐시 사용!
$ turbo build
>>> Remote caching enabled
>>> Downloading cache from remote...
✓ Built in 2s

4. 빌드 최적화

Next.js 16 설정 최적화:

// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  transpilePackages: ['@repo/ui', '@repo/database'],
  
  // 실험적 기능
  experimental: {
    // Turbopack 캐싱 (Next.js 16)
    turbopack: true,
    // 부분 사전 렌더링
    ppr: true,
    // React 컴파일러
    reactCompiler: true,
  },
  
  // 번들 분석
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.alias = {
        ...config.resolve.alias,
        '@': __dirname,
      };
    }
    return config;
  },
  
  // 압축 최적화
  compress: true,
  
  // 이미지 최적화
  images: {
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 60,
  },
};

5. 패키지 크기 최적화

불필요한 의존성 제거:

# 사용하지 않는 패키지 찾기
$ npx depcheck

# 중복된 패키지 찾기
$ pnpm dedupe

# 번들 크기 분석
$ npx @next/bundle-analyzer

6. 타입스크립트 최적화

packages/tsconfig/base.json

{
  "compilerOptions": {
    "incremental": true,
    "composite": true,
    "tsBuildInfoFile": ".tsbuildinfo",
    "skipLibCheck": true,
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  }
}

실제 프로덕션 배포 전략

Vercel 배포 (권장)

Turborepo는 Vercel에서 개발한 도구로, Vercel 플랫폼과의 통합이 매우 원활합니다.

1. 각 앱별 vercel.json 설정

// apps/storefront/vercel.json
{
  "buildCommand": "cd ../.. && turbo build --filter=storefront",
  "outputDirectory": ".next",
  "framework": "nextjs",
  "installCommand": "pnpm install"
}

2. GitHub Actions CI/CD

# .github/workflows/deploy.yml
name: Deploy to Vercel

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

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: pnpm/action-setup@v2
        with:
          version: 9
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      
      # Turborepo 원격 캐싱 설정
      - name: Setup Turborepo Remote Cache
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
        run: |
          echo "TURBO_TOKEN=$TURBO_TOKEN" >> $GITHUB_ENV
          echo "TURBO_TEAM=$TURBO_TEAM" >> $GITHUB_ENV
      
      - name: Install dependencies
        run: pnpm install
      
      - name: Build
        run: pnpm turbo build
      
      - name: Test
        run: pnpm turbo test
      
      - name: Lint
        run: pnpm turbo lint
      
      # Vercel 배포
      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          working-directory: ./apps/storefront

Docker 컨테이너 배포

Turborepo는 프루닝(pruning)을 통해 특정 타겟 빌드에 필요한 것만 포함된 희소 모노레포 하위 집합을 생성할 수 있습니다.

Dockerfile (Multi-stage build)

# Base image
FROM node:20-alpine AS base
RUN corepack enable pnpm

# Pruned stage - 필요한 것만 추출
FROM base AS pruner
WORKDIR /app
RUN pnpm add -g turbo
COPY . .
RUN turbo prune --scope=storefront --docker

# Dependencies stage
FROM base AS installer
WORKDIR /app

# 먼저 의존성만 설치 (캐싱 최적화)
COPY .gitignore .gitignore
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install --frozen-lockfile

# 소스 코드 복사 및 빌드
COPY --from=pruner /app/out/full/ .
COPY turbo.json turbo.json
RUN pnpm turbo build --filter=storefront

# Runner stage - 최종 실행 이미지
FROM base AS runner
WORKDIR /app

# 프로덕션 전용 사용자 생성
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs

# 빌드 결과물만 복사
COPY --from=installer --chown=nextjs:nodejs /app/apps/storefront/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/storefront/.next/static ./apps/storefront/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/storefront/public ./apps/storefront/public

EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

CMD ["node", "apps/storefront/server.js"]

docker-compose.yml

version: '3.8'

services:
  storefront:
    build:
      context: .
      dockerfile: apps/storefront/Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
    depends_on:
      - database
  
  admin:
    build:
      context: .
      dockerfile: apps/admin/Dockerfile
    ports:
      - "3001:3001"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
    depends_on:
      - database
  
  database:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

환경 변수 관리

루트 .env.example

# Database
DATABASE_URL="postgresql://admin:password@localhost:5432/myapp"

# Auth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key"

# API Keys
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."

# Analytics
GOOGLE_ANALYTICS_ID="G-XXXXXXXXXX"

앱별 환경 변수:

# apps/storefront/.env.local
NEXT_PUBLIC_API_URL="http://localhost:3002"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"

# apps/admin/.env.local
NEXT_PUBLIC_API_URL="http://localhost:3002"
NEXT_PUBLIC_SITE_URL="http://localhost:3001"

실전 팁과 트러블슈팅

1. 패키지 버전 불일치 문제

일관된 패키지 관리자 선택이 중요하며, workspace 프로토콜을 사용하여 로컬 패키지를 참조하면 변경사항이 즉시 반영됩니다.

// 올바른 방법: workspace 프로토콜 사용
{
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/database": "workspace:*"
  }
}

// 잘못된 방법
{
  "dependencies": {
    "@repo/ui": "file:../../packages/ui"  // ❌
  }
}

2. TypeScript 경로 문제

// apps/storefront/tsconfig.json
{
  "extends": "@repo/typescript-config/nextjs.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@repo/ui": ["../../packages/ui/src"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

3. 개발 서버 포트 충돌

# 각 앱에 고유한 포트 할당
# apps/storefront/package.json
"dev": "next dev -p 3000"

# apps/admin/package.json
"dev": "next dev -p 3001"

# apps/docs/package.json
"dev": "next dev -p 3002"

4. 빌드 시간 개선

# 변경된 패키지만 빌드
$ turbo build --filter=[HEAD^1]

# 병렬 작업 수 제한 (메모리 부족 시)
$ turbo build --concurrency=2

# 특정 패키지만 빌드
$ turbo build --filter=storefront...

5. 캐시 문제 해결

# 로컬 캐시 삭제
$ rm -rf .turbo

# 모든 node_modules 삭제 후 재설치
$ rm -rf node_modules apps/*/node_modules packages/*/node_modules
$ pnpm install

# 빌드 아티팩트 삭제
$ turbo clean
$ pnpm install
$ turbo build

마무리

핵심 정리

Turborepo는 모노레포 관리의 게임 체인저

  • 40-85% 빌드 시간 단축
  • 지능적인 캐싱과 병렬 처리
  • Next.js와의 완벽한 통합

여러 Next.js 앱을 하나의 저장소에서 관리

  • 코드 재사용성 극대화
  • 일관된 개발 환경
  • 독립적인 배포 가능

Shadcn UI로 디자인 시스템 구축

  • 아름답고 접근성 높은 컴포넌트
  • Tailwind CSS 기반
  • 커스터마이징 용이

프로덕션 레디

  • Vercel 배포 최적화
  • Docker 컨테이너화
  • CI/CD 파이프라인 구축

다음 단계

  1. 프로젝트 시작하기
  2. pnpm create turbo@latest my-project cd my-project pnpm dev
  3. 문서 살펴보기
  4. 커뮤니티 참여하기

추가 리소스


행복한 코딩 되세요! 🚀

 

반응형