Nx affected와 캐시로 CI 줄이기: Turborepo remote cache와 비교

Frontend

이 글은 Nx 시리즈 3편입니다.

Turborepo 시리즈 3편에서 remote cache로 CI를 줄이는 이야기를 했습니다. 이번 편은 그 정확한 짝입니다. Nx는 CI를 줄일 때 무엇을 다르게 하는가.

결론을 먼저 말하면, 캐시 자체는 두 도구가 비슷하게 강합니다. 차이는 캐시 위에 얹힌 두 가지에서 납니다. 하나는 affected(영향 범위를 그래프로 추려내는 것), 다른 하나는 분산 실행(Nx Cloud의 DTE)입니다.

한눈에 보면

  • 로컬 computation cache는 Turborepo·Nx 둘 다 강합니다. 입력 같으면 결과 재사용, 같은 원리입니다.
  • 원격 캐시도 둘 다 됩니다. Turborepo는 remote cache, Nx는 Nx Cloud(또는 self-hosted).
  • 진짜 차이는 둘입니다.
    • affected: Nx는 "안 바뀐 프로젝트"를 캐시 hit가 아니라 실행 후보에서 아예 제외합니다.
    • DTE(분산 태스크 실행): Nx Cloud는 한 PR의 task를 여러 CI 머신에 자동 분배합니다. Turborepo에는 직접 대응물이 없습니다.
  • 캐시 정확도(재현성)는 named input 설계에 달려 있고, 이건 두 도구 모두 팀의 몫입니다.
항목 Turborepo Nx
로컬 캐시 있음 있음
원격 캐시 Remote Cache Nx Cloud / self-hosted
변경 범위 축소 filter + 변경 기반 nx affected (graph 기반)
분산 실행 직접 대응물 없음 Nx Cloud DTE (자동 분배)
캐시 입력 정의 task inputs namedInputs + target inputs

캐시는 사실 비슷하다

먼저 비슷한 부분을 짚고 넘어가야 affected의 가치가 선명해집니다.

두 도구의 computation cache 원리는 같습니다. task의 입력(소스, 설정, 의존 결과, 환경)으로 해시를 만들고, 그 해시의 결과가 있으면 실행을 건너뛰고 출력물을 복원합니다.

입력 해시 = hash(소스 파일 + 설정 + 의존 패키지 결과 + 관련 env)
해시가 캐시에 있으면 → 실행 skip, 출력 복원
없으면 → 실행하고 결과 저장

원격 캐시도 마찬가지입니다. 로컬에서 만든 결과를 팀·CI가 공유해서, 같은 입력을 두 번 계산하지 않습니다.

# Turborepo
turbo run build   # remote cache 연결 시 팀 전체가 공유
 
# Nx
nx build web      # Nx Cloud 연결 시 동일

그래서 "캐시 누가 더 빠른가"는 의미 있는 질문이 아닙니다. 둘 다 잘합니다. 차이는 캐시 앞단뒷단에 있습니다.

앞단의 차이: affected

캐시는 "이미 한 일을 다시 안 하는" 최적화입니다. affected는 한 발 더 앞에서, "이 변경과 무관한 일은 후보에도 안 올리는" 최적화입니다.

2편에서 본 project graph가 여기서 일합니다. git diff로 바뀐 파일을 찾고, 그 파일이 속한 프로젝트에서 graph를 거꾸로 타고 올라가 영향받는 프로젝트만 추립니다.

nx affected -t build,test,lint --base=origin/main --head=HEAD

packages/utils 하나만 고쳤다고 해봅시다.

changed: packages/utils
 
packages/utils
  └─> packages/ui
        ├─> apps/web      (affected)
        └─> apps/admin    (affected)
 
apps/docs   ← utils/ui 안 씀     (not affected → 후보 제외)
packages/auth, packages/api      (not affected → 후보 제외)
 
실행 대상: web, admin, ui, utils 의 build/test/lint 만

여기서 Turborepo와의 차이를 정확히 봐야 합니다. Turborepo로도 비슷한 결과를 낼 수 있습니다. 캐시가 잘 잡혀 있으면 docs의 build는 cache hit로 즉시 끝나니까요. 결과적으로 빠릅니다.

차이는 모델입니다.

  • Turborepo: 전부 실행 대상에 올리되, 안 바뀐 건 캐시로 빠르게 통과. ("다 돌리는데 대부분 캐시 hit")
  • Nx affected: 안 바뀐 건 실행 그래프에서 제외. ("애초에 후보가 아님")

작은 레포에선 둘이 사실상 같습니다. 하지만 프로젝트가 수십 개가 되면 차이가 생깁니다. cache hit라도 task를 스케줄링하고 해시를 비교하는 비용이 있고, CI 로그에 수백 개의 "cache hit" 라인이 쌓이며, "이 PR이 실제로 무엇을 건드렸나"가 흐려집니다. affected는 그 목록 자체를 짧게 만들어 줍니다. CI 로그가 "이 PR은 web, admin만 건드림"으로 읽히는 게 운영에서 생각보다 큽니다.

정리하면, Turborepo는 캐시로 빠르게, Nx는 affected로 범위 자체를 좁혀서 같은 목적지에 도달합니다. 규모가 커질수록 후자의 모델이 설명하기 쉬워집니다.

캐시 정확도는 둘 다 팀의 몫

빠른 캐시보다 중요한 건 재현 가능한 캐시입니다. 잘못 잡힌 캐시는 빠른 게 아니라 위험한 겁니다. 특히 Next.js SSR 앱은 env, next.config, shared config 변경이 결과를 바꾸기 때문에 입력 정의가 어긋나면 잘못된 결과를 캐시하게 됩니다.

Turborepo는 task별 inputs로 이걸 관리합니다.

{
  "tasks": {
    "build": {
      "inputs": ["$TURBO_DEFAULT$", ".env.production", "tsconfig.json"],
      "outputs": [".next/**"]
    }
  }
}

Nx는 namedInputs로 입력 묶음을 정의하고, 환경 변수까지 명시적으로 입력에 넣을 수 있습니다.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json", { "env": "NEXT_PUBLIC_API_URL" }],
    "production": ["default", "!{projectRoot}/**/*.spec.tsx"]
  },
  "targetDefaults": {
    "build": { "inputs": ["production", "^production"] }
  }
}

차이를 읽어보면 이렇습니다.

  • Turborepo는 task마다 입력을 적고, env는 $TURBO_DEFAULT$나 별도 설정으로 다룹니다. 직관적이고, task 수가 적을 때 깔끔합니다.
  • Nx는 sharedGlobals처럼 "모든 프로젝트에 공통으로 영향을 주는 입력"(루트 tsconfig, 특정 env)을 한 곳에 모읍니다. config 패키지나 전역 설정이 많은 레포에서 누락이 줄어듭니다.

어느 쪽이든 핵심은 같습니다. "무엇이 바뀌면 이 결과가 달라지는가"를 빠짐없이 입력에 적는 것. 도구가 대신 해주지 않습니다. Turborepo 시리즈 4편에서 다룬 cache miss·잘못된 hit 문제는 Nx에서도 그대로 적용됩니다.

뒷단의 차이: 분산 실행(DTE)

affected가 앞단이라면, 뒷단에서 Nx만 가진 카드가 DTE(Distributed Task Execution), 최근 이름으로는 Nx Agents입니다.

상황을 그려보겠습니다. affected로 범위를 좁혔는데도 영향받는 프로젝트가 30개라면, 그 build·test·lint를 한 CI 머신에서 순차로 돌리면 여전히 오래 걸립니다.

Turborepo에서는 보통 이걸 사람이 CI 설정으로 쪼갭니다. matrix job을 만들어 "이 job은 web, 저 job은 admin"처럼 손으로 나누고 --filter로 분배하죠. 동작은 하지만, 프로젝트가 늘 때마다 분배를 다시 손봐야 합니다.

Nx Cloud의 DTE는 이걸 자동으로 합니다. CI에서 agent 머신 수만 정해주면, Nx가 task graph를 보고 알아서 분배하고 결과를 다시 모읍니다.

# 개념적인 흐름
- run: npx nx-cloud start-ci-run --distribute-on="5 linux-medium-js"
- run: npx nx affected -t lint test build

--distribute-on="5 ..."이 "5대로 분산해줘"이고, 어떤 task를 어느 머신에 둘지는 Nx가 정합니다. 한 머신이 일찍 끝나면 다음 task를 받아가는 식이라 머신을 놀리지 않습니다. 프로젝트가 늘어도 CI 설정은 그대로고, agent 수만 늘리면 됩니다.

이게 Turborepo와의 가장 큰 운영 차이입니다.

  • Turborepo: 분산은 CI matrix로 사람이 설계. 도구는 캐시까지.
  • Nx: 분산을 도구가 설계. affected로 좁히고, 남은 걸 자동 분배.

물론 공짜는 아닙니다. DTE는 Nx Cloud 기능이고, 일정 규모를 넘으면 유료입니다. 그리고 이 가치는 "affected 이후에도 돌릴 게 많이 남는" 큰 레포에서만 의미가 있습니다. 앱 두세 개면 DTE는 과합니다.

CI 흐름을 나란히 보면

같은 목표(PR에서 영향받는 것만, 빠르게 검증)를 두 도구로 짜면 이렇게 갈립니다.

# Turborepo: 캐시 + (필요 시) 수동 matrix 분배
- run: pnpm turbo run lint test build
#   remote cache로 안 바뀐 task는 hit
#   더 쪼개려면 job matrix + --filter 를 직접 구성
# Nx: affected 로 범위 축소 + Cloud 로 자동 분산
- run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js"
- run: pnpm nx affected -t lint test build --base=origin/main --head=HEAD

작은 레포라면 위쪽이 더 단순하고 충분합니다. 프로젝트가 수십 개로 늘고 CI 시간이 병목이 되는 순간, 아래쪽의 "affected + 자동 분산" 조합이 손으로 관리하던 matrix를 대체합니다.

그래서 언제 Nx의 CI 모델이 이득인가

정직하게 선을 그으면 이렇습니다.

  • 앱 1~3개, 패키지 몇 개 → Turborepo 캐시로 충분. affected의 이득이 작고 DTE는 과함.
  • 프로젝트 10개 이상, CI 시간이 진짜 병목 → affected로 범위가 의미 있게 줄고, DTE가 matrix 수작업을 없애 줌.
  • 프로젝트가 계속 늘어나는 조직 → "CI 설정을 프로젝트 수에 따라 다시 안 짜도 되는" 점이 장기적으로 큼.

즉 캐시는 동점이고, 레포가 클수록 affected와 DTE 쪽으로 저울이 기웁니다. 이건 1편에서 말한 "Nx의 강점은 규모가 있을 때 의미가 생긴다"의 가장 구체적인 사례입니다.

정리하면

  • 로컬·원격 캐시는 Turborepo와 Nx가 비슷하게 강합니다. 캐시만으로 우열을 가리긴 어렵습니다.
  • 차이는 캐시 앞단의 affected(범위를 graph로 좁힘)와 뒷단의 DTE(task를 CI 머신에 자동 분배)입니다.
  • 캐시 정확도(재현성)는 named input/inputs 설계에 달려 있고, 이건 두 도구 모두 팀의 책임입니다.
  • 작은 레포는 Turborepo 캐시로 충분하고, 큰 레포일수록 affected + DTE의 운영 이점이 커집니다.

다음 편에서는 속도 이야기를 넘어, Nx가 Turborepo와 가장 크게 갈리는 지점인 **경계 관리(module boundaries)와 코드 생성(generators)**을 봅니다. "누가 레포 건강도를 오래 지키게 하느냐"의 영역입니다.

다음 글에서 이어서 보기

같이 보면 좋은 글