Published on

GitHub Actions 재사용 워크플로우로 모노레포 CI 지옥 탈출

Authors

모노레포(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-nodecache: pnpm 같은 내장 캐시로 출발하는 편이 안전합니다.

더 공격적으로 최적화하려면 lockfile 기반 키를 명확히 하세요. 예를 들어 루트 pnpm-lock.yaml을 기준으로 잡으면 “패키지별 캐시 파편화”를 줄일 수 있습니다.

실패 격리: fail-fast: false

매트릭스로 여러 패키지를 돌릴 때 하나가 실패하면 나머지까지 중단되는 경우가 있습니다. 모노레포에서는 원인 파악을 위해서라도 fail-fast: false가 유리한 경우가 많습니다.

권한은 최소화

재사용 워크플로우는 권한도 상속/전달되므로, 배포 워크플로우는 permissions를 명시하고 id-token: write 같은 항목을 꼭 필요한 잡에만 부여하세요.

5단계: 점진적 마이그레이션 전략

이미 CI가 복잡하게 퍼져 있다면 한 번에 갈아엎지 말고 다음 순서가 안전합니다.

  1. 가장 많이 복제된 워크플로우 1개를 재사용으로 추출
  2. 1~2개 패키지만 호출 방식으로 전환해 안정성 확인
  3. 변경 감지 오케스트레이터를 도입해 실행 범위를 축소
  4. 배포/OIDC를 재사용 워크플로우로 통합

이 과정에서 Git 히스토리를 정리하다가 rebase 이후 PR이 꼬이는 상황도 자주 발생합니다. 팀에서 rebase를 적극 쓰는 문화라면 아래 글도 같이 참고해두면 좋습니다.

마무리: 모노레포 CI의 목적은 “표준화된 속도”

모노레포의 CI는 단순히 빨라야 하는 게 아니라, 표준화된 방식으로 빠르게 돌아야 합니다. 재사용 워크플로우는 그 표준을 코드로 고정하는 가장 강력한 도구입니다.

  • 공통 로직은 재사용 워크플로우로 모듈화
  • 오케스트레이터는 변경 감지와 실행 계획만 담당
  • 배포/OIDC 같은 민감 로직은 중앙에서 통제

이 구조로 바꾸면, 패키지가 5개에서 50개로 늘어도 CI 유지보수 비용은 선형으로 폭증하지 않습니다. “CI 지옥”의 본질은 복잡성이 아니라 중복과 분산이므로, 재사용 워크플로우로 중복을 제거하는 것부터 시작하면 됩니다.