- Published on
모노레포에서 GitHub Actions 재사용 워크플로우 설계·버전관리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모노레포(monorepo)는 공통 라이브러리·여러 서비스·인프라 코드가 한 저장소에 공존하기 때문에 CI/CD가 빠르게 복잡해집니다. 서비스마다 비슷한 빌드/테스트/배포 파이프라인을 반복해서 작성하면, 작은 변경도 수십 개 워크플로우에 복제되어 유지보수 비용이 폭발합니다.
GitHub Actions의 **재사용 워크플로우(reusable workflow, workflow_call)**는 이 문제를 해결하는 핵심 도구입니다. 하지만 “공통화”는 단순히 YAML을 한 곳에 모아두는 것 이상입니다. 입력/시크릿/권한 모델, 모노레포 변경 감지 전략, 캐시/아티팩트 설계, 그리고 무엇보다 **버전관리(호환성·릴리스·롤백)**까지 갖춰야 장기적으로 안정적으로 굴러갑니다.
이 글에서는 모노레포에서 재사용 워크플로우를 설계하는 표준 패턴과, 실무에서 사고를 줄이는 버전관리 전략을 끝까지 정리합니다.
재사용 워크플로우의 기본: 무엇을 “공통화”할 것인가
모노레포에서 공통화 후보는 보통 다음과 같습니다.
- 언어/런타임별 빌드·테스트 표준: Node/Go/Rust/Java 등
- 품질 게이트: lint, unit test, coverage, SAST
- 컨테이너 빌드/푸시: Buildx, SBOM, provenance
- 배포: Helm/ArgoCD/Cloud Run/ECS 등
- 릴리스 작업: 태깅, changelog, 버전 bump
여기서 중요한 원칙은 **“공통은 얇게, 확장은 입력으로”**입니다.
- 공통 워크플로우는 조직의 표준을 강제(예: 테스트는 반드시 실행)
- 서비스별 차이는
inputs로 주입(예: working-directory, test-command) - 예외 케이스는 “옵션”으로 제공하되 남발하지 않기(옵션이 많아지면 공통이 깨짐)
모노레포 디렉터리 구조 추천
재사용 워크플로우를 저장소 내부에서 관리할 때, 다음 구조가 가장 관리하기 좋습니다.
.github/
workflows/
ci-service-a.yml # 각 서비스 엔트리(얇게)
ci-service-b.yml
reusable/
node-ci.yml # 재사용 워크플로우
docker-build-push.yml
deploy-eks.yml
scripts/
services/
service-a/
service-b/
libs/
.github/workflows/*는 엔트리 포인트(트리거·경로필터·서비스별 입력만).github/reusable/*는 표준 파이프라인 구현체
이렇게 분리하면 “서비스별 트리거/조건”과 “공통 CI 로직”이 뒤섞이지 않습니다.
재사용 워크플로우 설계 핵심: inputs, secrets, permissions
workflow_call로 인터페이스를 먼저 설계하기
재사용 워크플로우는 내부 구현보다 **인터페이스(입력/시크릿/출력)**가 더 중요합니다.
# .github/reusable/node-ci.yml
name: reusable-node-ci
on:
workflow_call:
inputs:
working-directory:
type: string
required: true
node-version:
type: string
required: false
default: "20"
test-command:
type: string
required: false
default: "npm test"
cache-key-suffix:
type: string
required: false
default: ""
secrets:
NPM_TOKEN:
required: false
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- name: Install
run: npm ci
- name: Test
run: ${{ inputs.test-command }}
포인트:
defaults.run.working-directory로 서비스 경로를 강제 → YAML 반복 감소cache-dependency-path를 working directory 기준으로 설정 → 모노레포 캐시 충돌 감소permissions를 최소화(기본은contents: read) → 권한 과다 부여 방지
호출 워크플로우는 “얇게” 유지
# .github/workflows/ci-service-a.yml
name: ci-service-a
on:
pull_request:
paths:
- "services/service-a/**"
- ".github/reusable/node-ci.yml"
push:
branches: ["main"]
paths:
- "services/service-a/**"
jobs:
ci:
uses: ./.github/reusable/node-ci.yml
with:
working-directory: services/service-a
node-version: "20"
test-command: "npm run test:ci"
paths에 재사용 워크플로우 파일을 포함해두면, 공통 CI 변경 시 관련 서비스 CI가 함께 검증됩니다.
권한 모델: 재사용 워크플로우에서 가장 많이 터지는 지점
재사용 워크플로우는 호출자/피호출자 권한이 합성되는 방식 때문에, “왜 이 job에서만 403이 나지?” 같은 문제가 자주 생깁니다. 특히 클라우드 배포에서 OIDC를 쓰면 id-token: write가 필요합니다.
OIDC 기반 AWS AssumeRole을 예로 들면, 재사용 워크플로우에 다음이 필요합니다.
permissions:
contents: read
id-token: write
그리고 호출 워크플로우에서 권한을 더 줄여버리면 실패합니다. OIDC/AssumeRole에서 403이 발생할 때의 체크리스트는 아래 글이 바로 실전에서 도움이 됩니다.
변경 감지(Changed paths)로 모노레포 CI 비용 줄이기
모노레포에서 가장 큰 비용은 “변경되지 않은 서비스까지 전부 빌드/테스트”하는 것입니다. on.pull_request.paths만으로도 어느 정도 해결되지만, 다음 상황에서는 부족합니다.
- 공통 라이브러리 변경 시 영향받는 서비스만 선택적으로 실행하고 싶다
- PR에서 여러 서비스가 동시에 변경될 수 있다
- 매트릭스(matrix)로 변경된 서비스만 돌리고 싶다
이때는 dorny/paths-filter 같은 액션으로 변경 범위를 계산해 매트릭스를 구성하는 패턴이 안정적입니다.
name: ci-monorepo
on:
pull_request:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
service_a: ${{ steps.filter.outputs.service_a }}
service_b: ${{ steps.filter.outputs.service_b }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
service_a:
- 'services/service-a/**'
- 'libs/shared/**'
service_b:
- 'services/service-b/**'
- 'libs/shared/**'
service-a:
needs: changes
if: ${{ needs.changes.outputs.service_a == 'true' }}
uses: ./.github/reusable/node-ci.yml
with:
working-directory: services/service-a
service-b:
needs: changes
if: ${{ needs.changes.outputs.service_b == 'true' }}
uses: ./.github/reusable/node-ci.yml
with:
working-directory: services/service-b
이 구조의 장점은 “변경 감지 로직”이 한 곳에 모이고, 각 서비스 job은 재사용 워크플로우로 단순화된다는 점입니다.
캐시/아티팩트 전략: 재사용 워크플로우에서의 함정
재사용 워크플로우로 통합하면 캐시 키가 비슷해져 캐시가 섞이거나(cache poisoning), 반대로 캐시가 전혀 안 먹히는 상황이 발생합니다.
안전한 원칙:
- 캐시 키에
runner.os, 언어 버전, lockfile 해시, 서비스 경로를 포함 cache-dependency-path를 서비스별 lockfile로 정확히 지정- 모노레포에서
node_modules를 통째로 공유하려는 욕심은 버리기(충돌/오염 확률이 큼)
캐시가 안 먹히는 대표 원인과 해결 체크는 아래 글이 체계적입니다.
버전관리 “완전정복”: 재사용 워크플로우를 어떻게 릴리스할 것인가
재사용 워크플로우를 한 저장소에서만 쓴다면 uses: ./.github/reusable/xxx.yml로 충분합니다. 하지만 조직이 커지면 다음 요구가 생깁니다.
- 여러 저장소에서 동일한 워크플로우를 쓰고 싶다
- 공통 워크플로우 변경이 모든 저장소 CI를 동시에 깨지 않게 하고 싶다
- 롤백이 쉬워야 한다
이때는 워크플로우를 전용 저장소(예: org/ci-workflows)로 분리하고, @ref로 버전 핀ning을 합니다.
버전 핀ning: @main 금지, @v1 권장
다른 저장소에서 재사용 워크플로우를 호출하는 예:
jobs:
ci:
uses: org/ci-workflows/.github/workflows/node-ci.yml@v1
with:
working-directory: services/service-a
node-version: "20"
권장 규칙:
@main/@master는 금지: 공통 변경이 즉시 전파되어 광역 장애 유발@v1.2.3(불변 태그) 또는@v1(메이저 이동 태그) 사용v1태그는 호환되는 범위에서만 앞으로 이동(깨지는 변경은v2)
SemVer를 “인터페이스”에 적용하기
재사용 워크플로우의 호환성은 코드가 아니라 인터페이스가 기준입니다.
- PATCH: 내부 구현 변경(성능/버그 수정), 입력/출력/기본 동작 동일
- MINOR: optional input 추가, 새로운 output 추가(기존 호출은 그대로 동작)
- MAJOR: required input 추가/변경, output 이름 변경, 기본 동작 변경(예: 테스트 커맨드 변경)
특히 inputs의 required: true를 늘리는 순간은 거의 MAJOR입니다.
변경 전파를 통제하는 릴리스 프로세스
실무에서 추천하는 릴리스 흐름:
main에 머지 → CI로 재사용 워크플로우 자체 테스트- 릴리스 PR에서 버전 업데이트/릴리스 노트 생성
v1.2.3태그 생성- 호환 범위면
v1태그를v1.2.3으로 이동
v1을 이동 태그로 쓰면, 소비자 저장소는 @v1로 안정적으로 업데이트를 받되 MAJOR 파괴는 피할 수 있습니다.
재사용 워크플로우도 “테스트”해야 한다
워크플로우는 코드인데 테스트가 어렵다는 이유로 방치되기 쉽습니다. 다음 중 2가지는 반드시 넣는 것을 권합니다.
- 샘플 소비 워크플로우:
examples/또는 별도 테스트 repo에서 실제로uses:호출 - act(로컬 실행) 또는 최소한 lint:
actionlint로 문법/컨텍스트 검증
간단한 actionlint 예:
name: lint-workflows
on:
pull_request:
jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rhysd/actionlint@v1
보안/운영 관점 체크리스트
secrets 전달 최소화
재사용 워크플로우는 secrets:를 인터페이스로 노출할 수 있습니다. 하지만 “공통이라서” 모든 시크릿을 넘기기 시작하면, 특정 job에 불필요한 권한이 생깁니다.
- 가능하면 OIDC로 대체(클라우드 키 장기보관 지양)
- 꼭 필요한 시크릿만, 이름도 목적 중심으로 최소화
동시성(concurrency)로 배포 경합 방지
모노레포에서 같은 환경으로 배포가 겹치면 장애로 이어집니다.
concurrency:
group: deploy-prod
cancel-in-progress: false
서비스별로 분리하려면 deploy-prod-${{ inputs.service }} 같은 형태로 그룹을 나누면 됩니다.
실패 복구 관점: 워크플로우 변경도 결국 Git 작업
공통 워크플로우 변경 후 여러 PR/브랜치에 영향을 주는 상황에서, rebase/force push로 히스토리가 꼬이면 복구가 더 어려워집니다. 팀 규칙으로 “워크플로우 저장소는 특히 히스토리 안전”을 강조하는 편이 좋습니다.
실전 레시피: 빌드·푸시·배포까지 재사용 워크플로우로 쪼개기
재사용 워크플로우를 하나에 다 넣기보다, 다음처럼 레이어로 나누면 조합이 쉬워집니다.
node-ci.yml: 테스트/린트docker-build-push.yml: 이미지 빌드/레지스트리 푸시deploy-*.yml: 환경별 배포
예: Docker 빌드/푸시 워크플로우(요지)
# .github/reusable/docker-build-push.yml
name: reusable-docker-build-push
on:
workflow_call:
inputs:
context:
type: string
required: true
image:
type: string
required: true
tags:
type: string
required: true
secrets:
REGISTRY_PASSWORD:
required: true
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
push: true
tags: ${{ inputs.tags }}
그리고 서비스 워크플로우는 조합만 합니다.
jobs:
test:
uses: ./.github/reusable/node-ci.yml
with:
working-directory: services/service-a
image:
needs: test
uses: ./.github/reusable/docker-build-push.yml
with:
context: services/service-a
image: ghcr.io/org/service-a
tags: ghcr.io/org/service-a:${{ github.sha }}
secrets:
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
마무리: “공통화”의 끝은 버전과 계약(Contract)이다
모노레포에서 GitHub Actions 재사용 워크플로우를 잘 설계하면, CI/CD는 더 빨라지고 표준화되며 변경 비용이 급격히 줄어듭니다. 하지만 진짜 운영 난이도를 가르는 건 YAML 기술이 아니라 계약(입력/출력/권한)과 버전관리입니다.
정리하면 다음 4가지만 지켜도 품질이 크게 올라갑니다.
- 엔트리 워크플로우는 얇게, 공통 로직은
workflow_call로 inputs/secrets/permissions를 인터페이스로 엄격하게 설계- 변경 감지 + 매트릭스로 모노레포 비용 최적화
@main대신@v1/@v1.2.3로 핀ning하고 SemVer로 호환성 관리
이 기준으로 한 번만 골격을 잡아두면, 서비스가 늘어날수록 “워크플로우 복붙 지옥”이 아니라 “표준 파이프라인 확장”으로 조직의 속도가 붙습니다.