- Published on
GitHub Actions 모노레포 CI/CD 워크플로우 폭증 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모노레포(monorepo)는 코드 공유와 일관된 개발 경험을 제공하지만, GitHub Actions로 CI/CD를 붙이는 순간부터 다른 문제가 시작됩니다. 패키지(앱/라이브러리/인프라 모듈) 수가 늘어날수록 워크플로우 파일, 잡(job), 매트릭스(matrix)가 기하급수적으로 증가하고, PR 하나가 수십~수백 개의 실행(run)을 만들기도 합니다. 결과는 뻔합니다.
- 체크가 너무 많아 PR 리뷰가 느려짐
- 동일한 빌드/테스트가 중복 실행되어 러너 비용 증가
- 워크플로우 YAML이 복붙 지옥이 되어 변경이 어려움
- “어떤 변경이 어떤 파이프라인을 트리거했는지” 추적이 어려움
이 글은 워크플로우 폭증을 구조적으로 막는 전략을 중심으로, GitHub Actions에서 모노레포 CI/CD를 “늘려도 관리 가능한 형태”로 만드는 설계를 다룹니다.
폭증의 원인: 모노레포에서 흔히 저지르는 3가지 패턴
1) 패키지별 워크플로우 파일을 따로 만든다
apps/a.yml, apps/b.yml, packages/x.yml처럼 쪼개면 처음엔 단순해 보이지만, 공통 로직(캐시, Node 버전, 테스트 커맨드, 배포 조건)을 바꾸려면 파일을 전부 수정해야 합니다.
2) 변경 감지 없이 “항상 전체”를 돌린다
PR이 문서만 바뀌었는데도 전체 빌드/테스트/도커 빌드/배포가 실행되면 실행 수가 폭발합니다.
3) 매트릭스를 무제한으로 확장한다
package x node 18/20 + os ubuntu/macos + shard 1..N 같은 매트릭스는 그 자체로 병렬 실행 수를 늘립니다. 특히 “패키지 수 × 매트릭스”가 되면 감당이 안 됩니다.
핵심 전략 1: 트리거 단계에서 path 필터링으로 1차 차단
가장 싸고 강력한 방법은 워크플로우 자체가 뜨지 않게 하는 것입니다.
name: ci
on:
pull_request:
paths:
- "apps/**"
- "packages/**"
- ".github/workflows/**"
- "pnpm-lock.yaml"
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "infra/**"
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "Triggered only when relevant paths change"
- 문서(
docs/**)만 바뀌는 PR은 CI가 아예 실행되지 않게 만들 수 있습니다. - 단, 이 방식은 “패키지 단위”까지는 세밀하게 나누기 어렵습니다. 다음 전략이 필요합니다.
핵심 전략 2: 변경된 패키지만 계산해 동적 매트릭스로 실행 수를 제한
모노레포에서 가장 효과적인 패턴은:
- 변경된 패키지 목록을 계산
- 그 결과를
matrix로 넘겨 변경된 것만 테스트/빌드
(예시) pnpm 워크스페이스 + 변경 감지 스크립트
아래는 git diff 기반으로 “변경된 워크스페이스”를 추정하는 단순 예시입니다(정교하게 하려면 Nx/Turborepo/changesets 등을 권장).
#!/usr/bin/env bash
set -euo pipefail
BASE_REF=${1:-origin/main}
# 변경된 파일 목록
changed_files=$(git diff --name-only "$BASE_REF"...HEAD)
# apps/* 또는 packages/*의 1depth를 패키지로 간주
packages=$(echo "$changed_files" \
| awk -F/ '/^(apps|packages)\// {print $1"/"$2}' \
| sort -u \
| jq -R -s -c 'split("\n")[:-1]')
echo "$packages"
set -euo pipefail를 쓰는 경우, 파이프라인/빈 결과 처리에서 의도치 않게 실패하는 함정이 자주 발생합니다. 안전한 예외 처리 패턴은 bash set -euo pipefail 함정과 안전한 예외처리도 참고하면 좋습니다.
GitHub Actions에서 동적 matrix로 연결
name: monorepo-ci
on:
pull_request:
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
jobs:
detect:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: set-matrix
run: |
sudo apt-get update && sudo apt-get install -y jq
matrix=$(./scripts/changed-workspaces.sh origin/main)
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
test:
needs: detect
if: ${{ needs.detect.outputs.matrix != '[]' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
workspace: ${{ fromJson(needs.detect.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm -C ${{ matrix.workspace }} test
이 구조의 장점:
- PR에서 변경된 워크스페이스가 2개면, 테스트 잡도 2개만 실행
- 워크스페이스가 200개로 늘어도 “변경 기반”이면 실행 수가 선형으로 유지
핵심 전략 3: 공통 로직은 재사용 워크플로우로 중앙집중화
워크플로우 파일이 늘어나는 근본 원인은 “공통 단계를 각 워크플로우가 중복”하기 때문입니다. GitHub Actions의 Reusable Workflows(workflow_call)로 공통을 모듈화하면 폭증을 크게 줄일 수 있습니다.
공통 템플릿: .github/workflows/_node-ci.yml
name: _node_ci
on:
workflow_call:
inputs:
workspace:
required: true
type: string
node_version:
required: false
type: string
default: "20"
jobs:
node-ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm -C ${{ inputs.workspace }} lint
- run: pnpm -C ${{ inputs.workspace }} test
호출 워크플로우: 변경된 workspace마다 템플릿 호출
name: pr
on:
pull_request:
jobs:
detect:
# ... 위 detect와 동일
runs-on: ubuntu-latest
per-workspace:
needs: detect
strategy:
fail-fast: false
matrix:
workspace: ${{ fromJson(needs.detect.outputs.matrix) }}
uses: ./.github/workflows/_node-ci.yml
with:
workspace: ${{ matrix.workspace }}
node_version: "20"
이 방식으로 얻는 효과:
- “워크플로우 파일 수”가 패키지 수에 비례해 늘지 않음
- Node 버전 변경, 캐시 정책 변경 같은 작업을 한 군데서 처리
핵심 전략 4: concurrency로 중복 실행(특히 PR 업데이트 폭주) 차단
PR에 커밋을 연속으로 푸시하면 이전 실행이 끝나기도 전에 새 실행이 쌓입니다. 이때 같은 PR에 대해 최신 실행만 남기고 이전 실행을 취소하면 비용과 대기열을 크게 줄일 수 있습니다.
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
group키를 PR 번호로 묶으면 PR 단위로 직렬화/취소가 가능합니다.push이벤트까지 고려한다면 브랜치명으로 묶는 패턴도 자주 씁니다.
핵심 전략 5: “검증 CI”와 “배포 CD”를 분리하고, 배포는 환경별 게이트를 둔다
워크플로우 폭증은 보통 배포 파이프라인이 PR마다 과도하게 실행될 때 더 심해집니다.
권장 구조:
- PR: 테스트/빌드/정적분석까지만
- main merge: 이미지 빌드/배포 트리거
- production: 수동 승인(Environments protection rules) + 태그 기반
예시: main에서만 배포 잡 실행
jobs:
deploy:
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
runs-on: ubuntu-latest
steps:
- run: echo "deploy only on main"
Kubernetes/EKS로 배포하는 경우, 배포 단계에서 실패하면 CI 문제처럼 보이지만 실제로는 런타임 이슈인 경우가 많습니다. 예를 들어 이미지 풀 실패는 워크플로우가 아니라 IRSA/ECR 인증/노드 캐시 문제일 수 있으니, EKS ImagePullBackOff - ECR 인증·IRSA·노드캐시 진단 같은 체크리스트를 함께 갖추는 것이 운영 효율에 도움이 됩니다.
핵심 전략 6: 캐시/아티팩트 설계를 통일해 “중복 빌드” 자체를 줄인다
워크플로우 수를 줄여도, 각 워크플로우가 매번 처음부터 설치/빌드하면 총 실행 시간은 줄지 않습니다.
- 패키지 매니저 캐시(pnpm/yarn/npm) 통일
- 빌드 결과를 아티팩트로 공유(예: 프론트엔드 build 산출물)
- Docker 빌드는 BuildKit 캐시(
cache-from,cache-to) 사용
pnpm 캐시 기본 예시
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
캐시는 “워크플로우 폭증”의 직접 해결책은 아니지만, 폭증을 억제했을 때 체감 효과를 극대화합니다.
핵심 전략 7: 필수 체크는 ‘요약 가능한 단위’로 설계한다
PR 화면에 체크가 30개 뜨면 사람이 판단하기 어렵습니다. 다음 원칙을 권합니다.
- 필수 체크는 3~6개 수준으로 제한
- 나머지는 “비필수(옵션)”, “야간 스케줄”, “변경 시에만”으로 이동
- 패키지별 테스트는 내부적으로 매트릭스로 돌리되, 결과는 한 워크플로우로 묶어 표현
추가로 TypeScript 모노레포에서 TS 버전 업 시 특정 패키지만 깨지는 경우가 흔합니다. 이런 경우에도 “전체를 매번” 돌리기보다 변경 패키지 중심으로 돌리는 구조가 유리합니다. 관련 이슈로 TS 5.5에서 자주 보이는 오류는 TS 5.5 isolatedDeclarations 오류 해결 가이드도 참고할 만합니다.
추천 아키텍처: ‘단일 진입 워크플로우 + 변경 기반 매트릭스 + 재사용 템플릿’
정리하면, 모노레포에서 워크플로우 폭증을 막는 가장 현실적인 조합은 아래입니다.
- 트리거 path 필터링으로 관련 없는 변경 차단
- changed workspaces 계산으로 실행 대상을 최소화
- 동적 matrix로 변경된 패키지만 병렬 처리
- Reusable workflow로 공통 단계 중앙화
- concurrency로 PR 업데이트 중복 실행 취소
- PR 검증과 배포 분리로 CD 폭주 방지
이렇게 구성하면 레포가 커져도 “실행 수”는 변경량에 비례하고, “YAML 유지보수 비용”은 공통 템플릿 변경으로 수렴합니다.
마무리: 폭증을 ‘도구’가 아니라 ‘설계’로 막아야 한다
Nx/Turborepo 같은 도구를 도입하면 변경 감지와 캐시가 더 정교해지지만, 도구만으로는 해결되지 않습니다. GitHub Actions에서 실행 수가 폭증하는 이유는 대부분 트리거/변경 범위/중복 실행/공통화 부재라는 설계 문제입니다.
먼저 지금 레포에서 다음 3가지를 측정해보면 개선 포인트가 선명해집니다.
- PR 1개당 평균 workflow run 수
- 평균 대기 시간(queued)과 실행 시간
- “변경 없는 패키지”가 수행한 잡 비율
그 다음 이 글의 1~5번 전략을 순서대로 적용하면, 모노레포가 커져도 CI/CD는 충분히 통제 가능한 수준으로 유지할 수 있습니다.