Nx Cloud로 CI 시간 줄이기: 원격 캐시, 분산 작업 실행(DTE), affected, 보안 설정

이 글의 핵심

Nx Cloud는 모노레포의 태스크 그래프 위에 원격 캐시와 분산 실행을 얹어 CI 시간을 줄입니다. 캐시는 입력 선언이 정확해야 효과가 나고, 분산 실행은 에이전트 환경이 재현 가능해야 안정적입니다. affected의 base 커밋 선택과 토큰 권한 분리까지 실제 설정 기준으로 설명합니다.

이 글의 핵심

Nx는 모노레포의 프로젝트 간 의존성을 그래프로 파악하고, 그 그래프를 따라 build, test, lint 같은 태스크를 실행하는 빌드 시스템입니다. Nx Cloud는 그 위에 두 가지를 더합니다. 하나는 태스크 결과를 팀 전체가 공유하는 원격 캐시(remote caching)이고, 다른 하나는 CI에서 태스크를 여러 머신에 나누어 실행하는 분산 작업 실행(Distributed Task Execution, DTE)입니다. 원격 캐시는 같은 작업을 두 번 하지 않게 만들고, DTE는 해야 하는 작업을 더 많은 머신으로 동시에 처리하게 만듭니다. 줄이는 비용의 종류가 다르기 때문에 도입 순서와 실패 원인도 다릅니다.

Nx와 Nx Cloud는 버전마다 설정 키와 CLI 옵션이 자주 바뀌었습니다. 이 글의 설정은 Nx 19.7 이후의 형태를 기준으로 하며, 적용 전에 Nx 공식 문서에서 사용 중인 버전을 확인하는 것이 좋습니다. Nx 자체의 기본 개념은 Nx 가이드에서 다룹니다.


캐시가 동작하는 방식

Nx는 태스크를 실행하기 전에 그 태스크의 입력을 해시해 계산 해시(computation hash)를 만듭니다. 입력에는 프로젝트의 소스 파일, 의존하는 프로젝트의 소스나 해시, 설정 파일, 지정한 환경 변수, 그리고 실행 명령 자체가 포함됩니다. 같은 해시로 성공한 실행 기록이 있으면 Nx는 명령을 실행하지 않고 저장된 출력 파일을 복원한 뒤, 당시의 터미널 출력까지 그대로 재생합니다.

로컬 캐시는 이 기록을 개발자 머신의 디스크에 둡니다. 원격 캐시는 같은 기록을 Nx Cloud에 올려서, 동료나 CI가 한 번 실행한 결과를 다른 머신에서 내려받아 쓰게 합니다. PR CI가 빌드한 라이브러리를 머지 후 main CI가 다시 빌드하지 않고, 아침에 main을 받은 개발자도 그 결과를 내려받는 식입니다.

이 구조에서 캐시 효과를 결정하는 것은 머신 성능이 아니라 입력 정의의 정확도입니다. 입력을 너무 넓게 잡으면 상관없는 파일이 바뀔 때마다 캐시가 미스나고, 너무 좁게 잡으면 결과에 영향을 주는 변경이 있어도 예전 출력이 복원되는 더 위험한 문제가 생깁니다.

캐시 대상과 inputs·outputs 선언

Nx 17부터 캐시 대상은 tasksRunnerOptions의 cacheableOperations가 아니라 targetDefaults(또는 각 project.json의 타깃)의 cache: true로 지정합니다. 입력과 출력도 같은 위치에서 선언합니다.

// nx.json
{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "nxCloudId": "your-workspace-id",
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
      "!{projectRoot}/jest.config.[jt]s"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": ["production", "^production", { "env": "API_BASE_URL" }],
      "outputs": ["{workspaceRoot}/dist/{projectRoot}"]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^production"],
      "outputs": ["{workspaceRoot}/coverage/{projectRoot}"]
    },
    "lint": { "cache": true }
  }
}

namedInputs는 자주 쓰는 입력 묶음에 이름을 붙인 것입니다. production은 default에서 테스트 파일과 테스트 설정을 뺀 집합이므로, build의 입력을 production으로 두면 테스트 파일만 고친 PR에서는 빌드 캐시가 그대로 적중합니다. ^production의 ^는 “이 프로젝트가 의존하는 프로젝트들의 production 입력”을 뜻합니다. { "env": "API_BASE_URL" }처럼 빌드 결과에 영향을 주는 환경 변수를 입력에 명시하지 않으면, 값이 다른 두 환경이 같은 해시를 가지게 되어 스테이징용 빌드가 운영 빌드로 복원되는 사고가 날 수 있습니다.

outputs는 캐시에 저장하고 복원할 경로입니다. 여기에 빠진 폴더는 캐시가 적중해도 복원되지 않으므로, 다음 단계(배포, 도커 이미지 빌드)가 그 파일을 찾지 못해 실패합니다. 캐시 적중 후에만 나는 이런 실패는 원인을 찾기 어렵기 때문에, 새 타깃을 추가할 때 outputs를 함께 확인하는 습관이 중요합니다.

nxCloudId는 Nx 19.7부터 쓰이는 연결 방식으로, npx nx connect를 실행하면 기록됩니다. 그 이전 버전은 nxCloudAccessToken을 nx.json에 직접 두었는데, 이 값은 저장소에 커밋되므로 읽기 전용 토큰만 두는 것이 원칙이었습니다. CI에서는 어느 방식이든 NX_CLOUD_ACCESS_TOKEN 환경 변수가 nx.json의 값보다 우선합니다.

캐시가 자주 미스나는 원인

캐시 적중률이 낮을 때 Nx Cloud 웹 화면의 실행 상세에서 두 실행의 해시 입력을 비교하면, 어느 입력이 달라졌는지 볼 수 있습니다. 흔한 원인은 다음과 같습니다.

  • 입력 범위가 지나치게 넓은 경우: default에 README.md나 스토리북 파일까지 포함되어, 문서만 고쳐도 빌드가 다시 돕니다.
  • 생성 파일이 소스 폴더 안에 쓰이는 경우: 코드 생성기가 src/ 안에 타임스탬프가 들어간 파일을 만들면, 실행할 때마다 입력이 바뀝니다.
  • 런타임 차이를 반대로 놓치는 경우: Node 버전이나 OS는 기본 해시 입력에 들어가지 않으므로, 네이티브 바이너리를 만드는 태스크라면 { "runtime": "node --version" } 같은 런타임 입력을 추가해야 다른 환경에서 만든 결과가 복원되지 않습니다. 반대로 이런 입력을 넣으면 로컬(macOS)과 CI(Linux)가 서로의 캐시를 쓰지 못하므로, 꼭 필요한 타깃에만 둡니다.
  • 잠금 파일이 바뀌는 경우: 외부 패키지 버전은 잠금 파일에서 읽어 해시에 반영되므로, 의존성을 하나 올리면 그 패키지를 쓰는 모든 프로젝트의 캐시가 무효화됩니다. 이것은 정상 동작입니다.

캐시가 미스나는 것은 시간 손실에 그치지만, 잘못 적중하는 것은 틀린 결과를 배포하게 만듭니다. 그래서 입력을 줄이는 최적화는 “이 파일이 정말 결과에 영향을 주지 않는가”를 확인하면서 조금씩 진행해야 합니다.


분산 작업 실행(DTE)

DTE가 해결하는 문제

원격 캐시를 붙여도, 실제로 바뀐 코드에 대한 태스크는 어딘가에서 실행되어야 합니다. CI 잡 하나에서 nx affected -t test --parallel=3을 돌리면 동시에 실행되는 태스크 수는 그 머신의 코어와 메모리로 제한됩니다. 행렬(matrix) 잡으로 프로젝트 목록을 나누는 방법도 있지만, 나누는 기준이 의존성 그래프와 맞지 않으면 한 잡에서 빌드한 라이브러리를 다른 잡이 또 빌드하고, 어떤 잡은 일찍 끝나 놀고 어떤 잡은 오래 걸리는 불균형이 생깁니다.

DTE는 태스크 그래프 전체를 Nx Cloud가 보고, 여러 에이전트 머신에 실행 가능한 태스크를 하나씩 배정합니다. 의존하는 태스크가 끝나야 다음 태스크가 배정되고, 에이전트 간에 필요한 출력은 원격 캐시를 통해 전달됩니다. 메인 잡은 마치 로컬에서 실행한 것처럼 모든 태스크의 로그와 출력 파일을 모아 받습니다.

flowchart TB
  Main["메인 CI 잡: start-ci-run 후 nx affected 실행"]
  Sched["Nx Cloud: 태스크 그래프 스케줄링"]
  Cache["원격 캐시"]
  A1["에이전트 1"]
  A2["에이전트 2"]
  A3["에이전트 N"]
  Main --> Sched
  Sched --> A1
  Sched --> A2
  Sched --> A3
  A1 <--> Cache
  A2 <--> Cache
  A3 <--> Cache
  Cache --> Main

Nx Agents와 수동 DTE

현재 Nx Cloud에서 DTE를 쓰는 방법은 두 가지입니다. Nx Agents는 Nx Cloud가 관리하는 머신을 에이전트로 띄우는 방식으로, CI에는 메인 잡 하나만 있으면 됩니다. 수동 DTE는 에이전트를 CI의 별도 잡(또는 셀프호스티드 러너)으로 직접 띄우는 방식으로, 에이전트 실행 환경을 직접 통제해야 할 때 씁니다.

Nx Agents를 쓰는 GitHub Actions 예시는 다음과 같습니다.

name: CI
on:
  push:
    branches: [main]
  pull_request:

permissions:
  actions: read
  contents: read

jobs:
  main:
    runs-on: ubuntu-latest
    env:
      NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # 분산 실행 시작: 에이전트 3대, 마지막 태스크가 끝나면 종료
      - run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="build"

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci

      # affected 계산에 쓸 base/head SHA 설정
      - uses: nrwl/nx-set-shas@v4

      - run: npx nx affected -t lint test build

start-ci-run은 이후 같은 잡에서 실행되는 Nx 명령의 태스크를 에이전트로 보내도록 Nx Cloud에 알립니다. --distribute-on은 에이전트 수와 머신 종류이고, --stop-agents-after는 어느 타깃이 끝나면 에이전트를 내릴지 지정합니다. 이 옵션을 빼면 에이전트가 더 이상 할 일이 없는지 판단할 때까지 대기하므로 비용이 늘 수 있습니다. 에이전트의 의존성 설치 단계와 사용할 머신 종류는 저장소의 .nx/workflows/ 아래에 정의할 수 있습니다.

수동 DTE에서는 --distribute-on="manual"로 시작하고, 별도 잡들이 npx nx-cloud start-agent를 실행해 에이전트가 됩니다. 이때 에이전트 잡은 메인 잡과 같은 커밋, 같은 Node 버전, 같은 npm ci 결과를 가져야 합니다. 에이전트마다 설치된 의존성이 다르면, 같은 태스크가 어느 에이전트에 배정되느냐에 따라 결과가 달라지는 재현하기 어려운 실패가 생깁니다.

도입할 때 확인할 것

에이전트 수는 많을수록 좋지 않습니다. 에이전트마다 체크아웃과 의존성 설치라는 고정 비용이 있고, 태스크 그래프의 가장 긴 의존성 사슬(임계 경로)보다 빨리 끝날 수는 없기 때문입니다. 작은 저장소에서는 에이전트 설치 시간이 절약되는 시간보다 클 수도 있으므로, 적은 수로 시작해 전체 시간을 보며 늘리는 편이 낫습니다.

병렬성이 커지면 그동안 순차 실행 덕분에 드러나지 않던 테스트 간 간섭이 드러납니다. 같은 포트나 같은 임시 파일, 같은 테스트 DB를 쓰는 테스트는 다른 태스크와 동시에 실행되면서 간헐적으로 실패합니다. Nx Cloud는 실패한 태스크를 자동 재시도하는 기능과 불안정한(flaky) 태스크를 표시하는 기능을 제공하지만, 재시도는 증상을 가릴 뿐이므로 표시된 테스트의 격리 문제를 따로 고쳐야 합니다.


nx affected와 base 커밋

nx affected는 base와 head 두 커밋 사이에서 바뀐 파일을 구하고, 그 파일이 속한 프로젝트와 그 프로젝트에 의존하는 프로젝트를 모두 찾아 태스크를 실행합니다. 그래서 결과의 정확도는 base를 무엇으로 잡느냐에 달려 있습니다. 기본값은 nx.json의 defaultBase(보통 main)와 현재 작업 트리입니다.

PR에서는 base를 PR이 갈라져 나온 지점(main과의 merge-base)으로 잡으면 PR이 바꾼 범위만 실행됩니다. main 브랜치 푸시에서는 단순히 직전 커밋을 base로 잡으면 안 됩니다. 이전 커밋의 CI가 실패했는데 다음 커밋에서 그 부분을 건드리지 않았다면, 실패한 프로젝트가 다시 검사되지 않고 지나가기 때문입니다. 위 예시의 nrwl/nx-set-shas가 이 문제를 처리합니다. main에서는 마지막으로 성공한 워크플로 실행의 커밋을 찾아 NX_BASE로 설정하고, PR에서는 merge-base를 설정합니다. 이 액션이 워크플로 실행 이력을 조회하기 때문에 actions: read 권한이 필요합니다.

actions/checkout의 기본값은 커밋 하나만 받는 shallow clone이므로, base 커밋이 히스토리에 없어 affected 계산이 실패하거나 전체 프로젝트가 대상이 됩니다. fetch-depth: 0으로 전체 히스토리를 받는 것이 가장 간단한 해결책입니다.

affected 범위가 매번 거의 전체 프로젝트로 잡힌다면 도구보다 저장소 구조를 볼 필요가 있습니다. 모든 프로젝트가 import하는 거대한 shared 라이브러리가 있으면, 그 안의 파일 하나만 바뀌어도 전체가 영향을 받습니다. 이런 라이브러리를 용도별로 쪼개고, 프로젝트 태그(scope:payments, type:util 등)와 @nx/enforce-module-boundaries 린트 규칙으로 의존 방향을 제한하면 affected 범위와 캐시 무효화 범위가 함께 줄어듭니다.

PR에서는 affected로 빠른 피드백을 주고, main이나 릴리스 브랜치에서는 주기적으로 nx run-many -t test로 전체를 실행하는 조합도 흔합니다. 전체 실행이라도 대부분의 태스크는 원격 캐시에서 복원되므로, affected 계산이 놓친 부분이 있는지 확인하는 안전장치 역할을 저렴하게 할 수 있습니다.


보안: 토큰 권한과 캐시 오염

원격 캐시는 “이 해시에 대한 결과는 이 파일들이다”라는 기록을 신뢰하는 구조입니다. 누군가 캐시에 쓰기 권한을 가지고 조작된 빌드 결과를 올리면, 그 해시를 요청하는 모든 머신이 그 결과를 내려받아 배포까지 할 수 있습니다. 그래서 Nx Cloud의 CI 액세스 토큰은 읽기 전용과 읽기·쓰기 권한으로 나뉘어 있고, 운영 원칙은 쓰기 권한을 신뢰할 수 있는 실행에만 주는 것입니다.

  • 읽기·쓰기 토큰은 보호된 브랜치(main 등)에서 실행되는 CI의 시크릿으로만 둡니다.
  • 외부 포크에서 오는 PR 워크플로에는 GitHub가 기본적으로 시크릿을 전달하지 않으므로, 이런 실행은 캐시를 쓰지 못하거나 읽기 전용으로 동작하게 됩니다. 포크 PR에 시크릿을 넘기려고 pull_request_target을 쓰면 신뢰할 수 없는 코드가 쓰기 토큰을 가진 채 실행될 수 있으므로 피해야 합니다.
  • 개발자 로컬은 nx login으로 개인 인증을 쓰거나 읽기 전용 토큰을 씁니다. 로컬 머신에서 만든 결과가 팀 전체의 캐시에 올라가지 않게 하는 것이 핵심입니다.

캐시에 올라가는 출력 자체도 점검 대상입니다. 빌드 결과에 환경별 비밀 값이 인라인되면(예: 프론트엔드 번들에 들어간 API 키), 그 값이 캐시를 통해 캐시 읽기 권한이 있는 모든 사람에게 전달됩니다. 비밀 값은 빌드 시점이 아니라 배포·실행 시점에 주입하는 구조가 캐시와도 잘 맞습니다.

외부 SaaS에 빌드 산출물을 보낼 수 없는 조직이라면, Nx Cloud 엔터프라이즈의 단일 테넌트·온프레미스 배포 옵션을 검토하거나, 자체 스토리지(S3, GCS, Azure Blob 등)를 원격 캐시로 쓰는 Nx의 공식 플러그인을 쓰는 방법이 있습니다. 자체 버킷을 캐시로 쓸 때는 위의 읽기·쓰기 권한 분리를 버킷 정책으로 직접 구현해야 하고, DTE는 Nx Cloud 기능이므로 함께 쓸 수 없다는 점을 고려해야 합니다. 제공 형태와 라이선스 조건은 자주 바뀌므로 도입 시점의 공식 문서를 기준으로 판단해야 합니다.


큰 조직에서 효과를 유지하는 방법

Nx Cloud를 연결한 직후에는 효과가 크게 보이다가, 시간이 지나면서 적중률이 서서히 떨어지는 경우가 많습니다. 팀마다 새 프로젝트를 다른 방식으로 만들고, 각자 다른 스크립트로 빌드하고, 입력 선언 없이 타깃을 추가하기 때문입니다.

이를 막는 가장 효과적인 방법은 새 프로젝트를 Nx 제너레이터로만 만들게 하는 것입니다. 제너레이터가 project.json, 타깃 이름(build, test, lint, typecheck), 태그를 일관되게 만들면 nx.json의 targetDefaults가 모든 프로젝트에 같은 입력·출력 규칙을 적용할 수 있습니다. Node 버전과 패키지 매니저 버전은 package.json의 engines·packageManager 필드와 .nvmrc로 고정해, 로컬과 CI와 에이전트가 같은 런타임을 쓰게 합니다.

효과를 측정할 때는 평균 CI 시간 하나만 보지 말고, PR 피드백 시간과 main 통과 시간을 나누어 보고, 캐시 적중률의 추이와 가장 오래 걸리는 태스크 목록을 함께 봐야 합니다. 적중률이 떨어진 시점과 그 무렵 바뀐 설정을 연결해 보면, 대부분 입력 선언이 넓어진 변경이나 새로 추가된 비결정적 생성 단계를 찾을 수 있습니다.


같이 보면 좋은 글