Moment Note

모노레포 CI 최적화: 변경된 부분만 빌드·테스트하기

CI/CD & DevOps ·

모노레포가 커질수록 CI는 “매번 전부 빌드하고 전부 테스트하는” 관성 때문에 느려집니다. 패키지가 30개인데 하나만 고쳐도 30개를 전부 도는 파이프라인은 개발자 대기 시간을 잡아먹고 러너 비용을 태웁니다. 해법은 명확합니다. 변경된 부분과 그 영향을 받는 부분만 빌드·테스트하는 affected 전략입니다.

이 글에서는 의존성 그래프를 기반으로 “영향받은 프로젝트”를 계산하는 원리, Nx·Turborepo 같은 도구의 실전 설정, 그리고 원격 캐시와 결합해 CI 시간을 압축하는 방법을 다룹니다. 도구 없이 순수 git으로 근사하는 방법도 함께 살펴봅니다.

왜 “변경 파일”만으로는 부족한가

단순히 “이번 커밋에서 바뀐 파일이 속한 패키지만 테스트”하면 위험합니다. libs/utils를 고쳤는데 이를 임포트하는 apps/web을 테스트하지 않으면, 깨진 채로 병합됩니다. 따라서 핵심은 변경 지점에서 시작해 의존성 그래프를 역방향으로 순회하며 영향권을 넓히는 것입니다.

# 개념적 의존 관계
apps/web      ->  libs/ui  ->  libs/utils
apps/admin    ->  libs/ui
apps/worker   ->  libs/utils

# libs/utils 변경 시 affected = { utils, ui, web, admin, worker }
# libs/ui 변경 시   affected = { ui, web, admin }

이 “역방향 도달 가능성” 계산이 affected의 본질입니다. 도구들은 소스 임포트를 정적 분석하거나 package.json의 의존성을 파싱해 그래프를 만들고, 기준 커밋과의 diff로 시작점을 잡습니다.

기준(base) 선정: 무엇과 비교할 것인가

affected는 항상 “무엇 대비 변경인가”라는 base가 필요합니다. PR 파이프라인이라면 병합 대상 브랜치와의 공통 조상(merge-base)이 정답입니다. 단순히 이전 커밋과 비교하면 여러 커밋이 쌓인 PR에서 초기 변경을 놓칩니다.

# PR에서 안전한 base: target 브랜치와의 merge-base
git fetch origin main --depth=100
BASE=$(git merge-base origin/main HEAD)
git diff --name-only "$BASE" HEAD

주의할 점은 shallow clone입니다. CI는 기본적으로 얕게 체크아웃하는 경우가 많아 merge-base를 못 찾습니다. GitHub Actions라면 fetch-depth: 0으로 전체 히스토리를 가져와야 합니다.

- uses: actions/checkout@v4
  with:
    fetch-depth: 0   # affected 계산을 위해 히스토리 전체 필요

Nx로 affected 실행하기

Nx는 프로젝트 그래프를 자동 구성하고 nx affected 명령으로 영향받은 타깃만 실행합니다. --base--head로 비교 범위를 지정합니다.

# 영향받은 프로젝트만 빌드/테스트/린트
npx nx affected -t build test lint \
  --base=origin/main --head=HEAD \
  --parallel=3

# 무엇이 affected인지 먼저 확인(디버깅)
npx nx show projects --affected --base=origin/main --head=HEAD

# 그래프 시각화
npx nx graph --affected --base=origin/main

Nx는 파일 단위 해시 + 태스크 입력 정의로 캐시를 관리합니다. nx.json에서 어떤 입력이 태스크 결과에 영향을 주는지 선언하면, 관련 없는 파일(README 등) 변경은 캐시 무효화를 유발하지 않습니다.

{
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"]
    }
  },
  "namedInputs": {
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  }
}

Turborepo의 접근: 필터와 콘텐츠 해시

Turborepo는 --filter로 범위를 좁히고, 각 태스크의 입력 파일 해시로 캐시 히트를 판정합니다. ...[base] 문법은 “base 이후 변경된 패키지 + 그 하위 의존자”를 의미합니다.

# main 이후 변경된 패키지와 그것에 의존하는 것들만 빌드
turbo run build --filter="...[origin/main]"

# 특정 앱과 그 의존성만
turbo run test --filter="web..."

turbo.json에서 태스크 파이프라인과 입력을 정의합니다. dependsOn^build는 “의존 패키지의 build를 먼저”라는 뜻으로, 그래프 순서를 자동 보장합니다.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json", "package.json"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**", "test/**"]
    }
  }
}

원격 캐시: 팀 전체와 CI가 결과를 공유

affected가 실행 범위를 줄인다면, 원격 캐시는 이미 계산된 결과를 재사용해 실행 자체를 건너뜁니다. 동료가 CI에서 빌드한 libs/ui를 내 로컬이나 다음 CI 잡이 그대로 내려받는 식입니다. 캐시 키는 소스 해시 + 의존성 + 환경으로 결정됩니다.

# Turborepo 원격 캐시(자체 호스팅 예시 env)
TURBO_API=https://cache.example.com \
TURBO_TOKEN=$CI_CACHE_TOKEN \
TURBO_TEAM=platform \
turbo run build --remote-only

# 캐시 히트 시 로그
#   cache hit, replaying logs 3f9a...
#   >>> FULL TURBO

다만 원격 캐시는 정직한 입력 선언에 의존합니다. 태스크가 선언하지 않은 파일(예: 루트 .env, 시스템 시간)에 의존하면 캐시가 오염되어 “로컬은 되는데 CI는 깨지는” 재현 불가 버그가 생깁니다. 비결정적 산출물(타임스탬프 삽입 등)을 제거하는 것이 원격 캐시의 전제 조건입니다.

도구 없이 git만으로 근사하기

소규모 레포거나 도구 도입 전이라면, 변경 경로를 패턴 매칭해 잡을 나눠 실행할 수 있습니다. 완벽한 그래프 순회는 아니지만, 상위 디렉터리 단위로 파이프라인을 게이팅하는 데 충분합니다.

BASE=$(git merge-base origin/main HEAD)
CHANGED=$(git diff --name-only "$BASE" HEAD)

run_if() {  # $1: 경로 접두사, $2: 실행할 명령
  if echo "$CHANGED" | grep -q "^$1"; then
    echo "==> changes in $1, running"
    (cd "$1" && eval "$2")
  else
    echo "==> $1 unchanged, skip"
  fi
}

run_if "apps/web/"   "npm ci && npm test"
run_if "libs/utils/" "npm ci && npm test"

한계는 명확합니다. libs/utils 변경이 apps/web에 미치는 영향을 자동으로 잡지 못하므로, 공유 라이브러리 변경 시에는 보수적으로 전체를 도는 규칙을 넣어 안전망을 두는 것이 좋습니다.

마무리

모노레포 CI 최적화의 핵심은 “덜 실행하되 안전하게 덜 실행하는 것”입니다. 의존성 그래프를 정확히 파악하지 못한 채 변경 파일만 보고 스킵하면 깨진 코드를 병합하는 사고로 이어집니다. 반대로 매번 전부 도는 것은 비용 낭비입니다. Nx나 Turborepo의 affected/필터로 영향권을 정확히 계산하고, 원격 캐시로 중복 계산을 제거하며, base는 merge-base로 잡고 히스토리를 온전히 fetch하는 것 이 세 가지가 맞물려야 비로소 빠르면서도 신뢰할 수 있는 CI가 됩니다. 도구 도입이 부담스럽다면 git 기반 게이팅으로 시작하되, 공유 라이브러리 변경은 보수적으로 전체를 도는 안전 규칙을 반드시 두세요.