Published on

모노레포에서 GitHub Actions 재사용 워크플로우 설계·버전관리

Authors

모노레포(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 이름 변경, 기본 동작 변경(예: 테스트 커맨드 변경)

특히 inputsrequired: true를 늘리는 순간은 거의 MAJOR입니다.

변경 전파를 통제하는 릴리스 프로세스

실무에서 추천하는 릴리스 흐름:

  1. main에 머지 → CI로 재사용 워크플로우 자체 테스트
  2. 릴리스 PR에서 버전 업데이트/릴리스 노트 생성
  3. v1.2.3 태그 생성
  4. 호환 범위면 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로 호환성 관리

이 기준으로 한 번만 골격을 잡아두면, 서비스가 늘어날수록 “워크플로우 복붙 지옥”이 아니라 “표준 파이프라인 확장”으로 조직의 속도가 붙습니다.