13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] CI/CD 최적화

  • [Cloud] GitLab CI/CD, 월 300달러 린트 비용 낭비 막는 법

    [Cloud] GitLab CI/CD, 월 300달러 린트 비용 낭비 막는 법

    안녕하세요, 13년차 인프라 엔지니어 ’13년차의 서버실’입니다. 오늘은 많은 분들이 공감하실 만한, 하지만 간과하기 쉬운 CI/CD 비용 문제에 대해 이야기해보려고 해요. 특히 GitLab CI/CD 비용 최적화와 관련해서 제가 직접 겪었던 뼈아픈 경험과 그 해결 과정을 공유합니다. 사실 저도 처음엔 CI/CD를 구축하고 나면 다 끝난 줄 알았거든요. 그런데 시간이 지나면서 예상치 못한 곳에서 비용이 새고 있더라고요. 바로 불필요하게 돌아가는 린트(Lint) 비용이었죠. 한 달에 무려 300달러나 되는 돈이 린트 때문에 나가고 있었다니, 처음엔 믿을 수가 없었어요. 오늘은 이 낭비를 어떻게 막았는지, 그 노하우를 풀어볼게요!

    GitLab CI/CD 린트 비용 낭비 및 최적화 전후 비교 다이어그램

    GitLab CI/CD 파이프라인에서 불필요한 린트(Lint) 작업으로 인해 비용이 낭비되고, 이를 최적화하여 절감하는 과정을 시각적으로 보여주는 다이어그램.

    CI/CD 린트(Lint)가 왜 비용 낭비의 주범이 될까요?

    CI/CD(Continuous Integration/Continuous Deployment, 지속적 통합/지속적 배포)는 정말 신기한 도구거든요. 코드를 푸시할 때마다 자동으로 테스트하고 배포하니까요. 이 과정에서 린트(Lint, 코드 스타일 및 잠재적 오류 검사)는 코드 품질을 유지하는 데 정말 중요한 역할을 합니다. 문제가 크기 전에 미리 잡아주니 정말 고맙죠.

    그런데 여기서 문제가 발생합니다. 대부분의 CI/CD 설정은 코드가 변경될 때마다, 즉 <code>git push 할 때마다 모든 파이프라인 작업을 실행하도록 되어 있더라고요. 작은 오타 수정이나 README 파일 업데이트 같은 사소한 변경에도 린트 작업을 포함한 모든 CI 작업이 돌아가는 거죠. 린트 작업 자체는 비교적 가볍다고 생각하기 쉽지만, 이게 수십, 수백 번 반복되면 이야기가 달라집니다. 특히 GitLab.com 같은 SaaS(Software as a Service) 환경에서는 사용량에 따라 CI/CD Runner minutes(러너 사용 시간) 비용이 발생하거든요. 제가 운영하는 홈랩에서도 처음엔 이런 비용을 크게 신경 쓰지 않았는데, 프로젝트가 많아지고 커밋이 잦아지면서 슬금슬금 비용이 올라가는 걸 보고 깜짝 놀랐습니다.

    비용 낭비의 핵심 원인:

    • 잦은 커밋: 개발 과정에서 수많은 중간 커밋이 발생합니다.
    • 불필요한 실행: 코드를 변경하지 않는 파일(예: 주석, 문서)의 변경에도 린트가 실행됩니다.
    • 모든 브랜치 실행: 개발 브랜치, 피처 브랜치 등 모든 브랜치에 푸시될 때마다 린트가 실행됩니다.

    이런 상황을 제가 직접 겪어보니, “아, 이건 뭔가 바꿔야겠다!” 싶더라고요. 그래서 CI/CD 최적화 방안을 진지하게 고민하기 시작했습니다.

    GitLab CI/CD 비용 최적화를 위한 핵심 전략: 조건부 실행 (Conditional Execution)

    GitLab CI/CD에서 비용을 절감하는 가장 효과적인 방법 중 하나는 조건부 실행(Conditional Execution)입니다. 말 그대로 특정 조건이 충족될 때만 CI/CD 작업을 실행하도록 하는 거죠. 린트 작업의 경우, 모든 커밋에 대해 실행하기보다는 특정 상황에서만 실행하도록 제한할 수 있습니다.

    제가 주로 활용한 전략은 다음과 같습니다:

    1. 머지 리퀘스트(Merge Request) 시에만 실행: 피처 브랜치에서 개발할 때는 린트 작업을 건너뛰고, 메인 브랜치로 머지하기 위한 MR이 생성될 때만 린트 검사를 실행합니다. 이렇게 하면 불필요한 중간 커밋에 대한 린트 실행을 대폭 줄일 수 있거든요.
    2. 코드 변경이 있는 파일에 대해서만 실행: 정말 필요한 경우에만 린트 작업을 실행하도록 조건을 더 세분화할 수 있습니다. 예를 들어, 특정 코드 파일이 변경되었을 때만 린트 작업을 실행하는 방식이죠.

    GitLab CI/CD는 rules 키워드를 통해 이런 조건부 실행을 강력하게 지원합니다. 처음엔 only/except 문법을 사용하기도 했는데, rules가 훨씬 유연하고 강력하더라고요. rules를 사용하면 여러 조건을 조합해서 원하는 시나리오를 만들 수 있습니다.

    실전 구현: `.gitlab-ci.yml`에 조건부 린트 잡(Job) 추가하기

    자, 그럼 이제 제가 실제로 어떻게 GitLab CI 비용 절감을 이뤄냈는지 `.gitlab-ci.yml` 설정과 함께 설명해 드릴게요. 핵심은 린트 작업을 위한 잡(Job)에 rules를 적용하는 겁니다.

    1. 머지 리퀘스트(MR) 시에만 린트 실행하기

    가장 기본적이고 효과적인 방법입니다. 개발 브랜치에 푸시할 때는 린트를 건너뛰고, MR이 생성되거나 업데이트될 때만 린트를 실행합니다. 이렇게 하면 개발 중 발생하는 수많은 중간 커밋의 CI 비용을 아낄 수 있거든요.

    
    lint_job:
      stage: lint
      image: python:3.9-slim # 린트 도구에 맞는 이미지 사용
      script:
        - pip install flake8 # 예시: Python flake8 린터 설치
        - flake8 .
      rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # MR이 생성되거나 업데이트될 때만 실행
          when: on_success
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' # 기본 브랜치(master/main)에 푸시될 때 실행 (최종 검증)
          when: on_success
    

    위 코드에서 $CI_PIPELINE_SOURCE == "merge_request_event"는 머지 리퀘스트 파이프라인에서만 이 잡(Job)을 실행하라는 의미입니다. 그리고 $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH는 메인 브랜치(보통 main 또는 master)에 직접 푸시될 때도 린트가 실행되도록 해서 최종적인 코드 품질을 보장합니다. 이렇게 설정하니 월 300달러나 나가던 린트 비용이 확 줄어들더라고요! 🎉

    GitLab CI/CD 린트 조건부 실행을 위한 .gitlab-ci.yml rules 설정

    GitLab CI/CD 설정 파일(.gitlab-ci.yml)에서 ‘rules’ 키워드를 사용하여 린트(Lint) 작업을 조건부로 실행하도록 구성된 YAML 코드 스니펫.

    2. 특정 파일 변경 시에만 린트 실행하기 (고급)

    좀 더 세밀한 제어가 필요하다면 rules:changes를 활용할 수 있습니다. 예를 들어, Python 코드 파일(.py)이 변경되었을 때만 Python 린트를 실행하고, JavaScript 파일(.js)이 변경되었을 때만 JavaScript 린트를 실행하는 식이죠.

    
    python_lint_job:
      stage: lint
      image: python:3.9-slim
      script:
        - pip install flake8
        - flake8 .
      rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
          changes:
            - "**/*.py" # .py 파일 변경 시에만 실행
          when: on_success
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
          changes:
            - "**/*.py"
          when: on_success
    
    javascript_lint_job:
      stage: lint
      image: node:16-slim # Node.js 린터에 맞는 이미지 사용
      script:
        - npm install eslint
        - npx eslint .
      rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
          changes:
            - "**/*.js" # .js 파일 변경 시에만 실행
          when: on_success
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
          changes:
            - "**/*.js"
          when: on_success
    

    이 방식은 파이프라인을 더욱 효율적으로 만들지만, rules:changes는 GitLab Runner가 변경된 파일을 확인하는 과정에서 약간의 오버헤드가 발생할 수 있거든요. 하지만 특정 언어의 린트가 매우 무겁거나, 모노레포(Monorepo)처럼 여러 프로젝트가 한 레포지토리에 있을 때는 정말 유용하게 활용할 수 있습니다. 제가 홈랩에서 여러 마이크로서비스를 한 레포에 넣어두고 관리할 때 이 방법을 써봤는데, 확실히 비용 절감 효과가 좋았어요.

    ⚠️ 주의사항 및 트러블슈팅: 꼼꼼함이 핵심!

    GitLab CI/CD 최적화는 비용 절감이라는 큰 장점이 있지만, 몇 가지 주의할 점도 있습니다. 제가 삽질 좀 하면서 겪었던 시행착오들을 공유해 드릴게요.

    1. 조건 설정의 오작동: rules 문법이 생각보다 까다로울 수 있거든요. 조건을 너무 복잡하게 설정하면 예상치 못하게 잡(Job)이 실행되지 않거나, 반대로 불필요하게 실행될 수 있어요. 항상 테스트를 통해 의도한 대로 동작하는지 확인해야 합니다. rules:if와 rules:changes를 함께 사용할 때는 특히 주의해야 합니다.
    2. 개발자의 실수: 머지 리퀘스트 파이프라인에서만 린트를 실행하도록 했는데, 개발자가 실수로 로컬에서 린트를 돌리지 않고 MR을 올리는 경우가 생길 수 있거든요. 이렇게 되면 MR 파이프라인에서 린트 에러가 발생하고, 다시 수정해서 커밋해야 하는 번거로움이 생기죠. 이를 방지하기 위해 프리-커밋 훅(Pre-commit Hook) 같은 도구를 도입하여 로컬에서도 린트 검사를 강제하는 것을 고려해볼 수 있습니다. 저도 이 문제 때문에 초기에는 좀 곤란했는데, husky나 lint-staged 같은 도구를 도입해서 해결했어요.
    3. 초기 러너 비용: rules:changes를 사용할 때, GitLab Runner는 변경된 파일 목록을 가져오기 위해 Git 히스토리를 확인해야 합니다. 이 과정에서 필요한 최소한의 Git 클론(clone) 작업이 발생하므로, 아주 미미하지만 초기 러너 사용 시간이 발생할 수 있거든요. 대부분의 경우 무시할 만한 수준이지만, 극단적인 최적화를 목표로 한다면 고려할 만한 요소입니다.

    가장 중요한 건, 변경 사항을 적용한 후에 GitLab CI/CD 파이프라인을 여러 시나리오(새 브랜치 푸시, MR 생성, 메인 브랜치 푸시 등)로 테스트해보고, CI/CD Analytics(분석) 탭에서 러너 사용 시간을 주기적으로 확인하는 겁니다. 저도 처음엔 설정이 제대로 됐는지 확신이 없어서 파이프라인 로그를 꼼꼼히 뜯어봤거든요. 💡

    결과 검증: 실제로 비용이 줄었는지 확인하기

    그럼 이제 가장 중요한 부분이죠. 이렇게 설정하고 나면 정말 비용 절감이 되는지 어떻게 확인할까요? GitLab은 친절하게도 CI/CD 사용량에 대한 통계를 제공하더라고요. 저는 주로 Settings → CI/CD → Usage Quotas 섹션과 Analytics → CI/CD Analytics를 활용했습니다.

    제 경험으로는, 조건부 린트 잡을 적용하기 전과 후의 Runner minutes(러너 사용 시간) 그래프가 확연히 달라지는 것을 확인할 수 있었습니다. 특히 월말에 청구되는 금액을 비교해보니, 월 300달러 가까이 나가던 CI/CD 비용이 100달러 미만으로 줄어드는 것을 보고 정말 뿌듯했습니다. 🎉 이 정도면 꽤 괜찮은 성과 아닌가요?

    GitLab CI/CD 러너 사용 시간 및 비용 최적화 효과 차트

    GitLab CI/CD Usage Quotas 또는 CI/CD Analytics 대시보드에서 러너 사용 시간(Runner minutes)이 최적화 전후로 극적으로 감소한 것을 보여주는 차트 또는 그래프.

    ✅ 핵심 검증 포인트:

    • Runner minutes 감소: 월별, 주별 러너 사용 시간이 줄어들었는지 확인.
    • 파이프라인 실행 횟수 감소: 불필요한 린트 잡의 실행 횟수가 줄었는지 확인.
    • 청구서 확인: 실제 청구되는 금액이 줄었는지 최종적으로 확인.

    마무리: 작은 변화가 큰 절약을 만듭니다

    오늘은 GitLab CI/CD 비용 최적화, 특히 불필요한 린트 비용을 절감하는 방법에 대해 제 경험을 바탕으로 이야기해 봤습니다. 사실 린트 작업 하나에 월 300달러라는 비용이 나가는 건 좀 과하다 싶었거든요. 하지만 조건부 실행(Conditional Execution)이라는 작은 변화를 통해 예상보다 훨씬 큰 비용 절감 효과를 볼 수 있었습니다.

    이러한 비용 최적화는 린트 작업뿐만 아니라, 빌드(Build), 테스트(Test) 등 다른 CI/CD 잡에도 확장해서 적용할 수 있습니다. 예를 들어, 프론트엔드 코드만 변경되었을 때는 백엔드 테스트를 건너뛰는 식으로요. 항상 “이 잡이 지금 꼭 실행되어야 하는가?”라는 질문을 던져보면 최적화 포인트를 찾기 쉬울 겁니다.

    결국, CI/CD 최적화는 단순히 비용을 줄이는 것을 넘어, 파이프라인의 효율성을 높이고 개발자들이 더 빠르고 정확하게 피드백을 받을 수 있도록 돕는 중요한 과정입니다. 저도 이 과정을 통해 CI/CD에 대한 이해를 한 단계 더 높일 수 있었어요. 여러분도 이 글을 통해 비용 절감과 효율적인 CI/CD 운영에 도움이 되셨기를 바랍니다!

    GitLab CI/CD 비용 절감 전략 요약 인포그래픽

    GitLab CI/CD 비용 절감 전략을 요약하고, 지속적인 최적화의 중요성을 강조하는 인포그래픽 또는 비교표.

    다음 글에서는 GitHub Actions에서도 유사한 비용 최적화 전략을 어떻게 적용할 수 있는지 알아보는 시간을 가져볼게요. 궁금한 점이 있다면 언제든지 댓글로 남겨주세요!

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

    모노레포에서 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가 변경되면 이걸 쓰는 frontend나 api-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.before가 0000000000000000000000000000000000000000(제로 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.json의 name 필드와 달라서 --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/cache의 key를 package.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 같은 툴과 함께 쓰는 방법이 궁금하신 분들은 댓글로 남겨주세요. 다음 글에서 다뤄볼게요. 오늘도 긴 글 읽어주셔서 감사합니다! 🎉