Published on

GitHub Actions 재사용 워크플로우로 모노레포 빌드 50% 단축

Authors

모노레포(monorepo)는 코드 공유와 일관된 개발 경험을 주지만, CI 관점에서는 쉽게 “모든 걸 매번 다 돌리는” 구조로 굳어지면서 빌드 시간이 눈덩이처럼 불어납니다. 특히 GitHub Actions에서 서비스별 워크플로우를 복붙해 운영하다 보면, 캐시 키가 제각각이 되고, 동일한 준비 작업(체크아웃, 런타임 세팅, 의존성 설치, 테스트 환경 구성)을 여러 잡이 반복하면서 러너 시간을 그대로 태웁니다.

이 글에서는 GitHub Actions의 재사용 워크플로우(reusable workflows) 를 중심으로 모노레포 CI를 리팩터링해, 실제로 흔히 가능한 수준의 빌드 시간 50% 단축을 노리는 방법을 정리합니다. 핵심은 세 가지입니다.

  1. 공통 단계를 재사용 워크플로우로 모아 중복 제거
  2. 변경 감지로 “영향받는 패키지/서비스만” 실행해 불필요한 잡 제거
  3. 캐시/아티팩트 흐름을 표준화해 캐시 적중률 극대화

모노레포 CI가 느려지는 대표 원인

1) 복붙 워크플로우로 인한 준비 단계 중복

서비스 A, B, C가 각각 checkout 하고, 각각 Node/Java를 세팅하고, 각각 의존성을 설치합니다. 러너는 매번 새 VM이기 때문에 이 중복은 그대로 시간 비용입니다. 공통 단계를 하나로 모으고 입력값만 바꾸는 구조로 바꾸면, 유지보수성뿐 아니라 성능 튜닝도 쉬워집니다.

2) “전체 매트릭스”가 항상 실행됨

변경이 apps/web에만 있었는데도 apps/api, packages/shared, infra까지 전부 테스트/빌드하면 낭비가 큽니다. 변경 감지 기반으로 실행 대상을 줄이는 것만으로도 체감 성능이 크게 오릅니다.

3) 캐시 키가 제각각이라 적중률이 낮음

같은 pnpm을 쓰는데도 워크플로우마다 캐시 키가 다르거나, lockfile 경로가 달라 캐시가 깨지면 설치 시간이 매번 발생합니다. “캐시 정책을 표준화”하는 것이 중요합니다.

재사용 워크플로우란 무엇이고, 왜 효과가 큰가

GitHub Actions는 workflow_call을 통해 다른 워크플로우에서 호출 가능한 워크플로우를 만들 수 있습니다. 이를 통해:

  • 공통 CI 로직을 한 파일로 표준화
  • 입력값(inputs)으로 서비스 경로, Node 버전, 테스트 커맨드 등을 주입
  • 비밀값(secrets) 전달을 일관되게 처리
  • 캐시 전략을 한 곳에서 관리

즉, 모노레포에서 “서비스별 파이프라인”을 만들되, 실제 구현은 공통 워크플로우가 담당하도록 분리할 수 있습니다.

목표 아키텍처: Caller 워크플로우와 Reusable 워크플로우 분리

구조는 보통 아래처럼 갑니다.

  • /.github/workflows/ci.yml : PR/푸시 트리거, 변경 감지, 서비스별 호출
  • /.github/workflows/_reusable-node-ci.yml : Node 기반 빌드/테스트 공통 로직
  • /.github/workflows/_reusable-docker-build.yml : Docker 빌드/푸시 공통 로직(선택)

이렇게 분리하면 “변경 감지”는 상위 워크플로우가 책임지고, “각 서비스의 빌드/테스트 표준 절차”는 재사용 워크플로우가 책임집니다.

변경 감지로 실행 대상을 줄이기

빌드 시간을 줄이는 가장 강력한 레버는 애초에 실행할 잡 수를 줄이는 것입니다. 변경 감지는 dorny/paths-filter가 널리 쓰이며, 결과를 기반으로 조건 실행을 걸 수 있습니다.

아래 예시는 apps/web, apps/api, packages/shared 변경 여부를 감지합니다.

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

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.filter.outputs.web }}
      api: ${{ steps.filter.outputs.api }}
      shared: ${{ steps.filter.outputs.shared }}
    steps:
      - uses: actions/checkout@v4

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

여기서 포인트는 서비스가 공통 패키지(packages/shared)에 의존하면, shared 변경이 web/api를 함께 트리거하도록 필터에 포함시키는 것입니다.

재사용 워크플로우 만들기: Node CI 표준화

이제 실제 빌드/테스트는 재사용 워크플로우로 옮깁니다.

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

on:
  workflow_call:
    inputs:
      working_directory:
        required: true
        type: string
      node_version:
        required: false
        type: string
        default: '20'
      test_command:
        required: false
        type: string
        default: 'pnpm test'
      build_command:
        required: false
        type: string
        default: 'pnpm build'

jobs:
  node-ci:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working_directory }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node_version }}
          cache: 'pnpm'
          cache-dependency-path: |
            pnpm-lock.yaml

      - name: Enable corepack
        run: |
          corepack enable

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

      - name: Test
        run: |
          ${{ inputs.test_command }}

      - name: Build
        run: |
          ${{ inputs.build_command }}

이 구성의 성능 포인트

  • actions/setup-nodecache: 'pnpm'을 사용해 설치 시간을 크게 줄입니다.
  • cache-dependency-path를 lockfile에 고정해 캐시 키를 일관화합니다.
  • defaults.run.working-directory로 서비스별 경로만 바꿔 호출할 수 있게 합니다.

모노레포에서 “서비스마다 다른 CI 파일”을 유지할 필요가 크게 줄어듭니다.

Caller 워크플로우에서 조건부로 호출하기

이제 ci.yml에서 변경된 서비스만 재사용 워크플로우를 호출합니다.

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

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.filter.outputs.web }}
      api: ${{ steps.filter.outputs.api }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/shared/**'
            api:
              - 'apps/api/**'
              - 'packages/shared/**'

  web:
    needs: [ changes ]
    if: ${{ needs.changes.outputs.web == 'true' }}
    uses: ./.github/workflows/_reusable-node-ci.yml
    with:
      working_directory: 'apps/web'
      node_version: '20'
      test_command: 'pnpm test'
      build_command: 'pnpm build'

  api:
    needs: [ changes ]
    if: ${{ needs.changes.outputs.api == 'true' }}
    uses: ./.github/workflows/_reusable-node-ci.yml
    with:
      working_directory: 'apps/api'
      node_version: '20'
      test_command: 'pnpm test'
      build_command: 'pnpm build'

이렇게 하면 PR에서 web만 바뀌었을 때 api 잡은 아예 생성되지 않습니다. “잡을 줄이는 것”이 곧 빌드 시간 단축으로 직결됩니다.

캐시를 더 공격적으로: 빌드 산출물까지 캐시하기

테스트/빌드가 무거운 경우(예: Next.js, Turbo, Nx, Gradle 등)에는 의존성 캐시만으로 부족합니다. 이때는 도구별 캐시 디렉터리를 추가로 캐시합니다.

예를 들어 Next.js는 .next/cache가 대표적입니다.

- name: Cache Next.js
  uses: actions/cache@v4
  with:
    path: |
      ${{ inputs.working_directory }}/.next/cache
    key: next-cache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
    restore-keys: |
      next-cache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-

restore-keys를 두면 커밋이 바뀌어도 lockfile이 같을 때 캐시를 재사용할 확률이 올라갑니다.

병렬화는 “무조건”이 아니라 “통제”가 핵심

모노레포에서 병렬화를 과하게 걸면 러너가 동시에 여러 개 떠서 빨라질 것 같지만, 실제로는 다음 병목이 생깁니다.

  • 외부 의존(패키지 레지스트리, 테스트 DB, S3, Docker registry) 호출이 동시에 증가
  • 레이트 리밋/스로틀링으로 재시도 증가
  • 결과적으로 “느리고 불안정한 CI”가 됨

특히 API 호출이 많은 워크플로우는 429 계열을 만나기 쉽습니다. 레이트 리밋을 다루는 관점은 아래 글도 함께 참고해볼 만합니다.

GitHub Actions에서는 strategy.max-parallel로 병렬도를 제한하거나, 환경별로 concurrency를 걸어 “같은 브랜치에서 이전 실행을 취소”하는 식으로 안정성을 확보할 수 있습니다.

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

이 설정은 PR에 커밋을 여러 번 푸시할 때 이전 CI를 취소해 러너 낭비를 줄입니다.

Docker 빌드도 재사용 워크플로우로 분리하기

서비스별로 Docker 이미지를 만들고 푸시한다면, 이것도 재사용 워크플로우로 빼면 효과가 큽니다. 아래는 최소 예시입니다.

# .github/workflows/_reusable-docker-build.yml
name: reusable-docker-build

on:
  workflow_call:
    inputs:
      context:
        required: true
        type: string
      image_name:
        required: true
        type: string
      push:
        required: false
        type: boolean
        default: false
    secrets:
      registry_username:
        required: true
      registry_password:
        required: true

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - name: Login
        if: ${{ inputs.push }}
        run: |
          echo "${{ secrets.registry_password }}" | docker login -u "${{ secrets.registry_username }}" --password-stdin

      - name: Build
        run: |
          docker build -t "${{ inputs.image_name }}:${{ github.sha }}" "${{ inputs.context }}"

      - name: Push
        if: ${{ inputs.push }}
        run: |
          docker push "${{ inputs.image_name }}:${{ github.sha }}"

서비스별 호출은 아래처럼 간단해집니다.

docker-web:
  needs: [ changes, web ]
  if: ${{ needs.changes.outputs.web == 'true' }}
  uses: ./.github/workflows/_reusable-docker-build.yml
  with:
    context: 'apps/web'
    image_name: 'ghcr.io/my-org/web'
    push: false
  secrets:
    registry_username: ${{ github.actor }}
    registry_password: ${{ secrets.GITHUB_TOKEN }}

“50% 단축”을 현실로 만드는 체크리스트

아래 항목을 순서대로 적용하면, 많은 팀에서 50% 내외의 단축(또는 최소한 PR 평균 대기시간 절반 수준)을 충분히 노릴 수 있습니다.

1) 변경 감지로 잡 수를 먼저 줄이기

  • 서비스별 경로 필터링
  • 공통 패키지 변경 시 영향 서비스까지 포함
  • 문서/설정만 바뀐 경우 CI를 더 가볍게 하거나 스킵

2) 재사용 워크플로우로 공통 단계 통합

  • 런타임 세팅, 의존성 설치, 테스트/빌드 표준화
  • 입력값으로 커맨드/경로만 바꾸기
  • 비밀값 전달 규칙 통일

3) 캐시 정책 표준화

  • lockfile 기반 캐시 키
  • 도구별 캐시 디렉터리 추가(예: Next.js, Turbo, Gradle)
  • restore-keys로 캐시 재사용률 높이기

4) 병렬도와 concurrency로 안정성 확보

  • 동시에 외부 리소스를 두드리는 잡 수 제한
  • 같은 브랜치 중복 실행 취소

운영 팁: 재사용 워크플로우 버전 관리 전략

재사용 워크플로우를 “레포 내부 로컬 경로”로 호출하면 빠르게 시작할 수 있지만, 조직 내 여러 레포에서 공유하려면 별도 레포로 분리해 태그로 버전 고정하는 방식이 좋습니다.

  • 로컬 호출: uses: ./.github/workflows/_reusable-node-ci.yml
  • 원격 호출(예시): uses: my-org/ci-templates/.github/workflows/node-ci.yml@v1

원격 호출은 템플릿 변경이 전체 레포에 즉시 전파되는 리스크가 있으니, 반드시 태그나 고정 커밋 SHA로 핀ning하는 습관이 중요합니다.

마무리

모노레포 CI 최적화는 “러너를 좋은 걸로 바꾼다”보다, 중복을 제거하고 실행 대상을 줄이는 구조적 개선이 훨씬 큰 효과를 냅니다. 재사용 워크플로우는 그 구조화를 가능하게 하는 가장 실용적인 도구입니다.

정리하면,

  • 변경 감지로 불필요한 잡을 없애고
  • 재사용 워크플로우로 공통 단계를 표준화하며
  • 캐시와 concurrency로 안정성과 속도를 함께 잡으면

모노레포 빌드 시간 50% 단축은 충분히 현실적인 목표가 됩니다.

추가로, CI/CD가 외부 시스템 호출(레지스트리, 클라우드 API 등)로 인해 간헐적으로 429/스로틀링을 만나고 있다면, 병렬도 제한과 재시도 정책을 함께 점검해보세요. 비슷한 “스로틀링을 구조적으로 다루는” 관점은 EKS IRSA는 되는데 STS 429 Throttling 해결도 참고가 됩니다.