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

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- Part 1: GitHub Actions 완전 이해
- Part 2: Go 프로젝트 CI/CD
- Part 3: 브랜치 전략 완전 비교
- Part 4: 실전 프로젝트 — Go 마이크로서비스 풀 파이프라인
- Part 5: 성능 최적화 & 비용 절약
- 보너스: 실전 팁 모음
- 실전 퀴즈
- 참고 자료
들어가며
현대 소프트웨어 개발에서 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-hosted | Self-hosted |
|---|---|---|
| 설정 | 제로 설정 | 직접 프로비저닝 필요 |
| OS | Ubuntu, 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 }}"
}
커스텀 액션 만들기
세 가지 유형의 커스텀 액션을 만들 수 있습니다.
- 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
- JavaScript Action (Node.js 런타임, 복잡한 로직)
- 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 전략을 사용하면 다음과 같은 조합이 생성됩니다:
| GOOS | GOARCH | 비고 |
|---|---|---|
| linux | amd64 | 가장 일반적인 서버 환경 |
| linux | arm64 | AWS Graviton, 라즈베리 파이 |
| darwin | amd64 | macOS Intel |
| darwin | arm64 | macOS Apple Silicon (M1/M2/M3) |
| windows | amd64 | Windows 데스크톱 |
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 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# 타임존 데이터
COPY /usr/share/zoneinfo /usr/share/zoneinfo
# 바이너리
COPY /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 + 바이너리 | 최소 필수 파일만 | 쉘 없음 |
| scratch | 0MB + 바이너리 | 가장 작음 | 쉘, 패키지 매니저 없음 |
scratch 기반으로 빌드하면 최종 이미지가 10-15MB 수준으로 줄어듭니다. 원본 빌드 이미지 대비 98% 감소입니다.
distroless 사용 (디버깅 가능한 최소 이미지)
FROM gcr.io/distroless/static-debian12:nonroot
COPY /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 \
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), 자동으로:
- 모든 플랫폼용 바이너리가 크로스 컴파일됩니다
- 체크섬 파일이 생성됩니다
- Changelog가 자동 생성됩니다
- GitHub Release가 생성되고 바이너리가 업로드됩니다
- Docker 이미지가 빌드되어 GHCR에 푸시됩니다
- 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 ┘
워크플로우:
- main에서 feature 브랜치 생성
- 코드 작성 및 커밋
- PR 생성 → 코드 리뷰 + CI
- 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 Flow | GitHub Flow | Trunk-Based |
|---|---|---|---|
| 브랜치 수 | 5종류 | 2종류 | 1-2종류 |
| 복잡도 | 높음 | 낮음 | 매우 낮음 |
| 배포 빈도 | 주/월 단위 | 일 단위 | 시간 단위 |
| 릴리스 관리 | 명시적 | 암묵적 | Feature Flag |
| 머지 충돌 | 빈번 | 보통 | 최소 |
| CI/CD 요구 | 보통 | 높음 | 매우 높음 |
| 팀 규모 | 대규모 | 소-중규모 | 모든 규모 |
| 적합 분야 | 모바일, 엔터프라이즈 | SaaS, 웹 | 대규모 서비스 |
| 대표 기업 | 전통 SW 기업 | GitHub, Shopify | Google, Meta |
3-4. 브랜치 보호 규칙 설정
어떤 브랜치 전략을 선택하든, 브랜치 보호 규칙은 필수입니다.
Required Reviews
Settings → Branches → Branch 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 패턴, 파일 경로 제한 등)
Settings → Rules → Rulesets → New 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 단가 |
|---|---|---|---|---|
| Free | 2,000분 | 무료 | 무료 | 무료 |
| Pro | 3,000분 | 0.008/분 | 0.016/분 | 0.08/분 |
| Team | 3,000분 | 0.008/분 | 0.016/분 | 0.08/분 |
| Enterprise | 50,000분 | 0.008/분 | 0.016/분 | 0.08/분 |
macOS Runner는 Linux 대비 10배 비쌉니다. 가능하면 Linux에서 테스트하고, macOS는 필수적인 경우에만 사용하세요.
비용 계산 예시
일일 빌드 횟수: 50회
평균 빌드 시간: 8분
Linux Runner 사용
일일 비용: 50 x 8 x $0.008 = $3.20
월간 비용: $3.20 x 22일(영업일) = $70.40
연간 비용: $70.40 x 12 = $844.80
비용 절약 팁
-
캐싱 극대화: 캐시 히트율이 높을수록 빌드 시간이 줄어듭니다.
-
조건부 스킵: 변경 없는 서비스는 빌드하지 않습니다.
- 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 ./...
- 매트릭스 최적화: 전체 매트릭스는 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'
- timeout 설정: 무한 루프 방지로 예상치 못한 비용 발생을 차단합니다.
jobs:
test:
timeout-minutes: 15
steps:
- name: Test
timeout-minutes: 10
run: go test ./...
Self-hosted vs GitHub-hosted 비용 비교
| 항목 | GitHub-hosted | Self-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 이미지에 미치는 영향:
- scratch 이미지 사용 가능: C 라이브러리가 없어도 바이너리가 독립적으로 실행됩니다. scratch는 0바이트 베이스 이미지이므로 최종 이미지가 바이너리 크기(10-15MB)와 거의 같습니다.
- 보안 강화: 공격 표면이 최소화됩니다. 쉘, 패키지 매니저, 시스템 라이브러리가 없으므로 CVE에 노출될 가능성이 극히 낮습니다.
- 이식성: 어떤 Linux 커널에서도 동일하게 동작합니다.
단, net 패키지의 DNS resolver나 SQLite 같은 CGO 의존 라이브러리를 사용하는 경우에는 주의가 필요합니다. Go의 내장 DNS resolver(netgo 빌드 태그)를 사용하거나, 순수 Go 구현체로 대체해야 합니다.
Q3. Trunk-Based Development에서 Feature Flag 없이 미완성 기능을 main에 머지하면 어떤 문제가 발생하나요?
A3.
Feature Flag 없이 미완성 기능을 main에 머지하면 다음 문제가 발생합니다:
- 사용자 노출 위험: main에 머지된 코드는 즉시 배포될 수 있으므로, 미완성 기능이 사용자에게 노출됩니다.
- 불안정한 프로덕션: 미완성 로직으로 인한 예기치 않은 에러, 크래시, 데이터 손상 가능성이 있습니다.
- 롤백 어려움: 여러 개발자의 커밋이 섞여 있으므로 특정 기능만 롤백하기 어렵습니다.
- 배포 차단: 미완성 기능 때문에 다른 완성된 기능까지 배포가 지연될 수 있습니다.
Feature Flag의 핵심 가치는 "배포(deploy)와 릴리스(release)를 분리"하는 것입니다. 코드는 main에 있지만, Feature Flag를 통해 사용자에게는 보이지 않게 할 수 있습니다. 기능이 완성되면 Flag를 켜서 릴리스하고, 문제가 발생하면 Flag를 끄는 것만으로 롤백이 가능합니다.
Q4. GitHub Actions의 OIDC 인증이 기존 시크릿 기반 인증보다 안전한 이유를 설명하세요.
A4.
기존 시크릿 기반 인증의 문제점:
- 장기 자격 증명(Access Key/Secret Key)을 GitHub에 저장해야 합니다.
- 키가 유출되면 만료되지 않으므로 무제한 접근이 가능합니다.
- 키 로테이션을 수동으로 관리해야 합니다.
- 여러 리포지토리에서 같은 키를 공유하면 위험이 증폭됩니다.
OIDC 인증의 장점:
- 단기 토큰: 워크플로우 실행 시에만 유효한 임시 토큰을 발급받습니다. 실행이 끝나면 토큰이 자동으로 만료됩니다.
- 시크릿 저장 불필요: GitHub에 AWS/GCP 자격 증명을 저장하지 않으므로 유출 위험이 없습니다.
- 세분화된 권한: IAM 역할의 trust policy에서 특정 리포지토리, 브랜치, 환경에서만 접근을 허용할 수 있습니다.
- 감사 추적: 클라우드 제공자의 로그에서 어떤 워크플로우가 언제 접근했는지 추적할 수 있습니다.
동작 원리: GitHub Actions가 OIDC 토큰을 발행하면, AWS STS의 AssumeRoleWithWebIdentity API를 호출하여 임시 자격 증명을 받습니다. 이 토큰은 기본 1시간만 유효합니다.
Q5. 모노레포에서 path-based triggering을 사용할 때, 공유 라이브러리가 변경되면 어떻게 처리해야 하나요?
A5.
모노레포에서 공유 라이브러리(예: pkg/, lib/, common/)가 변경되면, 해당 라이브러리에 의존하는 모든 서비스를 다시 빌드하고 테스트해야 합니다.
구현 방법:
-
paths-filter로 변경 감지: dorny/paths-filter 액션으로 어떤 디렉토리가 변경되었는지 감지합니다.
-
의존성 그래프 기반 빌드: 공유 라이브러리 변경 시 의존하는 서비스를 모두 빌드합니다.
-
실전 패턴:
- 공유 라이브러리 변경이 감지되면 모든 서비스 빌드를 트리거합니다.
- 개별 서비스 변경이면 해당 서비스만 빌드합니다.
go.sum변경은 전체 빌드를 트리거할 수 있습니다.
-
최적화: 공유 라이브러리의 변경 범위를 분석하여 영향받는 서비스만 빌드할 수도 있지만, 복잡도가 높아지므로 대부분의 팀에서는 "공유 라이브러리 변경 시 전체 빌드"로 충분합니다.
핵심 원칙: 안전성(모든 서비스 빌드)과 효율성(변경된 서비스만 빌드) 사이의 균형을 팀의 상황에 맞게 선택합니다. 서비스 수가 적으면 전체 빌드가 충분하고, 수십 개가 넘으면 의존성 그래프 기반 빌드를 고려합니다.
참고 자료
- GitHub Actions Documentation - Workflow syntax reference
- GitHub Actions - Reusable workflows
- GitHub Actions - OIDC for cloud providers
- Go Documentation - Command go (build, test, vet)
- Go Documentation - Module reference
- golangci-lint - Go linters aggregator
- GoReleaser Documentation - Release automation for Go
- Docker Documentation - Multi-stage builds
- Google Distroless Container Images
- actions/cache - Caching dependencies
- docker/build-push-action - Build and push Docker images
- Git Flow - A successful Git branching model (Vincent Driessen)
- GitHub Flow - Understanding the GitHub flow
- Trunk-Based Development - trunkbaseddevelopment.com
- GitHub - Branch protection rules
- GitHub - Repository rulesets
- CODEOWNERS - About code owners
- Semantic Versioning 2.0.0
- Conventional Commits specification
- act - Run GitHub Actions locally
- GitHub - Understanding GitHub Actions billing
- AWS OIDC - GitHub Actions OpenID Connect
- Kubernetes - Rolling update deployment
- GitHub Container Registry (GHCR) Documentation
- OpenSSF Scorecard - Security health metrics for open source