Published on

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

Authors
Binance registration banner

모노레포(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 지옥”의 본질은 복잡성이 아니라 중복과 분산이므로, 재사용 워크플로우로 중복을 제거하는 것부터 시작하면 됩니다.