머지와 배포 분리하기: main push 대신 tag push로 production 배포하기

태그를 release artifact로 취급하고 머지 이벤트와 릴리스 이벤트를 분리한 GitHub Actions CI/CD 구성을 정리했습니다.

머지와 배포 분리하기: main push 대신 tag push로 production 배포하기

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으로 머지"는 두 가지 일을 동시에 하려 한다는 점입니다.

  1. 기록 병합: develop의 변경을 main의 히스토리에 흡수한다
  2. 릴리스 트리거: 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:*)"'

이 워크플로우의 역할은 이렇습니다.

  1. develop에 PR이 머지되면 트리거
  2. Claude Code Action이 PR 내용을 분석
  3. 이미 draft 상태의 다음 릴리스가 있으면 → 카테고리에 새 항목 추가
  4. 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. 참고 자료


9. 다음 단계

인프라/배포 정리 다음은 다시 UX/통합 영역입니다. 다음 글에서는 SaaS 제품을 실제로 리브랜딩하며 마주친 "한국어 조사 지옥"과 480개 문자열 치환 이야기를 다룹니다.