[CL] CD(지속적 배포) 파이프라인 구축 설계 - 100-hours-a-week/2-hertz-wiki GitHub Wiki

📚 목차


📄 CD 설계 문서

1. CD 설계 배경

1.1 CD 도입 배경

현대적인 소프트웨어 개발 환경에서 지속적 배포(Continuous Deployment)는 빠른 피드백 루프와 안정적인 배포를 위해 필수적입니다.

hertz는 서비스의 빠른 반복 개발과 신뢰할 수 있는 배포 프로세스를 구축하기 위해 GitHub Actions 기반의 CD 파이프라인을 도입합니다. 이를 통해 인적 오류를 최소화하고, 배포 과정의 일관성과 추적성을 확보하며, 개발팀의 생산성을 향상시키고자 합니다.

1.2 기존 방식의 문제점 (수동 FTP 배포)

기존의 수동 FTP 배포 방식은 다음과 같은 문제점을 가지고 있었습니다.

  • 인적 오류 위험: FTP 클라이언트를 이용한 파일 업로드 중 잘못된 파일 선택이나 누락 발생 가능
  • 일관성 부재: 배포자마다 파일 업로드 순서나 구조가 다를 수 있어 실행 오류 유발
  • 추적성 부족: 어떤 버전의 코드가 올라갔는지 확인이 불가능하고, 누가 언제 배포했는지 명확하지 않음
  • 시간 소모: 소스 수정 후 매번 수동으로 FTP 접속 → 파일 선택 → 덮어쓰기 등의 반복 작업 필요
  • 투명성 부족: 배포 상황이나 성공 여부를 팀원과 공유하거나 기록하기 어려움
  • 배포 안정성 부족: 서버 상태나 서비스 실행 결과를 자동으로 검증할 수 없어, 배포 후 오류가 늦게 발견됨

1.3 도식 비교(before vs after)

Before (수동 FTP 배포):

[수동]
개발자 → 코드 작성 → 직접 병합
       ↓
FTP 접속 → 로컬 빌드 결과 수동 업로드 → 서버 설정 수동 변경 → 브라우저로 직접 접속해 실행 확인

After (자동화된 CD 파이프라인):

[수동]
개발자 → 코드 작성 → GitHub PR → 병합
       ↓
[자동]
CI 테스트 → CD 서버로 코드 자동 배포 → 자동 빌드 및 서비스 재시작 → 헬스체크 → Discord 알림

1.4 기대 효과

  • 오류 감소: 수동 파일 업로드나 설정 실수 등 인적 오류 가능성 최소화
  • 일관성 확보: 모든 배포가 동일한 스크립트와 자동화 로직에 따라 수행됨
  • 가시성 향상: 배포 이력, 상태, 실패 여부 등을 팀원들이 실시간 확인 가능
  • 배포 시간 단축: FTP 접속 및 수동 업로드/설정 작업 제거로 배포 시간 50% 이상 단축
  • 개발자 생산성 향상: 반복적이고 소모적인 배포 작업 제거 → 핵심 개발에 집중 가능
  • 배포 안정성 향상: 배포 후 자동 헬스체크와 오류 탐지로 안정성 보장 가능

1.5 결론

CD 파이프라인 도입을 통해 더 빠르고, 더 안정적이며, 더 투명한 배포 프로세스를 구축할 수 있습니다. 이는 개발 생산성을 향상시키고, 서비스 안정성을 높이며, 최종적으로 사용자에게 더 나은 가치를 제공하는 기반이 될 것입니다.

2. CD 도구 선택

선택 도구: GitHub Actions (with SSH)

선택 배경 및 논리

현재 프로젝트는 GitHub 저장소 기반의 CI 파이프라인을 운영 중이며, 동일한 도구 체계 내에서 CD까지 일관되게 구성하는 것이 유지보수 및 운영 측면에서 가장 효율적입니다.

GitHub Actions는 CD 자동화를 위한 충분한 유연성과 확장성을 제공하며, 특히 SSH를 통한 단순한 서버 배포 방식과도 쉽게 통합할 수 있어 초기 서비스에 적합한 CD 플랫폼으로 판단하였습니다.

2.1 선택 이유 및 근거

항목 설명
CI-CD 일관성 확보 기존 CI 파이프라인(GitHub Actions)과 동일한 도구로 구성하여 학습 비용, 설정 복잡도 최소화
SSH 기반 배포 연동 용이 appleboy/ssh-action과 같은 공식 액션을 활용하여, GCP/EC2 등 원격 서버에 쉽게 연결 가능
환경별 자동 분기 처리 지원 브랜치 또는 입력값에 따라 production/staging 환경 분기를 간단하게 구현 가능
자동 + 수동 배포 모두 지원 workflow_runworkflow_dispatch 이벤트로 자동/수동 트리거를 유연하게 운영 가능
실패 대응 편의성 배포 실패 시 Webhook 알림, 헬스체크 실패 처리 등도 손쉽게 설정 가능
GitHub 생태계 확장성 AWS/GCP 배포, Docker 이미지 빌드, Discord/Slack 알림 등 풍부한 플러그인 지원
코드 기반의 배포 파이프라인 관리 YAML 설정으로 배포 전략을 버전 관리 가능하며, 리뷰 및 협업 흐름에 자연스럽게 통합

3. CD 배포 전략

3.1 선택한 배포 전략: 표준 배포(Standard Deployment) + 브랜치별 차별화

우리 팀은 현 단계에서 서비스의 특성과 인프라 환경을 고려하여 표준 배포 방식을 채택하되, 브랜치 별로 배포 방식을 차별화하는 전략을 선택했습니다.

주요 특징

  • main 브랜치: Continuous Deployment 방식 (CI 성공 시 자동 배포)
  • develop 브랜치: Continuous Delivery 방식 (수동 트리거로 선택적 배포)
  • 리소스 최적화: 스테이징 환경(develop)은 필요 시에만 시작하여 비용 절감 (사용 후에는 중지)
  • **스크립트를 통한 배포: Actions를 통한 SSH 접속으로 배포 스크립트 실행
  • 헬스 체크 자동화: 배포 후 서비스 정상 작동 확인

3.2 선택 이유와 서비스 특성 연계

  1. 아키텍처의 단순함
    • 무중단 배포를 위한 로드밸런서 구성, 인프라 이중화 등은 현 아키텍처에 과도한 복잡도 예상
    • 안정적인 롤백과 배포 일괄 통제가 가능한 표준 배포 방식이 변경 대응에 더 적합
  2. 점진적 자동화 도입
    • 초기 배포 이후 대규모 기능 변경 예상
    • 초기에는 완전 무중단 방식보다 안정성과 단순성 우선 (사용자가 가장 적은 시간대에 공지 후 배포 예정)
  3. 환경별 차별화 된 접근
    • 프로덕션(main): 안정적인 자동 배포로 신속한 기능 제공
    • 스테이징(develop): 테스트 환경으로서 필요 시에만 선택적 배포
  4. 리소스 최적화
    • 스테이징 환경은 필요 시에만 인스턴스 시작 및 중지
    • 클라우드 자원 비용 절감 및 효율적 관리
  5. 보다 안정적인 배포
    • 배포 후 헬스 체크를 통한 기본적인 서비스 가용성 확인
    • 프로덕션하기 전에 스테이징 환경에서 각 서비스간의 연동을 확인

3.3 향후 발전 방향

현재의 CD 전략은 기본적인 자동화와 일관성을 제공하지만, 향후 서비스 성장과 함께 다음과 같은 방향으로 발전시킬 계획입니다.

  1. Blue-Green 배포 도입
    • 프로덕션 환경에서 무중단 배포 구현
    • 즉각적인 롤백 기능 강화
  2. 환경 구성 자동화
    • Infrastructure as Code(IaC) 접근 확대
    • 환경 일관성 강화
  3. 모니터링 및 알림 고도화
    • 배포 후 성능 및 오류 모니터링 자동화
    • 이상 징후 즉시 알림 시스템

3.4 배포 프로세스 상세 흐름

  1. 코드 병합 단계
    • GitHub PR을 통한 코드 리뷰 및 병합
    • main 브랜치 병합 시 자동으로 CI 워크플로우 시작
  2. CI 단계
    • 코드 컴파일 및 빌드
    • 단위/통합 테스트 실행
  3. CD 트리거 단계
    • main 브랜치: CI 성공 시 자동으로 CD 워크플로우 시작
    • develop 브랜치: 수동으로 CD 워크플로우 시작 가능
    • 스테이징 환경 배포 시 필요에 따라 인스턴스 시작
  4. 배포 실행 단계
    • github-hosted runner로 SSH를 통해 서버에 접속
    • deploy.sh 스크립트 실행
    • 서비스 재시작
  5. 검증 단계
    • 헬스 체크 엔드포인트를 통한 서비스 정상 작동 확인
    • 실패 시 즉시 에러 리포팅
  6. 알림 단계
    • Discord를 통한 배포 결과 알림 (운영자 측 Discord)
    • 성공/실패 상태 및 관련 정보 제공

3.5 현재 롤백 전략

현재 구현된 롤백 전략은 다음과 같습니다.

  1. 이전 커밋 정보 저장
    • deploy.sh 내부에서 이전 배포 커밋 해시를 prev-deploy.txt에 저장
    • 정상적으로 배포에 성공한(현재상태) 커밋 해시는 last-deploy.txt에 저장
  2. 수동 롤백 메커니즘
    • 필요 시 github actions dispatch로 원하는 환경의 롤백 실행
    • 롤백 시 저장된 이전 커밋으로 체크아웃 후 재빌드 및 재시작

4. CD 파이프라인 설계

4.1 CD 트리거 조건

조건 설명
main 브랜치 CI 성공 시 자동 실행 workflow_run을 통한 배포 트리거
수동 실행 지원 workflow_dispatch를 통한 수동 롤백 또는 핫픽스 배포
브랜치 기반 환경 분기 main 브랜치 → production
develop 브랜치 → staging

4.2 서비스별 배포 전략 요약

서비스 브랜치 환경 방식 트리거
Backend maindevelop PRODDEV GCP Compute Engine SSH 기반 배포(GitHub Actions + SSH) CI 완료 후 자동 또는 수동 실행
Frontend maindevelop PRODDEV GCP Compute Engine SSH 기반 배포(GitHub Actions + SSH) CI 완료 후 자동 또는 수동 실행
AI Server maindevelop PRODDEV GCP Compute Engine SSH 기반 배포(GitHub Actions + SSH) CI 완료 후 자동 또는 수동 실행

4.3 공통 배포 흐름

  1. 배포 대상 서버 결정 (브랜치 및 입력값 기반)
  2. Git pull 및 배포 (deploy.sh)
  3. 서비스 재시작 (Spring Boot, Next.js, FastAPI)
  4. 헬스체크 (curl /health, 포트별로 상이)
  5. Discord Webhook으로 성공/실패 알림 전송

4.4 롤백 전략

항목 설명
트리거 workflow_dispatch 수동 실행 (환경 선택 입력 포함)
검증 로직 현재 브랜치와 트리거에 입력된 환경이 맞지 않으면 롤백 중단
방식 deploy.sh --rollback 실행 (저장된 커밋 해시 기반 롤백)
보호 정책 production 롤백은 main 브랜치에서만 허용
헬스체크 롤백 후 동일한 엔드포인트에 헬스체크 수행
알림 성공/실패 관계없이 Discord Webhook 전송

4.5 배포 디렉토리 및 구성 예시

서비스 서버 디렉토리 주요 구성
Backend /opt/springboot deploy.sh, build/libs/app.jar, prev-deploy.txt
Frontend /opt/frontend deploy.sh, .next/, prev-deploy.txt
AI Server /opt/ai-server deploy.sh, models/, prev-deploy.txt

5. CD 파이프라인

5.1. CD 파이프라인 다이어그램

카카오테크 - Tuning 아키텍처-빅뱅배포 - CD 파이프라인

5.2 RollBack 파이프라인

카카오테크 - Tuning 아키텍처-빅뱅배포 - CD 롤백

6. 스크립트

6.1 CD 스크립트

프론트엔드

name: Frontend CD (SSH)

on:
  workflow_run:
    workflows: ["Frontend CI"]
    types: [completed]
    branches: [main]

  workflow_dispatch:

env:
  HOST: ${{ secrets.FRONT_SERVER_HOST }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: |
      (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') || 
      github.event_name == 'workflow_dispatch'

    steps:
      - name: Set environment
        run: |
          if [ "${{ github.event_name }}" == "workflow_run" ](/100-hours-a-week/2-hertz-wiki/wiki/-"${{-github.event_name-}}"-==-"workflow_run"-); then
            BRANCH="${{ github.event.workflow_run.head_branch }}"
          else
            BRANCH="${{ github.ref_name }}"
          fi
          echo "BRANCH=$BRANCH" >> $GITHUB_ENV

      - name: Deploy via SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script_stop: true
          script: |
            cd /opt/frontend

            if [ ! -d ".git" ]; then
              git clone https://github.com/${{ github.repository }}.git .
              git checkout ${{ env.BRANCH }}
            else
              git fetch --all
              git checkout ${{ env.BRANCH }}
              git reset --hard origin/${{ env.BRANCH }}
            fi

            chmod +x deploy.sh
            ENV=${{ env.ENV }} ./deploy.sh

            # 헬스체크 (Next.js는 nginx 프록시나 직접 포트 확인 가능)
            echo "✅ Frontend 기동 확인 중..."
            curl -sf http://localhost:3000 || {
              echo "❌ 프론트엔드 헬스체크 실패"; exit 1;
            }

      - name: Send notification
        if: always()
        run: |
          curl -H "Content-Type: application/json" \
               -X POST \
               -d "{\"username\": \"CD Bot\", \"content\": \"✅ 프론트엔드 배포 완료 - 브랜치: ${{ env.BRANCH }}\"}" \
               ${{ secrets.DISCORD_WEBHOOK_URL }}

백엔드

name: Backend CD (SSH)

on:
  workflow_run:
    workflows: ["Backend CI"]
    types: [completed]
    branches: [main]

  workflow_dispatch:

env:
  PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
  ZONE: ${{ secrets.GCP_ZONE }}
  STAGING_INSTANCE: ${{ secrets.STAGING_INSTANCE }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: |
      (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') || 
      github.event_name == 'workflow_dispatch'

    steps:
      - name: Set environment
        run: |
          if [ "${{ github.event_name }}" == "workflow_run" ](/100-hours-a-week/2-hertz-wiki/wiki/-"${{-github.event_name-}}"-==-"workflow_run"-); then
            BRANCH="${{ github.event.workflow_run.head_branch }}"
          else
            BRANCH="${{ github.ref_name }}"
          fi
          echo "BRANCH=$BRANCH" >> $GITHUB_ENV

          if [ "$BRANCH" == "main" ](/100-hours-a-week/2-hertz-wiki/wiki/-"$BRANCH"-==-"main"-); then
            echo "ENV=production" >> $GITHUB_ENV
            echo "HOST=${{ secrets.PROD_SERVER_HOST }}" >> $GITHUB_ENV
          else
            echo "ENV=staging" >> $GITHUB_ENV
            echo "HOST=${{ secrets.STAGING_SERVER_HOST }}" >> $GITHUB_ENV
          fi

      - name: Start staging instance if ENV=staging
        if: env.ENV == 'staging'
        uses: google-github-actions/setup-gcloud@v1
        with:
          service_account_key: ${{ secrets.GCP_SA_KEY }}
          project_id: ${{ env.PROJECT_ID }}

      - name: Boot staging instance
        if: env.ENV == 'staging'
        run: |
          gcloud compute instances start $STAGING_INSTANCE --zone=$ZONE

      - name: Wait for staging instance to boot
        if: env.ENV == 'staging'
        run: sleep 30

      - name: Deploy via SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script_stop: true
          script: |
            cd /opt/springboot/${{ env.ENV }}

            if [ ! -d ".git" ]; then
              git clone https://github.com/${{ github.repository }}.git .
              git checkout ${{ env.BRANCH }}
            else
              git fetch --all
              git checkout ${{ env.BRANCH }}
              git reset --hard origin/${{ env.BRANCH }}
            fi

            # 배포 스크립트 실행
            chmod +x deploy.sh
            ENV=${{ env.ENV }} ./deploy.sh

            # Spring Boot 헬스체크
            echo "✅ Spring Boot 기동 확인 중..."
            curl -sf http://localhost:8080/actuator/health || {
              echo "❌ 헬스체크 실패"; exit 1;
            }

      - name: Send notification
        if: always()
        run: |
          curl -H "Content-Type: application/json" \
               -X POST \
               -d "{\"username\": \"CD Bot\", \"content\": \"✅ ${{ env.ENV }} Spring Boot 배포 완료 - 브랜치: ${{ env.BRANCH }}\"}" \
               ${{ secrets.DISCORD_WEBHOOK_URL }}

AI

name: AI Server CD (SSH)

on:
  workflow_run:
    workflows: ["AI Server CI"]
    types: [completed]
    branches: [main]

  workflow_dispatch:

env:
  HOST: ${{ secrets.AI_SERVER_HOST }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: |
      (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') || 
      github.event_name == 'workflow_dispatch'

    steps:
      - name: Set environment
        run: |
          if [ "${{ github.event_name }}" == "workflow_run" ](/100-hours-a-week/2-hertz-wiki/wiki/-"${{-github.event_name-}}"-==-"workflow_run"-); then
            BRANCH="${{ github.event.workflow_run.head_branch }}"
          else
            BRANCH="${{ github.ref_name }}"
          fi
          echo "BRANCH=$BRANCH" >> $GITHUB_ENV

      - name: Deploy via SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script_stop: true
          script: |
            cd /opt/ai-server

            if [ ! -d ".git" ]; then
              git clone https://github.com/${{ github.repository }}.git .
              git checkout ${{ env.BRANCH }}
            else
              git fetch --all
              git checkout ${{ env.BRANCH }}
              git reset --hard origin/${{ env.BRANCH }}
            fi

            chmod +x deploy.sh
            ENV=${{ env.ENV }} ./deploy.sh

            # FastAPI + AI 모델 헬스체크
            echo "✅ AI 서버 기동 확인 중..."
            curl -sf http://localhost:8000/health || {
              echo "❌ AI 서버 헬스체크 실패"; exit 1;
            }

      - name: Send notification
        if: always()
        run: |
          curl -H "Content-Type: application/json" \
               -X POST \
               -d "{\"username\": \"CD Bot\", \"content\": \"✅ AI 서버 배포 완료 - 브랜치: ${{ env.BRANCH }}\"}" \
               ${{ secrets.DISCORD_WEBHOOK_URL }}

6.2 Roll Back 스크립트

프론트엔드

name: Frontend Rollback (SSH)

on:
  workflow_dispatch:
    inputs:
      environment:
        description: '롤백 환경'
        required: true
        default: 'staging'
        type: choice
        options: [production, staging]

jobs:
  rollback:
    runs-on: ubuntu-latest
    steps:
      - name: Set environment
        run: |
          if [ "${{ github.event.inputs.environment }}" == "production" ](/100-hours-a-week/2-hertz-wiki/wiki/-"${{-github.event.inputs.environment-}}"-==-"production"-); then
            echo "HOST=${{ secrets.FRONT_PROD_SERVER_HOST }}" >> $GITHUB_ENV
            echo "BRANCH=main" >> $GITHUB_ENV
          else
            echo "HOST=${{ secrets.FRONT_STAGING_SERVER_HOST }}" >> $GITHUB_ENV
            echo "BRANCH=develop" >> $GITHUB_ENV
          fi
          echo "ENV=${{ github.event.inputs.environment }}" >> $GITHUB_ENV

      - name: Validate environment and branch match
        run: |
          CURRENT_BRANCH="${{ github.ref_name }}"
          TARGET_ENV="${{ github.event.inputs.environment }}"

          if [ "$CURRENT_BRANCH" == "main" && "$TARGET_ENV" == "staging" ](/100-hours-a-week/2-hertz-wiki/wiki/-"$CURRENT_BRANCH"-==-"main"-&&-"$TARGET_ENV"-==-"staging"-); then
            echo "❌ main 브랜치에서 staging 롤백은 허용되지 않습니다."
            exit 1
          fi
          if [ "$CURRENT_BRANCH" != "main" && "$TARGET_ENV" == "production" ](/100-hours-a-week/2-hertz-wiki/wiki/-"$CURRENT_BRANCH"-!=-"main"-&&-"$TARGET_ENV"-==-"production"-); then
            echo "❌ production 롤백은 main 브랜치에서만 허용됩니다."
            exit 1
          fi

          echo "✅ 브랜치와 환경이 올바르게 매칭되었습니다: $CURRENT_BRANCH → $TARGET_ENV"

      - name: Execute rollback
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script_stop: true
          script: |
            cd /opt/frontend

            if [ ! -d ".git" ]; then
              echo "[ERROR] Git 저장소가 없습니다"
              exit 1
            fi

            chmod +x deploy.sh
            ./deploy.sh --rollback

            echo "✅ Next.js 헬스체크 중..."
            curl -sf http://localhost:3000 || {
              echo "❌ 프론트엔드 헬스체크 실패"; exit 1;
            }

      - name: Send notification
        if: always()
        run: |
          curl -H "Content-Type: application/json" \
               -X POST \
               -d "{\"username\": \"Rollback Bot\", \"content\": \"⏪ ${{ env.ENV }} 프론트엔드 롤백 완료\"}" \
               ${{ secrets.DISCORD_WEBHOOK_URL }}

백엔드

name: Backend Rollback (SSH)

on:
  workflow_dispatch:
    inputs:
      environment:
        description: '롤백 환경'
        required: true
        default: 'staging'
        type: choice
        options: [production, staging]

jobs:
  rollback:
    runs-on: ubuntu-latest
    steps:
      # 환경 변수 설정
      - name: Set environment
        run: |
          if [ "${{ github.event.inputs.environment }}" == "production" ](/100-hours-a-week/2-hertz-wiki/wiki/-"${{-github.event.inputs.environment-}}"-==-"production"-); then
            echo "HOST=${{ secrets.PROD_SERVER_HOST }}" >> $GITHUB_ENV
            echo "BRANCH=main" >> $GITHUB_ENV
          else
            echo "HOST=${{ secrets.STAGING_SERVER_HOST }}" >> $GITHUB_ENV
            echo "BRANCH=develop" >> $GITHUB_ENV
          fi
          echo "ENV=${{ github.event.inputs.environment }}" >> $GITHUB_ENV

      # 선택된 브랜치와 환경이 안 맞으면 중단
      - name: Validate environment and branch match
        run: |
          CURRENT_BRANCH="${{ github.ref_name }}"
          TARGET_ENV="${{ github.event.inputs.environment }}"

          if [ "$CURRENT_BRANCH" == "main" && "$TARGET_ENV" == "staging" ](/100-hours-a-week/2-hertz-wiki/wiki/-"$CURRENT_BRANCH"-==-"main"-&&-"$TARGET_ENV"-==-"staging"-); then
            echo "❌ [ERROR] main 브랜치에서 staging 롤백은 허용되지 않습니다."
            exit 1
          fi
          if [ "$CURRENT_BRANCH" != "main" && "$TARGET_ENV" == "production" ](/100-hours-a-week/2-hertz-wiki/wiki/-"$CURRENT_BRANCH"-!=-"main"-&&-"$TARGET_ENV"-==-"production"-); then
            echo "❌ [ERROR] production 롤백은 main 브랜치에서만 허용됩니다."
            exit 1
          fi

          echo "✅ 브랜치와 환경이 올바르게 매칭되었습니다: $CURRENT_BRANCH → $TARGET_ENV"

      # SSH를 통한 롤백 실행
      - name: Execute rollback
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script_stop: true
          script: |
            # 앱 디렉토리로 이동
            cd /opt/springboot

            # Git 저장소 확인
            if [ ! -d ".git" ]; then
              echo "[ERROR] Git 저장소가 없습니다"
              exit 1
            fi

            # deploy.sh 스크립트를 롤백 모드로 실행
            chmod +x deploy.sh
            ./deploy.sh --rollback

            # Spring Boot 헬스체크
            echo "✅ Spring Boot 기동 확인 중..."
            curl -sf http://localhost:8080/actuator/health || {
              echo "❌ 헬스체크 실패"; exit 1;
            }

      # 롤백 결과 알림
      - name: Send notification
        if: always()
        run: |
          curl -H "Content-Type: application/json" \
               -X POST \
               -d "{\"username\": \"Rollback Bot\", \"content\": \"⏪ ${{ env.ENV }} 롤백 완료\"}" \
               ${{ secrets.DISCORD_WEBHOOK_URL }}

AI

name: AI Server Rollback (SSH)

on:
  workflow_dispatch:
    inputs:
      environment:
        description: '롤백 환경'
        required: true
        default: 'staging'
        type: choice
        options: [production, staging]

jobs:
  rollback:
    runs-on: ubuntu-latest
    steps:
      - name: Set environment
        run: |
          if [ "${{ github.event.inputs.environment }}" == "production" ](/100-hours-a-week/2-hertz-wiki/wiki/-"${{-github.event.inputs.environment-}}"-==-"production"-); then
            echo "HOST=${{ secrets.AI_PROD_SERVER_HOST }}" >> $GITHUB_ENV
            echo "BRANCH=main" >> $GITHUB_ENV
          else
            echo "HOST=${{ secrets.AI_STAGING_SERVER_HOST }}" >> $GITHUB_ENV
            echo "BRANCH=develop" >> $GITHUB_ENV
          fi
          echo "ENV=${{ github.event.inputs.environment }}" >> $GITHUB_ENV

      - name: Validate environment and branch match
        run: |
          CURRENT_BRANCH="${{ github.ref_name }}"
          TARGET_ENV="${{ github.event.inputs.environment }}"

          if [ "$CURRENT_BRANCH" == "main" && "$TARGET_ENV" == "staging" ](/100-hours-a-week/2-hertz-wiki/wiki/-"$CURRENT_BRANCH"-==-"main"-&&-"$TARGET_ENV"-==-"staging"-); then
            echo "❌ main 브랜치에서 staging 롤백은 허용되지 않습니다."
            exit 1
          fi
          if [ "$CURRENT_BRANCH" != "main" && "$TARGET_ENV" == "production" ](/100-hours-a-week/2-hertz-wiki/wiki/-"$CURRENT_BRANCH"-!=-"main"-&&-"$TARGET_ENV"-==-"production"-); then
            echo "❌ production 롤백은 main 브랜치에서만 허용됩니다."
            exit 1
          fi

          echo "✅ 브랜치와 환경이 올바르게 매칭되었습니다: $CURRENT_BRANCH → $TARGET_ENV"

      - name: Execute rollback
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script_stop: true
          script: |
            cd /opt/ai-server

            if [ ! -d ".git" ]; then
              echo "[ERROR] Git 저장소가 없습니다"
              exit 1
            fi

            chmod +x deploy.sh
            ./deploy.sh --rollback

            echo "✅ FastAPI 헬스체크 중..."
            curl -sf http://localhost:8000/health || {
              echo "❌ AI 서버 헬스체크 실패"; exit 1;
            }

      - name: Send notification
        if: always()
        run: |
          curl -H "Content-Type: application/json" \
               -X POST \
               -d "{\"username\": \"Rollback Bot\", \"content\": \"⏪ ${{ env.ENV }} AI 서버 롤백 완료\"}" \
               ${{ secrets.DISCORD_WEBHOOK_URL }}