Turborepo 모노레포 삽질 노트 2026: 캐시가 안 먹는 이유부터 CI 빌드 실패까지

Turborepo 모노레포 삽질 노트 2026 트러블슈팅 가이드

Troubleshooting · 2026

Turborepo 모노레포 삽질 노트

캐시 미작동 · Remote Cache 인증 · 순환 참조 · CI 빌드 실패 — 실전 트러블슈팅

Turborepo pnpm Next.js

모노레포 도입하면 생산성 올라간다는 말에 혹해서 Turborepo를 셋업했다가, 일주일을 통째로 날린 경험담을 공유한다. "turbo run build 했는데 왜 캐시가 안 먹지?"부터 "CI에서만 빌드가 터지는데 로컬은 멀쩡한" 상황까지 — 내가 실제로 겪은 삽질 포인트를 전부 정리했다.

Next.js + pnpm 워크스페이스 기반 모노레포에서 Turborepo를 세팅하는 과정이었고, 패키지 3개(web, admin, shared-ui)짜리 비교적 단순한 구조였는데도 이 정도로 삽질했다. 복잡한 모노레포를 운영하고 있다면 이 글이 시간을 꽤 아껴줄 거다.



🔥 삽질 타임라인 요약

삽질 소요 시간 고통 지수
outputs 설정 누락 3시간 ⭐⭐⭐⭐
Remote Cache 인증 삽질 반나절 ⭐⭐⭐⭐⭐
의존성 그래프 순환 참조 2시간 ⭐⭐⭐
turbo watch TUI 깨짐 1시간 ⭐⭐
CI/CD 환경 변수 누락 반나절 ⭐⭐⭐⭐⭐

1. outputs 설정 누락 — 캐시가 안 먹는 원인 1위

Turborepo를 처음 세팅하고 turbo run build를 돌렸는데, 분명히 아무것도 안 바꿨는데 매번 풀 빌드가 돌아갔다. 로그에는 "MISS"만 찍혔다. 캐시가 있으나 마나였다.

원인은 turbo.jsonoutputs 설정이 빠져있었기 때문이다. Turborepo는 태스크의 output 폴더를 기준으로 캐시를 저장/복원하는데, 이걸 안 잡아주면 뭘 캐싱해야 하는지 모른다.

❌ 문제의 turbo.json

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
      // outputs가 없다!
    }
  }
}

✅ 수정 후

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  }
}

💡 핵심: Next.js의 경우 .next/**를 outputs에 넣되, !.next/cache/**로 Next.js 자체 캐시는 제외해야 한다. 안 그러면 캐시 안에 캐시가 들어가서 용량이 미친 듯이 불어난다.


2. Remote Cache 인증 — "MISS" 지옥에서 벗어나기

로컬에서는 캐시가 잘 먹는데, CI(GitHub Actions)에서는 매번 MISS가 나왔다. 당연하다. 로컬 캐시는 내 머신에만 있으니까. CI 머신은 매번 새로 뜨는 환경이라 캐시가 텅 비어있다.

해결책은 Vercel Remote Cache다. Vercel에 프로젝트가 연결되어 있으면 무료로 쓸 수 있다.

세팅 과정

# 1. Vercel 계정 연결
npx turbo login

# 2. 프로젝트 연결
npx turbo link

이렇게만 하면 로컬은 끝이다. 문제는 CI다.

GitHub Actions에서 Remote Cache 연결

# .github/workflows/ci.yml
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

🚨 삽질 포인트: TURBO_TOKEN은 Vercel 대시보드 → Settings → Tokens에서 생성하는 건데, 이게 Vercel Personal Access Token이 아니라 Turborepo 전용 토큰이어야 한다. 나는 이 차이를 몰라서 PAT를 넣어놓고 "왜 403이지?" 하면서 반나절을 날렸다.

TURBO_TEAM은 Vercel 팀 slug이다. 개인 계정이면 Vercel username을 넣으면 된다. 이것도 빼먹으면 조용히 캐시가 안 먹는다. 에러도 안 뜨고 그냥 MISS만 찍힌다.


3. 의존성 그래프 순환 참조 — 빌드 순서가 꼬이는 이유

패키지 구조가 이랬다:

packages/
  shared-ui/     → @repo/shared-ui
  web/           → @repo/web (depends on shared-ui)
  admin/         → @repo/admin (depends on shared-ui)

여기까지는 깔끔하다. 문제는 shared-ui에서 web의 타입을 참조하려고 @repo/web을 devDependencies에 추가한 순간 발생했다. 순환 참조다.

web → shared-ui → web (순환!)

Turborepo는 이걸 감지하면 빌드 순서를 결정하지 못하고 에러를 뱉는다. 에러 메시지가 직관적이지 않아서 처음에 뭐가 문제인지 한참 헤맸다.

해결법: 공유 타입은 별도 패키지(@repo/types)로 분리했다. 이게 모노레포 설계의 기본 원칙인데, 처음에는 "패키지 하나 더 만드는 게 귀찮다"는 이유로 안 했다가 바로 삽질했다.

packages/
  types/         → @repo/types (공유 타입 전담)
  shared-ui/     → @repo/shared-ui (depends on types)
  web/           → @repo/web (depends on shared-ui, types)
  admin/         → @repo/admin (depends on shared-ui, types)

4. turbo watch가 TUI를 박살내는 문제

개발 모드에서 turbo watch dev를 돌리면 하위 패키지를 수정할 때마다 의존하는 패키지들이 연쇄적으로 재시작된다. 이론적으로는 맞는 동작인데, 실제로는 태스크가 시작→중단→재시작을 반복하면서 터미널 출력이 난장판이 된다.

특히 shared-ui를 수정하면 webadmin이 동시에 재시작되면서 에러 메시지가 뒤섞여 나온다. 실제 에러인지 재시작 과정의 일시적 에러인지 구분이 안 된다.

내 해결법: turbo watch 대신 각 앱별로 터미널을 따로 띄웠다. 좀 원시적이지만, 실무에서는 이게 더 안정적이었다.

# 터미널 1: shared-ui 빌드 watch
pnpm --filter @repo/shared-ui dev

# 터미널 2: web 앱
pnpm --filter @repo/web dev

# 터미널 3: admin 앱
pnpm --filter @repo/admin dev

Turborepo 2.x에서 watch 모드가 많이 개선됐다고는 하는데, 깊은 의존성 그래프에서는 아직 불안정한 부분이 있다.


5. CI에서만 빌드가 터지는 미스터리

로컬에서 turbo run build — 성공. GitHub Actions에서 같은 커맨드 — 실패. 이 상황이 가장 멘탈이 나간다.

원인 1: 환경 변수 누락

Next.js 빌드 시 NEXT_PUBLIC_* 환경 변수가 빌드 타임에 인라인되는데, CI에서 이 변수들을 안 넣어주면 빌드는 되지만 런타임에 undefined가 된다. 근데 어떤 변수는 빌드 자체를 터뜨리기도 한다.

// turbo.json에서 환경 변수 의존성 선언
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**"],
      "env": [
        "NEXT_PUBLIC_API_URL",
        "NEXT_PUBLIC_SENTRY_DSN",
        "DATABASE_URL"
      ]
    }
  }
}

💡 핵심: turbo.jsonenv 배열에 환경 변수를 선언하면, 해당 변수가 바뀔 때 캐시가 무효화된다. 이걸 안 하면 환경 변수가 달라져도 캐시된 빌드를 쓰는 사고가 난다. 스테이징 빌드에 프로덕션 API URL이 박히는 호러를 경험했다.

원인 2: pnpm 버전 불일치

로컬은 pnpm 9.x인데 CI는 pnpm 8.x가 깔려있었다. lockfile 포맷이 달라서 pnpm install부터 경고가 뜨고, 간헐적으로 의존성이 제대로 설치 안 됐다.

// package.json에 pnpm 버전 고정
{
  "packageManager": "pnpm@9.15.4"
}

// corepack으로 CI에서도 동일 버전 보장
corepack enable
corepack prepare pnpm@9.15.4 --activate

6. Composable Config — turbo.json 관리 팁

패키지가 늘어나면 turbo.json이 비대해진다. Turborepo 2.7부터 도입된 Composable Configuration을 쓰면 각 패키지에 turbo.json을 분산시킬 수 있다.

// apps/web/turbo.json
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"],
      "env": ["NEXT_PUBLIC_API_URL"]
    }
  }
}
// apps/admin/turbo.json
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"],
      "env": ["NEXT_PUBLIC_ADMIN_API_URL"]
    }
  }
}

"extends": ["//"]가 루트 turbo.json을 상속한다는 의미다. 이렇게 하면 앱별로 다른 환경 변수나 outputs를 깔끔하게 관리할 수 있다.


✅ 생존 체크리스트

이 글의 삽질을 반복하지 않으려면 이것만 체크하자:

  • outputs 선언: 모든 태스크에 outputs 빠짐없이 설정. lint처럼 output이 없는 태스크는 빈 배열 []로 명시.
  • env 선언: 빌드에 영향 주는 환경 변수는 전부 env 배열에 나열. 안 하면 캐시 오염.
  • 패키지 매니저 버전 고정: packageManager 필드 + corepack으로 로컬/CI 환경 통일.
  • 순환 참조 차단: 공유 타입은 별도 패키지로 분리. 패키지 간 양방향 의존 절대 금지.
  • Remote Cache 토큰 확인: Vercel PAT가 아니라 Turborepo 전용 토큰인지 반드시 확인.

🎯 마무리 — 그래서 Turborepo 쓸 만한가?

삽질을 엄청 했지만 결론적으로 Turborepo는 쓸 만하다. 세팅이 끝나고 나면 CI 빌드 시간이 체감 절반 이하로 줄었고, 로컬 개발에서도 변경 안 한 패키지는 캐시 히트가 나니까 빌드 대기 시간이 확 줄었다.

다만 "설치하면 끝"이 아니라는 거다. outputs, env, Remote Cache 설정을 제대로 안 하면 캐시가 있으나 마나고, 의존성 그래프 설계를 잘못하면 빌드 순서가 꼬인다. 이 글에서 정리한 삽질 포인트만 피하면 도입 시간을 상당히 줄일 수 있을 거다.

패키지 매니저 선택이 고민이라면 pnpm vs npm vs Yarn 비교 글을 참고하자. 모노레포에는 pnpm 워크스페이스가 가장 잘 맞는다는 게 내 결론이다.


🔗 관련 글

댓글