Published on

GitHub Actions 재사용 워크플로우로 모노레포 CI 통합

Authors

모노레포는 코드 공유와 일관된 개발 경험을 제공하지만, CI는 반대로 복잡해지기 쉽습니다. 패키지 수가 늘어날수록 워크플로우 YAML이 복제되고, 테스트 옵션이 미묘하게 달라지며, 캐시 키가 제각각이 되고, 결국 "어느 서비스는 통과하는데 어느 서비스는 실패" 같은 운영 이슈로 번집니다.

이 글에서는 GitHub Actions의 재사용 워크플로우(reusable workflow)와 컴포지트 액션(composite action)을 조합해, 모노레포 CI를 표준화하고 중복을 제거하는 방법을 설명합니다. 특히 다음을 목표로 합니다.

  • 서비스별로 흩어진 CI 정의를 하나의 표준으로 통합
  • 변경된 패키지만 선택적으로 빌드 및 테스트
  • 캐시/아티팩트/권한을 일관성 있게 관리
  • 호출자 워크플로우에서는 최소한의 설정만 유지

왜 재사용 워크플로우인가

GitHub Actions에서 "재사용"을 구현하는 대표적인 방법은 두 가지입니다.

  • 재사용 워크플로우: workflow_call로 다른 워크플로우가 호출 가능
  • 컴포지트 액션: 여러 step을 하나의 액션처럼 묶어 재사용

둘 다 중복 제거에 유용하지만, 모노레포 CI 통합에서는 재사용 워크플로우가 특히 강력합니다.

  • 표준 job 구조(예: lint, test, build, upload artifact)를 아예 워크플로우 단위로 강제 가능
  • 호출하는 쪽은 uses: ./.github/workflows/ci-reusable.yml 형태로 간단히 연결
  • secrets, permissions, inputs를 명시적으로 계약(contract)처럼 관리 가능

반면 컴포지트 액션은 "step 묶음"에는 좋지만, job/permissions/strategy 같은 워크플로우 상위 구조를 표준화하기엔 제약이 있습니다.

목표 아키텍처

권장 구조는 아래처럼 "호출자"와 "표준 CI"를 분리하는 것입니다.

  • 호출자 워크플로우: 트리거와 변경 감지, 패키지 리스트 산출
  • 재사용 워크플로우: 패키지 단위 CI 실행(매트릭스), 캐시, 테스트, 빌드, 아티팩트

예시 디렉터리 구조는 다음과 같습니다.

  • apps/web
  • apps/api
  • packages/shared
  • .github/workflows/monorepo-ci.yml (호출자)
  • .github/workflows/ci-reusable.yml (표준 CI)

호출자 워크플로우: 변경된 패키지 감지 후 재사용 CI 호출

먼저 PR이나 push에서 변경된 경로를 기준으로 영향을 받는 패키지를 계산하고, 그 결과를 재사용 워크플로우에 넘깁니다.

아래 예시는 "경로 필터"를 이용해 변경된 영역을 구분하고, 매트릭스 입력으로 전달하는 패턴입니다.

# .github/workflows/monorepo-ci.yml
name: monorepo-ci

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4

      - name: Detect changed paths
        id: filter
        uses: dorny/paths-filter@v3
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/shared/**'
            api:
              - 'apps/api/**'
              - 'packages/shared/**'

      - name: Build matrix JSON
        id: set-matrix
        shell: bash
        run: |
          items=()
          if [ "${{ steps.filter.outputs.web }}" = "true" ]; then
            items+=("{\"name\":\"web\",\"path\":\"apps/web\"}")
          fi
          if [ "${{ steps.filter.outputs.api }}" = "true" ]; then
            items+=("{\"name\":\"api\",\"path\":\"apps/api\"}")
          fi

          if [ ${#items[@]} -eq 0 ]; then
            echo 'matrix={"include":[]}' >> $GITHUB_OUTPUT
            exit 0
          fi

          json='{"include":['
          for i in "${items[@]}"; do
            json+="$i,"
          done
          json=${json%,}
          json+=']}'

          echo "matrix=$json" >> $GITHUB_OUTPUT

  ci:
    needs: detect
    if: ${{ fromJson(needs.detect.outputs.matrix).include[0] != null }}
    uses: ./.github/workflows/ci-reusable.yml
    with:
      matrix: ${{ needs.detect.outputs.matrix }}
      node_version: '20'
    secrets: inherit

핵심 포인트는 다음입니다.

  • detect job은 변경 감지 결과를 matrix JSON으로 만들고 output으로 노출
  • ci job은 재사용 워크플로우를 uses로 호출
  • 변경이 없으면 if로 CI 전체를 스킵
  • secrets: inherit로 호출자에서 접근 가능한 시크릿을 전달(필요 시 제한 가능)

재사용 워크플로우: 표준 CI를 매트릭스로 실행

이제 실제 lint/test/build는 재사용 워크플로우에서 표준화합니다. workflow_call로 입력을 받고, strategy.matrix에 주입합니다.

# .github/workflows/ci-reusable.yml
name: ci-reusable

on:
  workflow_call:
    inputs:
      matrix:
        required: true
        type: string
      node_version:
        required: false
        type: string
        default: '20'

permissions:
  contents: read

jobs:
  package-ci:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(inputs.matrix) }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node_version }}
          cache: 'npm'

      - name: Install
        working-directory: ${{ matrix.path }}
        run: npm ci

      - name: Lint
        working-directory: ${{ matrix.path }}
        run: npm run lint --if-present

      - name: Test
        working-directory: ${{ matrix.path }}
        run: npm test --if-present

      - name: Build
        working-directory: ${{ matrix.path }}
        run: npm run build --if-present

      - name: Upload artifact
        if: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.name }}-dist
          path: ${{ matrix.path }}/dist
          if-no-files-found: ignore

여기서 얻는 효과는 명확합니다.

  • 서비스가 늘어도 워크플로우 파일은 거의 늘지 않음
  • 모든 패키지가 동일한 node 버전/캐시/실행 순서를 따름
  • fail-fast: false로 한 패키지 실패가 다른 패키지까지 즉시 중단시키는 상황을 완화

캐시 전략: 모노레포에서 자주 터지는 함정

모노레포는 의존성 설치 방식에 따라 캐시가 오히려 독이 될 수 있습니다.

  • 각 패키지에서 npm ci를 돌리면 캐시 히트율이 낮아질 수 있음
  • 루트에서 한 번 설치하고 워크스페이스로 빌드하는 구조라면, 캐시는 루트 기준이 적절

예를 들어 npm workspaces를 쓰는 경우, 아래처럼 루트에서 설치하고 각 패키지 스크립트를 실행하는 형태가 더 안정적입니다.

- name: Install at root
  run: npm ci

- name: Test package
  run: npm run -w ${{ matrix.name }} test --if-present

이때 actions/setup-nodecache: 'npm'은 기본적으로 lockfile을 기준으로 캐시 키를 잡습니다. 루트 lockfile 하나로 통일되어 있으면 캐시 효율이 좋아집니다.

권한과 시크릿: 재사용 워크플로우에서 더 엄격히

CI 통합을 하다 보면 배포나 클라우드 접근 권한이 섞이기 시작합니다. 이때 재사용 워크플로우는 "권한 경계"를 만들기 좋습니다.

  • 테스트/빌드 워크플로우는 contents: read
  • 배포 워크플로우는 별도로 분리하고, 필요한 job에만 id-token: write 부여

특히 OIDC로 클라우드 자격 증명을 발급받는 경우, 권한 설정이 조금만 어긋나도 인증 오류가 나기 쉽습니다. 배포를 재사용 워크플로우로 만들 때는 permissionsid-token 설정을 먼저 점검하세요.

재사용 워크플로우 입력 설계 팁

입력을 설계할 때는 "유연성"보다 "표준화"에 우선순위를 두는 편이 장기적으로 유지보수 비용이 낮습니다.

권장 입력 예시:

  • matrix: 필수. 어떤 패키지를 돌릴지 정의
  • node_version: 선택. 런타임 표준을 중앙에서 관리
  • run_e2e: 선택. 비용 큰 작업을 조건부로
  • artifact_name_prefix: 선택. 아티팩트 네이밍 통일

반대로 아래 입력은 신중해야 합니다.

  • custom_commands: 임의 커맨드 문자열을 주입하는 방식은 표준 CI를 무력화할 수 있음

PR과 main의 차등 실행: 비용 최적화

모노레포에서는 PR마다 모든 빌드를 돌리면 비용이 빠르게 증가합니다. 다음 패턴이 자주 쓰입니다.

  • PR: lint/unit test까지만, 빌드는 선택
  • main push: build 및 artifact 업로드
  • nightly: 전체 패키지 풀 스캔(변경 감지 없이)

재사용 워크플로우 내부에서 github.event_namegithub.ref_name으로 분기하면 호출자 워크플로우가 단순해집니다.

- name: E2E
  if: ${{ inputs.run_e2e && github.event_name == 'push' && github.ref_name == 'main' }}
  run: npm run e2e

병렬성과 리소스: 매트릭스 동시 실행 제한

패키지 수가 많아지면 매트릭스가 과도하게 병렬화되어 러너 큐가 밀리거나 외부 리소스(테스트 DB, API rate limit)를 두드릴 수 있습니다.

  • strategy.max-parallel로 동시 실행 수 제한
  • 통합 테스트가 외부 리소스를 쓰면 concurrency로 직렬화
strategy:
  fail-fast: false
  max-parallel: 3
  matrix: ${{ fromJson(inputs.matrix) }}

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

트러블슈팅 체크리스트

1) 재사용 워크플로우가 시크릿을 못 받는다

  • 호출자에서 secrets: inherit를 사용했는지 확인
  • 또는 workflow_call.secrets를 명시적으로 선언하는 방식을 고려

2) 매트릭스 JSON 파싱 에러

  • inputs.matrix는 문자열이므로 반드시 fromJson으로 파싱
  • JSON 생성 시 따옴표 이스케이프가 깨지기 쉬우니, 가능하면 jq를 사용

jq를 쓰는 예시는 다음과 같습니다.

- name: Build matrix with jq
  id: set-matrix
  shell: bash
  run: |
    include='[]'
    if [ "${{ steps.filter.outputs.web }}" = "true" ]; then
      include=$(echo "$include" | jq '. + [{"name":"web","path":"apps/web"}]')
    fi
    if [ "${{ steps.filter.outputs.api }}" = "true" ]; then
      include=$(echo "$include" | jq '. + [{"name":"api","path":"apps/api"}]')
    fi
    echo "matrix=$(jq -cn --argjson include "$include" '{include:$include}')" >> $GITHUB_OUTPUT

3) 배포 단계에서 인증이 간헐적으로 실패

  • OIDC, 토큰 권한, audience, subject 조건을 먼저 점검
  • 배포 job만 별도 워크플로우로 분리해 권한 범위를 최소화

관련해서는 아래 글이 실전에서 도움이 됩니다.

모노레포 CI 통합의 다음 단계: CD와 정책 적용

CI 표준화가 끝나면 다음 단계는 보통 CD(배포) 표준화와 정책 적용입니다.

  • 환경별 승인 규칙(예: production은 required reviewers)
  • Argo CD 같은 GitOps 기반 배포로 연결
  • 서비스별 RBAC/프로젝트 경계를 명확히

GitOps를 쓰는 팀이라면 AppProject 권한 문제로 Sync가 실패하는 케이스가 흔합니다.

정리

GitHub Actions 재사용 워크플로우를 모노레포 CI의 "표준 실행 엔진"으로 두면, 서비스가 늘어도 CI 품질과 일관성을 유지하기 쉬워집니다. 호출자 워크플로우는 변경 감지와 트리거만 담당하고, 실제 실행 로직은 재사용 워크플로우로 고정하면 다음과 같은 이점이 생깁니다.

  • YAML 복제 제거와 유지보수 비용 감소
  • 패키지별 실행 편차 감소로 CI 신뢰도 상승
  • 권한 및 시크릿 범위 통제로 보안 개선
  • 변경된 패키지만 선택 실행해 비용 최적화

모노레포 CI가 커질수록 "어떻게 더 많은 일을 하느냐"보다 "어떻게 같은 일을 한 번만 정의하느냐"가 핵심이 됩니다. 재사용 워크플로우는 그 문제를 가장 깔끔하게 풀 수 있는 도구입니다.