Turborepo 모노레포 빌드 최적화: codegen 68% + 빌드 98% 개선 + Package Configurations


배경

NestJS + Turborepo 기반 모노레포에서 코드 생성(gen:api, gen:prisma, gen:proto) 속도가 느려 개발 흐름이 자주 끊겼다. 특히 pnpm gen:api가 80초나 걸리고 있었는데, 분석해보니 대부분이 불필요한 작업이었다.

모노레포 구조는 다음과 같다:

├── backend/              # NestJS monorepo (eterno, hiker 두 앱)
├── frontend/             # React/Next.js 프론트엔드들
├── packages/
│   ├── smart-contracts/  # Solidity 컨트랙트 (hardhat)
│   ├── chain-config/     # 블록체인 설정
│   ├── error-codes/      # 에러 코드 정의
│   └── service-settings/ # 서비스 설정
└── scripts/
    └── gen.sh            # 코드 생성 오케스트레이터

문제 분석: gen:api는 왜 80초나 걸렸나

Turbo의 --dry 옵션으로 태스크 그래프를 시각화하면 문제가 보인다:

npx turbo run gen:api --filter=backend --dry=json

기존 태스크 체인:

gen:api
  → depends on backend#build
    → depends on build:eterno-backend (tsc && nest build eterno-backend)
    → depends on build:hiker-backend  (tsc && nest build hiker-backend)
      → both depend on ^build (upstream 패키지 전체 빌드)
        → smart-contracts#build (hardhat compile --force && tsc) ← 20초
        → chain-config, error-codes, service-settings 빌드

세 가지 문제를 발견했다:

  1. gen:apibackend#build에 의존nest start api-generator는 자체 tsconfig으로 독립 컴파일하므로 backend#build가 생성하는 dist/는 사용하지 않고, upstream workspace 패키지(smart-contracts, chain-config 등)의 빌드 결과물만 필요
  2. smart-contracts가 매번 전체 재컴파일--force 플래그 + turbo 캐시 비활성화 + prebuild: rm -rf dist로 3중 캐시 무효화
  3. gen.sh에서 중복 빌드 + 순차 실행build_backend()를 별도로 호출한 뒤 turbo run gen:api가 또 upstream을 빌드

개선 1: 불필요한 backend#build 의존성 제거

nest start api-generator가 실제로 무엇을 컴파일하는지 확인했다:

// scripts/api-generator/tsconfig.app.json
{
  "include": [
    "../../apps/eterno-backend/**/*",
    "../../apps/hiker-backend/**/*",
    "../../libs/**/*",
    "../../scripts/**/*"
  ]
}

backend 소스코드 전체를 자체적으로 컴파일한다. backend#build가 생성하는 dist/는 사용하지 않고, 출력도 별도 경로(dist/scripts/api-generator/)에 생성한다.

하지만 backend 소스가 import ... from 'smart-contracts'처럼 upstream workspace 패키지를 import하고, 이 패키지들은 main: "./dist/index.js"로 빌드 결과물을 export한다. 따라서 이들의 dist/가 없으면 TypeScript 컴파일이 실패한다. 즉, backend 자체 빌드 결과물은 불필요하고 upstream 패키지 빌드 결과물만 필요하므로 backend#build^build로 변경할 수 있다.

따라서 backend#build(backend 자체 빌드) 대신 ^build(upstream 패키지만 빌드)로 변경:

// turbo.json
"gen:api": {
  "cache": false,
- "dependsOn": ["clean:api", "backend#build"]
+ "dependsOn": ["clean:api", "^build"]
}

Turbo에서 ^는 “현재 패키지가 의존하는 upstream 패키지들의 해당 태스크”를 의미한다.

이 한 줄로 tsc × 2 + nest build × 2 체인이 제거되었다:

BeforeAfter
pnpm gen:api80초56초
태스크 수8개6개

개선 2: smart-contracts 빌드 캐시 활성화

56초 중 ~20초를 smart-contracts#build가 차지하고 있었다. 세 겹의 캐시 무효화가 문제:

// packages/smart-contracts/package.json
{
  "prebuild": "rm -rf dist",              // 1. dist 매번 삭제
  "build": "hardhat compile --force && tsc" // 2. --force로 hardhat 캐시 무시
}

// turbo.json
"build": { "cache": false }                // 3. turbo 캐시 비활성화

두 가지를 수정했다:

hardhat compile에서 --force 제거:

- "build": "hardhat compile --force && tsc"
+ "build": "hardhat compile && tsc"

--force가 없으면 hardhat은 artifacts/cache/를 확인하여 Solidity 소스가 변경되지 않았으면 컴파일을 스킵한다.

turbo.json에서 smart-contracts만 선택적으로 캐시 활성화:

"smart-contracts#build": {
  "cache": true,
  "inputs": ["contracts/**", "src/**", "hardhat.config.ts", "tsconfig.json", "package.json"],
  "outputs": ["dist/**", "typechain-types/**", "artifacts/**"]
}

기본 build 태스크가 cache: false이므로, 패키지별 오버라이드(smart-contracts#build)로 이 패키지만 캐시를 켰다. inputs에 정의된 파일이 변경되지 않으면 turbo가 빌드 스크립트 실행 자체를 스킵하고 outputs를 캐시에서 복원한다.

두 캐시는 레이어가 다르다:

gen:api 실행
  → turbo: smart-contracts 입력 파일 변경됨?
    → NO → 캐시에서 outputs 복원 (0초)
    → YES → prebuild + hardhat compile 실행
             → hardhat: Solidity 소스 변경됨?
               → NO → 컴파일 스킵 (2초)
               → YES → 전체 컴파일 (20초)
BeforeAfter (1st run)After (2nd run)
pnpm gen:api59초29초26초

개선 3: gen.sh 중복 빌드 제거 + 병렬화

gen.sh를 보니 generate_api() 함수 안에서 build_backend()를 별도로 호출하고 있었다:

function generate_api() {
  build_backend    # turbo run build --filter backend (tsc×2 + nest build×2)
  turbo run clean:api
  turbo run gen:api  # ^build로 upstream 또 빌드 → 중복!
}

generate_prisma    # 순차 실행
generate_proto     # 순차 실행
generate_api

두 가지 문제:

  • build_backend()가 turbo.json의 gen:api → ^build 의존성과 중복
  • prismaproto가 독립적인데 순차 실행

수정:

function generate_api() {
  # build_backend 제거 — turbo가 ^build로 자동 처리
  turbo run gen:api
}

generate_prisma &   # 백그라운드 실행
generate_proto &    # 백그라운드 실행
wait                # 둘 다 완료 대기
generate_api        # prisma 타입이 필요하므로 이후 실행
BeforeAfter
pnpm gen (전체)139초94초

개선 4: 전체 빌드 캐싱 전면 적용

codegen 파이프라인 개선 이후, pnpm build 전체에도 같은 캐싱 전략을 확장했다. 기존에는 모든 빌드 태스크가 cache: false(smart-contracts 제외)여서, 소스 변경이 없어도 매번 전체 재빌드가 실행되고 있었다.

outputs 설정 버그 발견

캐싱을 활성화하기 전에 기존 outputs 설정을 점검했는데, 두 가지 버그를 발견했다:

// turbo.json — 기존 설정
"build:eterno-backend": {
  "outputs": ["dist/**"]  // ❌ backend/dist/를 가리킴
}
// 실제 nest build 출력: backend/apps/eterno-backend/dist/

"hiker#build": {
  // outputs 미설정 → 기본값 dist/** 사용
}
// 실제 vite 출력: frontend/hiker/build/  (vite.config.ts의 outDir)

cache: false였기 때문에 outputs가 틀려도 문제가 드러나지 않았다. 하지만 cache: true로 전환하면 turbo가 캐시 HIT 시 이 경로에서 파일을 복원하므로, 잘못된 outputs는 빌드 결과물 누락으로 이어진다. 이것이 과거 cache: true 적용 시 겪었던 문제의 원인이었을 가능성이 높다.

수정 내용

// turbo.json
"build": {
-  "cache": false,
+  "cache": true,
   "dependsOn": ["^build"],
   "outputs": ["dist/**"]
}

"build:eterno-backend": {
  "cache": true,
- "outputs": ["dist/**"]
+ "outputs": ["apps/eterno-backend/dist/**"]
}

"hiker#build": {
  "cache": true,
+ "outputs": ["build/**"]
}

inputs 전략: 대부분의 패키지에서 inputs를 명시하지 않았다. turbo는 inputs가 없으면 패키지 내 모든 파일을 해싱하는데, 이것이 명시적 inputs 목록보다 안전하다. 새 파일이 추가될 때 inputs에 빠뜨릴 위험이 없기 때문이다. 예외적으로 backend 빌드 태스크(build:eterno-backend, build:hiker-backend)만 inputs를 명시했는데, 같은 패키지 내에서 앱별 소스를 구분해야 하기 때문이다.

캐시 검증

캐싱이 올바르게 동작하는지 12개 시나리오를 자동 검증하는 스크립트를 작성했다:

./scripts/verify-turbo-cache.sh

각 시나리오는: 파일 변경 → turbo run build --dry=json으로 캐시 상태 확인 → git checkout으로 복원. 예를 들어:

시나리오변경Expected MISSExpected HIT
chain-config 변경packages/chain-config/index.tschain-config, backend×2, eterno, ovdr-officialhiker, odds, smart-contracts
eterno-backend만 변경backend/apps/eterno-backend/src/main.tsbuild:eterno-backendbuild:hiker-backend, 전체 프론트
prisma 생성코드 변경backend/prisma/_generated/build:eterno-backend, build:hiker-backend전체 패키지, 전체 프론트

12개 시나리오 전부 PASS.

결과

시나리오Before (캐시 없음)After (캐시 적용)개선
pnpm build 1회차 (cold)4분 42초4분 38초동일
pnpm build 2회차 (변경 없음)4분 5초6초-98%

cold build는 동일하지만, 소스 변경이 없는 2회차 빌드가 4분 → 6초로 단축되었다. 실제 개발 시에는 변경된 패키지만 rebuild되고 나머지는 캐시에서 복원되므로, 대부분의 빌드에서 큰 시간 절감 효과가 있다.

개선 5: Package Configurations로 설정 분산

전체 빌드 캐싱을 적용하면서 루트 turbo.json에 패키지별 오버라이드가 9개나 쌓였다:

// turbo.json — 비대해진 루트 설정
{
  "tasks": {
    "build": { ... },
    "backend#build": { ... },
    "build:eterno-backend": { ... },
    "build:hiker-backend": { ... },
    "smart-contracts#build": { ... },
    "@ovdr/odds#build": { ... },
    "eterno#build": { ... },
    "hiker#build": { ... },
    "ovdr-official#build": { ... },
    "ovdr-webview#build": { ... }
  }
}

어떤 패키지가 어떤 설정을 사용하는지 파악하려면 루트 파일을 뒤져야 한다. Turborepo는 이 문제를 해결하는 Package Configurations 기능을 제공한다 — 각 패키지에 turbo.json을 두고 "extends": ["//"]로 루트 설정을 상속받는 방식이다.

발견 경로: Turborepo Claude Skill

이 리팩토링 아이디어는 Vercel이 공식 제공하는 Turborepo Claude Skill에서 왔다. npx skills CLI로 설치할 수 있다:

$ npx skills search turborepo

vercel/turborepo@turborepo  7.7K installs
antfu/skills@turborepo      2.9K installs
wshobson/agents@turborepo-caching  2.2K installs

$ npx skills add vercel/turborepo@turborepo --yes

설치하면 .claude/skills/turborepo/에 symlink가 생기고, 이후 turbo 관련 작업 시 Claude가 자동으로 참조한다. 이 skill의 Anti-Patterns 섹션에 다음 내용이 있다:

Package-Specific Task Overrides in Root turbo.json When multiple packages need different task configurations, use Package Configurations (turbo.json in each package) instead of cluttering root turbo.json with package#task overrides.

적용

각 패키지에 turbo.json을 생성하고, 해당 패키지의 build 설정만 오버라이드:

// frontend/eterno/turbo.json
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**"],
      "env": ["NEXT_PUBLIC_*"]
    }
  }
}
// frontend/hiker/turbo.json
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "outputs": ["build/**"]
    }
  }
}
// backend/turbo.json
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "cache": false,
      "dependsOn": ["build:eterno-backend", "build:hiker-backend"]
    },
    "build:eterno-backend": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": ["apps/eterno-backend/src/**", "libs/**", "prisma/_generated/**", "..."],
      "outputs": ["apps/eterno-backend/dist/**"]
    },
    "build:hiker-backend": { "..." }
  }
}

루트 turbo.json은 공통 태스크 정의만 남겼다:

// turbo.json — 정리된 루트 설정
{
  "globalDependencies": ["pnpm-lock.yaml"],
  "tasks": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "prisma-generator-nestjs-dto#build": {
      "cache": true,
      "outputs": ["dist/**"]
    },
    "clean": { "cache": false },
    "lint": { "dependsOn": ["^build"], "outputs": [] },
    "gen:api": { "cache": false, "dependsOn": ["clean:api", "^build"] },
    "..."
  }
}

prisma-generator-nestjs-dto#build만 루트에 남겼다 — git 서브모듈이라 패키지 내에 turbo.json을 추가하려면 서브모듈 레포를 수정해야 하기 때문이다.

검증

기존 검증 스크립트(13개 시나리오)를 그대로 실행하여 캐시 동작이 동일한지 확인했다:

═══════════════════════════════════════════
 Results
═══════════════════════════════════════════
  ✓ No changes (all HIT)
  ✓ chain-config 변경 → backend/eterno/ovdr-official MISS
  ✓ error-codes 변경 → backend/eterno/ovdr-official MISS
  ✓ @ovdr/odds 변경 → eterno/ovdr-official MISS, backend HIT
  ...
  ✓ pnpm-lock.yaml 변경 → 전체 MISS (globalDependencies)

Total: 13 passed, 0 failed
═══════════════════════════════════════════

동작은 완전히 동일하면서, 설정이 해당 패키지 가까이에 위치하게 되었다.

최종 결과

개별 커밋

커밋변경 내용대상BeforeAfter개선
1gen:api 의존성 backend#build^buildpnpm gen:api80초56초-24초 (30%)
2smart-contracts 캐시 활성화 + --force 제거pnpm gen:api59초26초-33초 (56%)
3gen.sh build_backend 제거 + prisma/proto 병렬화pnpm gen139초94초-45초 (32%)
4전체 빌드 캐싱 전면 적용pnpm build (2회차)4분 5초6초-98%

누적 효과

대상최초 Before최종 After총 개선
pnpm gen:api80초26초-54초 (68%)
pnpm gen (전체)139초94초-45초 (32%)
pnpm build (2회차)4분 5초6초-98%

배운 것

turbo --dry로 태스크 그래프를 먼저 확인하라. 실행 전에 어떤 태스크가 왜 실행되는지 보면 불필요한 의존성을 바로 발견할 수 있다.

빌드 도구의 자체 캐시를 존중하라. --force 플래그나 rm -rf dist를 습관적으로 넣으면 도구가 제공하는 캐시 메커니즘을 무력화한다. 캐시 무효화는 의도적으로, 필요할 때만 해야 한다.

“이 빌드 결과를 누가 사용하는가?”를 추적하라. backend#builddist/gen:api가 사용하지 않는다는 걸 확인하는 데 가장 중요한 건 tsconfig의 include 범위와 출력 경로를 비교하는 것이었다.

outputs가 정확하지 않으면 캐싱은 독이 된다. 캐시 HIT 시 turbo는 빌드를 스킵하고 outputs에 명시된 파일만 복원한다. outputs가 실제 빌드 결과물과 다르면 파일이 누락되어 의존 태스크가 실패한다. cache: false일 때는 매번 빌드가 실행되므로 outputs 오류가 드러나지 않는다.

inputs는 명시하지 않는 편이 안전하다. turbo는 inputs가 없으면 패키지 내 모든 파일을 해싱한다. 명시적 inputs 목록은 새 파일 추가 시 빠뜨릴 위험이 있다. 같은 패키지 내에서 서로 다른 태스크의 소스를 구분해야 할 때만 inputs를 사용하면 된다.

설정은 코드 가까이에 두라. 패키지별 빌드 설정이 루트 turbo.json에 모여 있으면, 어떤 패키지가 어떤 outputs를 쓰는지 파악하기 어렵다. Package Configurations(패키지/turbo.json + "extends": ["//"])로 분산하면 해당 패키지를 열었을 때 설정을 바로 확인할 수 있다.

도구의 공식 스킬/문서를 활용하라. Turborepo의 Claude Skill(npx skills add vercel/turborepo@turborepo)은 anti-pattern 목록과 decision tree를 제공한다. 작업 중 자동으로 참조되어 Package Configurations 같은 개선 포인트를 발견할 수 있었다.