- Published on
GitHub Actions 재사용 워크플로우로 모노레포 CI 지옥 탈출
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모노레포(monorepo)를 운영하다 보면 CI가 가장 먼저 ‘지옥’이 됩니다. 패키지 수가 늘수록 워크플로우 YAML은 복붙으로 증식하고, 작은 수정 하나가 수십 개 파일에 퍼지며, PR마다 불필요한 빌드가 돌고, 캐시 키는 제각각이라 비용만 늘어납니다.
이 글은 GitHub Actions의 재사용 워크플로우(reusable workflows) 를 중심으로, 모노레포 CI를 “한 번 설계하고 오래 쓰는” 구조로 바꾸는 방법을 설명합니다. 핵심은 다음 3가지입니다.
- 공통 로직을
workflow_call로 모듈화해 중복 제거 - 변경된 패키지만 골라 돌리는 변경 감지 + 매트릭스 전략
- 배포/권한(OIDC) 같은 민감 영역을 재사용 워크플로우로 표준화
참고로 AWS 배포를 Actions OIDC로 묶는 과정에서 자주 겪는 이슈는 아래 글이 같이 도움이 됩니다.
모노레포 CI가 망가지는 전형적인 패턴
1) 패키지별 워크플로우 복제
apps/web, apps/api, packages/ui, packages/core… 각 폴더마다 ci.yml이 생기고, 결국 “사실상 동일한” 단계가 N번 반복됩니다.
- Node 버전 바꾸기
- pnpm 버전 바꾸기
- 캐시 키 수정
- 테스트 커맨드 변경
이런 변경이 생길 때마다 N개 YAML을 수정하는 순간, CI는 유지보수 불가능한 자산이 됩니다.
2) 모든 PR이 전체 빌드/테스트
모노레포의 장점은 코드 공유인데, CI가 이를 오해하면 단점이 됩니다. packages/ui의 CSS 수정이 apps/api의 통합 테스트까지 트리거하는 식입니다.
3) 배포/권한 로직이 서비스별로 제각각
특히 AWS OIDC assume role, ECR 로그인, Helm 배포 같은 단계가 팀/서비스마다 다르게 구현되면, 장애가 났을 때 “어느 워크플로우가 정답인지”부터 혼란이 시작됩니다.
재사용 워크플로우란 무엇인가
GitHub Actions의 재사용 워크플로우는 워크플로우를 함수처럼 호출하는 기능입니다.
- 호출하는 쪽:
uses: owner/repo/.github/workflows/xxx.yml@ref - 호출받는 쪽:
on: workflow_call로 입력/시크릿/출력 정의
이렇게 만들면 공통 CI를 한 파일로 유지하고, 각 패키지는 “입력값만 다르게” 호출할 수 있습니다.
목표 아키텍처: 오케스트레이터 + 공통 CI 모듈
추천 구조는 다음처럼 2단입니다.
- 오케스트레이터(루트 워크플로우): 변경 감지, 매트릭스 구성, 어떤 패키지를 돌릴지 결정
- 재사용 워크플로우(모듈): Node 설치, 캐시, 빌드/테스트, 아티팩트 업로드 등 공통 단계 수행
추가로 배포도 재사용 워크플로우로 분리해 “권한/보안/릴리즈 표준”을 강제할 수 있습니다.
1단계: 공통 CI 재사용 워크플로우 만들기
아래는 Node 기반 패키지(예: pnpm, turborepo, nx 등)에 적용 가능한 공통 CI 템플릿입니다.
파일: .github/workflows/reusable-node-ci.yml
name: reusable-node-ci
on:
workflow_call:
inputs:
workdir:
description: "패키지 작업 디렉터리"
required: true
type: string
node_version:
required: false
type: string
default: "20"
install_command:
required: false
type: string
default: "pnpm install --frozen-lockfile"
test_command:
required: false
type: string
default: "pnpm test"
build_command:
required: false
type: string
default: "pnpm build"
cache_key_prefix:
required: false
type: string
default: "pnpm"
secrets:
NPM_TOKEN:
required: false
jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.workdir }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
cache: "pnpm"
- name: Configure npm auth (optional)
if: ${{ secrets.NPM_TOKEN != '' }}
run: |
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Install
run: ${{ inputs.install_command }}
- name: Test
run: ${{ inputs.test_command }}
- name: Build
run: ${{ inputs.build_command }}
포인트는 다음입니다.
defaults.run.working-directory로 패키지별 디렉터리 이동을 표준화- install/test/build 커맨드를 입력값으로 받아 프레임워크 차이를 흡수
setup-node의 내장 캐시를 우선 활용해 단순화
2단계: 변경 감지로 “필요한 패키지만” 실행하기
모노레포 CI 최적화의 핵심은 “어떤 패키지가 영향을 받았는지”를 계산하는 것입니다.
방법은 크게 3가지가 있습니다.
- turborepo의
turbo run --filter=...활용 - nx의 affected 기능 활용
- Git diff 기반으로 직접 변경 폴더를 계산
여기서는 도구 독립적인 Git diff 기반 예시를 보여드리겠습니다.
파일: .github/workflows/ci.yml
name: ci
on:
pull_request:
push:
branches: [ main ]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: set-matrix
shell: bash
run: |
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.sha }}"
if [ -z "$BASE_SHA" ]; then
BASE_SHA="${{ github.sha }}^"
fi
CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" || true)
# 예시: apps/* 또는 packages/* 변경만 대상
WORKDIRS=$(echo "$CHANGED" \
| awk -F/ '/^(apps|packages)\// {print $1"/"$2}' \
| sort -u \
| jq -R -s -c 'split("\n")[:-1]')
if [ "$WORKDIRS" = "[]" ]; then
echo 'matrix={"include":[]}' >> $GITHUB_OUTPUT
exit 0
fi
MATRIX=$(echo "$WORKDIRS" \
| jq -c '{"include": map({"workdir": .})}')
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
package-ci:
needs: detect-changes
if: ${{ fromJson(needs.detect-changes.outputs.matrix).include[0] != null }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
uses: ./.github/workflows/reusable-node-ci.yml
with:
workdir: ${{ matrix.workdir }}
node_version: "20"
install_command: "pnpm install --frozen-lockfile"
test_command: "pnpm test"
build_command: "pnpm build"
이 구성이 주는 효과는 즉각적입니다.
- PR에서
apps/web만 바뀌면apps/web만 CI 실행 - 패키지가 20개여도 변경된 1개만 돌면 1개만 돈다
- 공통 단계는 재사용 워크플로우 한 곳에서 관리
변경 감지 스크립트의 실전 팁
fetch-depth: 0은 diff 계산에 거의 필수입니다(얕은 clone이면 base 커밋이 없어 실패).- 루트 설정 파일(
pnpm-lock.yaml,turbo.json,tsconfig.base.json) 변경은 “전체 영향”으로 취급해야 합니다.
예를 들어 아래처럼 예외 규칙을 추가할 수 있습니다.
if echo "$CHANGED" | grep -E '^(pnpm-lock.yaml|package.json|turbo.json|tsconfig.base.json)$' >/dev/null; then
WORKDIRS=$(ls -d apps/* packages/* 2>/dev/null | jq -R -s -c 'split("\n")[:-1]')
fi
3단계: 배포도 재사용 워크플로우로 표준화하기(OIDC 포함)
CI가 정리되면 다음 문제는 배포입니다. 모노레포는 보통 “서비스별 배포 방식”이 다르고, 이때부터 다시 YAML이 복제되기 시작합니다.
배포 워크플로우를 재사용으로 만들면 좋은 점은 다음입니다.
- OIDC assume role, AWS region, role ARN 같은 보안 민감 설정을 한 곳에서 통제
- ECR 로그인, 이미지 태깅 규칙, Helm values 규칙을 표준화
- 서비스는 입력값만 제공
파일: .github/workflows/reusable-aws-oidc-deploy.yml
name: reusable-aws-oidc-deploy
on:
workflow_call:
inputs:
aws_region:
required: true
type: string
role_arn:
required: true
type: string
deploy_command:
required: true
type: string
secrets:
# 필요 시 추가 시크릿
EXTRA_SECRET:
required: false
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ inputs.aws_region }}
role-to-assume: ${{ inputs.role_arn }}
- name: Deploy
run: ${{ inputs.deploy_command }}
호출하는 쪽은 단순해집니다.
jobs:
deploy-web:
if: ${{ github.ref == 'refs/heads/main' }}
uses: ./.github/workflows/reusable-aws-oidc-deploy.yml
with:
aws_region: "ap-northeast-2"
role_arn: "arn:aws:iam::123456789012:role/gha-deploy"
deploy_command: "./scripts/deploy-web.sh"
OIDC는 설정이 조금만 어긋나도 assume role이 실패하기 쉬운데, 이걸 서비스별로 흩어두면 해결 시간이 기하급수로 늘어납니다. 트러블슈팅이 필요하면 위에서 소개한 내부 글을 함께 참고하세요.
4단계: 재사용 워크플로우 설계 체크리스트
입력값은 “정책”과 “변수”를 분리
- 정책(표준): Node 버전, 캐시 방식, checkout 방식, 권한 범위
- 변수(서비스별): workdir, 빌드 커맨드, 테스트 커맨드, 배포 커맨드
정책이 입력값으로 열려 있으면, 시간이 지나 다시 각자도생이 됩니다.
캐시는 단순하게, 키는 의도적으로
Node 생태계에서는 캐시가 복잡해지기 쉬운데, 일단은 actions/setup-node의 cache: pnpm 같은 내장 캐시로 출발하는 편이 안전합니다.
더 공격적으로 최적화하려면 lockfile 기반 키를 명확히 하세요. 예를 들어 루트 pnpm-lock.yaml을 기준으로 잡으면 “패키지별 캐시 파편화”를 줄일 수 있습니다.
실패 격리: fail-fast: false
매트릭스로 여러 패키지를 돌릴 때 하나가 실패하면 나머지까지 중단되는 경우가 있습니다. 모노레포에서는 원인 파악을 위해서라도 fail-fast: false가 유리한 경우가 많습니다.
권한은 최소화
재사용 워크플로우는 권한도 상속/전달되므로, 배포 워크플로우는 permissions를 명시하고 id-token: write 같은 항목을 꼭 필요한 잡에만 부여하세요.
5단계: 점진적 마이그레이션 전략
이미 CI가 복잡하게 퍼져 있다면 한 번에 갈아엎지 말고 다음 순서가 안전합니다.
- 가장 많이 복제된 워크플로우 1개를 재사용으로 추출
- 1~2개 패키지만 호출 방식으로 전환해 안정성 확인
- 변경 감지 오케스트레이터를 도입해 실행 범위를 축소
- 배포/OIDC를 재사용 워크플로우로 통합
이 과정에서 Git 히스토리를 정리하다가 rebase 이후 PR이 꼬이는 상황도 자주 발생합니다. 팀에서 rebase를 적극 쓰는 문화라면 아래 글도 같이 참고해두면 좋습니다.
마무리: 모노레포 CI의 목적은 “표준화된 속도”
모노레포의 CI는 단순히 빨라야 하는 게 아니라, 표준화된 방식으로 빠르게 돌아야 합니다. 재사용 워크플로우는 그 표준을 코드로 고정하는 가장 강력한 도구입니다.
- 공통 로직은 재사용 워크플로우로 모듈화
- 오케스트레이터는 변경 감지와 실행 계획만 담당
- 배포/OIDC 같은 민감 로직은 중앙에서 통제
이 구조로 바꾸면, 패키지가 5개에서 50개로 늘어도 CI 유지보수 비용은 선형으로 폭증하지 않습니다. “CI 지옥”의 본질은 복잡성이 아니라 중복과 분산이므로, 재사용 워크플로우로 중복을 제거하는 것부터 시작하면 됩니다.