본문 바로가기
IT/Cloud

[Cloud] GitHub Actions 모노레포: 매트릭스 빌드로 CI/CD 최적화하기

by 수누다 2026. 5. 12.

모노레포에서 CI/CD, 이게 생각보다 복잡하더라고요

처음 팀에서 모노레포(Monorepo)로 전환했을 때 솔직히 자신 있었거든요. "뭐, CI/CD 파이프라인 하나 잘 짜면 되는 거 아니야?" 했는데... 현실은 달랐습니다. 프론트엔드, 백엔드 API, 공통 라이브러리가 한 레포에 다 들어가 있으니까 서로 상관없는 변경사항인데도 전체 빌드가 돌고, 빌드 시간은 점점 길어지고. 결국 개발자들이 "PR 올리면 20분 기다려야 해요"라고 불만을 터뜨리기 시작했죠.

그때 제대로 파고든 게 GitHub Actions 모노레포 환경에서의 매트릭스 빌드(Matrix Build) 전략이었습니다. 인프라 업무를 오래 하면서 CI/CD 툴을 여럿 써봤는데, GitHub Actions의 매트릭스 전략은 모노레포 환경에서 정말 강력하더라고요. 오늘은 그 경험을 바탕으로 실전에서 바로 쓸 수 있는 가이드를 공유해 드리려 합니다.

▲ GitHub Actions 모노레포 환경의 전체 CI/CD 파이프라인 흐름 — 변경된 패키지만 선택적으로 빌드/테스트하는 구조

GitHub Actions 모노레포와 매트릭스 빌드, 쉽게 이해해 봅시다

모노레포(Monorepo)란?

쉽게 말해, 여러 개의 프로젝트나 패키지를 하나의 Git 저장소에서 관리하는 방식입니다. 예전에는 프로젝트마다 레포를 따로 만드는 멀티레포(Multi-repo) 방식이 흔했는데, 요즘은 Google, Meta, Microsoft 같은 대형 기업들도 모노레포를 적극 활용하고 있죠.

  • ✅ 코드 공유와 재사용이 쉬움
  • ✅ 의존성 관리가 일원화됨
  • ✅ 변경 사항의 영향 범위를 한눈에 파악 가능
  • ⚠️ CI/CD 파이프라인 설계가 까다로워짐
  • ⚠️ 레포 크기가 커질수록 빌드 시간이 늘어날 수 있음

GitHub Actions 매트릭스 빌드(Matrix Build)란?

GitHub Actions의 strategy.matrix 기능은 여러 변수 조합에 대해 병렬로 잡(Job)을 실행하는 기능입니다. 예를 들어 Node.js 16, 18, 20 버전에서 동시에 테스트를 돌린다거나, 여러 패키지를 동시에 빌드할 때 쓰죠. 모노레포에서는 이걸 "변경된 패키지 목록"과 조합해서 쓰면 진짜 강력해집니다.

방식 빌드 대상 빌드 시간 장단점
전체 빌드 (Naive) 모든 패키지 길다 설정 단순, 시간 낭비 큼
매트릭스 + 변경 감지 변경된 패키지만 짧다 설정 복잡, 효율 극대화
캐시 + 매트릭스 변경된 패키지만 매우 짧다 가장 최적화된 방식

GitHub Actions 모노레포 CI/CD 실전 구현: 단계별로 따라해 보세요

1단계: 레포 구조 잡기

먼저 제가 실제로 운영하는 모노레포 구조를 보여드릴게요. 완벽할 필요는 없고, 이런 식으로 패키지가 분리되어 있으면 됩니다.

my-monorepo/
├── .github/
│   └── workflows/
│       ├── ci.yml          # 메인 CI 워크플로우
│       └── detect-changes.yml
├── packages/
│   ├── frontend/           # React 프론트엔드
│   │   ├── package.json
│   │   └── src/
│   ├── api-server/         # Node.js API 서버
│   │   ├── package.json
│   │   └── src/
│   └── shared-lib/         # 공통 라이브러리
│       ├── package.json
│       └── src/
├── package.json            # 루트 package.json (워크스페이스 설정)
└── pnpm-workspace.yaml     # pnpm 워크스페이스 설정

2단계: 변경된 패키지 감지하기

여기가 핵심입니다. 어떤 패키지가 바뀌었는지 감지해야 매트릭스를 동적으로 구성할 수 있거든요. 저는 git diff와 간단한 스크립트를 조합해서 씁니다.

# .github/workflows/ci.yml
name: Monorepo CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  # 1. 변경된 패키지 목록을 동적으로 생성
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
      has-changes: ${{ steps.set-matrix.outputs.has-changes }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 전체 히스토리 필요 (diff 비교용)

      - name: Detect changed packages
        id: set-matrix
        run: |
          # PR이면 base 브랜치와 비교, push면 이전 커밋과 비교
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE_SHA=${{ github.event.pull_request.base.sha }}
          else
            BASE_SHA=${{ github.event.before }}
          fi

          CHANGED_PACKAGES='[]'

          # packages/ 하위 디렉토리를 순회하며 변경 여부 확인
          for pkg_dir in packages/*/; do
            pkg_name=$(basename $pkg_dir)
            changed=$(git diff --name-only $BASE_SHA ${{ github.sha }} -- $pkg_dir | head -1)
            if [ -n "$changed" ]; then
              CHANGED_PACKAGES=$(echo $CHANGED_PACKAGES | jq --arg p "$pkg_name" '. + [$p]')
            fi
          done

          echo "Changed packages: $CHANGED_PACKAGES"

          if [ "$CHANGED_PACKAGES" = "[]" ]; then
            echo "has-changes=false" >> $GITHUB_OUTPUT
          else
            echo "has-changes=true" >> $GITHUB_OUTPUT
          fi

          echo "matrix={\"package\":$CHANGED_PACKAGES}" >> $GITHUB_OUTPUT

💡 : fetch-depth: 0 설정이 없으면 git diff가 제대로 안 됩니다. 처음에 이거 빠뜨려서 삽질 좀 했어요. 얕은 클론(shallow clone)에서는 비교 기준이 되는 커밋을 못 찾거든요.

3단계: 매트릭스 빌드 잡 구성

이제 감지된 패키지 목록으로 매트릭스를 구성합니다. needs 키워드로 앞 단계에 의존하게 만들고, if 조건으로 변경 사항이 없을 때는 스킵하게 하면 됩니다.

  # 2. 변경된 패키지만 병렬로 빌드 & 테스트
  build-and-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.has-changes == 'true'
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
      fail-fast: false  # 하나 실패해도 나머지는 계속 실행
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 8

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # 캐시 설정 — 빌드 시간 단축에 핵심!
      - name: Cache build artifacts
        uses: actions/cache@v4
        with:
          path: packages/${{ matrix.package }}/.next
          key: ${{ runner.os }}-${{ matrix.package }}-${{ hashFiles('packages/${{ matrix.package }}/package.json') }}
          restore-keys: |
            ${{ runner.os }}-${{ matrix.package }}-

      - name: Build package
        run: pnpm --filter ${{ matrix.package }} build

      - name: Run tests
        run: pnpm --filter ${{ matrix.package }} test

      - name: Upload test results
        if: always()  # 테스트 실패해도 결과는 업로드
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ matrix.package }}
          path: packages/${{ matrix.package }}/test-results/

▲ 매트릭스 빌드가 실행될 때 GitHub Actions 화면 — 변경된 패키지들이 병렬로 동시에 빌드되는 것을 확인할 수 있음

4단계: 의존성 있는 패키지 처리

근데 여기서 문제가 하나 생깁니다. shared-lib가 변경되면 이걸 쓰는 frontendapi-server도 다시 빌드해야 하거든요. 이 의존성 체인을 처리하는 게 모노레포 CI/CD의 진짜 어려운 부분입니다.

#!/bin/bash
# scripts/detect-affected.sh
# 변경된 패키지 + 그에 의존하는 패키지까지 모두 감지

CHANGED_DIRECT=$1  # 직접 변경된 패키지 (JSON 배열 문자열)
AFFECTED_PACKAGES=$CHANGED_DIRECT

# 각 패키지의 package.json을 읽어서 의존성 체인 추적
for pkg_dir in packages/*/; do
  pkg_name=$(basename $pkg_dir)
  pkg_json="$pkg_dir/package.json"

  if [ -f "$pkg_json" ]; then
    # 이 패키지가 변경된 패키지에 의존하는지 확인
    for changed in $(echo $CHANGED_DIRECT | jq -r '.[]'); do
      deps=$(cat $pkg_json | jq -r '.dependencies // {} | keys[]' 2>/dev/null)
      if echo "$deps" | grep -q "$changed"; then
        # 의존성이 있으면 affected 목록에 추가
        AFFECTED_PACKAGES=$(echo $AFFECTED_PACKAGES | jq --arg p "$pkg_name" '. + [$p] | unique')
      fi
    done
  fi
done

echo $AFFECTED_PACKAGES

이 스크립트를 detect-changes 잡에서 호출하면 됩니다. 물론 Nx나 Turborepo 같은 모노레포 전용 툴을 쓰면 이런 의존성 그래프를 자동으로 처리해 주기도 하죠. 규모가 커지면 Turborepo로 마이그레이션하는 걸 고려하고 있어요.

⚠️ 실제로 겪은 GitHub Actions 모노레포 트러블슈팅

문제 1: BASE_SHA가 비어있는 경우

첫 번째 커밋이거나 새 브랜치를 push할 때 github.event.before0000000000000000000000000000000000000000(제로 SHA)로 오는 경우가 있습니다. 이때 git diff가 에러를 뱉거든요.

      - name: Detect changed packages
        id: set-matrix
        run: |
          # 제로 SHA 처리
          BASE_SHA=${{ github.event.before }}
          ZERO_SHA="0000000000000000000000000000000000000000"

          if [ "$BASE_SHA" = "$ZERO_SHA" ] || [ -z "$BASE_SHA" ]; then
            # 첫 커밋이면 모든 패키지를 빌드 대상으로
            echo "First push or new branch — building all packages"
            ALL_PACKAGES=$(ls packages/ | jq -R -s -c 'split("\\n") | map(select(length > 0))')
            echo "matrix={\"package\":$ALL_PACKAGES}" >> $GITHUB_OUTPUT
            echo "has-changes=true" >> $GITHUB_OUTPUT
          else
            # 기존 로직 실행
            # ... (이전 코드)
            :
          fi

문제 2: 매트릭스가 빈 배열일 때 워크플로우 에러

변경된 패키지가 없어서 매트릭스가 빈 배열 []이 되면 GitHub Actions가 에러를 냅니다. has-changes 출력값으로 if 조건을 걸어두는 게 중요한 이유가 여기 있어요. 또한 최종 상태 체크 잡을 별도로 두는 것도 좋은 패턴입니다.

  # 모든 빌드 완료 후 최종 상태 확인 잡
  ci-success:
    needs: [detect-changes, build-and-test]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Check all jobs status
        run: |
          if [ "${{ needs.detect-changes.result }}" != "success" ]; then
            echo "detect-changes job failed"
            exit 1
          fi

          # build-and-test가 스킵됐거나 성공한 경우 모두 OK
          if [ "${{ needs.build-and-test.result }}" = "failure" ]; then
            echo "Some builds failed!"
            exit 1
          fi

          echo "All checks passed! 🎉"

문제 3: pnpm 워크스페이스에서 filter가 안 먹힐 때

패키지 이름이 package.jsonname 필드와 달라서 --filter가 안 먹히는 경우가 있었습니다. 디렉토리명으로 필터하려면 --filter ./packages/패키지명 형식을 써야 합니다.

      - name: Build package
        run: |
          # 디렉토리 경로로 필터 (이름 불일치 문제 방지)
          pnpm --filter ./packages/${{ matrix.package }} build

전체 GitHub Actions 워크플로우 완성본

지금까지 설명한 내용을 하나로 합친 완성본입니다. 실제로 제가 운영 중인 설정을 기반으로 정리했어요.

# .github/workflows/ci.yml
name: Monorepo CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true  # 같은 브랜치 중복 실행 방지

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
      has-changes: ${{ steps.set-matrix.outputs.has-changes }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect changed packages
        id: set-matrix
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE_SHA=${{ github.event.pull_request.base.sha }}
          else
            BASE_SHA=${{ github.event.before }}
          fi

          ZERO_SHA="0000000000000000000000000000000000000000"
          CHANGED_PACKAGES='[]'

          if [ "$BASE_SHA" = "$ZERO_SHA" ] || [ -z "$BASE_SHA" ]; then
            CHANGED_PACKAGES=$(ls packages/ | jq -R -s -c 'split("\\n") | map(select(length > 0))')
          else
            for pkg_dir in packages/*/; do
              pkg_name=$(basename $pkg_dir)
              changed=$(git diff --name-only $BASE_SHA ${{ github.sha }} -- $pkg_dir | head -1)
              if [ -n "$changed" ]; then
                CHANGED_PACKAGES=$(echo $CHANGED_PACKAGES | jq --arg p "$pkg_name" '. + [$p]')
              fi
            done
          fi

          echo "Affected packages: $CHANGED_PACKAGES"

          if [ "$CHANGED_PACKAGES" = "[]" ]; then
            echo "has-changes=false" >> $GITHUB_OUTPUT
          else
            echo "has-changes=true" >> $GITHUB_OUTPUT
          fi
          echo "matrix={\"package\":$CHANGED_PACKAGES}" >> $GITHUB_OUTPUT

  build-and-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.has-changes == 'true'
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
      fail-fast: false
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v3
        with:
          version: 8

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Cache build
        uses: actions/cache@v4
        with:
          path: |
            packages/${{ matrix.package }}/.next
            packages/${{ matrix.package }}/dist
          key: ${{ runner.os }}-build-${{ matrix.package }}-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-build-${{ matrix.package }}-

      - name: Build
        run: pnpm --filter ./packages/${{ matrix.package }} build

      - name: Test
        run: pnpm --filter ./packages/${{ matrix.package }} test

      - name: Upload artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: results-${{ matrix.package }}
          path: packages/${{ matrix.package }}/test-results/
          retention-days: 7

  ci-success:
    needs: [detect-changes, build-and-test]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Final status check
        run: |
          if [ "${{ needs.build-and-test.result }}" = "failure" ]; then
            echo "Build failed!"
            exit 1
          fi
          echo "CI passed! 🎉"

▲ 완성된 CI/CD 파이프라인 실행 결과 — detect-changes → build-and-test (병렬) → ci-success 순서로 실행되는 것을 확인

✅ 결과: 빌드 시간이 얼마나 줄었을까요?

이 방식을 적용하고 나서 체감이 꽤 컸습니다. 물론 레포마다 상황이 다르겠지만, 제 경우엔 이랬어요.

  • 📦 전체 패키지 수: 6개
  • ⏱️ 기존 전체 빌드 시간: 약 18~22분
  • ⚡ 매트릭스 빌드 적용 후 (1~2개 패키지 변경 시): 4~7분
  • 🎉 개발자들 PR 머지 속도 체감상 확 빨라짐

특히 공통 라이브러리 변경이 없는 일반적인 기능 개발 PR에서 효과가 컸습니다. 프론트엔드만 건드렸을 때 백엔드 빌드까지 기다릴 필요가 없으니까요. 당연한 말 같지만, 이걸 자동화로 구현하는 게 핵심이죠.

추가로 concurrency 설정으로 같은 브랜치에서 중복 실행을 방지한 것도 빌드 큐 낭비를 줄이는 데 도움이 됐습니다. GitHub Actions 무료 플랜은 동시 실행 잡 수에 제한이 있으니까요.

💡 CI/CD 최적화 추가 팁

캐시 전략으로 빌드 시간 단축

  • actions/cachekeypackage.json 해시 기반으로 설정하면 의존성이 바뀔 때만 캐시가 무효화됩니다
  • 빌드 결과물(dist, .next)도 캐시하면 재빌드 시 시간 절약 가능
  • 캐시 hit rate는 Actions 실행 로그에서 확인 가능 — 이걸 모니터링하는 습관을 들이세요

Turborepo와 함께 쓰기

패키지가 10개 이상으로 늘어나면 Turborepo(터보레포) 같은 모노레포 빌드 시스템을 도입하는 게 좋습니다. 의존성 그래프 분석, 원격 캐시, 병렬 실행 최적화를 자동으로 해주거든요. 다음 글에서 Turborepo와 GitHub Actions를 함께 쓰는 방법을 다룰 예정입니다.

Branch Protection Rules 연동

앞서 만든 ci-success 잡을 GitHub 브랜치 보호 규칙(Branch Protection Rules)의 필수 상태 체크로 등록해 두세요. 매트릭스 빌드가 스킵된 경우에도 ci-success는 항상 실행되므로, PR 머지 조건으로 쓰기에 딱입니다.

▲ GitHub Actions 모노레포 CI/CD 최적화 전략 요약 — 변경 감지, 매트릭스 빌드, 캐시 전략의 세 가지 축

자주 묻는 질문

Q. Nx를 쓰면 이런 GitHub Actions 설정이 필요 없나요?

Nx(엔엑스)를 쓰면 nx affected 명령어로 변경된 패키지 감지를 자동화할 수 있습니다. 다만 Nx 자체 학습 곡선이 있고, 기존 프로젝트에 도입하는 비용이 있어요. 팀 규모와 프로젝트 복잡도에 따라 선택하시면 됩니다. 오늘 설명한 방식은 외부 툴 없이 GitHub Actions만으로 구현할 수 있다는 게 장점이에요.

Q. PR이 아닌 직접 push에서도 잘 되나요?

네, 됩니다. 다만 github.event.before가 제로 SHA로 오는 케이스(새 브랜치 최초 push)를 반드시 처리해야 합니다. 위 코드에 그 처리가 포함되어 있으니 참고하세요.

Q. 모노레포에서 Docker 이미지 빌드도 같은 방식으로?

동일한 패턴으로 적용 가능합니다. 매트릭스의 각 항목에 대해 docker build를 실행하고 레지스트리에 push하면 되죠. 이 내용은 이전 글에서 다룬 Docker 멀티 스테이지 빌드 최적화와 함께 보시면 더 이해가 쉬울 거예요.

마무리: GitHub Actions 모노레포 CI/CD, 처음엔 복잡해 보여도

처음에 이 구조를 짤 때 "이게 맞나?" 싶은 순간이 여러 번 있었습니다. 특히 동적 매트릭스 생성 부분에서 fromJson이 제대로 안 먹혀서 한참 헤맸던 기억이 나네요. 근데 한번 제대로 잡아두면 이후에는 거의 손댈 일이 없어요.

핵심을 정리하면 이렇습니다.

  1. 변경 감지: git diff로 변경된 패키지만 추려냄
  2. 동적 매트릭스: 감지 결과를 JSON으로 출력해서 매트릭스에 주입
  3. 병렬 빌드: strategy.matrix로 변경된 패키지들을 동시에 빌드
  4. 캐시 활용: actions/cache로 반복 빌드 시간 단축
  5. 안전장치: ci-success 잡으로 브랜치 보호 규칙과 연동

혹시 레포 규모가 더 크거나, Turborepo/Nx 같은 툴과 함께 쓰는 방법이 궁금하신 분들은 댓글로 남겨주세요. 다음 글에서 다뤄볼게요. 오늘도 긴 글 읽어주셔서 감사합니다! 🎉