문제: 하나의 태그, 여러 개의 산출물
모노레포나 폴리글랏 서비스에서는 하나의 릴리스가 곧 여러 산출물을 의미한다. Docker 이미지, Python wheel, npm 패키지, Helm 차트, 정적 바이너리가 한 커밋에서 동시에 나온다. 초기에는 "main에 머지되면 배포" 방식으로 시작하지만, 곧 두 가지 문제에 부딪힌다. 첫째, 어떤 커밋이 실제로 프로덕션에 나갔는지 추적이 흐려진다. 둘째, 산출물마다 버전이 어긋난다. 이미지 태그는 1.4.2인데 차트는 1.4.1을 참조하는 식이다.
태그(예: v1.4.2)를 릴리스의 단일 진입점(single source of truth)으로 삼으면 이 문제가 정리된다. 태그가 붙는 순간이 곧 릴리스 시점이고, 태그 이름이 곧 모든 산출물의 버전이 된다.
왜 브랜치가 아니라 태그인가
브랜치 기반 배포는 "언제"가 모호하다. 머지 시점, 야간 배치, 수동 트리거가 섞이면서 재현이 어렵다. 태그는 불변(immutable) 참조라는 점이 핵심이다. v1.4.2가 가리키는 커밋은 바뀌지 않으므로, 6개월 뒤에도 동일한 산출물을 다시 빌드할 수 있다.
| 기준 | 브랜치 트리거 | 태그 트리거 |
|---|---|---|
| 배포 시점 | 머지마다(모호) | 태그 생성 시(명시적) |
| 버전 근거 | 커밋 해시/빌드번호 | 태그 이름 = SemVer |
| 재현성 | 낮음 | 높음(불변 참조) |
| 롤백 | revert 후 재빌드 | 이전 태그 재배포 |
태그 컨벤션 설계
단일 버전 정책이면 v1.4.2로 충분하다. 하지만 모노레포에서 컴포넌트별 릴리스 주기가 다르면 접두어를 붙이는 것이 안전하다. api-v1.4.2, worker-v2.0.0 형태다. 워크플로에서 접두어로 어떤 아티팩트를 빌드할지 분기한다. SemVer의 pre-release 표기(-rc.1)를 활용하면 스테이징과 프로덕션을 하나의 규칙으로 구분할 수 있다.
GitHub Actions: 태그 트리거 파이프라인
태그 push를 트리거로 잡고, 태그 이름에서 버전과 프리릴리스 여부를 파싱한다. 산출물별 job은 matrix로 병렬화하되, 공통 버전은 상위 job에서 한 번만 계산한다.
name: release
on:
push:
tags: ['v*.*.*', 'v*.*.*-rc.*']
jobs:
meta:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- id: v
run: |
TAG="${GITHUB_REF_NAME}"
VER="${TAG#v}"
echo "version=$VER" >> "$GITHUB_OUTPUT"
if [[ "$TAG" == *-rc.* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
build:
needs: meta
runs-on: ubuntu-latest
strategy:
matrix:
artifact: [image, wheel, chart]
steps:
- uses: actions/checkout@v4
- name: build ${{ matrix.artifact }}
env:
VERSION: ${{ needs.meta.outputs.version }}
run: make release-${{ matrix.artifact }} VERSION="$VERSION"
핵심은 meta job이 버전을 단 한 번 계산해 모든 하위 job에 주입한다는 점이다. 각 job이 독립적으로 버전을 계산하면 경합이나 표기 불일치가 생긴다.
버전 주입: 산출물마다 방식이 다르다
버전 문자열을 각 산출물의 메타데이터에 심는 과정이 실무에서 가장 자주 깨진다. Docker는 빌드 인자, Python은 동적 버전, Helm은 Chart.yaml 치환으로 각각 처리한다.
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:?version required}"
# Docker: 라벨과 태그에 버전 주입
docker build \
--build-arg APP_VERSION="$VERSION" \
--label "org.opencontainers.image.version=$VERSION" \
-t "registry.example.com/api:$VERSION" .
# Helm: Chart.yaml의 version/appVersion 치환 후 패키지
yq -i ".version = \"$VERSION\" | .appVersion = \"$VERSION\"" chart/Chart.yaml
helm package chart/ --version "$VERSION"
# Python: 태그를 소스로 버전 생성 (setuptools-scm)
SETUPTOOLS_SCM_PRETEND_VERSION="$VERSION" python -m build
Python은 setuptools-scm이 태그를 직접 읽으므로 CI에서 fetch-tags가 필요하다. 얕은 체크아웃(shallow clone)이면 태그가 없어 버전이 0.0.0으로 잡히는 함정이 있다.
원자성과 부분 실패 처리
가장 위험한 상태는 3개 산출물 중 2개만 배포된 경우다. 완전한 트랜잭션은 불가능하지만, 실무에서는 두 단계로 위험을 낮춘다. 첫째, 빌드·서명·검증을 모두 마친 뒤 마지막 단계에서만 레지스트리에 push한다(build-then-publish 분리). 둘째, GitHub Release 생성은 모든 publish가 성공한 뒤 맨 마지막에 둔다. Release가 존재하면 "완전히 나갔다"는 신호가 된다.
중복 태그 재실행에도 대비해야 한다. 이미 존재하는 버전의 재빌드를 막으려면 push 전에 레지스트리를 조회해 멱등성(idempotency)을 확보한다.
if docker manifest inspect "registry.example.com/api:$VERSION" &>/dev/null; then
echo "이미 게시된 버전. 스킵." && exit 0
fi
docker push "registry.example.com/api:$VERSION"
주의점 정리
- 태그 삭제 후 재생성 금지: 불변성이 무너지면 태그 기반 추적의 전제가 깨진다. 잘못 나갔으면 새 패치 버전을 올린다.
- 프리릴리스 게이팅:
prerelease=true일 때 프로덕션 승격을 자동으로 막고 스테이징에만 배포한다. - 서명과 SBOM: 릴리스 산출물에는
cosign서명과 SBOM을 함께 붙여 공급망 검증 근거를 남긴다. - 버전 단일 계산: 태그 파싱은 파이프라인 상단에서 한 번만. 하위 job은 결과를 주입받기만 한다.
태그를 릴리스의 유일한 진입점으로 고정하면, 여러 산출물이 하나의 버전으로 묶이고 재현·롤백·감사가 모두 태그 하나로 수렴한다. 자동화의 목표는 화려한 파이프라인이 아니라, "이 태그가 무엇을 배포했는가"에 항상 명확히 답할 수 있는 상태다.