들어가며
현대 소프트웨어 개발에서 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 }}"
}
**커스텀 액션 만들기**
세 가지 유형의 커스텀 액션을 만들 수 있습니다.
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
2. **JavaScript Action** (Node.js 런타임, 복잡한 로직)
3. **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 --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 + 바이너리 | 최소 필수 파일만 | 쉘 없음 |
| scratch | 0MB + 바이너리 | 가장 작음 | 쉘, 패키지 매니저 없음 |
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
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
**비용 절약 팁**
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 ./...
3. **매트릭스 최적화**: 전체 매트릭스는 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'
4. **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
실전 퀴즈
**A1.**
concurrency group은 동일한 그룹 내에서 워크플로우 실행이 겹치지 않도록 제어합니다.
- **PR에서 `cancel-in-progress: true`**: 개발자가 새 커밋을 푸시하면 이전 CI 실행이 의미 없으므로 취소하여 Runner 비용을 절약합니다.
- **프로덕션 배포에서 `cancel-in-progress: false`**: 진행 중인 배포를 중간에 취소하면 서비스가 불안정한 상태에 놓일 수 있으므로, 현재 배포가 완료될 때까지 새 배포를 대기시킵니다.
핵심은 "취소해도 안전한가?"를 기준으로 판단하는 것입니다. CI 테스트는 취소해도 무해하지만, 프로덕션 배포는 원자적으로 완료되어야 합니다.
**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 구현체로 대체해야 합니다.
**A3.**
Feature Flag 없이 미완성 기능을 main에 머지하면 다음 문제가 발생합니다:
1. **사용자 노출 위험**: main에 머지된 코드는 즉시 배포될 수 있으므로, 미완성 기능이 사용자에게 노출됩니다.
2. **불안정한 프로덕션**: 미완성 로직으로 인한 예기치 않은 에러, 크래시, 데이터 손상 가능성이 있습니다.
3. **롤백 어려움**: 여러 개발자의 커밋이 섞여 있으므로 특정 기능만 롤백하기 어렵습니다.
4. **배포 차단**: 미완성 기능 때문에 다른 완성된 기능까지 배포가 지연될 수 있습니다.
Feature Flag의 핵심 가치는 "배포(deploy)와 릴리스(release)를 분리"하는 것입니다. 코드는 main에 있지만, Feature Flag를 통해 사용자에게는 보이지 않게 할 수 있습니다. 기능이 완성되면 Flag를 켜서 릴리스하고, 문제가 발생하면 Flag를 끄는 것만으로 롤백이 가능합니다.
**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시간만 유효합니다.
**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
현재 단락 (1/1729)
현대 소프트웨어 개발에서 CI/CD는 선택이 아니라 생존 조건입니다. 코드를 작성하고, 테스트를 돌리고, 빌드하고, 배포하는 모든 과정을 수동으로 처리하던 시대는 끝났습니다. Gi...