Optimizing Builds in a Turborepo Monorepo: 68% Faster Codegen, 98% Faster Builds, and Package Configurations Turborepo 모노레포 빌드 최적화: codegen 68% + 빌드 98% 개선 + Package Configurations


Background

In our NestJS + Turborepo monorepo, code generation (gen:api, gen:prisma, gen:proto) was slow enough that it kept breaking our development flow. pnpm gen:api in particular was taking 80 seconds, and when I dug in, most of that turned out to be unnecessary work.

The monorepo is structured like this:

├── backend/              # NestJS monorepo (two apps: eterno, hiker)
├── frontend/             # React/Next.js frontends
├── packages/
│   ├── smart-contracts/  # Solidity contracts (hardhat)
│   ├── chain-config/     # Blockchain configuration
│   ├── error-codes/      # Error code definitions
│   └── service-settings/ # Service settings
└── scripts/
    └── gen.sh            # Code generation orchestrator

Diagnosis: Why Did gen:api Take 80 Seconds?

Visualizing the task graph with Turbo’s --dry option makes the problem obvious:

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

The original task chain:

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 (builds every upstream package)
        → smart-contracts#build (hardhat compile --force && tsc) ← 20s
        → chain-config, error-codes, service-settings builds

I found three problems:

  1. gen:api depended on backend#build — nest start api-generator compiles independently with its own tsconfig, so it never uses the dist/ produced by backend#build. All it needs are the build outputs of the upstream workspace packages (smart-contracts, chain-config, etc.).
  2. smart-contracts was fully recompiled every time — the cache was invalidated three ways: the --force flag, turbo caching being disabled, and prebuild: rm -rf dist.
  3. gen.sh built things twice and ran everything sequentially — it called build_backend() separately, and then turbo run gen:api built the upstream packages all over again.

Improvement 1: Removing the Unnecessary backend#build Dependency

First, I checked what nest start api-generator actually compiles:

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

It compiles the entire backend source tree on its own. It doesn’t use the dist/ produced by backend#build, and it writes its output to a separate path (dist/scripts/api-generator/).

However, the backend source imports upstream workspace packages, e.g. import ... from 'smart-contracts', and those packages export their build output via main: "./dist/index.js". So if their dist/ doesn’t exist, TypeScript compilation fails. In other words, the backend’s own build output is unnecessary; only the upstream packages’ build output is required, which means we can change backend#build → ^build.

So I replaced backend#build (building the backend itself) with ^build (building only the upstream packages):

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

In Turbo, ^ means “this task in the upstream packages that the current package depends on.”

That single line eliminated the tsc × 2 + nest build × 2 chain:

BeforeAfter
pnpm gen:api80s56s
Number of tasks86

Improvement 2: Enabling the smart-contracts Build Cache

Of the 56 seconds, ~20 were spent in smart-contracts#build. The culprit was three layers of cache invalidation:

// packages/smart-contracts/package.json
{
  "prebuild": "rm -rf dist",              // 1. deletes dist every time
  "build": "hardhat compile --force && tsc" // 2. --force ignores the hardhat cache
}

// turbo.json
"build": { "cache": false }                // 3. turbo cache disabled

I made two changes:

Removed --force from hardhat compile:

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

Without --force, hardhat checks artifacts/ and cache/ and skips compilation if the Solidity sources haven’t changed.

Selectively enabled caching for smart-contracts only in turbo.json:

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

Since the default build task has cache: false, I used a package-specific override (smart-contracts#build) to turn caching on for just this package. If none of the files listed in inputs have changed, turbo skips running the build script entirely and restores outputs from the cache.

The two caches operate at different layers:

Run gen:api
  → turbo: have smart-contracts input files changed?
    → NO → restore outputs from cache (0s)
    → YES → run prebuild + hardhat compile
             → hardhat: have the Solidity sources changed?
               → NO → skip compilation (2s)
               → YES → full compile (20s)
BeforeAfter (1st run)After (2nd run)
pnpm gen:api59s29s26s

Improvement 3: Removing Duplicate Builds from gen.sh + Parallelizing

Looking at gen.sh, the generate_api() function was calling build_backend() separately:

function generate_api() {
  build_backend    # turbo run build --filter backend (tsc×2 + nest build×2)
  turbo run clean:api
  turbo run gen:api  # builds upstream again via ^build → duplicate!
}

generate_prisma    # runs sequentially
generate_proto     # runs sequentially
generate_api

Two problems:

  • build_backend() duplicated the gen:api → ^build dependency already declared in turbo.json
  • prisma and proto are independent, yet they ran sequentially

The fix:

function generate_api() {
  # removed build_backend — turbo handles it automatically via ^build
  turbo run gen:api
}

generate_prisma &   # run in background
generate_proto &    # run in background
wait                # wait for both to finish
generate_api        # runs afterwards because it needs the prisma types
BeforeAfter
pnpm gen (full)139s94s

Improvement 4: Rolling Out Build Caching Across the Board

After improving the codegen pipeline, I extended the same caching strategy to the full pnpm build. Previously every build task (except smart-contracts) had cache: false, so a full rebuild ran every time, even when no source had changed.

Discovering Bugs in the outputs Configuration

Before enabling caching, I reviewed the existing outputs settings and found two bugs:

// turbo.json — original configuration
"build:eterno-backend": {
  "outputs": ["dist/**"]  // ❌ points to backend/dist/
}
// Actual nest build output: backend/apps/eterno-backend/dist/

"hiker#build": {
  // outputs not set → falls back to the default dist/**
}
// Actual vite output: frontend/hiker/build/  (outDir in vite.config.ts)

Because caching was set to cache: false, the wrong outputs never surfaced as a problem. But once you switch to cache: true, turbo restores files from these paths on a cache HIT, so incorrect outputs lead to missing build artifacts. This was very likely the cause of the problems we had run into when we tried cache: true in the past.

The Changes

// 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/**"]
}

The inputs strategy: For most packages I didn’t specify inputs. When inputs is absent, turbo hashes every file in the package, which is safer than an explicit inputs list — there’s no risk of forgetting to add a new file to inputs. The one exception is the backend build tasks (build:eterno-backend, build:hiker-backend), where I did specify inputs, because they need to distinguish per-app sources within the same package.

Verifying the Cache

I wrote a script that automatically verifies 12 scenarios to make sure caching behaves correctly:

./scripts/verify-turbo-cache.sh

Each scenario: modify a file → check cache status with turbo run build --dry=json → restore with git checkout. For example:

ScenarioChangeExpected MISSExpected HIT
chain-config changedpackages/chain-config/index.tschain-config, backend×2, eterno, ovdr-officialhiker, odds, smart-contracts
Only eterno-backend changedbackend/apps/eterno-backend/src/main.tsbuild:eterno-backendbuild:hiker-backend, all frontends
Prisma generated code changedbackend/prisma/_generated/build:eterno-backend, build:hiker-backendall packages, all frontends

All 12 scenarios PASS.

Results

ScenarioBefore (no cache)After (cached)Improvement
pnpm build 1st run (cold)4m 42s4m 38sSame
pnpm build 2nd run (no changes)4m 5s6s-98%

The cold build is unchanged, but a second build with no source changes dropped from 4 minutes to 6 seconds. In day-to-day development, only the packages that changed get rebuilt and everything else is restored from the cache, so most builds see a big time savings.

Improvement 5: Splitting Up Configuration with Package Configurations

While rolling out build caching across the board, the root turbo.json accumulated as many as 9 package-specific overrides:

// turbo.json — bloated root configuration
{
  "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": { ... }
  }
}

To figure out which package uses which settings, you have to dig through the root file. Turborepo offers a feature that solves exactly this: Package Configurations — you put a turbo.json in each package and inherit the root configuration with "extends": ["//"].

How I Found It: The Turborepo Claude Skill

The idea for this refactor came from the Turborepo Claude Skill officially provided by Vercel. You can install it with the 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

Installing it creates a symlink at .claude/skills/turborepo/, and from then on Claude automatically references it whenever you work on anything turbo-related. The skill’s Anti-Patterns section includes the following:

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.

Applying It

I created a turbo.json in each package that overrides only that package’s build settings:

// 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": { "..." }
  }
}

The root turbo.json now holds only the shared task definitions:

// turbo.json — cleaned-up root configuration
{
  "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"] },
    "..."
  }
}

Only prisma-generator-nestjs-dto#build stays in the root — it’s a git submodule, so adding a turbo.json inside the package would mean modifying the submodule’s repository.

Verification

I ran the existing verification script (13 scenarios) as-is to confirm that caching behaves identically:

═══════════════════════════════════════════
 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
═══════════════════════════════════════════

(In the script output above, “변경” means “changed” and “전체” means “all”.)

The behavior is exactly the same, but each piece of configuration now lives right next to the package it belongs to.

Final Results

Individual Commits

CommitChangeTargetBeforeAfterImprovement
1gen:api dependency backend#build → ^buildpnpm gen:api80s56s-24s (30%)
2Enable smart-contracts cache + remove --forcepnpm gen:api59s26s-33s (56%)
3Remove build_backend from gen.sh + parallelize prisma/protopnpm gen139s94s-45s (32%)
4Roll out build caching across the boardpnpm build (2nd run)4m 5s6s-98%

Cumulative Impact

TargetOriginal BeforeFinal AfterTotal Improvement
pnpm gen:api80s26s-54s (68%)
pnpm gen (full)139s94s-45s (32%)
pnpm build (2nd run)4m 5s6s-98%

Lessons Learned

Check the task graph with turbo --dry first. If you look at which tasks run, and why, before you execute anything, unnecessary dependencies jump right out.

Respect your build tools’ own caches. Habitually throwing in a --force flag or rm -rf dist defeats the caching mechanisms the tools give you. Cache invalidation should be deliberate, and only when needed.

Trace “who actually consumes this build output?” The key to confirming that gen:api didn’t use backend#build’s dist/ was comparing the tsconfig include scope against the output paths.

If outputs isn’t accurate, caching becomes poison. On a cache HIT, turbo skips the build and restores only the files listed in outputs. If outputs doesn’t match the actual build artifacts, files go missing and dependent tasks fail. With cache: false, the build runs every time, so outputs errors never surface.

It’s safer not to specify inputs. Without inputs, turbo hashes every file in the package. An explicit inputs list risks missing newly added files. Use inputs only when you need to distinguish sources for different tasks within the same package.

Keep configuration close to the code. When per-package build settings are all piled into the root turbo.json, it’s hard to tell which package uses which outputs. Splitting them out with Package Configurations (<package>/turbo.json + "extends": ["//"]) means you can see the settings as soon as you open the package.

Make use of your tools’ official skills and docs. Turborepo’s Claude Skill (npx skills add vercel/turborepo@turborepo) provides a list of anti-patterns and a decision tree. Because it was referenced automatically while I worked, I was able to spot improvements like 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:api가 backend#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 의존성과 중복
  • prisma와 proto가 독립적인데 순차 실행

수정:

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#build의 dist/를 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 같은 개선 포인트를 발견할 수 있었다.