왜 전체 빌드가 문제가 되는가

모노레포가 커지면 하나의 커밋이 수십 개 프로젝트를 담은 저장소 전체를 건드리는 것처럼 보인다. 실제로는 문서 한 줄만 고쳤는데도 CI가 모든 서비스의 빌드·테스트·이미지 푸시를 돌리는 상황이 흔하다. 이렇게 되면 파이프라인 시간은 선형으로 늘고, 러너 비용이 낭비되며, 무관한 flaky 테스트 때문에 배포가 막힌다. 개발자는 "내 변경과 상관없는 실패"에 익숙해지고, 결국 CI 신호 자체를 신뢰하지 않게 된다.

핵심은 변경된 파일 → 영향받은 프로젝트 → 그 프로젝트의 의존 프로젝트로 이어지는 집합만 골라내는 것이다. 이 집합을 정확히 계산하면 CI 실행량을 크게 줄이면서도 안전성을 잃지 않는다.

기준 커밋(base) 정하기

"무엇이 바뀌었는가"는 항상 어떤 커밋과 비교하느냐에 달려 있다. 잘못된 base를 쓰면 변경을 놓쳐 깨진 코드를 통과시킨다. PR에서는 머지 대상 브랜치와의 공통 조상(merge-base)을, main 브랜치 푸시에서는 직전 성공 커밋을 base로 삼는 것이 안전하다.

#!/usr/bin/env bash
set -euo pipefail

# PR: 대상 브랜치와의 공통 조상
if [ -n "${GITHUB_BASE_REF:-}" ]; then
  git fetch --no-tags origin "$GITHUB_BASE_REF"
  BASE=$(git merge-base HEAD "origin/$GITHUB_BASE_REF")
else
  # main 푸시: 이전 커밋(없으면 첫 커밋)
  BASE=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD)
fi

git diff --name-only "$BASE" HEAD

shallow clone에서는 merge-base가 없어 실패하니, CI 체크아웃 단계에서 fetch-depth: 0으로 충분한 히스토리를 받아야 한다.

변경 파일을 프로젝트로 매핑하기

변경된 경로를 프로젝트 루트에 매핑한다. 디렉터리 규칙이 명확하다면 경로 prefix 매칭만으로 충분하다. 단, 루트의 공용 파일(package.json lock, 공유 CI 설정, 베이스 Docker 이미지)이 바뀌면 전체를 빌드하도록 안전장치를 둬야 한다.

import subprocess, sys

PROJECTS = ["services/api", "services/worker", "libs/core", "web/admin"]
GLOBAL_TRIGGERS = ("pnpm-lock.yaml", ".github/workflows/", "Dockerfile.base")

def changed_files(base):
    out = subprocess.run(
        ["git", "diff", "--name-only", base, "HEAD"],
        capture_output=True, text=True, check=True).stdout
    return [l for l in out.splitlines() if l]

def affected(files):
    if any(f.startswith(GLOBAL_TRIGGERS) or f in GLOBAL_TRIGGERS for f in files):
        return set(PROJECTS)  # 공용 변경 → 전체 빌드
    hit = set()
    for f in files:
        for p in PROJECTS:
            if f.startswith(p + "/"):
                hit.add(p)
    return hit

if __name__ == "__main__":
    print("\n".join(sorted(affected(changed_files(sys.argv[1])))))

의존성 그래프까지 확장하기

여기서 멈추면 안 된다. libs/core가 바뀌면 그것을 임포트하는 services/api도 다시 빌드·테스트해야 한다. 직접 매핑만 하면 이 역방향(reverse) 의존을 놓친다. Nx, Turborepo, Bazel 같은 도구는 프로젝트 그래프를 이해하므로 이 계산을 대신해 준다.

# Nx: base 이후 영향받은 프로젝트만 대상으로 실행
npx nx affected --target=build --base=$BASE --head=HEAD

# Turborepo: 변경 필터로 파이프라인 실행
npx turbo run build --filter=...[$BASE...HEAD]

... 접두/접미는 "그 프로젝트에 의존하는 것까지 포함"을 의미한다. 도구를 도입하지 않는다면, 프로젝트별 의존 목록을 직접 관리하며 위상 정렬로 하류(downstream) 프로젝트를 닫힘 집합으로 계산해야 한다.

도구 비교

도구그래프 계산적합한 상황
경로 규칙 직접 구현수동 관리프로젝트 수가 적고 의존이 단순
Nx / Turborepo자동(JS/TS 중심)Node 생태계 모노레포
Bazel정밀(다언어)언어 혼합·대규모, 재현성 중시

CI에 연결하기

계산 결과를 후속 job의 매트릭스로 넘기면, 영향받은 프로젝트만 병렬로 돈다. GitHub Actions에서는 job output과 fromJSON을 조합한다.

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      projects: ${{ steps.calc.outputs.projects }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: calc
        run: |
          LIST=$(python affected.py "$(git merge-base HEAD origin/main)")
          JSON=$(printf '%s' "$LIST" | jq -R . | jq -sc .)
          echo "projects=$JSON" >> "$GITHUB_OUTPUT"
  build:
    needs: detect
    if: needs.detect.outputs.projects != '[]'
    strategy:
      matrix:
        project: ${{ fromJSON(needs.detect.outputs.projects) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make build DIR=${{ matrix.project }}

주의할 점

몇 가지 함정이 실제 장애로 이어진다. 첫째, 필수 상태 체크(required check)가 특정 job 이름을 요구하면, 그 job이 스킵될 때 PR이 영원히 머지되지 않는다. 스킵 대신 항상 성공하는 요약 job을 하나 두고 그것을 required로 지정한다. 둘째, 생성 파일·런타임 설정처럼 git diff에 안 잡히는 의존은 그래프가 놓친다. 셋째, base 계산이 틀리면 조용히 빌드를 건너뛰므로, 정기적으로(예: nightly) 전체 빌드를 돌려 영향 계산 자체를 검증해야 한다. 마지막으로, 캐시 키를 프로젝트 단위로 좁혀야 "적게 빌드"의 이점이 캐시 미스로 상쇄되지 않는다.

정리하면, 정확한 base·경로 매핑·역방향 의존·안전한 fallback 네 가지를 갖추면 영향받은 것만 빌드하는 CI를 신뢰할 수 있게 운영할 수 있다.