머지와 배포 분리하기: main push 대신 tag push로 production 배포하기
태그를 release artifact로 취급하고 머지 이벤트와 릴리스 이벤트를 분리한 GitHub Actions CI/CD 구성을 정리했습니다.
1. 문제 상황
제가 운영하는 B2B SaaS의 초기 CI/CD는 아주 단순했습니다.
# .github/workflows/deploy.yml (Before)
on:
push:
branches: [main, develop]
jobs:
deploy-production:
if: github.ref == 'refs/heads/main'
steps:
- name: SSH and redeploy
run: ssh server "cd /app && git pull && docker compose up -d"
main에 push가 올라가면 자동으로 프로덕션에 배포됩니다. 스테이징도 마찬가지로 develop에 merge되면 자동 배포입니다. 빠르고 단순했습니다.
그런데 작은 불편들이 누적됐습니다
불편 1: 머지 자체가 배포 행위가 됨. PR을 머지할 때마다 "이게 지금 프로덕션에 나가도 될까?"를 고민하게 됩니다. 개발은 끝났지만 아직 배포는 이르다 싶은 feature를 머지 보류하게 되고, 여러 feature가 한 번에 꼬이기 시작합니다.
불편 2: 여러 PR을 한 번에 배포할 수 없음. 하루에 5개의 작은 PR을 머지하면 배포가 5번 나갑니다. 각각 2~3분씩 걸리므로 연속 머지가 사실상 불가능해집니다(이전 배포가 끝나야 다음 배포가 안정적으로 동작). 머지를 일부러 늦추거나 묶어서 한 번에 보내는 편법이 생깁니다.
불편 3: 롤백이 애매함. "어제 배포한 버전으로 되돌리자"가 곧 "어제 시점의 main HEAD로 git reset하자"인데, 그 이후에 머지된 커밋이 있으면 이력이 꼬입니다. 롤백이 강제 푸시와 맞물려 실수로 이어질 위험이 있었습니다.
불편 4: 릴리스 노트가 없음. 언제 뭐가 나갔는지 추적이 어려웠습니다. 사용자에게 "어제 올라간 거 해결됐어요"라고 안내하려 해도 어제가 언제의 어떤 커밋들인지 찾아봐야 했습니다.
네 가지 모두 근본 원인이 하나입니다. "코드가 main에 도달한 시점"과 "배포되는 시점"이 같은 이벤트에 묶여 있었습니다.
2. 원인 분석
2.1 Git 워크플로우 구조
이 프로젝트의 브랜치 구조는 전형적입니다.
feature/* → develop → main
↑ ↑
| └─ (예전) production 배포 트리거
└─ staging 배포 트리거 (유지)
develop은 staging 환경(dev.example.com)과 연결됩니다. QA·사내 테스트용입니다. main은 production(example.com)과 연결됩니다. develop에 쌓인 변경이 충분히 검증되면 main으로 PR을 올리고 머지합니다.
문제는 "main으로 머지"는 두 가지 일을 동시에 하려 한다는 점입니다.
- 기록 병합: develop의 변경을 main의 히스토리에 흡수한다
- 릴리스 트리거: production에 새 버전을 배포한다
이 두 가지는 논리적으로 다른 이벤트입니다. 기록 병합은 "이 변경들이 릴리스 후보가 됐다"는 의미이고, 릴리스 트리거는 "지금 이 후보를 실제로 배포한다"는 의미입니다. 한 이벤트에 섞이면 "머지하려는데 지금 배포되면 안 되는 경우"가 있을 때마다 스트레스가 생깁니다.
2.2 이상적인 분리
원하는 모델은 이렇습니다.
develop → main : 머지 (기록 병합) — 배포 안 함
main에 tag v1.2.3 : 릴리스 (배포 트리거) — 배포 실행
main에 머지는 자유롭게 하되, 배포는 별도의 명시적 액션(태그 생성)이 있어야만 발생합니다. 이렇게 하면 불편 네 가지가 한 번에 해결됩니다.
- 머지는 배포 부담 없이 할 수 있음
- 여러 PR을 main에 쌓아둔 뒤 태그 한 번으로 묶어 배포
- 롤백은 "이전 태그 체크아웃 + 재배포"로 명확
- 태그별 릴리스 노트가 자동 생성됨
3. 해결 방법
3.1 deploy 워크플로우 수정: main push 트리거 제거, tag push 추가
# .github/workflows/deploy.yml (After)
name: Deploy
on:
push:
branches: [develop] # ← main 제거
tags: ["v*"] # ← 버전 태그 추가
jobs:
ci:
uses: ./.github/workflows/ci.yml # lint + typecheck + build
deploy-production:
needs: ci
if: startsWith(github.ref, 'refs/tags/v') # ← 태그일 때만
runs-on: ubuntu-latest
environment: production
steps:
- name: Connect to Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TAILSCALE_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_CLIENT_SECRET }}
tags: tag:ci
- name: Deploy to Production
run: |
tailscale ssh ubuntu@server << 'DEPLOY_SCRIPT'
cd ~/docker/prod
git fetch origin main --tags
git checkout ${{ github.ref_name }} # ← 태그 이름으로 체크아웃
cd web
docker compose -p prod -f docker-compose.yml down
docker compose -p prod -f docker-compose.yml build --no-cache
docker compose -p prod -f docker-compose.yml up -d
sleep 10
docker compose -p prod -f docker-compose.yml exec -T app prisma migrate deploy
docker system prune -f
echo "Production deployment completed"
DEPLOY_SCRIPT
deploy-staging:
needs: ci
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: staging
# ... staging은 그대로 develop 푸시로 트리거
핵심 변경 사항:
branches: [main, develop] → branches: [develop] + tags: ["v*"]
main 브랜치 push는 더 이상 워크플로우를 트리거하지 않습니다. 대신 v로 시작하는 태그(v1.0.0, v1.2.3 등)가 푸시되면 트리거됩니다.
git checkout ${{ github.ref_name }}
github.ref_name은 태그 이름(예: v1.2.3)을 담고 있습니다. 배포 서버가 정확히 이 태그의 커밋을 체크아웃하므로 "언제 배포한 버전이 무엇이었는가"가 태그로 고정됩니다.
3.2 CI 워크플로우에서 main push 독립 트리거 제거
# .github/workflows/ci.yml (발췌)
on:
pull_request:
workflow_call: # deploy.yml에서 호출
# main push 독립 트리거 제거
# (이전에는 on.push.branches에 main이 있었음)
CI는 PR 생성 시와 deploy 워크플로우에서 호출될 때만 실행됩니다. 머지 직후에 별도로 도는 CI가 없어져서 액션 러닝 시간도 절약됩니다.
3.3 release-drafter로 자동 changelog 관리
머지할 때마다 "이 PR이 다음 릴리스에 포함될 것"이라는 정보를 어디에든 기록해야 합니다. 수동 changelog는 곧 잊혀질 게 뻔하니 자동화가 필요합니다.
release-drafter는 GitHub에서 공식적으로 권장하는 도구지만, 저희는 더 유연한 접근을 택했습니다. Claude Code Action으로 릴리스 노트를 자동 작성하는 워크플로우를 만들었습니다.
# .github/workflows/release-drafter.yml
name: Release Notes Drafter
on:
pull_request:
types: [closed]
branches: [develop] # ← develop에 머지될 때마다 트리거
jobs:
draft-release-notes:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Draft Release Notes
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
PR #${{ github.event.pull_request.number }} has been merged.
Your task: analyze this merged PR and append its release notes
to an existing draft GitHub release.
## Steps
1. Run `gh pr view ${{ github.event.pull_request.number }} --json title,body,files`
2. Run `gh pr diff ${{ github.event.pull_request.number }}`
3. Check for an existing draft release:
`gh release list --json tagName,isDraft,name | head -5`
4. Write a concise release note entry in English:
- One line summary with PR reference
- 2-4 sub-bullets for key details if significant
- Categorize under: New Features, Bug Fixes, Refactoring,
Performance, Testing, Documentation, or Maintenance
5. If a draft release exists:
- Get its current body
- Append the new entry under the correct category
- Update: `gh release edit <tag> --notes-file /tmp/release-notes.md`
6. If no draft exists:
- Determine next version from latest tag
- feat → bump minor, otherwise → bump patch
- Create: `gh release create <next-tag> --draft ...`
## Rules
- Write release notes in English
- Be concise but informative
- Do NOT include internal refactoring details unless public API affected
claude_args: '--model claude-sonnet-4-6 --max-turns 15 --allowedTools "Write,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh release:*),Bash(git tag:*),Bash(git log:*),Bash(git diff:*)"'
이 워크플로우의 역할은 이렇습니다.
- develop에 PR이 머지되면 트리거
- Claude Code Action이 PR 내용을 분석
- 이미 draft 상태의 다음 릴리스가 있으면 → 카테고리에 새 항목 추가
- draft가 없으면 → 마지막 태그 기준으로 다음 버전 결정(feat면 minor, 그 외는 patch) → 새 draft 생성
결과적으로 GitHub Releases 페이지에 항상 "다음에 배포될 것들"이 쌓인 draft가 존재합니다. 개발자는 머지만 하면 자동으로 Changelog가 갱신됩니다.
3.4 publish 워크플로우: 태그 푸시 시 draft를 공식 릴리스로 전환
# .github/workflows/release.yml
name: Publish Release
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Publish draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
# release-drafter가 만들어둔 draft 찾기
draft_tag=$(gh release list --json tagName,isDraft \
-q '.[] | select(.isDraft) | .tagName' | head -1)
if [ -n "$draft_tag" ]; then
# Draft 발견 → 태그 이름/제목 교체하고 publish
gh release edit "$draft_tag" --tag "$TAG" --title "$TAG" --draft=false
echo "Published draft release as $TAG"
else
# Draft 없음 → 최소 릴리스 생성
gh release create "$TAG" --title "$TAG" --generate-notes
echo "Created new release $TAG (no draft found)"
fi
태그가 푸시되면 기존 draft를 찾아 태그 이름을 붙이고 공식 릴리스로 전환합니다. draft 내용(release-drafter가 쌓아온 changelog)이 그대로 릴리스 노트가 됩니다. draft가 없는 비정상 케이스는 fallback으로 --generate-notes(GitHub이 자동으로 PR 제목을 모아주는 기본 옵션)를 씁니다.
3.5 실제 릴리스 절차
위 세 파일이 자리 잡으면 개발자의 릴리스 절차는 다음 네 단계입니다.
# 1. develop의 변경을 main으로 머지 (배포 안 됨)
gh pr create --base main --head develop --title "release: v1.2.3"
gh pr merge <PR-number> --merge
# 2. main 체크아웃 후 최신 상태
git checkout main && git pull
# 3. 태그 생성 + 푸시 → deploy & publish 트리거
git tag v1.2.3
git push origin v1.2.3
# 4. (완료) — deploy.yml이 production에 배포, release.yml이 draft를 publish
git push origin v1.2.3 한 줄이 production 배포의 유일한 진입점입니다. 이 명령이 없으면 main에 무엇이 있든 production은 변하지 않습니다.
4. 롤백 전략
기존 모델에서는 "어제 배포한 버전으로 되돌리기"가 강제 푸시·히스토리 조작을 동반했습니다. 새 모델에서는 단순합니다.
# 이전 태그를 다시 푸시 (태그는 이미 존재하니까 재배포만 트리거하고 싶다면)
# GitHub UI에서 이전 Release의 "Rerun" 버튼 사용
# 또는 수동 workflow_dispatch로 실행
워크플로우에 workflow_dispatch를 추가하면 관리자가 UI에서 "이 태그로 배포" 버튼을 누를 수 있습니다.
on:
push:
branches: [develop]
tags: ["v*"]
workflow_dispatch: # ← 수동 트리거
inputs:
tag:
description: 'Deploy tag (e.g., v1.2.2)'
required: true
롤백이 "어떤 버전을 배포할지 입력"이라는 단순 명령으로 바뀝니다. 이전 커밋을 reset하거나 브랜치를 꼬는 일이 없습니다.
5. 핵심 개념 정리
| 개념 | 설명 |
|---|---|
| Merge event | 코드가 main에 흡수되는 시점 (배포와 무관) |
| Release event | Production에 배포되는 시점 (태그 생성) |
| Draft release | release-drafter가 누적 관리하는 "다음 릴리스 예약" |
github.ref_name |
태그 이름 (예: v1.2.3), 서버에서 체크아웃용 |
workflow_call |
재사용 가능 워크플로우로 CI를 deploy에서 호출 |
workflow_dispatch |
수동 트리거 입력 받기 (롤백, 긴급 배포) |
Before/After 비교
| 항목 | Before | After |
|---|---|---|
| 배포 트리거 | main push | tag push (v*) |
| 머지 마찰 | 높음 (매번 배포 부담) | 낮음 (기록 병합만) |
| 롤백 방법 | 강제 푸시·reset | 이전 태그 재배포 |
| Changelog | 수동 관리 | 자동 draft 누적 |
| CI 러닝 시간 | main 머지 후 중복 실행 | deploy에서만 실행 |
| 여러 PR 묶어 배포 | 어려움 | 자연스러움 |
워크플로우 책임 분리
| 워크플로우 | 트리거 | 역할 |
|---|---|---|
ci.yml |
PR / workflow_call | lint + typecheck + build |
deploy.yml |
develop push / v* tag push |
staging / production 배포 |
release-drafter.yml |
PR merged to develop | draft 릴리스 노트 누적 |
release.yml |
v* tag push |
draft → 공식 릴리스 publish |
6. 베스트 프랙티스
- [ ] main push에 배포 트리거를 걸지 말 것. 머지 이벤트와 릴리스 이벤트는 개념적으로 분리되어야 합니다.
- [ ] 태그를 릴리스의 source of truth로. "어떤 버전이 프로덕션에 있는가?"에 대한 답은 태그이지 특정 브랜치의 HEAD가 아닙니다.
- [ ] release-drafter로 changelog 자동 누적. 수동 changelog는 언젠가 쓰이지 않게 됩니다.
- [ ]
workflow_call로 CI 재사용. deploy.yml에서 ci.yml을 호출하면 동일한 검증 단계를 한 곳에서 정의할 수 있습니다. - [ ]
workflow_dispatch추가로 수동 배포 경로 확보. 긴급 롤백·핫픽스 시에 수동 트리거가 없으면 곤란해집니다. - [ ] 태그 네이밍 규칙 고정.
v*,v1.2.3,v1.2.3-rc1등 패턴을 워크플로우 if에 맞춰 일관되게. - [ ] 배포 서버에서
git checkout <tag>.git pull이 아니라 특정 태그 체크아웃이어야 "어제 어느 커밋이 배포됐는가"가 명확합니다. - [ ] Migration 실행을 배포 단계에 포함.
prisma migrate deploy를 태그 기반 배포 시 자동 실행하게 두면 "마이그레이션 빠뜨림" 사고를 줄일 수 있습니다.
7. FAQ
Q1. 왜 main branch protection으로 충분하지 않나요?
A. Branch protection은 "누가·어떻게 main에 쓸 수 있는가"를 제한합니다. 그러나 배포 트리거와 머지 이벤트의 결합 자체는 해결하지 않습니다. Protection이 걸려 있어도 일단 머지되는 순간 배포가 나가는 구조는 그대로입니다. 태그 기반 트리거는 이 결합을 끊습니다.
Q2. 태그를 푸시해야 배포되면, 배포를 깜빡할 수 있지 않나요?
A. 네, 그게 장점입니다. 배포는 명시적 액션이어야 합니다. "배포를 깜빡했다"는 "의도적이지 않은 배포가 없다"와 같은 말입니다. 스테이징(develop)은 여전히 자동이므로 QA는 지속되고, 프로덕션만 관리자의 의도로 나갑니다.
Q3. release-drafter가 categorize를 잘못하면?
A. 머지된 PR의 conventional commit prefix(feat, fix, refactor 등)를 Claude가 분석해 카테고리를 정합니다. 잘못 분류되면 draft release를 GitHub UI에서 직접 수정할 수 있습니다. draft는 publish 전까지 자유롭게 편집 가능합니다. 또한 워크플로우의 prompt에 분류 기준을 명시해두면 일관성이 높아집니다.
Q4. main과 develop을 둘 다 쓰는 대신 main 하나로 통합해도 되지 않나요?
A. 가능합니다. 그 경우 main이 "개발 중 최신"이 되고, 태그만이 릴리스를 결정합니다. 저희 프로젝트는 develop을 staging 환경과 연결해 QA 사이클을 명확히 구분하고 있어서 main/develop 두 브랜치를 유지합니다. 팀 규모나 릴리스 빈도에 따라 trunk-based(단일 브랜치 + 태그)도 충분한 선택입니다.
Q5. 태그 네이밍을 v1.2.3 대신 release-2026-04-12 같은 날짜 기반으로 할 수도 있나요?
A. 네, 워크플로우의 tags: ["v*"] 패턴만 맞게 바꾸면 됩니다(tags: ["release-*"]). 다만 SemVer 스타일이 널리 통용되어 도구 체인(예: release-drafter의 기본 version 추론)과 잘 맞습니다. 날짜 기반은 "이 릴리스에 어떤 수준의 변경이 있었는가"를 표현하지 못한다는 단점이 있습니다.
Q6. 수동으로 태그를 푸시하는 대신 release를 GitHub UI에서 publish하면 자동으로 태그도 생성되지 않나요?
A. 네, 그 방법도 가능합니다. GitHub Releases 페이지에서 "Publish release"를 누르면 태그가 생성되며 push 이벤트가 발생합니다. 저희는 CLI 기반 흐름(git tag v1.2.3 && git push origin v1.2.3)을 선호해서 그 방식을 쓰지만, GUI 배포를 원한다면 이쪽이 더 편합니다. 둘 다 결국 태그 push 이벤트로 수렴합니다.
8. 참고 자료
- GitHub Actions: Events that trigger workflows — tags
- release-drafter 공식 리포
- Trunk-based development
- 검색 키워드: "GitHub Actions tag push deploy", "decouple merge deploy workflow"
9. 다음 단계
인프라/배포 정리 다음은 다시 UX/통합 영역입니다. 다음 글에서는 SaaS 제품을 실제로 리브랜딩하며 마주친 "한국어 조사 지옥"과 480개 문자열 치환 이야기를 다룹니다.