Skip to content
Published on

GitHub Actions CI/CD 완전 정복: Go 빌드부터 브랜치 전략, 자동 배포까지

Authors

들어가며

현대 소프트웨어 개발에서 CI/CD는 선택이 아니라 생존 조건입니다. 코드를 작성하고, 테스트를 돌리고, 빌드하고, 배포하는 모든 과정을 수동으로 처리하던 시대는 끝났습니다. GitHub Actions는 이 모든 것을 YAML 파일 하나로 자동화할 수 있게 해주며, 특히 Go 프로젝트와의 궁합이 탁월합니다.

Go는 정적 바이너리 컴파일, 빠른 빌드 속도, 내장 테스트 프레임워크, 크로스 컴파일 지원 등 CI/CD 파이프라인에 최적화된 특성을 가지고 있습니다. 여기에 적절한 브랜치 전략까지 결합하면, 하루에 수십 번 안전하게 배포하는 팀을 만들 수 있습니다.

이 글에서는 GitHub Actions의 아키텍처부터 Go 프로젝트 CI/CD 구축, 브랜치 전략 비교, 실전 마이크로서비스 파이프라인, 그리고 성능 최적화와 비용 절감까지 — DevOps 엔지니어가 알아야 할 모든 것을 다룹니다.


Part 1: GitHub Actions 완전 이해

1-1. GitHub Actions 아키텍처

GitHub Actions는 크게 4가지 계층으로 구성됩니다.

Workflow, Job, Step, Action 계층 구조

Workflow (.github/workflows/*.yml)
  └── Job (하나의 Runner에서 실행)
        └── Step (순차 실행)
              └── Action (재사용 가능한 단위 작업)
  • Workflow: .github/workflows/ 디렉토리의 YAML 파일 하나가 하나의 워크플로우입니다. 이벤트에 의해 트리거됩니다.
  • Job: 같은 Runner에서 실행되는 Step의 묶음입니다. Job 간에는 기본적으로 병렬 실행되며, needs 키워드로 의존성을 설정할 수 있습니다.
  • Step: Job 내에서 순차적으로 실행되는 개별 작업입니다. 쉘 명령어(run) 또는 Action(uses)을 실행합니다.
  • Action: 재사용 가능한 단위 작업입니다. Marketplace에 수천 개의 커뮤니티 Action이 공개되어 있습니다.

Runner: GitHub-hosted vs Self-hosted

항목GitHub-hostedSelf-hosted
설정제로 설정직접 프로비저닝 필요
OSUbuntu, Windows, macOS자유 선택
비용무료 한도 + 분당 과금인프라 비용만
성능2-core, 7GB RAM (Linux)커스텀 가능
보안GitHub 관리직접 관리
Docker지원 (Linux만)자유 설정
캐시GitHub Cache (10GB)로컬 디스크

Self-hosted Runner는 AWS Spot Instance에 올리면 비용을 80% 이상 절약할 수 있습니다. 단, 보안 관리와 Runner 가용성을 직접 책임져야 합니다.

이벤트 트리거

GitHub Actions는 다양한 이벤트에 반응할 수 있습니다.

on:
  # 코드 푸시 시
  push:
    branches: [main, develop]
    paths:
      - 'src/**'
      - 'go.mod'
    tags:
      - 'v*'

  # PR 이벤트
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

  # 크론 스케줄 (UTC 기준)
  schedule:
    - cron: '0 2 * * 1' # 매주 월요일 02:00 UTC

  # 수동 트리거 (UI 버튼)
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        type: choice
        options:
          - staging
          - production

  # API 호출로 트리거
  repository_dispatch:
    types: [deploy-trigger]

Concurrency Control

동일한 브랜치에서 여러 워크플로우가 동시에 실행되는 것을 방지합니다.

concurrency:
  group: deploy-production
  cancel-in-progress: false  # 진행 중인 배포는 취소하지 않음

# 또는 PR 단위로
concurrency:
  group: ci-pr-$GITHUB_REF
  cancel-in-progress: true  # 새 커밋 시 이전 CI 취소

여기서 cancel-in-progress: true를 설정하면 새로운 실행이 시작될 때 이전 실행이 자동으로 취소됩니다. PR에서는 보통 이렇게 설정하고, 프로덕션 배포에서는 false로 설정합니다.

YAML 문법 핵심

name: CI Pipeline

env:
  GO_VERSION: '1.23'
  REGISTRY: ghcr.io

jobs:
  test:
    runs-on: ubuntu-latest
    # 조건부 실행
    if: github.event_name == 'pull_request'
    strategy:
      matrix:
        go-version: ['1.22', '1.23']
        os: [ubuntu-latest, macos-latest]
    steps:
      - uses: actions/checkout@v4
      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}
      - run: go test ./...

  build:
    needs: [test] # test Job 완료 후 실행
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - id: meta
        run: echo "tags=v1.0.0" >> "$GITHUB_OUTPUT"

핵심 키워드 정리:

  • env: 환경 변수 (워크플로우/잡/스텝 레벨)
  • secrets: 암호화된 비밀 값 참조
  • needs: Job 간 의존성 정의
  • if: 조건부 실행
  • matrix: 여러 조합으로 병렬 실행
  • outputs: Job 간 데이터 전달

1-2. 핵심 액션 TOP 20

실무에서 가장 많이 사용하는 GitHub Actions를 카테고리별로 정리합니다.

기본 액션

# 1. 소스 코드 체크아웃
- uses: actions/checkout@v4
  with:
    fetch-depth: 0 # 전체 히스토리 (태그 기반 버전에 필요)

# 2. Go 설정
- uses: actions/setup-go@v5
  with:
    go-version: '1.23'
    cache: true # Go 모듈 캐시 자동 활성화

# 3. 캐시 관리
- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/go-build
      ~/go/pkg/mod
    key: go-mod-${{ hashFiles('**/go.sum') }}
    restore-keys: |
      go-mod-

Docker 관련 액션

# 4. Docker 레지스트리 로그인
- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

# 5. Docker 빌드 & 푸시
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/myorg/myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

# 6. Docker 메타데이터 (태그 자동 생성)
- uses: docker/metadata-action@v5
  with:
    images: ghcr.io/myorg/myapp
    tags: |
      type=sha,prefix=
      type=ref,event=branch
      type=semver,pattern=v{{version}}

아티팩트 관리

# 7. 아티팩트 업로드
- uses: actions/upload-artifact@v4
  with:
    name: go-binary-linux
    path: ./bin/myapp
    retention-days: 5

# 8. 아티팩트 다운로드
- uses: actions/download-artifact@v4
  with:
    name: go-binary-linux
    path: ./bin/

코드 품질 & 보안

# 9. golangci-lint
- uses: golangci/golangci-lint-action@v6
  with:
    version: v1.62
    args: --timeout=5m

# 10. CodeQL 분석
- uses: github/codeql-action/analyze@v3
  with:
    languages: go

# 11. Dependency Review (PR에서 새 의존성 검토)
- uses: actions/dependency-review-action@v4

릴리스 & 알림

# 12. GitHub Release 생성
- uses: softprops/action-gh-release@v2
  with:
    files: dist/*
    generate_release_notes: true

# 13. PR 라벨 자동 부여
- uses: actions/labeler@v5
  with:
    repo-token: ${{ secrets.GITHUB_TOKEN }}

# 14. Slack 알림
- uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "text": "Deployment complete: ${{ github.sha }}"
      }

커스텀 액션 만들기

세 가지 유형의 커스텀 액션을 만들 수 있습니다.

  1. Composite Action (가장 간단, YAML만으로 작성)
# .github/actions/go-setup/action.yml
name: 'Go Setup with Cache'
description: 'Setup Go with module and build cache'
inputs:
  go-version:
    description: 'Go version'
    required: false
    default: '1.23'
runs:
  using: 'composite'
  steps:
    - uses: actions/setup-go@v5
      with:
        go-version: ${{ inputs.go-version }}
    - uses: actions/cache@v4
      with:
        path: |
          ~/.cache/go-build
          ~/go/pkg/mod
        key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
      shell: bash
    - run: go mod download
      shell: bash
  1. JavaScript Action (Node.js 런타임, 복잡한 로직)
  2. Docker Action (컨테이너 기반, 어떤 언어든 가능)

1-3. 시크릿과 환경 관리

시크릿 계층 구조

GitHub는 세 가지 레벨의 시크릿을 제공합니다.

레벨범위사용 사례
Repository단일 리포지토리API 키, DB 비밀번호
Environment특정 환경(staging/prod)환경별 설정
Organization조직 전체 리포지토리공통 레지스트리 인증

Environment 시크릿은 Repository 시크릿보다 우선합니다. 같은 이름이면 Environment 값이 사용됩니다.

OIDC로 클라우드 인증 (비밀키 없이)

전통적인 방법은 AWS Access Key를 시크릿에 저장하는 것이었지만, OIDC를 사용하면 장기 자격 증명 없이 인증할 수 있습니다.

jobs:
  deploy:
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions
          aws-region: ap-northeast-2

      # GCP도 마찬가지
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/123/locations/global/workloadIdentityPools/github/providers/github
          service_account: github-actions@my-project.iam.gserviceaccount.com

OIDC 인증의 장점:

  • 시크릿에 장기 자격 증명을 저장하지 않으므로 유출 위험이 없음
  • IAM 역할의 세분화된 권한 제어 가능
  • 자격 증명 자동 갱신, 로테이션 불필요

Environment Protection Rules

프로덕션 배포에 안전 장치를 설정합니다.

jobs:
  deploy-prod:
    environment:
      name: production
      url: https://myapp.example.com
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        run: echo "Deploying to production..."

GitHub UI에서 production Environment에 다음을 설정할 수 있습니다:

  • Required Reviewers: 배포 전 특정 사용자의 승인 필요
  • Wait Timer: 승인 후 대기 시간 (예: 5분)
  • Deployment Branches: main 브랜치에서만 배포 허용
  • Custom Rules: 외부 시스템과 연동한 승인 게이트

Part 2: Go 프로젝트 CI/CD

2-1. Go 빌드 시스템 이해

Go의 빌드 시스템은 CI/CD에 매우 친화적입니다. 단일 정적 바이너리 컴파일, 빠른 빌드 속도, 내장 테스트 프레임워크를 기본 제공합니다.

go build 핵심 플래그

# 기본 빌드
go build -o bin/myapp ./cmd/myapp

# 프로덕션 빌드 (최적화 플래그)
CGO_ENABLED=0 go build \
  -trimpath \
  -ldflags="-s -w -X main.version=v1.2.3 -X main.commit=abc123 -X main.buildDate=2026-03-23" \
  -o bin/myapp \
  ./cmd/myapp

각 플래그의 의미:

  • CGO_ENABLED=0: C 라이브러리 의존성 제거, 순수 Go 정적 바이너리 생성
  • -trimpath: 빌드 경로 정보 제거 (재현 가능한 빌드)
  • -ldflags="-s -w": 디버그 심볼 제거 → 바이너리 크기 20-30% 감소
  • -X main.version=...: 빌드 시 변수 값 주입 (버전 정보)

크로스 컴파일

Go는 다른 언어와 달리 별도의 크로스 컴파일러 설치 없이 환경 변수만으로 크로스 컴파일이 가능합니다.

# Linux AMD64
GOOS=linux GOARCH=amd64 go build -o bin/myapp-linux-amd64 ./cmd/myapp

# macOS ARM64 (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o bin/myapp-darwin-arm64 ./cmd/myapp

# Windows AMD64
GOOS=windows GOARCH=amd64 go build -o bin/myapp-windows-amd64.exe ./cmd/myapp

# Linux ARM64 (AWS Graviton, Raspberry Pi)
GOOS=linux GOARCH=arm64 go build -o bin/myapp-linux-arm64 ./cmd/myapp

테스트 명령어

# 기본 테스트
go test ./...

# 레이스 컨디션 탐지 + 커버리지
go test -race -cover -coverprofile=coverage.out ./...

# 벤치마크
go test -bench=. -benchmem -count=5 ./...

# 특정 테스트만 실행
go test -run TestUserService -v ./internal/service/

# 병렬 실행 제어
go test -parallel=4 ./...

# 타임아웃 설정
go test -timeout=5m ./...

# 커버리지 리포트 생성
go tool cover -html=coverage.out -o coverage.html

린트 체계

# go vet: 컴파일러가 잡지 못하는 의심스러운 코드 탐지
go vet ./...

# staticcheck: 고급 정적 분석
staticcheck ./...

# golangci-lint: 50+ 린터 통합 도구
golangci-lint run --timeout=5m

# 보안 취약점 스캔
govulncheck ./...

golangci-lint 설정 파일 예시:

# .golangci.yml
run:
  timeout: 5m
  go: '1.23'

linters:
  enable:
    - errcheck
    - gosimple
    - govet
    - ineffassign
    - staticcheck
    - unused
    - gosec
    - revive
    - misspell
    - prealloc
    - exportloopref
    - gocritic

linters-settings:
  errcheck:
    check-type-assertions: true
  gocritic:
    enabled-tags:
      - diagnostic
      - performance
      - style
  revive:
    rules:
      - name: unexported-return
        disabled: true

issues:
  exclude-rules:
    - path: _test\.go
      linters:
        - errcheck
        - gosec

의존성 관리

# 의존성 정리
go mod tidy

# 벤더링 (CI에서 네트워크 의존성 제거)
go mod vendor

# 의존성 보안 스캔
govulncheck ./...

# 의존성 그래프 확인
go mod graph

# 특정 모듈 업데이트
go get -u github.com/gin-gonic/gin@latest

2-2. Go CI 워크플로우

실제 Go 프로젝트에서 사용할 수 있는 완전한 CI 워크플로우입니다.

name: Go CI

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

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

env:
  GO_VERSION: '1.23'
  GOLANGCI_LINT_VERSION: 'v1.62'

permissions:
  contents: read
  pull-requests: write

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
      - uses: golangci/golangci-lint-action@v6
        with:
          version: ${{ env.GOLANGCI_LINT_VERSION }}
          args: --timeout=5m
      - name: Check go mod tidy
        run: |
          go mod tidy
          git diff --exit-code go.mod go.sum

  test:
    name: Test (Go ${{ matrix.go-version }} / ${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        go-version: ['1.22', '1.23']
        os: [ubuntu-latest, macos-latest, windows-latest]
        exclude:
          - os: windows-latest
            go-version: '1.22'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}
          cache: true

      - name: Run tests with coverage
        run: go test -race -coverprofile=coverage.out -covermode=atomic ./...

      - name: Upload coverage
        if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.23'
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage.out

  coverage-report:
    name: Coverage Report
    needs: [test]
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: coverage
      - name: Generate coverage comment
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const coverage = fs.readFileSync('coverage.out', 'utf8');
            const lines = coverage.split('\n');
            const total = lines.length - 2;
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `## Coverage Report\nTotal statements covered: ${total} lines\nSee artifacts for full report.`
            });

  security:
    name: Security Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
      - name: Run govulncheck
        run: |
          go install golang.org/x/vuln/cmd/govulncheck@latest
          govulncheck ./...
      - name: Run gosec
        uses: securego/gosec@master
        with:
          args: ./...

  build:
    name: Build
    needs: [lint, test]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        goos: [linux, darwin, windows]
        goarch: [amd64, arm64]
        exclude:
          - goos: windows
            goarch: arm64
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: Build binary
        env:
          GOOS: ${{ matrix.goos }}
          GOARCH: ${{ matrix.goarch }}
          CGO_ENABLED: '0'
        run: |
          VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
          COMMIT=$(git rev-parse --short HEAD)
          BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
          EXTENSION=""
          if [ "$GOOS" = "windows" ]; then
            EXTENSION=".exe"
          fi
          go build \
            -trimpath \
            -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${BUILD_DATE}" \
            -o "bin/myapp-${GOOS}-${GOARCH}${EXTENSION}" \
            ./cmd/myapp

      - uses: actions/upload-artifact@v4
        with:
          name: binary-${{ matrix.goos }}-${{ matrix.goarch }}
          path: bin/
          retention-days: 7

매트릭스 빌드 상세

위 워크플로우에서 matrix 전략을 사용하면 다음과 같은 조합이 생성됩니다:

GOOSGOARCH비고
linuxamd64가장 일반적인 서버 환경
linuxarm64AWS Graviton, 라즈베리 파이
darwinamd64macOS Intel
darwinarm64macOS Apple Silicon (M1/M2/M3)
windowsamd64Windows 데스크톱

exclude로 windows/arm64 조합은 제외했습니다. fail-fast: false로 설정하면 하나의 조합이 실패해도 나머지는 계속 실행됩니다.

캐싱 전략

# actions/setup-go의 내장 캐시 (go.sum 해시 기반)
- uses: actions/setup-go@v5
  with:
    go-version: '1.23'
    cache: true # 이것만으로 go module 캐시 활성화

# 더 세밀한 캐시 제어가 필요할 때
- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/go-build
      ~/go/pkg/mod
    key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
    restore-keys: |
      go-${{ runner.os }}-

캐시 히트 시 go mod download가 수초 내에 완료됩니다. 캐시가 없으면 대규모 프로젝트에서 1-2분이 걸릴 수 있습니다.


2-3. Go 멀티 스테이지 Docker 빌드

Go의 정적 바이너리 컴파일 특성을 활용하면, 최종 Docker 이미지를 극도로 작게 만들 수 있습니다.

빌더 패턴

# ============================================
# Stage 1: Build
# ============================================
FROM golang:1.23-alpine AS builder

# 빌드에 필요한 도구 설치
RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /app

# 의존성 먼저 복사 (레이어 캐싱 최적화)
COPY go.mod go.sum ./
RUN go mod download

# 소스 코드 복사
COPY . .

# 빌드 (정적 바이너리)
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
    -trimpath \
    -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
    -o /app/bin/server \
    ./cmd/server

# ============================================
# Stage 2: Runtime (Scratch - 최소 이미지)
# ============================================
FROM scratch

# TLS 인증서 (HTTPS 호출에 필요)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# 타임존 데이터
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# 바이너리
COPY --from=builder /app/bin/server /server

# 비-root 사용자 (보안)
USER 65534:65534

EXPOSE 8080
ENTRYPOINT ["/server"]

이미지 크기 비교

베이스 이미지크기장점단점
golang:1.23~850MB디버깅 용이배포에 부적합
golang:1.23-alpine~250MB빌드 스테이지에 적합여전히 큼
alpine:3.20~7MB + 바이너리쉘 포함, 디버깅 가능공격 표면 존재
distroless~2MB + 바이너리최소 필수 파일만쉘 없음
scratch0MB + 바이너리가장 작음쉘, 패키지 매니저 없음

scratch 기반으로 빌드하면 최종 이미지가 10-15MB 수준으로 줄어듭니다. 원본 빌드 이미지 대비 98% 감소입니다.

distroless 사용 (디버깅 가능한 최소 이미지)

FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=builder /app/bin/server /server

USER nonroot:nonroot

EXPOSE 8080
ENTRYPOINT ["/server"]

distroless는 scratch보다 약간 크지만, /etc/passwd, CA 인증서 등 필수 파일이 포함되어 있어 실무에서 더 편리합니다.

GitHub Actions에서 Docker 빌드

jobs:
  docker:
    name: Build and Push Docker Image
    needs: [test, lint]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

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

      - name: Set up QEMU (멀티 플랫폼 빌드)
        uses: docker/setup-qemu-action@v3

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

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            VERSION=${{ github.ref_name }}
            COMMIT=${{ github.sha }}

Docker Layer Caching 전략

GitHub Actions에서 Docker 빌드 캐시를 활용하는 방법은 크게 두 가지입니다.

# 방법 1: GitHub Actions Cache Backend (추천)
cache-from: type=gha
cache-to: type=gha,mode=max

# 방법 2: Registry Cache
cache-from: type=registry,ref=ghcr.io/myorg/myapp:cache
cache-to: type=registry,ref=ghcr.io/myorg/myapp:cache,mode=max

GitHub Actions Cache Backend는 별도의 레지스트리 설정 없이 GitHub의 캐시 인프라를 직접 활용합니다. 캐시 용량은 리포지토리당 10GB입니다.

멀티 플랫폼 빌드

QEMU를 사용하여 단일 Runner에서 amd64와 arm64 이미지를 동시에 빌드할 수 있습니다. arm64 빌드는 에뮬레이션으로 인해 amd64보다 2-3배 느리지만, 별도의 ARM Runner 없이도 가능합니다.

보안 베스트 프랙티스

# 비-root 사용자 실행
USER 65534:65534

# 읽기 전용 파일시스템 (Kubernetes에서)
# securityContext:
#   readOnlyRootFilesystem: true

# 헬스 체크
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD ["/server", "healthcheck"]

2-4. Go 릴리스 자동화

GoReleaser

GoReleaser는 Go 프로젝트의 릴리스를 자동화하는 도구입니다. 크로스 컴파일, 체크섬 생성, changelog, 패키지 매니저 배포를 한 번에 처리합니다.

# .goreleaser.yaml
version: 2

before:
  hooks:
    - go mod tidy
    - go generate ./...

builds:
  - id: myapp
    main: ./cmd/myapp
    binary: myapp
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}

archives:
  - id: default
    format: tar.gz
    name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}'
    format_overrides:
      - goos: windows
        format: zip

checksum:
  name_template: 'checksums.txt'

changelog:
  sort: asc
  filters:
    exclude:
      - '^docs:'
      - '^test:'
      - '^ci:'
      - Merge pull request

brews:
  - repository:
      owner: myorg
      name: homebrew-tap
    homepage: https://github.com/myorg/myapp
    description: My application
    license: MIT

dockers:
  - image_templates:
      - 'ghcr.io/myorg/myapp:{{ .Version }}-amd64'
    dockerfile: Dockerfile
    build_flag_templates:
      - '--platform=linux/amd64'
    use: buildx

nfpms:
  - package_name: myapp
    vendor: My Organization
    homepage: https://github.com/myorg/myapp
    maintainer: team@myorg.com
    description: My application
    license: MIT
    formats:
      - deb
      - rpm

GitHub Actions에서 GoReleaser 실행

name: Release

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: write
  packages: write

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

      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'

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

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          version: '~> v2'
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

태그를 푸시하면 (git tag v1.2.3 && git push --tags), 자동으로:

  1. 모든 플랫폼용 바이너리가 크로스 컴파일됩니다
  2. 체크섬 파일이 생성됩니다
  3. Changelog가 자동 생성됩니다
  4. GitHub Release가 생성되고 바이너리가 업로드됩니다
  5. Docker 이미지가 빌드되어 GHCR에 푸시됩니다
  6. Homebrew tap이 업데이트됩니다

Semantic Release (Conventional Commits 기반)

Conventional Commits를 사용하면 커밋 메시지에서 자동으로 버전을 결정할 수 있습니다.

feat: add user authentication       → v1.1.0 (MINOR)
fix: resolve null pointer error     → v1.0.1 (PATCH)
feat!: redesign API endpoints       → v2.0.0 (MAJOR)
# .github/workflows/semantic-release.yml
name: Semantic Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Semantic Release
        uses: cycjimmy/semantic-release-action@v4
        with:
          extra_plugins: |
            @semantic-release/changelog
            @semantic-release/git
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Part 3: 브랜치 전략 완전 비교

브랜치 전략은 팀의 개발 문화, 배포 빈도, 프로젝트 특성에 따라 선택해야 합니다. 여기서는 가장 널리 사용되는 세 가지 전략을 상세히 비교합니다.

3-1. Git Flow

Git Flow는 Vincent Driessen이 2010년에 제안한 브랜치 모델로, 5개의 브랜치 유형을 사용합니다.

main ─────────────────────────────────────────→
  ↑                    ↑                    ↑
  │ merge              │ merge              │ hotfix
  │                    │                    │
release/1.0 ─→    release/2.0 ─→        hotfix/urgent ─→
  ↑                    ↑
  │ merge              │ merge
  │                    │
develop ──────────────────────────────────────→
  ↑         ↑         ↑
  │         │         │
feature/A  feature/B  feature/C

5개 브랜치 유형:

  • main: 프로덕션 릴리스만 반영. 항상 배포 가능한 상태.
  • develop: 다음 릴리스를 위한 통합 브랜치. feature 브랜치들이 여기에 머지됨.
  • feature/xxx: 새 기능 개발. develop에서 분기하여 develop으로 머지.
  • release/x.y: 릴리스 준비. develop에서 분기하여 main과 develop에 머지.
  • hotfix/xxx: 긴급 수정. main에서 분기하여 main과 develop에 머지.

장점:

  • 릴리스 주기가 명확하게 관리됨
  • 버전별 관리가 용이 (v1.0, v2.0 등)
  • 프로덕션과 개발 코드가 명확히 분리
  • 동시에 여러 릴리스 준비 가능

단점:

  • 브랜치가 많아 관리가 복잡
  • 머지 충돌이 빈번하게 발생
  • 배포 속도가 느림 (릴리스 브랜치를 거쳐야 함)
  • CI/CD 자동화와 상충하는 부분이 있음

적합한 경우:

  • 모바일 앱 (App Store/Play Store 릴리스 주기)
  • 엔터프라이즈 소프트웨어 (분기별 릴리스)
  • 여러 버전을 동시에 지원해야 하는 프로젝트
  • 규제가 엄격한 산업 (금융, 의료)
# Git Flow용 CI 워크플로우
name: Git Flow CI

on:
  push:
    branches:
      - main
      - develop
      - 'feature/**'
      - 'release/**'
      - 'hotfix/**'
  pull_request:
    branches: [main, develop]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
      - run: go test ./...

      # release 또는 hotfix 브랜치에서만 빌드
      - name: Build
        if: startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/heads/hotfix/')
        run: go build -o bin/myapp ./cmd/myapp

      # main 브랜치에서만 배포
      - name: Deploy
        if: github.ref == 'refs/heads/main'
        run: echo "Deploying to production..."

3-2. GitHub Flow

GitHub Flow는 Git Flow의 복잡성을 줄인 단순한 브랜치 모델입니다. main 브랜치와 feature 브랜치만 사용합니다.

main ─── PR1 merge ─── PR2 merge ─── PR3 merge ──→
  ↑         ↑            ↑            ↑
  │         │            │            │
  └─ feat/A ┘      feat/B ┘     fix/C

워크플로우:

  1. main에서 feature 브랜치 생성
  2. 코드 작성 및 커밋
  3. PR 생성 → 코드 리뷰 + CI
  4. main에 머지 → 자동 배포

장점:

  • 매우 단순하고 이해하기 쉬움
  • PR 기반으로 코드 리뷰가 자연스럽게 통합
  • 머지 즉시 배포되므로 빠른 피드백 루프
  • CI/CD 자동화에 가장 적합

단점:

  • 릴리스 버전 관리가 어려움
  • main이 항상 배포 가능해야 하므로 높은 CI 신뢰도 필요
  • 대규모 기능 개발 시 PR이 커질 수 있음

적합한 경우:

  • SaaS 웹 서비스 (하루 여러 번 배포)
  • 소규모~중규모 팀 (2-15명)
  • 빠른 이터레이션이 필요한 프로젝트
  • 마이크로서비스 아키텍처
# GitHub Flow용 CI/CD
name: GitHub Flow CI/CD

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

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
      - run: golangci-lint run
      - run: go test -race ./...

  deploy:
    needs: [ci]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment:
      name: production
      url: https://myapp.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Build and Deploy
        run: |
          go build -o bin/myapp ./cmd/myapp
          echo "Deploying to production..."

3-3. Trunk-Based Development

Trunk-Based Development(TBD)는 모든 개발자가 main(trunk)에 직접 커밋하거나, 매우 짧은 수명(1-2일)의 feature 브랜치를 사용하는 전략입니다.

main ── A ── B ── C ── D ── E ── F ── G ── H ──→
             ↑              ↑         ↑
             │              │         │
        short/feat     short/fix   short/feat
        (1-2)        (수시간)     (1)

핵심 원칙:

  • Feature 브랜치의 수명은 최대 1-2일
  • 하루에 여러 번 main에 머지
  • 미완성 기능은 Feature Flag으로 숨김
  • 모든 커밋이 배포 가능한 상태여야 함

Feature Flag 예시 (Go):

package feature

import "os"

type Flags struct {
    NewDashboard   bool
    BetaAPI        bool
    DarkMode       bool
}

func LoadFlags() Flags {
    return Flags{
        NewDashboard: os.Getenv("FF_NEW_DASHBOARD") == "true",
        BetaAPI:      os.Getenv("FF_BETA_API") == "true",
        DarkMode:     os.Getenv("FF_DARK_MODE") == "true",
    }
}

// 사용 예
func HandleDashboard(w http.ResponseWriter, r *http.Request) {
    flags := LoadFlags()
    if flags.NewDashboard {
        renderNewDashboard(w, r)
    } else {
        renderLegacyDashboard(w, r)
    }
}

장점:

  • 머지 충돌이 최소화 (브랜치 수명이 짧으므로)
  • CI/CD 파이프라인이 단순
  • 코드베이스의 무결성이 항상 유지
  • 빠른 피드백과 배포 주기

단점:

  • Feature Flag 관리 오버헤드
  • 매우 높은 테스트 커버리지와 CI 신뢰도 필요
  • 미숙한 팀에서는 main을 망가뜨릴 위험
  • 대규모 리팩토링이 어려울 수 있음

적합한 경우:

  • Google, Meta, Netflix 수준의 CI/CD 성숙도를 가진 팀
  • 하루 수십 번 이상 배포하는 조직
  • 마이크로서비스 아키텍처
  • 높은 테스트 자동화 수준

3가지 전략 비교표:

항목Git FlowGitHub FlowTrunk-Based
브랜치 수5종류2종류1-2종류
복잡도높음낮음매우 낮음
배포 빈도주/월 단위일 단위시간 단위
릴리스 관리명시적암묵적Feature Flag
머지 충돌빈번보통최소
CI/CD 요구보통높음매우 높음
팀 규모대규모소-중규모모든 규모
적합 분야모바일, 엔터프라이즈SaaS, 웹대규모 서비스
대표 기업전통 SW 기업GitHub, ShopifyGoogle, Meta

3-4. 브랜치 보호 규칙 설정

어떤 브랜치 전략을 선택하든, 브랜치 보호 규칙은 필수입니다.

Required Reviews

SettingsBranchesBranch protection rules → Add rule
- Branch name pattern: main
- Require a pull request before merging
  - Required approving reviews: 2
  - Dismiss stale reviews when new commits are pushed
  - Require review from Code Owners

Required Status Checks

- Require status checks to pass before merging
  - ci / lint (Required)
  - ci / test (Required)
  - ci / security (Required)

CODEOWNERS 파일

# .github/CODEOWNERS

# 전체 리포지토리 기본 리뷰어
* @myorg/backend-team

# Go 코드
*.go @myorg/go-team

# 인프라 코드
.github/ @myorg/devops-team
Dockerfile @myorg/devops-team
k8s/ @myorg/devops-team

# API 스펙
api/ @myorg/api-team @myorg/backend-team

# 보안 관련
**/security* @myorg/security-team

CODEOWNERS를 설정하면 해당 파일이 변경된 PR에서 자동으로 리뷰어가 지정됩니다.

Rulesets (GitHub의 새로운 규칙 시스템)

Branch Protection Rules의 진화된 버전으로, 더 세밀하고 유연한 규칙을 설정할 수 있습니다.

주요 차이점:

  • 여러 브랜치/태그 패턴에 하나의 Ruleset 적용 가능
  • Organization 레벨에서 관리 가능
  • Bypass list로 특정 사용자/팀 예외 처리
  • 더 많은 규칙 유형 (commit message 패턴, 파일 경로 제한 등)
SettingsRulesRulesetsNew ruleset
- Name: Production Protection
- Enforcement: Active
- Bypass list: @myorg/release-managers
- Target branches: main, release/*
- Rules:
  - Require a pull request
  - Require status checks (ci/lint, ci/test)
  - Require signed commits
  - Block force pushes
  - Require linear history

Part 4: 실전 프로젝트 — Go 마이크로서비스 풀 파이프라인

4-1. 전체 아키텍처

실전에서 사용하는 Go 마이크로서비스의 완전한 CI/CD 파이프라인을 구축합니다.

Developer Push
┌─────────────────┐
Lint Job      │ ← golangci-lint, go vet
└────────┬────────┘
┌─────────────────┐
Test Job      │ ← unit test, integration test, coverage
└────────┬────────┘
┌─────────────────┐
Build Job     │ ← cross-compile, GoReleaser
└────────┬────────┘
┌─────────────────┐
Docker Job    │ ← multi-stage build, GHCR push
└────────┬────────┘
┌─────────────────┐
Deploy Job    │ ← K8s rollout, canary
└────────┬────────┘
┌─────────────────┐
Smoke Test Job │ ← health check, API validation
└────────┬────────┘
┌─────────────────┐
Notify Job     │ ← Slack webhook
└─────────────────┘

4-2. 전체 워크플로우 YAML

name: Go Microservice Full Pipeline

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

concurrency:
  group: pipeline-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  GO_VERSION: '1.23'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
  K8S_NAMESPACE: production

permissions:
  contents: read

# ============================================
# Job 1: Lint
# ============================================
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: golangci-lint
        uses: golangci/golangci-lint-action@v6
        with:
          version: v1.62
          args: --timeout=5m --issues-exit-code=1

      - name: go vet
        run: go vet ./...

      - name: Check formatting
        run: |
          gofmt_output=$(gofmt -l .)
          if [ -n "$gofmt_output" ]; then
            echo "Files not formatted:"
            echo "$gofmt_output"
            exit 1
          fi

      - name: Check go.mod tidy
        run: |
          go mod tidy
          git diff --exit-code go.mod go.sum

  # ============================================
  # Job 2: Test
  # ============================================
  test:
    name: Test
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: Run unit tests
        run: |
          go test -race -count=1 \
            -coverprofile=coverage-unit.out \
            -covermode=atomic \
            $(go list ./... | grep -v /integration/)
        env:
          ENVIRONMENT: test

      - name: Run integration tests
        run: |
          go test -race -count=1 \
            -coverprofile=coverage-integration.out \
            -covermode=atomic \
            -tags=integration \
            ./internal/integration/...
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable
          REDIS_URL: redis://localhost:6379

      - name: Merge coverage
        run: |
          go install github.com/wadey/gocovmerge@latest
          gocovmerge coverage-unit.out coverage-integration.out > coverage.out
          go tool cover -func=coverage.out | tail -1

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage.out

  # ============================================
  # Job 3: Security Scan
  # ============================================
  security:
    name: Security
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}

      - name: govulncheck
        run: |
          go install golang.org/x/vuln/cmd/govulncheck@latest
          govulncheck ./...

      - name: gosec
        uses: securego/gosec@master
        with:
          args: '-no-fail -fmt json -out gosec-report.json ./...'

      - name: Upload security report
        uses: actions/upload-artifact@v4
        with:
          name: security-report
          path: gosec-report.json

  # ============================================
  # Job 4: Build
  # ============================================
  build:
    name: Build
    needs: [lint, test, security]
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: Determine version
        id: version
        run: |
          if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
            VERSION="${GITHUB_REF#refs/tags/}"
          else
            VERSION="dev-$(git rev-parse --short HEAD)"
          fi
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
          echo "Building version: ${VERSION}"

      - name: Build binaries
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          COMMIT="$(git rev-parse --short HEAD)"
          DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${DATE}"

          for OS in linux darwin; do
            for ARCH in amd64 arm64; do
              echo "Building ${OS}/${ARCH}..."
              CGO_ENABLED=0 GOOS=${OS} GOARCH=${ARCH} \
                go build -trimpath -ldflags="${LDFLAGS}" \
                -o "bin/myapp-${OS}-${ARCH}" ./cmd/myapp
            done
          done

      - uses: actions/upload-artifact@v4
        with:
          name: binaries
          path: bin/

  # ============================================
  # Job 5: Docker Build & Push
  # ============================================
  docker:
    name: Docker
    needs: [build]
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    permissions:
      contents: read
      packages: write
    outputs:
      image-digest: ${{ steps.build-push.outputs.digest }}
      image-tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4

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

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

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

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        id: build-push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            VERSION=${{ needs.build.outputs.version }}
            COMMIT=${{ github.sha }}

  # ============================================
  # Job 6: Deploy to Kubernetes
  # ============================================
  deploy:
    name: Deploy
    needs: [docker]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
    permissions:
      id-token: write
      contents: read
    environment:
      name: production
      url: https://myapp.example.com
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (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: Setup kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: 'v1.30.0'

      - name: Update kubeconfig
        run: aws eks update-kubeconfig --name my-cluster --region ap-northeast-2

      - name: Deploy with canary
        run: |
          IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.docker.outputs.image-digest }}"

          # 카나리 배포 (10% 트래픽)
          kubectl set image deployment/myapp-canary \
            myapp="${IMAGE}" \
            -n ${{ env.K8S_NAMESPACE }}
          kubectl rollout status deployment/myapp-canary \
            -n ${{ env.K8S_NAMESPACE }} --timeout=120s

          # 카나리 검증 (5분 대기 후 메트릭 확인)
          echo "Waiting for canary metrics..."
          sleep 60

          # 전체 롤아웃
          kubectl set image deployment/myapp \
            myapp="${IMAGE}" \
            -n ${{ env.K8S_NAMESPACE }}
          kubectl rollout status deployment/myapp \
            -n ${{ env.K8S_NAMESPACE }} --timeout=300s

  # ============================================
  # Job 7: Smoke Test
  # ============================================
  smoke-test:
    name: Smoke Test
    needs: [deploy]
    runs-on: ubuntu-latest
    steps:
      - name: Health check
        run: |
          for i in $(seq 1 10); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myapp.example.com/health)
            if [ "$STATUS" = "200" ]; then
              echo "Health check passed (attempt $i)"
              exit 0
            fi
            echo "Attempt $i failed (status: $STATUS), retrying..."
            sleep 10
          done
          echo "Health check failed after 10 attempts"
          exit 1

      - name: API validation
        run: |
          # 기본 API 엔드포인트 검증
          curl -sf https://myapp.example.com/api/v1/version | jq .
          curl -sf https://myapp.example.com/api/v1/ready

  # ============================================
  # Job 8: Notification
  # ============================================
  notify:
    name: Notify
    needs: [smoke-test]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Slack notification
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "${{ needs.smoke-test.result == 'success' && 'Deployment successful' || 'Deployment failed' }} for *${{ github.repository }}*\n*Branch:* ${{ github.ref_name }}\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}"
                  }
                }
              ]
            }

  # ============================================
  # Job 9: Rollback (실패 시)
  # ============================================
  rollback:
    name: Rollback
    needs: [smoke-test]
    runs-on: ubuntu-latest
    if: failure()
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Configure AWS credentials
        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: Setup kubectl
        uses: azure/setup-kubectl@v4

      - name: Rollback deployment
        run: |
          aws eks update-kubeconfig --name my-cluster --region ap-northeast-2
          kubectl rollout undo deployment/myapp -n ${{ env.K8S_NAMESPACE }}
          kubectl rollout status deployment/myapp -n ${{ env.K8S_NAMESPACE }} --timeout=120s
          echo "Rollback completed successfully"

4-3. Reusable Workflow 패턴

여러 마이크로서비스에서 동일한 CI/CD 파이프라인을 재사용할 수 있습니다.

공통 CI 워크플로우 (호출되는 쪽)

# .github/workflows/go-ci-reusable.yml
name: Go CI (Reusable)

on:
  workflow_call:
    inputs:
      go-version:
        description: 'Go version to use'
        required: false
        type: string
        default: '1.23'
      working-directory:
        description: 'Working directory'
        required: false
        type: string
        default: '.'
      run-integration-tests:
        description: 'Run integration tests'
        required: false
        type: boolean
        default: false
    secrets:
      SLACK_WEBHOOK:
        required: false
    outputs:
      coverage:
        description: 'Test coverage percentage'
        value: ${{ jobs.test.outputs.coverage }}

jobs:
  lint:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ inputs.go-version }}
      - uses: golangci/golangci-lint-action@v6
        with:
          version: v1.62
          working-directory: ${{ inputs.working-directory }}

  test:
    runs-on: ubuntu-latest
    outputs:
      coverage: ${{ steps.coverage.outputs.total }}
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ inputs.go-version }}
          cache: true

      - name: Unit tests
        run: go test -race -coverprofile=coverage.out ./...

      - name: Integration tests
        if: inputs.run-integration-tests
        run: go test -race -tags=integration ./internal/integration/...

      - name: Coverage
        id: coverage
        run: |
          TOTAL=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}')
          echo "total=${TOTAL}" >> "$GITHUB_OUTPUT"

서비스별 워크플로우 (호출하는 쪽)

# service-a/.github/workflows/ci.yml
name: Service A CI

on:
  push:
    branches: [main]
    paths:
      - 'services/service-a/**'
  pull_request:
    paths:
      - 'services/service-a/**'

jobs:
  ci:
    uses: myorg/shared-workflows/.github/workflows/go-ci-reusable.yml@main
    with:
      go-version: '1.23'
      working-directory: services/service-a
      run-integration-tests: true
    secrets:
      SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}

이렇게 하면 10개의 마이크로서비스가 있더라도 CI 파이프라인 로직은 하나만 관리하면 됩니다. 변경이 필요할 때 공통 워크플로우 하나만 수정하면 모든 서비스에 적용됩니다.


4-4. 고급 패턴

Path-based Triggering (모노레포)

모노레포에서 변경된 서비스만 빌드하는 패턴입니다.

name: Monorepo CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      services: ${{ steps.changes.outputs.changes }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: changes
        with:
          filters: |
            user-service:
              - 'services/user/**'
            order-service:
              - 'services/order/**'
            payment-service:
              - 'services/payment/**'
            shared-lib:
              - 'pkg/**'

  build-user:
    needs: [detect-changes]
    if: needs.detect-changes.outputs.services == 'true' || contains(needs.detect-changes.outputs.services, 'user-service') || contains(needs.detect-changes.outputs.services, 'shared-lib')
    uses: ./.github/workflows/go-ci-reusable.yml
    with:
      working-directory: services/user

  build-order:
    needs: [detect-changes]
    if: contains(needs.detect-changes.outputs.services, 'order-service') || contains(needs.detect-changes.outputs.services, 'shared-lib')
    uses: ./.github/workflows/go-ci-reusable.yml
    with:
      working-directory: services/order

shared-lib(공통 라이브러리)가 변경되면 모든 서비스를 다시 빌드합니다. 개별 서비스만 변경되면 해당 서비스만 빌드합니다.

Deployment Approval Gates

프로덕션 배포 전에 수동 승인을 요구하는 패턴입니다.

deploy-staging:
  environment:
    name: staging
  # staging은 자동 배포

deploy-production:
  needs: [deploy-staging]
  environment:
    name: production # GitHub UI에서 승인 필요
    url: https://myapp.example.com
  # production은 승인 후 배포

GitHub UI에서 Environment 설정에 Required Reviewers를 추가하면, 워크플로우가 해당 Job에 도달했을 때 지정된 리뷰어의 승인을 기다립니다.

Rollback 자동화

name: Rollback

on:
  workflow_dispatch:
    inputs:
      revision:
        description: 'Rollback to revision (0 for previous)'
        required: false
        default: '0'
      service:
        description: 'Service to rollback'
        required: true
        type: choice
        options:
          - user-service
          - order-service
          - payment-service

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Configure kubectl
        run: |
          aws eks update-kubeconfig --name my-cluster

      - name: Rollback
        run: |
          REVISION="${{ github.event.inputs.revision }}"
          SERVICE="${{ github.event.inputs.service }}"
          if [ "$REVISION" = "0" ]; then
            kubectl rollout undo deployment/${SERVICE} -n production
          else
            kubectl rollout undo deployment/${SERVICE} --to-revision=${REVISION} -n production
          fi
          kubectl rollout status deployment/${SERVICE} -n production --timeout=120s

GitHub Packages + GHCR

GitHub Container Registry(GHCR)는 GitHub에 내장된 Docker 레지스트리입니다.

# 이미지 푸시
- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}
# 이미지 네이밍 컨벤션
# ghcr.io/OWNER/REPO:TAG
# ghcr.io/myorg/user-service:v1.2.3
# ghcr.io/myorg/user-service:sha-abc1234
# ghcr.io/myorg/user-service:main

GHCR의 장점:

  • GitHub 리포지토리와 직접 연결
  • GITHUB_TOKEN으로 인증 (별도 시크릿 불필요)
  • Private/Public 이미지 모두 지원
  • 무료 계정에서도 사용 가능 (Private은 스토리지 한도 있음)

Part 5: 성능 최적화 & 비용 절약

5-1. 빌드 속도 최적화

캐싱 전략 총정리

# Go Module Cache
- uses: actions/cache@v4
  with:
    path: ~/go/pkg/mod
    key: gomod-${{ runner.os }}-${{ hashFiles('**/go.sum') }}

# Go Build Cache
- uses: actions/cache@v4
  with:
    path: ~/.cache/go-build
    key: gobuild-${{ runner.os }}-${{ hashFiles('**/*.go') }}
    restore-keys: |
      gobuild-${{ runner.os }}-

# Docker Layer Cache (GitHub Actions backend)
- uses: docker/build-push-action@v6
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

# npm Cache (프론트엔드가 있는 경우)
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

캐시 키 설계 원칙:

  • 정확한 매칭을 위한 primary key (해시 포함)
  • Fallback을 위한 restore-keys (부분 매칭)
  • OS별 분리 (크로스 플랫폼 빌드 시)

병렬 vs 순차 Job 설계

# 좋은 설계: 독립적인 Job은 병렬 실행
jobs:
  lint: # ──┐
    ... #   ├── 병렬
  test: # ──┤
    ... #   ├── 병렬
  security: # ──┘
    ...

  build:
    needs: [lint, test, security] # 위 3개 모두 완료 후

  docker:
    needs: [build] # 순차

  deploy:
    needs: [docker] # 순차

lint, test, security는 서로 의존하지 않으므로 병렬 실행하고, build는 이 세 Job이 모두 성공한 후에 실행합니다. 이렇게 하면 전체 파이프라인 시간이 크게 줄어듭니다.

Self-hosted Runner on Spot Instances

# actions-runner-controller (ARC)로 K8s에서 Self-hosted Runner 운영
# AWS Spot Instance 기반으로 비용 절약

# runner-deployment.yaml (Kubernetes)
# ARC를 사용하면 워크플로우 수요에 따라
# Runner가 자동으로 스케일 업/다운됩니다.

Spot Instance 기반 Self-hosted Runner의 장점:

  • GitHub-hosted 대비 70-80% 비용 절감
  • 커스텀 하드웨어 사양 (더 많은 CPU/RAM)
  • 로컬 디스크 캐시 (더 빠른 빌드)
  • GPU 지원 (ML 프로젝트)

주의사항:

  • Spot 인스턴스는 언제든 중단될 수 있으므로 graceful shutdown 처리 필요
  • Runner 보안 관리는 직접 해야 함
  • Runner 가용성 모니터링 필요

5-2. 비용 최적화

GitHub Actions 요금 체계 (2025-2026 기준)

플랜무료 분/월Linux 단가Windows 단가macOS 단가
Free2,000분무료무료무료
Pro3,000분0.008/분0.016/분0.08/분
Team3,000분0.008/분0.016/분0.08/분
Enterprise50,000분0.008/분0.016/분0.08/분

macOS Runner는 Linux 대비 10배 비쌉니다. 가능하면 Linux에서 테스트하고, macOS는 필수적인 경우에만 사용하세요.

비용 계산 예시

일일 빌드 횟수: 50평균 빌드 시간: 8Linux Runner 사용

일일 비용: 50 x 8 x $0.008 = $3.20
월간 비용: $3.20 x 22(영업일) = $70.40
연간 비용: $70.40 x 12 = $844.80

비용 절약 팁

  1. 캐싱 극대화: 캐시 히트율이 높을수록 빌드 시간이 줄어듭니다.

  2. 조건부 스킵: 변경 없는 서비스는 빌드하지 않습니다.

- name: Check for changes
  id: changes
  run: |
    if git diff --name-only HEAD~1 | grep -q "^src/"; then
      echo "changed=true" >> "$GITHUB_OUTPUT"
    else
      echo "changed=false" >> "$GITHUB_OUTPUT"
    fi

- name: Build
  if: steps.changes.outputs.changed == 'true'
  run: go build ./...
  1. 매트릭스 최적화: 전체 매트릭스는 main 브랜치에서만, PR에서는 핵심 조합만 실행합니다.
strategy:
  matrix:
    include:
      # PR에서는 최소한의 조합만
      - os: ubuntu-latest
        go: '1.23'
      # main에서는 전체 매트릭스
      - os: macos-latest
        go: '1.23'
        if: github.ref == 'refs/heads/main'
  1. timeout 설정: 무한 루프 방지로 예상치 못한 비용 발생을 차단합니다.
jobs:
  test:
    timeout-minutes: 15
    steps:
      - name: Test
        timeout-minutes: 10
        run: go test ./...

Self-hosted vs GitHub-hosted 비용 비교

항목GitHub-hostedSelf-hosted (Spot)
월 1,000분 기준~8달러~2달러 (EC2 비용)
월 10,000분 기준~80달러~15달러
월 50,000분 기준~400달러~60달러
관리 비용0인력 투입 필요
설정 난이도없음중간~높음

월 10,000분 이상 사용한다면 Self-hosted Runner를 검토해볼 가치가 있습니다.


5-3. 모니터링 & 트러블슈팅

Workflow 실행 시간 추적

GitHub는 워크플로우 실행 시간과 결과를 기본적으로 기록합니다. 하지만 더 상세한 분석이 필요하다면:

- name: Track job timing
  run: |
    echo "Job started at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
    START_TIME=$(date +%s)
    # ... 실제 작업 ...
    END_TIME=$(date +%s)
    DURATION=$((END_TIME - START_TIME))
    echo "Duration: ${DURATION} seconds"

실패율 분석

GitHub CLI로 워크플로우 실행 이력을 조회할 수 있습니다.

# 최근 20회 실행 결과
gh run list --workflow=ci.yml --limit=20

# 실패한 실행만 필터
gh run list --workflow=ci.yml --status=failure --limit=10

# 특정 실행의 로그 확인
gh run view 12345678 --log

Step Debug Logging

문제를 진단하기 어려울 때 디버그 로깅을 활성화합니다.

방법 1: Repository Secret으로 설정

ACTIONS_STEP_DEBUG = true
ACTIONS_RUNNER_DEBUG = true

방법 2: 수동 실행 시 디버그 활성화

on:
  workflow_dispatch:

# GitHub UI에서 "Enable debug logging" 체크박스 선택

디버그 모드에서는 각 Step의 상세한 실행 로그, 환경 변수, 파일 시스템 상태 등을 확인할 수 있습니다.

act로 로컬 테스트

act는 GitHub Actions 워크플로우를 로컬에서 실행할 수 있는 도구입니다. Docker를 사용하여 Runner 환경을 에뮬레이션합니다.

# 설치 (macOS)
brew install act

# 기본 실행 (push 이벤트)
act push

# 특정 Job만 실행
act -j test

# 시크릿 전달
act push --secret-file .env.secrets

# 특정 이벤트 데이터로 실행
act pull_request --eventpath event.json

# 사용 가능한 워크플로우 목록
act -l

act의 제한사항:

  • Service Container 미지원 (Postgres, Redis 등)
  • OIDC 인증 미지원
  • GitHub API 호출 제한
  • 일부 Action이 로컬에서 동작하지 않을 수 있음

그래도 기본적인 YAML 문법 검증과 스텝 실행 테스트에는 매우 유용합니다.


보너스: 실전 팁 모음

Conventional Commits 컨벤션

팀에서 일관된 커밋 메시지를 사용하면 자동 changelog 생성, semantic versioning 자동화가 가능합니다.

feat: 새 기능 추가 (MINOR 버전 증가)
fix: 버그 수정 (PATCH 버전 증가)
docs: 문서 변경
style: 코드 포맷팅 (기능 변경 없음)
refactor: 리팩토링 (기능 변경 없음)
perf: 성능 개선
test: 테스트 추가/수정
ci: CI 설정 변경
chore: 기타 변경

# Breaking Change (MAJOR 버전 증가)
feat!: API 엔드포인트 변경

커밋 메시지 린트 (GitHub Actions)

name: Commit Lint

on:
  pull_request:

jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: wagoid/commitlint-github-action@v6
        with:
          configFile: .commitlintrc.yml

Dependabot 자동 의존성 업데이트

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: gomod
    directory: /
    schedule:
      interval: weekly
      day: monday
    open-pull-requests-limit: 10
    reviewers:
      - myorg/backend-team
    labels:
      - dependencies
      - go
    commit-message:
      prefix: 'deps'

  - package-ecosystem: docker
    directory: /
    schedule:
      interval: weekly
    labels:
      - dependencies
      - docker

  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    labels:
      - dependencies
      - ci

GitHub Actions 보안 하드닝

# 1. 최소 권한 원칙
permissions:
  contents: read  # 기본적으로 읽기만

# 2. Action 버전 SHA 고정
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

# 3. 외부 Action 감사
# Marketplace Action 사용 전 소스 코드 확인

# 4. OpenSSF Scorecard
- uses: ossf/scorecard-action@v2
  with:
    results_file: scorecard-results.sarif

실전 퀴즈

Q1. GitHub Actions에서 concurrency group의 역할은 무엇이며, PR과 프로덕션 배포에서 cancel-in-progress 설정이 다른 이유는?

A1.

concurrency group은 동일한 그룹 내에서 워크플로우 실행이 겹치지 않도록 제어합니다.

  • PR에서 cancel-in-progress: true: 개발자가 새 커밋을 푸시하면 이전 CI 실행이 의미 없으므로 취소하여 Runner 비용을 절약합니다.
  • 프로덕션 배포에서 cancel-in-progress: false: 진행 중인 배포를 중간에 취소하면 서비스가 불안정한 상태에 놓일 수 있으므로, 현재 배포가 완료될 때까지 새 배포를 대기시킵니다.

핵심은 "취소해도 안전한가?"를 기준으로 판단하는 것입니다. CI 테스트는 취소해도 무해하지만, 프로덕션 배포는 원자적으로 완료되어야 합니다.

Q2. Go에서 CGO_ENABLED=0으로 빌드하는 이유와, 이것이 Docker 이미지에 미치는 영향은?

A2.

CGO_ENABLED=0은 C 라이브러리(libc 등)에 대한 동적 링크를 비활성화하고, 순수 Go 코드만으로 정적 바이너리를 생성합니다.

Docker 이미지에 미치는 영향:

  1. scratch 이미지 사용 가능: C 라이브러리가 없어도 바이너리가 독립적으로 실행됩니다. scratch는 0바이트 베이스 이미지이므로 최종 이미지가 바이너리 크기(10-15MB)와 거의 같습니다.
  2. 보안 강화: 공격 표면이 최소화됩니다. 쉘, 패키지 매니저, 시스템 라이브러리가 없으므로 CVE에 노출될 가능성이 극히 낮습니다.
  3. 이식성: 어떤 Linux 커널에서도 동일하게 동작합니다.

단, net 패키지의 DNS resolver나 SQLite 같은 CGO 의존 라이브러리를 사용하는 경우에는 주의가 필요합니다. Go의 내장 DNS resolver(netgo 빌드 태그)를 사용하거나, 순수 Go 구현체로 대체해야 합니다.

Q3. Trunk-Based Development에서 Feature Flag 없이 미완성 기능을 main에 머지하면 어떤 문제가 발생하나요?

A3.

Feature Flag 없이 미완성 기능을 main에 머지하면 다음 문제가 발생합니다:

  1. 사용자 노출 위험: main에 머지된 코드는 즉시 배포될 수 있으므로, 미완성 기능이 사용자에게 노출됩니다.
  2. 불안정한 프로덕션: 미완성 로직으로 인한 예기치 않은 에러, 크래시, 데이터 손상 가능성이 있습니다.
  3. 롤백 어려움: 여러 개발자의 커밋이 섞여 있으므로 특정 기능만 롤백하기 어렵습니다.
  4. 배포 차단: 미완성 기능 때문에 다른 완성된 기능까지 배포가 지연될 수 있습니다.

Feature Flag의 핵심 가치는 "배포(deploy)와 릴리스(release)를 분리"하는 것입니다. 코드는 main에 있지만, Feature Flag를 통해 사용자에게는 보이지 않게 할 수 있습니다. 기능이 완성되면 Flag를 켜서 릴리스하고, 문제가 발생하면 Flag를 끄는 것만으로 롤백이 가능합니다.

Q4. GitHub Actions의 OIDC 인증이 기존 시크릿 기반 인증보다 안전한 이유를 설명하세요.

A4.

기존 시크릿 기반 인증의 문제점:

  • 장기 자격 증명(Access Key/Secret Key)을 GitHub에 저장해야 합니다.
  • 키가 유출되면 만료되지 않으므로 무제한 접근이 가능합니다.
  • 키 로테이션을 수동으로 관리해야 합니다.
  • 여러 리포지토리에서 같은 키를 공유하면 위험이 증폭됩니다.

OIDC 인증의 장점:

  1. 단기 토큰: 워크플로우 실행 시에만 유효한 임시 토큰을 발급받습니다. 실행이 끝나면 토큰이 자동으로 만료됩니다.
  2. 시크릿 저장 불필요: GitHub에 AWS/GCP 자격 증명을 저장하지 않으므로 유출 위험이 없습니다.
  3. 세분화된 권한: IAM 역할의 trust policy에서 특정 리포지토리, 브랜치, 환경에서만 접근을 허용할 수 있습니다.
  4. 감사 추적: 클라우드 제공자의 로그에서 어떤 워크플로우가 언제 접근했는지 추적할 수 있습니다.

동작 원리: GitHub Actions가 OIDC 토큰을 발행하면, AWS STS의 AssumeRoleWithWebIdentity API를 호출하여 임시 자격 증명을 받습니다. 이 토큰은 기본 1시간만 유효합니다.

Q5. 모노레포에서 path-based triggering을 사용할 때, 공유 라이브러리가 변경되면 어떻게 처리해야 하나요?

A5.

모노레포에서 공유 라이브러리(예: pkg/, lib/, common/)가 변경되면, 해당 라이브러리에 의존하는 모든 서비스를 다시 빌드하고 테스트해야 합니다.

구현 방법:

  1. paths-filter로 변경 감지: dorny/paths-filter 액션으로 어떤 디렉토리가 변경되었는지 감지합니다.

  2. 의존성 그래프 기반 빌드: 공유 라이브러리 변경 시 의존하는 서비스를 모두 빌드합니다.

  3. 실전 패턴:

    • 공유 라이브러리 변경이 감지되면 모든 서비스 빌드를 트리거합니다.
    • 개별 서비스 변경이면 해당 서비스만 빌드합니다.
    • go.sum 변경은 전체 빌드를 트리거할 수 있습니다.
  4. 최적화: 공유 라이브러리의 변경 범위를 분석하여 영향받는 서비스만 빌드할 수도 있지만, 복잡도가 높아지므로 대부분의 팀에서는 "공유 라이브러리 변경 시 전체 빌드"로 충분합니다.

핵심 원칙: 안전성(모든 서비스 빌드)과 효율성(변경된 서비스만 빌드) 사이의 균형을 팀의 상황에 맞게 선택합니다. 서비스 수가 적으면 전체 빌드가 충분하고, 수십 개가 넘으면 의존성 그래프 기반 빌드를 고려합니다.


참고 자료

  1. GitHub Actions Documentation - Workflow syntax reference
  2. GitHub Actions - Reusable workflows
  3. GitHub Actions - OIDC for cloud providers
  4. Go Documentation - Command go (build, test, vet)
  5. Go Documentation - Module reference
  6. golangci-lint - Go linters aggregator
  7. GoReleaser Documentation - Release automation for Go
  8. Docker Documentation - Multi-stage builds
  9. Google Distroless Container Images
  10. actions/cache - Caching dependencies
  11. docker/build-push-action - Build and push Docker images
  12. Git Flow - A successful Git branching model (Vincent Driessen)
  13. GitHub Flow - Understanding the GitHub flow
  14. Trunk-Based Development - trunkbaseddevelopment.com
  15. GitHub - Branch protection rules
  16. GitHub - Repository rulesets
  17. CODEOWNERS - About code owners
  18. Semantic Versioning 2.0.0
  19. Conventional Commits specification
  20. act - Run GitHub Actions locally
  21. GitHub - Understanding GitHub Actions billing
  22. AWS OIDC - GitHub Actions OpenID Connect
  23. Kubernetes - Rolling update deployment
  24. GitHub Container Registry (GHCR) Documentation
  25. OpenSSF Scorecard - Security health metrics for open source