Skip to content
Published on

GitHub Actions 고급 CI/CD: 매트릭스 빌드, 캐시 전략, 보안 하드닝

Authors
  • Name
    Twitter

서론: 왜 지금 GitHub Actions 고급 최적화인가

2026년 현재, GitHub Actions는 전 세계에서 가장 널리 사용되는 CI/CD 플랫폼으로 자리 잡았습니다. GitHub 공식 발표에 따르면 Fortune 100 기업의 90% 이상이 GitHub Actions를 사용하고 있으며, 매일 수백만 개의 워크플로우가 실행되고 있습니다. 하지만 대부분의 팀은 기본적인 빌드-테스트-배포 파이프라인에 머물러 있습니다.

프로덕션 환경에서는 단순한 파이프라인만으로는 부족합니다. 멀티 플랫폼 지원, 빌드 시간 최적화, 비밀 관리, 공급망 보안(Supply Chain Security)까지 고려해야 합니다. 특히 2025년 말 발생한 tj-actions/changed-files 액션 공급망 공격 사건은 GitHub Actions 보안 하드닝의 중요성을 다시 한번 상기시켜 주었습니다.

이 글에서는 매트릭스 빌드 최적화, 캐시 전략, Reusable Workflow, OIDC 기반 인증, 보안 하드닝까지 프로덕션 수준의 CI/CD 파이프라인을 구축하기 위한 고급 기법을 다룹니다.

1차 출처 및 공식 문서

출처설명
GitHub Actions 공식 문서워크플로우 문법, 이벤트, 런너 등 전체 레퍼런스
GitHub Blog - Actions Security Best Practices공급망 보안 및 액션 보안 모범 사례
OpenID Connect in GitHub ActionsOIDC 토큰 기반 클라우드 인증 공식 가이드
GitHub Actions - Caching Dependencies의존성 캐싱 전략 공식 가이드
Reusable Workflows워크플로우 재사용 패턴 공식 문서
GitHub Actions Runner - Self-hosted셀프 호스트 러너 설정 및 운영 가이드

1. 매트릭스 빌드 최적화

1.1 기본 매트릭스 전략

매트릭스 전략(Matrix Strategy)은 여러 환경 조합에 대해 병렬로 워크플로우를 실행하는 핵심 기능입니다. OS, 언어 버전, 의존성 버전 등을 조합하여 교차 테스트를 자동화할 수 있습니다.

name: Matrix CI
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      max-parallel: 6
      matrix:
        os: [ubuntu-22.04, ubuntu-24.04, macos-14]
        node-version: [20, 22]
        include:
          - os: ubuntu-24.04
            node-version: 22
            coverage: true
        exclude:
          - os: macos-14
            node-version: 20

    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm test

      - name: Upload coverage
        if: ${{ matrix.coverage }}
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

위 설정에서 fail-fast: false는 하나의 매트릭스 조합이 실패하더라도 나머지 조합의 실행을 계속하도록 합니다. 이렇게 하면 어떤 환경에서 문제가 발생하는지 한번에 파악할 수 있습니다.

1.2 동적 매트릭스 생성

변경된 파일에 따라 테스트 대상을 동적으로 결정하는 패턴은 대규모 모노레포에서 빌드 시간을 크게 절감합니다.

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

      - name: Detect changed services
        id: set-matrix
        run: |
          CHANGED=$(git diff --name-only HEAD~1 HEAD | grep -oP '^services/\K[^/]+' | sort -u | jq -R . | jq -s .)
          if [ "$CHANGED" = "[]" ]; then
            echo "matrix={\"service\":[\"dummy\"]}" >> $GITHUB_OUTPUT
          else
            echo "matrix={\"service\":$CHANGED}" >> $GITHUB_OUTPUT
          fi

  build:
    needs: detect-changes
    if: ${{ fromJson(needs.detect-changes.outputs.matrix).service[0] != 'dummy' }}
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - name: Build service
        run: |
          echo "Building ${{ matrix.service }}"
          cd services/${{ matrix.service }}
          docker build -t ${{ matrix.service }}:${{ github.sha }} .

1.3 매트릭스 최적화 팁

  • max-parallel 제한: 동시 실행 수를 제한하여 GitHub-hosted 러너의 동시성 한도를 초과하지 않도록 합니다
  • fail-fast 전략 선택: 빠른 피드백이 중요하면 true, 전체 호환성 확인이 중요하면 false
  • include로 특수 케이스 추가: 특정 조합에만 추가 환경 변수나 단계를 지정
  • exclude로 불필요한 조합 제거: 지원하지 않는 조합(예: Windows + ARM)을 명시적으로 제외

2. 캐시 전략 심화

2.1 캐시 유형별 비교

캐시 전략장점단점적합한 상황
actions/cache범용적, 세밀한 제어 가능키 관리 수동커스텀 빌드 도구
setup-node cache 옵션설정 간단, 자동 키 생성Node.js 전용Node.js 프로젝트
Docker layer caching이미지 빌드 시간 단축캐시 크기 제한(10GB)컨테이너 빌드
Artifact cachingJob 간 데이터 전달워크플로우 내 한정빌드 결과물 공유

2.2 고급 캐시 설정

효과적인 캐시 키 전략은 빌드 시간을 50~70% 단축할 수 있습니다.

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

      # 다단계 캐시 복원 전략
      - name: Cache node_modules
        uses: actions/cache@v4
        id: npm-cache
        with:
          path: |
            node_modules
            ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      # Next.js 빌드 캐시
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
          restore-keys: |
            ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
            ${{ runner.os }}-nextjs-

      # Gradle 캐시 (Java/Kotlin 프로젝트)
      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Install dependencies
        if: steps.npm-cache.outputs.cache-hit != 'true'
        run: npm ci

      - name: Build
        run: npm run build

2.3 Docker 빌드 캐시 최적화

Docker 이미지 빌드에서 BuildKit 캐시를 활용하면 빌드 시간을 크게 단축할 수 있습니다.

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

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

      - name: Build and push with cache
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64

cache-from: type=ghacache-to: type=gha,mode=max를 사용하면 GitHub Actions 캐시 백엔드를 통해 Docker 레이어를 캐시합니다. mode=max는 중간 레이어까지 모두 캐시하여 후속 빌드 속도를 극대화합니다.

2.4 캐시 관리 주의사항

  • 캐시 크기 제한: 저장소당 총 10GB까지 캐시를 저장할 수 있으며, 초과 시 가장 오래된 캐시부터 삭제됩니다
  • 캐시 격리: pull_request 이벤트의 캐시는 기본 브랜치의 캐시를 읽을 수 있지만, 기본 브랜치에 쓸 수 없습니다
  • 캐시 무효화: lock 파일(package-lock.json, go.sum 등)의 해시를 캐시 키에 포함시켜 의존성 변경 시 자동으로 캐시가 갱신되도록 합니다
  • 보안: 캐시에 민감한 정보(토큰, 크레덴셜)가 포함되지 않도록 주의합니다. Fork된 저장소에서 캐시 포이즈닝(poisoning) 공격이 가능할 수 있습니다

3. Reusable Workflow와 Composite Action

3.1 Reusable Workflow

조직 전체에서 표준화된 CI/CD 파이프라인을 공유하려면 Reusable Workflow를 사용합니다. 호출하는 쪽에서는 uses 키워드로 다른 저장소의 워크플로우를 참조합니다.

호출되는 워크플로우 (.github/workflows/reusable-deploy.yml):

name: Reusable Deploy Workflow

on:
  workflow_call:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: string
      image-tag:
        description: 'Docker image tag'
        required: true
        type: string
    secrets:
      KUBE_CONFIG:
        required: true
    outputs:
      deploy-url:
        description: 'Deployed URL'
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v4

      - name: Configure kubectl
        run: |
          echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig
          export KUBECONFIG=kubeconfig

      - name: Deploy to Kubernetes
        id: deploy
        run: |
          kubectl set image deployment/app \
            app=${{ inputs.image-tag }} \
            -n ${{ inputs.environment }}
          kubectl rollout status deployment/app \
            -n ${{ inputs.environment }} --timeout=300s
          URL=$(kubectl get ingress app -n ${{ inputs.environment }} -o jsonpath='{.spec.rules[0].host}')
          echo "url=https://$URL" >> $GITHUB_OUTPUT

호출하는 워크플로우:

name: Production Deploy

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        id: meta
        run: |
          TAG="ghcr.io/${{ github.repository }}:${{ github.ref_name }}"
          docker build -t $TAG .
          docker push $TAG
          echo "tags=$TAG" >> $GITHUB_OUTPUT

  deploy-staging:
    needs: build
    uses: my-org/shared-workflows/.github/workflows/reusable-deploy.yml@v2
    with:
      environment: staging
      image-tag: ${{ needs.build.outputs.image-tag }}
    secrets:
      KUBE_CONFIG: ${{ secrets.STAGING_KUBE_CONFIG }}

  deploy-production:
    needs: [build, deploy-staging]
    uses: my-org/shared-workflows/.github/workflows/reusable-deploy.yml@v2
    with:
      environment: production
      image-tag: ${{ needs.build.outputs.image-tag }}
    secrets:
      KUBE_CONFIG: ${{ secrets.PROD_KUBE_CONFIG }}

3.2 Composite Action

Composite Action은 여러 단계(steps)를 하나의 재사용 가능한 액션으로 묶습니다. Reusable Workflow와 달리 Job이 아닌 Step 수준에서 재사용됩니다.

# .github/actions/setup-and-test/action.yml
name: 'Setup and Test'
description: 'Install dependencies, lint, and test'

inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '22'
  working-directory:
    description: 'Working directory for the project'
    required: false
    default: '.'

outputs:
  coverage-percentage:
    description: 'Test coverage percentage'
    value: ${{ steps.coverage.outputs.percentage }}

runs:
  using: 'composite'
  steps:
    - 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
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      run: npm ci

    - name: Lint
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      run: npm run lint

    - name: Test with coverage
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      run: npm test -- --coverage --coverageReporters=text-summary

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

3.3 Reusable Workflow vs Composite Action 비교

구분Reusable WorkflowComposite Action
재사용 단위Job 전체Step 단위
호출 방식jobs.*.usessteps.*.uses
secrets 전달명시적으로 전달 필요호출자의 컨텍스트 상속
중첩 호출최대 4단계최대 10단계
러너 선택워크플로우 내에서 지정호출자의 러너 사용
적합한 용도전체 파이프라인 표준화공통 setup/teardown 단계

4. GitHub-hosted vs Self-hosted Runner 비교

4.1 비교표

항목GitHub-hosted RunnerSelf-hosted Runner
비용분당 과금 (Linux 0.008 USD/분)인프라 비용만 발생
환경매번 깨끗한 VM지속적 환경 (캐시 유지 가능)
커스터마이즈제한적 (사전 설치 도구만)완전 자유 (GPU, 특수 HW)
보안GitHub이 관리조직이 직접 관리
네트워크퍼블릭 인터넷프라이빗 네트워크 접근 가능
스케일링자동수동 또는 오토스케일링 구성 필요
동시성 한도플랜에 따라 다름직접 제어
유지보수불필요OS 패치, 러너 업데이트 필요

4.2 Self-hosted Runner 보안 주의사항

Self-hosted Runner를 퍼블릭 저장소에 사용하면 안 됩니다. Fork된 PR에서 악의적인 코드가 러너에서 실행될 수 있기 때문입니다. 반드시 프라이빗 저장소에서만 사용하거나, Actions Runner Controller(ARC)와 같은 도구로 임시(ephemeral) 러너를 사용하세요.

# Actions Runner Controller (ARC) - Kubernetes 기반 오토스케일링 셀프 호스트 러너
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: runner-deployment
spec:
  replicas: 3
  template:
    spec:
      repository: my-org/my-repo
      ephemeral: true
      labels:
        - self-hosted
        - linux
        - x64
        - gpu
      resources:
        limits:
          nvidia.com/gpu: 1
          memory: '16Gi'
        requests:
          cpu: '4'
          memory: '8Gi'
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: runner-autoscaler
spec:
  scaleTargetRef:
    kind: RunnerDeployment
    name: runner-deployment
  minReplicas: 1
  maxReplicas: 10
  metrics:
    - type: TotalNumberOfQueuedAndInProgressWorkflowRuns
      repositoryNames:
        - my-org/my-repo

5. OIDC 기반 클라우드 인증 (보안 하드닝 핵심)

5.1 왜 OIDC인가

기존 방식에서는 AWS Access Key, GCP Service Account Key 등 장기 크레덴셜을 GitHub Secrets에 저장해서 사용했습니다. 이 방식에는 여러 위험이 있습니다.

  • 시크릿 유출 시 즉시 악용 가능
  • 키 로테이션을 수동으로 관리해야 함
  • 어떤 워크플로우가 어떤 권한을 사용했는지 추적이 어려움

OIDC(OpenID Connect) 토큰을 사용하면 GitHub Actions가 클라우드 프로바이더에 단기 토큰으로 인증합니다. 장기 크레덴셜이 필요 없어 보안이 크게 향상됩니다.

5.2 AWS OIDC 인증 설정

name: Deploy to AWS
on:
  push:
    branches: [main]

permissions:
  id-token: write # OIDC 토큰 요청에 필요
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    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
          role-session-name: github-actions-${{ github.run_id }}
          aws-region: ap-northeast-2

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

      - name: Verify deployment
        run: |
          aws ecs wait services-stable \
            --cluster production \
            --services my-app

5.3 GCP OIDC 인증 설정

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

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
          service_account: 'deploy@my-project.iam.gserviceaccount.com'

      - name: Deploy to Cloud Run
        uses: google-github-actions/deploy-cloudrun@v2
        with:
          service: my-app
          region: asia-northeast3
          image: gcr.io/my-project/my-app:${{ github.sha }}

6. 보안 하드닝 종합 가이드

6.1 최소 권한 원칙 적용

GitHub Actions의 GITHUB_TOKEN은 기본적으로 광범위한 권한을 가집니다. 반드시 필요한 권한만 명시적으로 선언해야 합니다.

name: Secure Workflow

# 전역 수준에서 모든 권한을 최소화
permissions: {}

on:
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    # Job 수준에서 필요한 권한만 선언
    permissions:
      contents: read
      checks: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

6.2 액션 버전 피닝 (Supply Chain Security)

태그(v4) 대신 커밋 SHA를 사용하여 액션을 고정합니다. 태그는 악의적으로 변경될 수 있지만 커밋 SHA는 변경이 불가능합니다.

steps:
  # 위험: 태그는 변경될 수 있음
  # - uses: actions/checkout@v4

  # 안전: 커밋 SHA로 고정
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

  - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
    with:
      node-version: '22'

  - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
    with:
      path: node_modules
      key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

GitHub에서 제공하는 Dependabot을 활용하면 이러한 SHA 피닝을 자동으로 관리할 수 있습니다. .github/dependabot.yml에 다음을 추가합니다.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: 'github-actions'
    directory: '/'
    schedule:
      interval: 'weekly'
    groups:
      actions:
        patterns:
          - '*'

6.3 시크릿 관리 모범 사례

jobs:
  secure-deploy:
    runs-on: ubuntu-latest
    environment: production # Environment Protection Rules 적용
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      # 시크릿을 환경 변수로 노출할 때 주의사항
      - name: Deploy with secrets
        env:
          # 개별 시크릿만 명시적으로 전달
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: |
          # 시크릿이 로그에 노출되지 않도록 마스킹
          echo "::add-mask::$DATABASE_URL"
          echo "::add-mask::$API_KEY"
          ./deploy.sh

6.4 Fork PR 보안 설정

Fork된 저장소에서 오는 Pull Request는 보안 위험이 높습니다. 다음 설정을 권장합니다.

  • pull_request_target 대신 pull_request 이벤트를 사용합니다. pull_request_target은 Base 저장소의 시크릿에 접근할 수 있어 위험합니다
  • Fork PR에서는 시크릿을 사용하는 Job을 실행하지 않도록 조건을 설정합니다
  • Environment Protection Rules로 프로덕션 배포에 수동 승인을 필수화합니다
jobs:
  test:
    # Fork PR에서도 안전하게 테스트 실행
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - run: npm ci
      - run: npm test

  deploy-preview:
    # Fork PR에서는 실행하지 않음
    if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Deploy preview
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./deploy-preview.sh

6.5 Artifact Attestation (빌드 출처 증명)

GitHub Actions는 SLSA(Supply-chain Levels for Software Artifacts) 프레임워크를 지원하여 빌드 결과물의 출처를 증명할 수 있습니다.

jobs:
  build-with-attestation:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      attestations: write
      packages: write
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Build Docker image
        run: |
          docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .

      - name: Push to GHCR
        run: |
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Generate artifact attestation
        uses: actions/attest-build-provenance@v2
        with:
          subject-name: ghcr.io/${{ github.repository }}
          subject-digest: sha256:${{ steps.build.outputs.digest }}
          push-to-registry: true

7. 실전 통합 워크플로우: 프로덕션 파이프라인

지금까지 다룬 모든 기법을 통합한 프로덕션 수준의 CI/CD 파이프라인 예시입니다.

name: Production CI/CD Pipeline

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

# 전역 최소 권한
permissions:
  contents: read

# 동일 브랜치에서 이전 실행 취소
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check

  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/4
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results-${{ matrix.shard }}
          path: test-results/
          retention-days: 7

  security-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          scan-ref: '.'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
      - name: Upload scan results
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

  build-and-push:
    needs: [lint, test, security-scan]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    outputs:
      image-digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - 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: build
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Attest build provenance
        uses: actions/attest-build-provenance@v2
        with:
          subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - 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

      - name: Deploy to EKS
        run: |
          aws eks update-kubeconfig --name production-cluster
          kubectl set image deployment/app \
            app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          kubectl rollout status deployment/app --timeout=300s

8. 운영 주의사항과 실패 사례

8.1 자주 겪는 실패 사례

사례 1: 캐시 포이즈닝 공격

퍼블릭 저장소에서 Fork PR이 악의적인 의존성을 캐시에 주입한 후, 이후 빌드에서 해당 캐시를 사용하여 악성 코드가 실행된 사례가 있습니다. 대응 방안으로 pull_request 이벤트에서 캐시 쓰기를 제한하고, 캐시 키에 브랜치 정보를 포함시킵니다.

사례 2: 시크릿 로그 노출

디버깅을 위해 환경 변수를 출력하는 과정에서 시크릿이 로그에 노출된 사례입니다. GitHub Actions는 등록된 시크릿을 자동으로 마스킹하지만, 시크릿을 가공(base64 인코딩 등)한 후 출력하면 마스킹이 우회됩니다. echo "::add-mask::" 명령으로 가공된 값도 반드시 마스킹해야 합니다.

사례 3: 서드파티 액션 태그 변조

2025년 말 발생한 tj-actions/changed-files 공급망 공격에서는, 공격자가 액션의 태그를 악의적인 코드를 포함한 커밋으로 재지정했습니다. 태그 대신 커밋 SHA를 사용했다면 영향을 받지 않았을 것입니다.

사례 4: concurrency 미설정으로 인한 중복 배포

빠르게 연속 Push를 했을 때 여러 배포 Job이 동시에 실행되어 롤백이 복잡해진 사례입니다. concurrency 그룹을 설정하여 동일 환경에 대한 동시 배포를 방지해야 합니다.

8.2 비용 최적화 팁

  • concurrency 설정: 동일 브랜치의 이전 실행을 자동 취소하여 불필요한 실행 시간을 절감합니다
  • path 필터: 관련 파일이 변경되었을 때만 워크플로우를 트리거합니다
  • 테스트 샤딩: 테스트를 병렬 분할 실행하여 전체 시간을 단축합니다
  • Larger Runner 활용: GitHub-hosted Larger Runner를 사용하면 빌드 시간 단축으로 전체 비용이 줄어들 수 있습니다

9. 프로덕션 CI/CD 체크리스트

보안

  • 전역 permissions: {}로 최소 권한 원칙 적용
  • 모든 서드파티 액션을 커밋 SHA로 피닝
  • Dependabot으로 액션 업데이트 자동화
  • OIDC 토큰으로 클라우드 인증 (장기 크레덴셜 제거)
  • Fork PR에서 시크릿 접근 차단
  • Environment Protection Rules로 프로덕션 배포 수동 승인 필수화
  • pull_request_target 사용 시 보안 검토 완료

성능

  • 의존성 캐시 설정 (actions/cache 또는 setup-* 내장 캐시)
  • Docker BuildKit 캐시 활용 (type=gha)
  • 매트릭스 빌드로 병렬 테스트 실행
  • concurrency 설정으로 중복 실행 방지
  • path 필터로 불필요한 워크플로우 실행 제거
  • 테스트 샤딩으로 테스트 시간 분산

유지보수

  • Reusable Workflow로 공통 패턴 표준화
  • Composite Action으로 반복 Step 모듈화
  • 워크플로우 파일에 충분한 주석 추가
  • 실패 알림 설정 (Slack, Email 등)
  • 정기적인 워크플로우 감사 (미사용 시크릿, 불필요한 권한 확인)

공급망 보안

  • 빌드 결과물에 Artifact Attestation 적용
  • SLSA 프레임워크 레벨 확인 및 목표 설정
  • 컨테이너 이미지 서명 (Sigstore/Cosign)
  • SBOM(Software Bill of Materials) 생성 및 관리

마무리

GitHub Actions의 고급 기능을 활용하면 단순한 빌드/테스트 자동화를 넘어 프로덕션 수준의 보안과 효율성을 갖춘 CI/CD 파이프라인을 구축할 수 있습니다. 특히 다음 세 가지를 핵심으로 기억하시기 바랍니다.

  1. 보안 우선: 최소 권한 원칙, SHA 피닝, OIDC 인증은 선택이 아닌 필수입니다
  2. 캐시 전략: 적절한 캐시 설정만으로도 빌드 시간을 50% 이상 단축할 수 있습니다
  3. 모듈화: Reusable Workflow와 Composite Action으로 유지보수성을 높이고 조직 전체의 표준을 확립합니다

CI/CD 파이프라인은 한 번 만들고 끝나는 것이 아니라, 지속적으로 개선하고 보안을 강화해야 하는 살아있는 시스템입니다. 정기적으로 체크리스트를 점검하고, 새로운 보안 위협에 대응하는 것이 중요합니다.


참고 자료