Skip to content
Published on

GitHub Actions 고급 패턴: Reusable Workflows·Composite Actions·모노레포 CI/CD 최적화 전략

Authors
  • Name
    Twitter
GitHub Actions 고급 패턴

들어가며

조직의 리포지토리가 10개를 넘어가면 CI/CD 파이프라인의 중복이 눈에 띄기 시작한다. 빌드-테스트-배포 YAML이 리포지토리마다 복사-붙여넣기로 퍼져 있고, 하나의 보안 패치를 적용하려면 수십 개의 워크플로우를 일일이 수정해야 한다. Node.js 버전을 18에서 20으로 올리는 단순한 변경이 30개 리포지토리에 걸린 PR로 번지는 경험을 해본 적이 있을 것이다.

GitHub Actions는 이 문제를 해결하기 위해 두 가지 재사용 메커니즘을 제공한다. Reusable Workflows는 전체 워크플로우를 템플릿화하여 호출할 수 있게 해주고, Composite Actions는 여러 스텝을 하나의 액션으로 묶어 모듈화할 수 있게 해준다.

여기에 모노레포 환경까지 겹치면 복잡도가 급격히 올라간다. 변경된 패키지만 빌드하고, 의존성 그래프를 추적하며, 캐시를 패키지별로 격리해야 한다. 이 글에서는 이 세 가지 주제를 실전 코드와 함께 깊이 다룬다.

Reusable Workflows 심화

workflow_call 트리거의 구조

Reusable Workflow는 on: workflow_call 트리거를 정의하는 것으로 시작한다. 호출자(caller)는 uses 키워드로 이 워크플로우를 참조하며, inputs, secrets, outputs를 통해 데이터를 주고받는다.

2025년 11월 업데이트 이후 주요 제약 사항은 다음과 같다:

  • 중첩 호출 최대 10단계 까지 지원 (A가 B를 호출하고, B가 C를 호출하는 체인)
  • 하나의 워크플로우 파일에서 최대 50개 Reusable Workflow 호출 가능
  • 호출자와 피호출자는 같은 조직이거나, 피호출자 리포지토리가 퍼블릭이어야 함
  • env 컨텍스트는 피호출 워크플로우에 전달되지 않음

중앙 집중식 워크플로우 관리 패턴

대규모 조직에서는 .github 리포지토리나 별도의 platform-workflows 리포지토리에 Reusable Workflow를 모아두고, 모든 팀이 이를 참조하게 한다. 이 패턴의 핵심은 버전 태그 관리다.

org-platform/
  .github/
    workflows/
      build-node.yml        # Node.js 빌드 파이프라인
      build-python.yml      # Python 빌드 파이프라인
      deploy-k8s.yml        # Kubernetes 배포 파이프라인
      security-scan.yml     # 보안 스캔 공통 파이프라인

Reusable Workflow 정의 예시

아래는 Node.js 애플리케이션의 빌드-테스트-배포를 템플릿화한 Reusable Workflow다.

# org-platform/.github/workflows/build-node.yml
name: Reusable Node.js Build

on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.js 버전'
        required: false
        type: string
        default: '20'
      working-directory:
        description: '작업 디렉토리'
        required: false
        type: string
        default: '.'
      run-e2e:
        description: 'E2E 테스트 실행 여부'
        required: false
        type: boolean
        default: false
      artifact-name:
        description: '빌드 아티팩트 이름'
        required: false
        type: string
        default: 'build-output'
    secrets:
      NPM_TOKEN:
        description: 'npm 레지스트리 토큰'
        required: false
      SONAR_TOKEN:
        description: 'SonarQube 분석 토큰'
        required: false
    outputs:
      build-version:
        description: '빌드된 버전'
        value: ${{ jobs.build.outputs.version }}
      test-coverage:
        description: '테스트 커버리지'
        value: ${{ jobs.test.outputs.coverage }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'
          cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json

      - name: Install dependencies
        working-directory: ${{ inputs.working-directory }}
        run: npm ci

      - name: Build
        working-directory: ${{ inputs.working-directory }}
        run: npm run build

      - name: Extract version
        id: version
        working-directory: ${{ inputs.working-directory }}
        run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: ${{ inputs.working-directory }}/dist/
          retention-days: 7

  test:
    runs-on: ubuntu-latest
    needs: build
    outputs:
      coverage: ${{ steps.coverage.outputs.percentage }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'

      - name: Install dependencies
        working-directory: ${{ inputs.working-directory }}
        run: npm ci

      - name: Run unit tests
        working-directory: ${{ inputs.working-directory }}
        run: npm run test -- --coverage

      - name: Extract coverage
        id: coverage
        working-directory: ${{ inputs.working-directory }}
        run: |
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"

      - name: SonarQube analysis
        if: secrets.SONAR_TOKEN != ''
        uses: SonarSource/sonarqube-scan-action@v3
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  e2e:
    if: inputs.run-e2e
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4

      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: ${{ inputs.working-directory }}/dist/

      - name: Run E2E tests
        working-directory: ${{ inputs.working-directory }}
        run: npm run test:e2e

Caller Workflow 예시

위의 Reusable Workflow를 호출하는 caller 워크플로우는 다음과 같다.

# my-service/.github/workflows/ci.yml
name: CI Pipeline

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

jobs:
  build-and-test:
    uses: org-platform/.github/workflows/build-node.yml@v2.3.0
    with:
      node-version: '20'
      working-directory: '.'
      run-e2e: ${{ github.ref == 'refs/heads/main' }}
      artifact-name: 'my-service-build'
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
      SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  deploy-staging:
    needs: build-and-test
    if: github.ref == 'refs/heads/develop'
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    with:
      environment: staging
      image-tag: ${{ needs.build-and-test.outputs.build-version }}
    secrets: inherit

  deploy-production:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    with:
      environment: production
      image-tag: ${{ needs.build-and-test.outputs.build-version }}
    secrets: inherit

secrets: inherit를 사용하면 caller의 모든 시크릿이 자동으로 전달된다. 보안이 중요한 환경에서는 명시적으로 필요한 시크릿만 전달하는 것이 좋다.

Composite Actions 구축

action.yml 구조와 핵심 개념

Composite Action은 여러 스텝을 하나의 재사용 가능한 액션으로 묶는다. Reusable Workflow와 달리 잡 수준이 아닌 스텝 수준에서 동작하며, 하나의 잡 안에 여러 Composite Action을 포함할 수 있다.

JavaScript/Docker/Composite 액션 비교

항목JavaScript ActionDocker ActionComposite Action
실행 환경Node.js 런타임Docker 컨테이너러너 직접 실행
시작 시간빠름 (수 초)느림 (이미지 풀)빠름 (수 초)
플랫폼 호환Linux/macOS/WindowsLinux 전용러너 OS에 따라 다름
복잡한 로직최적적합셸 스크립트 수준
기존 도구 활용npm 패키지 사용어떤 도구든 설치 가능다른 액션 조합 가능
유지보수 난이도중간 (빌드 필요)낮음 (Dockerfile)낮음 (YAML만)
시크릿 접근직접 접근 가능직접 접근 가능환경변수로만 전달

Composite Action 정의 예시

아래는 Docker 이미지 빌드와 푸시를 모듈화한 Composite Action이다.

# .github/actions/docker-build-push/action.yml
name: 'Docker Build and Push'
description: 'Docker 이미지를 빌드하고 레지스트리에 푸시합니다'

inputs:
  registry:
    description: '컨테이너 레지스트리 URL'
    required: true
  image-name:
    description: '이미지 이름'
    required: true
  dockerfile:
    description: 'Dockerfile 경로'
    required: false
    default: './Dockerfile'
  context:
    description: '빌드 컨텍스트 경로'
    required: false
    default: '.'
  build-args:
    description: '빌드 인자 (줄바꿈 구분)'
    required: false
    default: ''
  push:
    description: '이미지 푸시 여부'
    required: false
    default: 'true'

outputs:
  image-digest:
    description: '빌드된 이미지 다이제스트'
    value: ${{ steps.build.outputs.digest }}
  image-tag:
    description: '이미지 태그'
    value: ${{ steps.meta.outputs.tags }}

runs:
  using: 'composite'
  steps:
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ inputs.registry }}/${{ inputs.image-name }}
        tags: |
          type=sha,prefix=
          type=ref,event=branch
          type=ref,event=tag
          type=semver,pattern=v{{version}}
          type=raw,value=latest,enable={{is_default_branch}}

    - name: Build and push
      id: build
      uses: docker/build-push-action@v6
      with:
        context: ${{ inputs.context }}
        file: ${{ inputs.dockerfile }}
        push: ${{ inputs.push }}
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        build-args: ${{ inputs.build-args }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
        provenance: true
        sbom: true

    - name: Print image info
      shell: bash
      run: |
        echo "Image digest: ${{ steps.build.outputs.digest }}"
        echo "Image tags: ${{ steps.meta.outputs.tags }}"

Composite Action 사용 예시

# my-service/.github/workflows/build-image.yml
name: Build Docker Image

on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'Dockerfile'

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push
        id: docker
        uses: ./.github/actions/docker-build-push
        with:
          registry: ghcr.io
          image-name: ${{ github.repository }}
          build-args: |
            APP_VERSION=${{ github.sha }}
            BUILD_DATE=${{ github.event.head_commit.timestamp }}

      - name: Deploy notification
        run: |
          echo "Deployed image with digest: ${{ steps.docker.outputs.image-digest }}"

Reusable Workflows vs Composite Actions 비교

두 메커니즘은 용도가 다르다. 선택 기준을 명확히 해야 파이프라인 아키텍처가 깔끔해진다.

항목Reusable WorkflowsComposite Actions
동작 수준잡(Job) 수준스텝(Step) 수준
시크릿 접근secrets 키워드로 직접 전달환경변수로만 전달 가능
중첩 호출최대 10단계최대 10단계
조건부 실행잡 단위 if 가능스텝 단위 if 가능
러너 지정피호출 측에서 지정 가능호출자의 러너에서 실행
호출 방식jobs.xxx.uses:steps.xxx.uses:
outputs 전달워크플로우 outputs 지원스텝 outputs 지원
환경(environment)별도 environment 지정 가능호출자의 environment 공유
최대 호출 수워크플로우당 50개제한 없음
적합한 시나리오전체 파이프라인 템플릿공통 스텝 모듈

선택 가이드:

  • 전체 CI/CD 파이프라인을 표준화하고 싶다면 Reusable Workflows를 사용한다. 빌드-테스트-배포 전 과정을 하나의 템플릿으로 만들고, 팀마다 inputs만 다르게 넘긴다.
  • 특정 작업(Docker 빌드, Slack 알림, 캐시 복원 등)을 모듈화하고 싶다면 Composite Actions를 사용한다. 여러 워크플로우에서 스텝 수준으로 재사용한다.
  • 두 가지를 조합하는 것이 가장 강력하다. Reusable Workflow 안에서 Composite Action을 호출하면, 파이프라인 전체 흐름은 워크플로우로 관리하고 세부 스텝은 액션으로 모듈화할 수 있다.

모노레포 CI/CD 최적화

모노레포에서 가장 중요한 원칙은 변경된 패키지만 빌드하는 것이다. 모든 변경에 대해 전체 리포지토리를 빌드하면 CI 시간이 기하급수적으로 증가한다.

dorny/paths-filter를 활용한 변경 감지

dorny/paths-filter는 PR이나 push에서 변경된 파일 경로를 기반으로 boolean 필터를 생성한다. 이 필터를 후속 잡의 조건으로 사용하면, 변경된 패키지만 선택적으로 빌드할 수 있다.

동적 matrix 전략과 fromJSON 패턴

정적 matrix는 모든 조합을 항상 실행한다. 모노레포에서는 변경된 패키지 목록을 동적으로 생성하고 이를 matrix에 주입하는 패턴이 필수다. fromJSON 함수를 사용하면 JSON 문자열을 matrix 값으로 변환할 수 있다.

모노레포 path-filter + dynamic matrix YAML

아래는 모노레포에서 변경 감지와 동적 matrix를 결합한 실전 워크플로우다.

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

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

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

      - name: Detect changed packages
        uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'packages/api/**'
              - 'packages/shared/**'
            web:
              - 'packages/web/**'
              - 'packages/shared/**'
            shared:
              - 'packages/shared/**'
            docs:
              - 'packages/docs/**'

  build-matrix:
    needs: detect-changes
    if: needs.detect-changes.outputs.packages != '[]'
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - name: Build dynamic matrix
        id: set-matrix
        run: |
          CHANGES='${{ needs.detect-changes.outputs.packages }}'
          MATRIX=$(echo "$CHANGES" | jq -c '{package: .}')
          echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"

  build:
    needs: build-matrix
    if: needs.build-matrix.outputs.matrix != ''
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.build-matrix.outputs.matrix) }}
      fail-fast: false
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Build package
        run: npm run build --workspace=packages/${{ matrix.package }}

      - name: Test package
        run: npm run test --workspace=packages/${{ matrix.package }}

  integration-test:
    needs: [detect-changes, build]
    if: >-
      needs.detect-changes.outputs.api-changed == 'true' ||
      needs.detect-changes.outputs.web-changed == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Install all dependencies
        run: npm ci

      - name: Run integration tests
        run: npm run test:integration

  deploy:
    needs: [detect-changes, build, integration-test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - package: api
            changed: ${{ needs.detect-changes.outputs.api-changed }}
          - package: web
            changed: ${{ needs.detect-changes.outputs.web-changed }}
      fail-fast: false
    steps:
      - name: Skip if not changed
        if: matrix.changed != 'true'
        run: echo "Skipping deploy for ${{ matrix.package }} (no changes)"

      - uses: actions/checkout@v4
        if: matrix.changed == 'true'

      - name: Deploy
        if: matrix.changed == 'true'
        run: |
          echo "Deploying ${{ matrix.package }}..."
          # 실제 배포 로직

캐싱 최적화 YAML

모노레포에서 캐시 전략은 패키지별 격리가 핵심이다. 전역 캐시와 패키지별 캐시를 계층적으로 관리한다.

# .github/workflows/cache-optimized.yml
name: Cache Optimized Build

on:
  push:
    branches: [main]
  pull_request:

env:
  TURBO_CACHE_DIR: .turbo

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      # 1단계: npm 의존성 캐시 (package-lock.json 기반)
      - name: Setup Node.js with dependency cache
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # 2단계: Turborepo 원격 캐시 대신 로컬 캐시 사용
      - name: Cache Turborepo
        uses: actions/cache@v4
        with:
          path: .turbo
          key: turbo-${{ runner.os }}-${{ github.sha }}
          restore-keys: |
            turbo-${{ runner.os }}-

      # 3단계: Next.js 빌드 캐시 (pages별 증분 빌드)
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: packages/web/.next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('packages/web/**/*.ts', 'packages/web/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-

      # 4단계: ESLint 캐시
      - name: Cache ESLint
        uses: actions/cache@v4
        with:
          path: .eslintcache
          key: eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*') }}-${{ github.sha }}
          restore-keys: |
            eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*') }}-
            eslint-${{ runner.os }}-

      # 5단계: Jest 캐시
      - name: Cache Jest
        uses: actions/cache@v4
        with:
          path: /tmp/jest_rs
          key: jest-${{ runner.os }}-${{ hashFiles('**/jest.config.*') }}
          restore-keys: |
            jest-${{ runner.os }}-

      - name: Install dependencies
        run: npm ci

      - name: Build with Turborepo
        run: npx turbo run build --cache-dir=.turbo

      - name: Test with Turborepo
        run: npx turbo run test --cache-dir=.turbo

      - name: Lint with cache
        run: npx turbo run lint --cache-dir=.turbo

위 캐시 전략을 적용하면 일반적인 모노레포에서 다음과 같은 효과를 얻는다:

캐시 대상캐시 미스 시캐시 히트 시절감 비율
npm 의존성45~90초5~10초80~90%
Turborepo 빌드120~300초3~8초95%+
Next.js 증분 빌드60~180초10~30초70~85%
ESLint 분석30~60초5~15초60~75%
Jest 테스트캐시 불가transform 캐시20~40%

프로덕션 운영 가이드

워크플로우 버전 관리 전략

Reusable Workflow와 Composite Action의 참조 방식에 따라 안정성과 보안이 크게 달라진다.

참조 방식예시장점단점
브랜치uses: org/repo/.github/workflows/ci.yml@main항상 최신 버전예고 없이 깨질 수 있음
태그uses: org/repo/.github/workflows/ci.yml@v2.3.0안정적, SemVer 의미 전달태그 덮어쓰기 가능
커밋 SHAuses: org/repo/.github/workflows/ci.yml@a1b2c3d4가장 안전, 변경 불가가독성 낮음

권장 전략: 개발 중에는 브랜치 참조, 프로덕션에서는 SHA 참조를 사용한다. Dependabot이나 Renovate를 설정하면 SHA가 업데이트될 때 자동으로 PR을 생성해준다.

조직 전체 거버넌스와 표준화

조직 수준에서 다음을 강제한다:

  • 필수 워크플로우(Required Workflows): Organization Settings에서 특정 Reusable Workflow를 모든 리포지토리에 강제 실행할 수 있다. 보안 스캔, 라이선스 검사 등에 활용한다.
  • CODEOWNERS: .github/workflows/ 디렉토리에 CODEOWNERS를 설정하여 워크플로우 변경 시 플랫폼 팀의 리뷰를 강제한다.
  • 워크플로우 권한 제한: Organization 수준에서 GITHUB_TOKEN의 기본 권한을 read로 설정하고, 필요한 권한만 워크플로우에서 명시하게 한다.

보안: OIDC와 최소 권한 원칙

클라우드 배포 시 장기 자격 증명(Access Key 등)을 시크릿에 저장하는 대신, **OIDC(OpenID Connect)**를 사용하여 임시 토큰을 발급받는다.

# OIDC를 사용한 AWS 배포 예시
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: ap-northeast-2
          role-session-name: github-actions-${{ github.run_id }}

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster production \
            --service my-api \
            --force-new-deployment

이 방식은 시크릿 유출 위험을 근본적으로 제거하며, IAM Role의 trust policy에서 특정 리포지토리와 브랜치만 허용할 수 있다.

실패 사례와 트러블슈팅

사례 1: 시크릿 전달 실패로 배포 중단

증상: Reusable Workflow에서 AWS 배포가 실패. 에러 메시지는 "credentials not found".

원인: Caller 워크플로우에서 secrets: inherit 대신 명시적 시크릿 전달을 사용했는데, 새로 추가된 AWS_ROLE_ARN 시크릿을 전달하지 않았다.

교훈: secrets: inherit를 사용하면 편리하지만, 어떤 시크릿이 전달되는지 추적하기 어렵다. 프로덕션 환경에서는 명시적 전달을 권장하되, 새로운 시크릿 추가 시 caller 업데이트를 잊지 않도록 CI에서 검증 스텝을 추가하라.

해결 패턴:

# 시크릿 전달 검증을 포함한 caller 워크플로우
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - name: Validate required secrets
        run: |
          MISSING=""
          if [ -z "$AWS_ROLE" ]; then MISSING="$MISSING AWS_ROLE_ARN"; fi
          if [ -z "$NPM_TK" ]; then MISSING="$MISSING NPM_TOKEN"; fi
          if [ -n "$MISSING" ]; then
            echo "::error::Missing required secrets:$MISSING"
            exit 1
          fi
        env:
          AWS_ROLE: ${{ secrets.AWS_ROLE_ARN }}
          NPM_TK: ${{ secrets.NPM_TOKEN }}

  deploy:
    needs: validate
    uses: org-platform/.github/workflows/deploy-k8s.yml@v2.3.0
    secrets:
      AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

사례 2: 캐시 미스로 CI 시간 폭증

증상: 월요일 아침마다 모노레포 CI 시간이 평소 5분에서 25분으로 증가.

원인: GitHub Actions 캐시는 7일간 미사용 시 자동 삭제된다. 주말 동안 캐시가 만료되어 월요일 첫 빌드에서 모든 캐시가 미스 처리되었다.

해결: 스케줄 워크플로우로 주말에도 캐시를 갱신하고, restore-keys 패턴을 계층적으로 설정한다.

디버깅 체크리스트

문제 발생 시 다음 순서로 점검한다:

  1. 워크플로우 권한 확인: permissions 블록이 필요한 권한을 모두 포함하는가
  2. 시크릿 스코프 확인: Organization 시크릿인지, Repository 시크릿인지, Environment 시크릿인지
  3. 캐시 키 패턴 확인: hashFiles 경로가 실제 존재하는 파일을 가리키는가
  4. Reusable Workflow 접근 권한: 피호출 리포지토리의 Settings에서 Actions 접근을 허용했는가
  5. matrix 값 검증: fromJSON에 전달되는 문자열이 유효한 JSON인지 확인
  6. 환경변수 스코프: Reusable Workflow에는 caller의 env가 전달되지 않는다는 점을 기억

운영 시 주의사항

호출 제한과 동시성 관리

GitHub Actions는 다음과 같은 제한이 있다:

  • 동시 실행 잡: Free 플랜 20개, Team 플랜 60개, Enterprise 플랜 180개
  • 워크플로우 실행 큐: 리포지토리당 최대 500개
  • matrix 최대 조합: 256개
  • 워크플로우 실행 시간: 최대 6시간 (self-hosted는 무제한)

동시성 문제를 방지하려면 concurrency 그룹을 설정한다:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

같은 브랜치에 대한 배포가 동시에 실행되는 것을 방지하며, cancel-in-progress: false로 현재 실행 중인 배포를 취소하지 않는다.

환경변수 스코프 주의

Reusable Workflow에서 가장 흔한 실수는 환경변수 스코프를 혼동하는 것이다:

  • env 키워드로 정의한 환경변수는 Reusable Workflow에 전달되지 않는다
  • vars 컨텍스트(Repository/Organization 변수)는 Reusable Workflow에서 접근 가능하다
  • github 컨텍스트는 Reusable Workflow에서 caller의 값이 전달된다

필요한 값은 반드시 inputs로 명시적으로 전달해야 한다.

대규모 모노레포 성능 팁

  • Sparse checkout 사용: 변경된 패키지 디렉토리만 체크아웃하여 checkout 시간을 단축한다.
- uses: actions/checkout@v4
  with:
    sparse-checkout: |
      packages/api
      packages/shared
      package.json
      package-lock.json
    sparse-checkout-cone-mode: false
  • Turborepo Remote Cache: Vercel의 Remote Cache나 자체 호스팅 캐시 서버를 사용하면 로컬 개발과 CI 간 빌드 캐시를 공유할 수 있다.
  • Affected 패키지만 실행: Nx의 nx affected나 Turborepo의 변경 감지를 활용하여 의존성 그래프 기반으로 영향받은 패키지만 빌드한다.
  • 병렬 잡 분산: matrix 전략으로 패키지별 빌드를 병렬화하되, shared 라이브러리는 선행 빌드로 분리한다.

참고자료