Turborepo로 운영하며 자주 만나는 문제들: cache miss, shared package 안티패턴, 경계 붕괴

Frontend

이 글은 Turborepo 시리즈 4편입니다.

앞선 글들에서 Turborepo의 개념, 시작 방법, 운영 고도화까지 정리했다면, 결국 마지막에 남는 것은 실제 문제입니다.

실무에서 더 자주 나오는 질문은 보통 이런 식입니다.

  • 왜 cache hit가 날 것 같은데 계속 miss가 나는가
  • 왜 작은 설정 변경 하나가 레포 전체 build를 흔드는가
  • shared 패키지가 시간이 갈수록 잡동사니 폴더가 되는가
  • 왜 어떤 앱은 분명히 영향이 없어 보이는데 같이 다시 실행되는가

즉, Turborepo 운영에서 중요한 것은 "도구를 잘 쓴다"보다, 문제가 생겼을 때 graph와 task 관점으로 원인을 설명할 수 있는가입니다.

이 글에서는 실제로 자주 만나는 문제를 증상 -> 흔한 원인 -> 확인 방법 -> 대응 방식 순서로 정리해보겠습니다.

먼저 한눈에 보면

운영 중 자주 만나는 문제는 대체로 네 가지로 모입니다.

문제 흔한 원인
cache miss가 너무 많다 inputs가 너무 넓거나, 환경 차이가 제대로 관리되지 않음
작은 수정에도 전체가 다시 돈다 공통 패키지 fan-out이 너무 넓음
shared package가 계속 커진다 경계 없는 공통화
왜 다시 실행됐는지 설명이 안 된다 graph를 보지 않고 감으로 운영함

빠르게 정리하면, Turborepo 문제의 대부분은 캐시 기능 자체보다 package 경계와 task 입력 정의에서 시작됩니다.

1. 문제: cache miss가 너무 자주 난다

증상

  • 같은 브랜치에서 비슷한 작업을 반복하는데도 hit가 거의 안 남
  • local cache는 그럭저럭 맞는데 CI에서는 계속 miss가 남
  • remote cache를 붙였는데 체감이 별로 없음

흔한 원인

가장 흔한 원인은 inputs가 너무 넓거나, 반대로 중요한 입력이 누락된 경우입니다.

예를 들어 너무 넓은 입력:

{
  "tasks": {
    "build": {
      "inputs": ["$TURBO_DEFAULT$", "../../**/*"]
    }
  }
}

이런 구성은 편해 보이지만, 실제로는 레포 어딘가의 작은 수정만 있어도 캐시가 쉽게 깨집니다.

반대로 너무 좁은 입력:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**"]
    }
  }
}

이 설정에 .env.production, next.config.ts, 공통 설정 변경이 빠져 있으면, 캐시는 맞는 것처럼 보여도 결과가 불안정할 수 있습니다.

어떻게 확인할까?

먼저 "왜 다시 실행됐는가"를 감으로 보지 말고 graph와 task 기준으로 확인해야 합니다.

turbo run build --graph

그리고 팀 안에서는 아래 질문을 같이 보게 됩니다.

  • 이 task의 입력에는 무엇이 들어가는가
  • 설정 파일 변경이 반영되고 있는가
  • 환경 변수 차이를 같은 결과로 보고 있지는 않은가

어떻게 대응할까?

운영 단계에서는 보통 아래처럼 조금 더 명시적으로 잡습니다.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env.production", "next.config.ts"],
      "outputs": [".next/**", "dist/**"]
    }
  }
}

핵심은 이렇습니다.

  • 입력은 너무 넓어도 안 되고
  • 너무 좁아도 안 되며
  • 결과를 바꾸는 파일만 명시적으로 잡는 편이 좋습니다

즉, cache miss 문제는 캐시 서버보다 입력 정의 문제인 경우가 많습니다.

2. 문제: shared package 하나 바꿨는데 전체 앱이 흔들린다

증상

  • packages/ui 수정이 web, admin, docs 전체 build를 다시 돌림
  • config-typescript 수정이 lint, test, build를 전부 흔듦
  • 사소한 공통 설정 변경인데 CI 시간이 갑자기 길어짐

흔한 원인

공통 패키지의 dependency fan-out이 너무 넓은 경우가 많습니다.

예를 들어:

packages/ui
  ├─> apps/web
  ├─> apps/admin
  └─> apps/docs
 
packages/config-typescript
  ├─> apps/web
  ├─> apps/admin
  ├─> apps/docs
  └─> packages/ui

이 구조에서는 작은 수정 하나도 영향 범위가 넓습니다.

어떻게 확인할까?

이런 문제는 보통 graph를 보면 바로 감이 옵니다.

changed: packages/config-typescript
 
affected
  ├─> apps/web
  ├─> apps/admin
  ├─> apps/docs
  └─> packages/ui

이럴 때 중요한 질문은 이것입니다.

  • 이 공통 패키지가 정말 이렇게 많은 곳에 필요했는가
  • 공통화가 생산성인지, 단순 fan-out인지

어떻게 대응할까?

공통 패키지는 "재사용 가능하다"보다 "공통화했을 때 영향 범위를 감당할 수 있는가"로 봐야 합니다.

예를 들어:

  • config-typescript
    • 정말 모든 앱이 같은 설정을 가져야 하는가
  • ui
    • 모든 앱이 같은 UI package를 공유해야 하는가
  • api
    • 서비스별 API client까지 한 패키지에 묶는 것이 맞는가

즉, shared package는 편의와 함께 변경 반경을 가져옵니다.

3. 문제: shared, common, utils 패키지가 점점 비대해진다

증상

  • packages/shared 안에 UI, hook, domain util, API helper가 다 들어감
  • utils가 사실상 공용 쓰레기통이 됨
  • 신규 인력이 commonshared 차이를 설명하지 못함

흔한 원인

처음에는 공통화가 좋아 보여서 아래처럼 패키지를 만들기 쉽습니다.

packages/
  shared/
  common/
  utils/
  hooks/
  lib/

문제는 이름이 추상적일수록 경계가 약해진다는 점입니다.

어떻게 확인할까?

아래 질문을 던져보면 금방 드러납니다.

  • 이 패키지 이름만 보고 책임이 설명되는가
  • 다른 사람이 들어왔을 때 어디에 코드를 넣어야 할지 바로 알 수 있는가
  • 이 패키지 안에 도메인 코드와 범용 코드가 섞여 있지는 않은가

어떻게 대응할까?

초기에는 아래처럼 공통성이 명확한 패키지만 두는 편이 더 안정적입니다.

packages/
  ui/
  api/
  config-eslint/
  config-typescript/

즉, 추상적인 공통 패키지보다 책임이 드러나는 패키지 이름이 더 중요합니다.

4. 문제: 왜 다시 실행됐는지 설명이 안 된다

증상

  • "이건 안 돌아도 될 것 같은데 왜 다시 돌지?"가 반복됨
  • 특정 앱만 바뀌었다고 생각했는데 다른 앱 build도 같이 실행됨
  • 캐시가 맞는지 틀리는지 결과만 보고 추측하게 됨

흔한 원인

이 경우는 대부분 graph를 충분히 보지 않고 감으로 운영하기 때문입니다.

예를 들어:

turbo run build

만 계속 쓰고, 왜 특정 task가 포함됐는지는 확인하지 않는 상태입니다.

어떻게 확인할까?

이럴 때는 아래 명령을 자주 보는 습관이 필요합니다.

turbo run build --graph

그리고 아래 질문을 바로 답할 수 있어야 합니다.

  • 어떤 package dependency 때문에 이 task가 포함됐는가
  • 어떤 상위 build가 선행되어야 했는가
  • 어떤 변경이 이 task를 invalidation했는가

어떻게 대응할까?

운영이 안정된 팀은 보통 "왜 이 task가 돌았는지"를 사람 감이 아니라 graph와 설정으로 설명합니다.

즉:

  • 의존성 선언을 명확히 하고
  • turbo.json inputs/outputs를 정리하고
  • 설명이 안 되면 graph를 먼저 봅니다

5. 문제: CI를 쪼갰는데 더 복잡해진다

증상

  • job은 많아졌는데 디버깅이 더 어려워짐
  • cache 전략이 job마다 다르게 흩어짐
  • 로그가 분산돼서 문제를 찾기 힘듦

흔한 원인

병목이 명확하지 않은 상태에서 job을 너무 빨리 나누는 경우가 많습니다.

예를 들어:

- run: pnpm turbo run lint --filter=web
- run: pnpm turbo run lint --filter=admin
- run: pnpm turbo run test --filter=web
- run: pnpm turbo run test --filter=admin
- run: pnpm turbo run build --filter=web
- run: pnpm turbo run build --filter=admin

이런 구성은 세밀해 보이지만, 실제로는 레포 규모에 비해 과할 수 있습니다.

어떻게 확인할까?

먼저 질문해야 하는 것은 아래입니다.

  • 지금 진짜 병목은 설치인가, build인가, test인가
  • 앱별 배포가 실제로 분리되어 있는가
  • 실패 지점을 더 빨리 찾는 것이 목적인가, 전체 시간을 줄이는 것이 목적인가

어떻게 대응할까?

초기에는 보통 단순하게 가는 편이 낫습니다.

- run: pnpm turbo run lint test build

그다음 병목이 보이면:

  • lint/test/build 단계 분리
  • 앱 기준 분리
  • shared package 검증 분리

순서로 조금씩 나누는 편이 현실적입니다.

6. 문제: 공통 설정 패키지가 레포 전체를 흔든다

증상

  • config-eslint를 조금 바꿨는데 lint 전체가 흔들림
  • config-typescript 수정이 build까지 넓게 영향 줌
  • 작은 설정 수정인데 CI 시간이 크게 증가함

흔한 원인

공통 설정은 재사용성이 높아서 좋아 보이지만, 동시에 거의 모든 프로젝트의 입력이 되기 쉽습니다.

예를 들어:

{
  "extends": "@repo/config-typescript/base.json"
}

이 패턴 자체는 자연스럽지만, 이 설정을 바꾸면 레포 전체의 타입 체크와 build 범위가 같이 흔들릴 수 있습니다.

어떻게 대응할까?

설정 패키지는 가능하면:

  • 정말 자주 공통으로 유지해야 하는 것만 넣고
  • 앱별 차이는 앱 안에서 흡수하며
  • 변경이 잦은 설정은 공통 패키지로 너무 빨리 올리지 않는 편

이 더 안정적입니다.

정리하면

Turborepo 운영 중 자주 만나는 문제는 대부분 기능 부족보다 경계와 입력 정의가 불분명한 상태에서 나옵니다.

실무 기준으로 다시 정리하면:

  • cache miss가 많다면 inputs를 먼저 보아야 하고
  • shared package가 비대해지면 공통화 기준을 다시 보아야 하며
  • 왜 다시 실행됐는지 설명이 안 되면 graph를 먼저 보아야 하고
  • CI가 복잡해졌다면 병목이 무엇인지부터 다시 확인해야 합니다

중요한 것은 Turborepo를 빠른 도구로만 보는 것이 아니라, 레포의 실행 구조와 변경 반경을 설명하게 만드는 도구로 보는 것입니다. 이 관점이 잡히면 cache miss, fan-out, 경계 붕괴 같은 문제도 훨씬 빨리 정리할 수 있습니다.

같이 보면 좋은 글