CI CD 파이프라인 구현 - 100-hours-a-week/KTB4-16th-Cloud GitHub Wiki

CI/CD 파이프라인 재설계 및 구현

1. CI 파이프라인 재설계 및 구현

1. v1 CI와 달라지는 점

구분 v1 v2
검증 순서 정적 검사 → 컴파일 정적 검사 → 컴파일 → 테스트 실행 → 커버리지 검사
테스트 CI에서 실행하지 않음 CI에서 자동 실행
커버리지 측정하지 않음 60% 미만이면 실패 (풀스택, AI 팀 개발 이후 변경 가능성 있음)
대상 서비스 Backend, Frontend, AI Backend, Chat Server, AI
파일 구성 서비스당 CI 파일 하나에 main, develop, PR 흐름이 같이 있음 dev, prod 파일로 분리
개발 서버 배포 CI 파일 안의 deploy job CD 파일(cd-dev.yml)로 이동
  • CI는 검증과 이미지 릴리즈까지만 하고 배포는 CD가 맡는다. 그래서 CI 파일 안에 있던 개발 서버 배포를 cd-dev.yml로 옮겼다.
  • 파일을 나눴기 때문에 브랜치는 on 블록에서 정해진다. job마다 걸어 둔 ref_name 가드는 없앴고, event_name 가드(push인지 pull_request인지)는 그대로 뒀다.
  • 파일 이름과 워크플로 name이 바뀌어서 CD의 workflow_run.workflows도 같이 바꿨다. 이름이 다르면 오류 없이 CD가 안 돌기 때문에 CI name을 바꿀 때는 CD도 같이 봐야 한다.

2. 워크플로 파일 구성

서비스마다 .github/workflows 아래에 4개를 둔다. develop과 main 두 브랜치에 같은 내용으로 둔다.

파일 워크플로 name 트리거 하는 일
ci-dev.yml (서비스) CI dev develop 대상 PR, develop push PR 검증, 개발 이미지 릴리즈
ci-prod.yml (서비스) CI prod main 대상 PR, main push PR 검증, 프로덕션 이미지 릴리즈
cd-dev.yml (서비스) CD dev CI dev 성공(develop), 수동 개발 서버 배포
cd-prod.yml (서비스) CD prod CI prod 성공(main), 수동 MIG Rolling Update
  • CI 파일은 이 파트(1번), CD 파일은 2번 CD 파트에 서비스별로 정리했다.
  • Chat Server는 개발 서버에 배포하지 않아서 cd-dev.yml이 없다. cd-prod.yml도 수동 실행만 한다.
  • pr-ci와 pr-notify는 dev, prod 파일 내용이 같고 job 이름과 알림 문구만 다르다. 검사 내용을 바꿀 때는 두 파일을 같이 고쳐야 한다.

3. 기준

  • 커버리지 기준은 60%로 한다. 테스트 코드를 처음 도입하는 단계라서 핵심 기능의 정상 동작과 예외 동작 테스트를 먼저 확보하는 것이 목표다.
  • 측정 도구는 Backend와 Chat Server는 JaCoCo, AI Server는 coverage.py를 쓴다.
  • 합격 여부는 Line 커버리지로 판단하고, Branch 커버리지는 리포트로만 확인한다.
  • 테스트가 하나라도 실패하거나 커버리지가 60% 미만이면 해당 검사가 실패한다.
    • 60%라는 수치는 인프라팀에서 정한 수치이기 때문에 추후 풀스택, AI 팀의 개발 진행도를 확인한 후 변경 가능성이 있다.
  • 병합 차단은 GitHub 저장소 Settings → Branches의 브랜치 보호 규칙에서 6번의 job을 Required status checks로 지정해야 동작한다.

4. 저장소별 CI 워크플로

4-1. Backend

v1 대비 변경점은 다음과 같다.

  • pull_request 대상 브랜치에 main 추가 (develop에 PR 없이 직접 push하는 경우가 있기 때문에 - CI 수정, CD 이름 수정 같은 것)
  • pr-ci: 컴파일을 compileJava compileTestJava로 바꾸고, 테스트 및 커버리지 검사와 리포트 업로드를 추가
  • main-ci: 테스트 및 커버리지 검사를 추가
  • push-ci: 테스트 및 커버리지 검사를 추가
  • 개발 서버 배포(deploy)는 CI에서 빼고 cd-dev.yml로 옮김. 알림도 CI 결과(ci-dev)와 배포 결과(cd-dev)로 나뉨

ci-dev.yml

name: Backend CI dev

# develop 브랜치 전용 CI. PR 검증, push 시 개발 이미지 릴리즈까지만 한다.
# 개발 서버 배포는 cd-dev.yml이 이 워크플로가 성공하면 이어서 한다.
on:
  push:
    branches:
      - develop
  pull_request:
    branches:
      - develop

jobs:
  pr-ci:
    name: Backend CI dev pull_request
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 1

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      # PR 게이트웨이 검사 유지
      - name: Run Checkstyle
        run: ./gradlew checkstyleMain

      # 컴파일 (테스트 코드 포함)
      - name: Run Compile
        run: ./gradlew compileJava compileTestJava

      # 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
      - name: Run Tests and Coverage Verification
        run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

      # 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: build/reports/jacoco/test/html
          retention-days: 7

      - name: Upload test report
        if: ${{ failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: build/reports/tests/test
          retention-days: 7

  pr-notify:
    name: Discord notification (dev PR)
    runs-on: ubuntu-latest
    needs: pr-ci
    if: ${{ always() }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "백엔드 Develop pull_request CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]
  push-ci:
    if: github.event_name == 'push'
    name: Backend CI dev release
    runs-on: ubuntu-latest
    concurrency:
      group: backend-develop-release
      cancel-in-progress: false

    permissions:
      contents: write
      id-token: write
      issues: write
      pull-requests: write

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # semantic-release가 Git 태그 및 히스토리를 분석해야 하므로 0 유지 필수
          fetch-depth: 0

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      # 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
      - name: Run Tests and Coverage Verification
        run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

      # 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: build/reports/jacoco/test/html
          retention-days: 7

      - name: Upload test report
        if: ${{ failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: build/reports/tests/test
          retention-days: 7

      - name: Build jar
        run: ./gradlew bootJar

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
      # release.config.develop.json을 .releaserc.json으로 복사해서 쓴다 (main-ci와 동일한 이유).
      - name: Release backend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.develop.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
  push-notify:
    name: Discord notification (dev release)
    runs-on: ubuntu-latest
    needs: push-ci
    if: ${{ always() && github.event_name == 'push' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Backend Dev push CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend Dev CI: ${{ needs.push-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.push-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

ci-prod.yml

name: Backend CI prod

# main 브랜치 전용 CI. PR 검증, push 시 프로덕션 이미지 릴리즈까지만 한다.
# 프로덕션 배포는 cd-prod.yml이 이 워크플로가 성공하면 이어서 한다.
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  pr-ci:
    name: Backend CI prod pull_request
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 1

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      # PR 게이트웨이 검사 유지
      - name: Run Checkstyle
        run: ./gradlew checkstyleMain

      # 컴파일 (테스트 코드 포함)
      - name: Run Compile
        run: ./gradlew compileJava compileTestJava

      # 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
      - name: Run Tests and Coverage Verification
        run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

      # 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: build/reports/jacoco/test/html
          retention-days: 7

      - name: Upload test report
        if: ${{ failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: build/reports/tests/test
          retention-days: 7

  pr-notify:
    name: Discord notification (prod PR)
    runs-on: ubuntu-latest
    needs: pr-ci
    if: ${{ always() }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "백엔드 Main pull_request CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]
  main-ci:
    if: github.event_name == 'push'
    name: Backend CI prod release
    runs-on: ubuntu-latest
    concurrency:
      group: backend-main-release
      cancel-in-progress: false

    permissions:
      contents: write      # Git 태그, Release 생성
      id-token: write      # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      - name: Run Checkstyle
        run: ./gradlew checkstyleMain

      # 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
      - name: Run Tests and Coverage Verification
        run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

      # 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: build/reports/jacoco/test/html
          retention-days: 7

      - name: Upload test report
        if: ${{ failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: build/reports/tests/test
          retention-days: 7

      - name: Build jar
        run: ./gradlew bootJar

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
      # release.config.main.json을 이 job 안에서만 .releaserc.json으로 복사해서 쓴다.
      - name: Release backend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.main.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
  main-notify:
    name: Discord notification (prod release)
    runs-on: ubuntu-latest
    needs: main-ci
    if: ${{ always() && github.event_name == 'push' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Backend main CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend CI: ${{ needs.main-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.main-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

4-2. Chat Server

저장소는 KTB4-16th-CHAT이다. Backend와 같은 구조에서 개발 서버 배포 job(deploy)만 뺐다.

  • Chat Server 테스트는 실제 MySQL이 있어야 돈다. ChatRoomRepositoryTest는 @DataJpaTest인데 내장 DB로 바꾸지 않고(replace = NONE), MuloChatApplicationTests는 @SpringBootTest라 Flyway 마이그레이션까지 실행한다. application.yaml의 CHAT_DB_URL에도 기본값이 없다.
    • 그래서 테스트를 돌리는 job(pr-ci, main-ci, push-ci)에 MySQL 8.4 서비스 컨테이너를 붙이고, 테스트 스텝에 CHAT_DB_* 환경변수를 넣었다. CI 안에서만 쓰는 테스트 값이라 시크릿으로 만들지 않았다.
  • 저장소에 checkstyle 플러그인, jacoco 설정, Dockerfile, release.config 파일이 아직 없어서 추가해야 한다. (build.gradle은 5번 참고)
    • checkstyle.xml과 release.config.main.json, release.config.develop.json은 Backend 파일을 그대로 복사한다.
    • Dockerfile은 Backend 것에서 HEIC 변환용 apt 설치를 빼고 포트를 application.yaml의 server.port인 8081로 바꾼다.
  • 현재 develop 코드로 로컬에서 MySQL을 띄워 측정했더니 테스트 8개는 통과했고 Line 커버리지는 59%였다. 기준 60%에 1%p 모자라서 이대로 적용하면 CI가 실패한다. 테스트를 더 추가하기 전까지는 Required status check 지정을 미룬다.

ci-dev.yml

name: Chat CI dev

# develop 브랜치 전용 CI. PR 검증, push 시 개발 이미지 릴리즈까지만 한다.
# 개발 서버 배포는 cd-dev.yml이 이 워크플로가 성공하면 이어서 한다.
on:
  push:
    branches:
      - develop
  pull_request:
    branches:
      - develop

jobs:
  pr-ci:
    name: Chat CI dev pull_request
    runs-on: ubuntu-latest
    # 테스트가 실제 MySQL을 쓴다 (@DataJpaTest는 DB 교체 안 함, @SpringBootTest는 Flyway 실행)
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: mulo_chat
          MYSQL_USER: chat
          MYSQL_PASSWORD: chat
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h localhost -u root -proot --silent"
          --health-interval=5s
          --health-timeout=5s
          --health-retries=20
    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 1

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      # PR 게이트웨이 검사 유지
      - name: Run Checkstyle
        run: ./gradlew checkstyleMain

      # 컴파일 (테스트 코드 포함)
      - name: Run Compile
        run: ./gradlew compileJava compileTestJava

      # 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
      - name: Run Tests and Coverage Verification
        env:
          CHAT_DB_URL: jdbc:mysql://127.0.0.1:3306/mulo_chat
          CHAT_DB_USERNAME: chat
          CHAT_DB_PASSWORD: chat
        run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

      # 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: build/reports/jacoco/test/html
          retention-days: 7

      - name: Upload test report
        if: ${{ failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: build/reports/tests/test
          retention-days: 7

  pr-notify:
    name: Discord notification (dev PR)
    runs-on: ubuntu-latest
    needs: pr-ci
    if: ${{ always() }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Chat Develop pull_request CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Chat CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]
  push-ci:
    if: github.event_name == 'push'
    name: Chat CI dev release
    runs-on: ubuntu-latest
    # 테스트가 실제 MySQL을 쓴다 (@DataJpaTest는 DB 교체 안 함, @SpringBootTest는 Flyway 실행)
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: mulo_chat
          MYSQL_USER: chat
          MYSQL_PASSWORD: chat
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h localhost -u root -proot --silent"
          --health-interval=5s
          --health-timeout=5s
          --health-retries=20    concurrency:
      group: backend-develop-release
      cancel-in-progress: false

    permissions:
      contents: write
      id-token: write
      issues: write
      pull-requests: write

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # semantic-release가 Git 태그 및 히스토리를 분석해야 하므로 0 유지 필수
          fetch-depth: 0

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      # 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
      - name: Run Tests and Coverage Verification
        env:
          CHAT_DB_URL: jdbc:mysql://127.0.0.1:3306/mulo_chat
          CHAT_DB_USERNAME: chat
          CHAT_DB_PASSWORD: chat
        run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

      # 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: build/reports/jacoco/test/html
          retention-days: 7

      - name: Upload test report
        if: ${{ failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: build/reports/tests/test
          retention-days: 7

      - name: Build jar
        run: ./gradlew bootJar

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
      # release.config.develop.json을 .releaserc.json으로 복사해서 쓴다 (main-ci와 동일한 이유).
      - name: Release backend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.develop.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
  push-notify:
    name: Discord notification (dev release)
    runs-on: ubuntu-latest
    needs: push-ci
    if: ${{ always() && github.event_name == 'push' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Chat Dev push CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Chat Dev CI: ${{ needs.push-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.push-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

ci-prod.yml

name: Chat CI prod

# main 브랜치 전용 CI. PR 검증, push 시 프로덕션 이미지 릴리즈까지만 한다.
# 프로덕션 배포는 cd-prod.yml이 이 워크플로가 성공하면 이어서 한다.
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  pr-ci:
    name: Chat CI prod pull_request
    runs-on: ubuntu-latest
    # 테스트가 실제 MySQL을 쓴다 (@DataJpaTest는 DB 교체 안 함, @SpringBootTest는 Flyway 실행)
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: mulo_chat
          MYSQL_USER: chat
          MYSQL_PASSWORD: chat
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h localhost -u root -proot --silent"
          --health-interval=5s
          --health-timeout=5s
          --health-retries=20
    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 1

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      # PR 게이트웨이 검사 유지
      - name: Run Checkstyle
        run: ./gradlew checkstyleMain

      # 컴파일 (테스트 코드 포함)
      - name: Run Compile
        run: ./gradlew compileJava compileTestJava

      # 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
      - name: Run Tests and Coverage Verification
        env:
          CHAT_DB_URL: jdbc:mysql://127.0.0.1:3306/mulo_chat
          CHAT_DB_USERNAME: chat
          CHAT_DB_PASSWORD: chat
        run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

      # 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: build/reports/jacoco/test/html
          retention-days: 7

      - name: Upload test report
        if: ${{ failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: build/reports/tests/test
          retention-days: 7

  pr-notify:
    name: Discord notification (prod PR)
    runs-on: ubuntu-latest
    needs: pr-ci
    if: ${{ always() }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Chat Main pull_request CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Chat CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]
  main-ci:
    if: github.event_name == 'push'
    name: Chat CI prod release
    runs-on: ubuntu-latest
    # 테스트가 실제 MySQL을 쓴다 (@DataJpaTest는 DB 교체 안 함, @SpringBootTest는 Flyway 실행)
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: mulo_chat
          MYSQL_USER: chat
          MYSQL_PASSWORD: chat
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h localhost -u root -proot --silent"
          --health-interval=5s
          --health-timeout=5s
          --health-retries=20    concurrency:
      group: backend-main-release
      cancel-in-progress: false

    permissions:
      contents: write      # Git 태그, Release 생성
      id-token: write      # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      - name: Run Checkstyle
        run: ./gradlew checkstyleMain

      # 테스트 실행 + 커버리지 검사 (60% 미만이면 여기서 실패)
      - name: Run Tests and Coverage Verification
        env:
          CHAT_DB_URL: jdbc:mysql://127.0.0.1:3306/mulo_chat
          CHAT_DB_USERNAME: chat
          CHAT_DB_PASSWORD: chat
        run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

      # 리포트는 실패했을 때도 확인할 수 있도록 항상 올린다
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: jacoco-report
          path: build/reports/jacoco/test/html
          retention-days: 7

      - name: Upload test report
        if: ${{ failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: build/reports/tests/test
          retention-days: 7

      - name: Build jar
        run: ./gradlew bootJar

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
      # release.config.main.json을 이 job 안에서만 .releaserc.json으로 복사해서 쓴다.
      # `semantic-release --extends <path>`로 직접 넘기면 플러그인 모듈 해석 기준 경로가
      # 달라져서 semantic-release 코어에 번들된 기본 플러그인(commit-analyzer 등)조차
      # 못 찾는 문제가 있었다(2026-09-30 실제 발생, main-ci에서 재현 확인). cosmiconfig가
      # 원래 쓰던 자동 탐색 방식 그대로 두고, 파일만 이 job 시작 시점에 맞바꿔 충돌을 피한다.
      - name: Release backend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.main.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
  main-notify:
    name: Discord notification (prod release)
    runs-on: ubuntu-latest
    needs: main-ci
    if: ${{ always() && github.event_name == 'push' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Chat main CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Chat CI: ${{ needs.main-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.main-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

4-3. AI Server

v1 대비 변경점은 다음과 같다.

  • 워크플로 이름을 AI CI main에서 AI CI dev, AI CI prod로 바꿈
  • 릴리즈 스텝에서 cp release.config.develop.json .releaserc.json (dev) 또는 cp release.config.main.json .releaserc.json (prod)을 실행
  • 의존성 설치를 requirements-dev.txt로 변경 (pytest, coverage 포함)
    • requirements.txt에 coverage를 넣으면 운영 이미지에도 들어가서 용량만 늘고, 개발 도구가 운영에 섞이게 됨
  • 컴파일 뒤에 테스트 및 커버리지 검사와 리포트 업로드 추가
  • 개발 서버 배포(deploy)는 CI에서 빼고 cd-dev.yml로 옮김

ci-dev.yml

name: AI CI dev

# develop 브랜치 전용 CI. PR 검증, push 시 개발 이미지 릴리즈까지만 한다.
# 개발 서버 배포는 cd-dev.yml이 이 워크플로가 성공하면 이어서 한다.
on:
  push:
    branches:
      - develop
  pull_request:
    branches:
      - develop

jobs:
  pr-ci:
    name: AI CI dev pull_request
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      # 1. 코드 체크아웃
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 1

      # 2. Ruff 설치 + 린트 검사
      - name: Ruff 실행
        uses: astral-sh/ruff-action@v3
        with:
          args: "check"

      # 3. Ruff로 포맷 검사
      - name: Run Ruff Formatter Check
        run: ruff format --check .

      # 4. Python 환경 설정
      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"

      # 5. 애플리케이션 의존성 설치
      - name: Install dependencies
        run: python -m pip install -r requirements-dev.txt

      # 6. 컴파일
      - name: Check Python syntax
        run: python -m compileall .

      # 7. 테스트 실행 중 커버리지 측정
      - name: Run tests with coverage
        run: coverage run -m pytest

      # 8. 커버리지 60% 미만이면 여기서 실패
      - name: Coverage verification
        run: |
          coverage report --fail-under=60
          coverage xml
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml
          retention-days: 7

  pr-notify:
    name: Discord notification (dev PR)
    runs-on: ubuntu-latest
    needs: pr-ci

    # CI가 실패해도 알림 작업 실행
    if: ${{ always() }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "AI CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "AI develop CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]
  push-ci:
    if: github.event_name == 'push'
    concurrency:
      group: ai-develop-release
      cancel-in-progress: false
    name: AI CI dev release
    runs-on: ubuntu-latest

    permissions:
      contents: write
      id-token: write

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      # 1. 코드 체크아웃
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # semantic-release가 Git 태그 및 히스토리를 분석해야 하므로 0 유지 필수
          fetch-depth: 0

      # 2. Python 환경 설정
      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"

      # 3. 애플리케이션 의존성 설치
      - name: Install dependencies
        run: python -m pip install -r requirements-dev.txt

      # 4. 컴파일
      - name: Check Python syntax
        run: python -m compileall .

      # 7. 테스트 실행 중 커버리지 측정
      - name: Run tests with coverage
        run: coverage run -m pytest

      # 8. 커버리지 60% 미만이면 여기서 실패
      - name: Coverage verification
        run: |
          coverage report --fail-under=60
          coverage xml
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml
          retention-days: 7

      # 5. Node.js 설정
      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
      - name: Release ai image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.develop.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
  push-notify:
    name: Discord notification (dev release)
    runs-on: ubuntu-latest
    needs: push-ci
    if: ${{ always() && github.event_name == 'push' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "AI Dev push CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "AI Dev CI: ${{ needs.push-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.push-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

ci-prod.yml

name: AI CI prod

# main 브랜치 전용 CI. PR 검증, push 시 프로덕션 이미지 릴리즈까지만 한다.
# 프로덕션 배포는 cd-prod.yml이 이 워크플로가 성공하면 이어서 한다.
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  pr-ci:
    name: AI CI prod pull_request
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      # 1. 코드 체크아웃
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 1

      # 2. Ruff 설치 + 린트 검사
      - name: Ruff 실행
        uses: astral-sh/ruff-action@v3
        with:
          args: "check"

      # 3. Ruff로 포맷 검사
      - name: Run Ruff Formatter Check
        run: ruff format --check .

      # 4. Python 환경 설정
      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"

      # 5. 애플리케이션 의존성 설치
      - name: Install dependencies
        run: python -m pip install -r requirements-dev.txt

      # 6. 컴파일
      - name: Check Python syntax
        run: python -m compileall .

      # 7. 테스트 실행 중 커버리지 측정
      - name: Run tests with coverage
        run: coverage run -m pytest

      # 8. 커버리지 60% 미만이면 여기서 실패
      - name: Coverage verification
        run: |
          coverage report --fail-under=60
          coverage xml
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml
          retention-days: 7

  pr-notify:
    name: Discord notification (prod PR)
    runs-on: ubuntu-latest
    needs: pr-ci

    # CI가 실패해도 알림 작업 실행
    if: ${{ always() }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "AI CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "AI main CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]
  main-ci:
    if: github.event_name == 'push'
    name: AI CI prod release
    runs-on: ubuntu-latest
    concurrency:
      group: ai-main-release
      cancel-in-progress: false

    # 워크플로우 권한 정의
    permissions:
      contents: write      # Git 태그, Release 생성
      id-token: write      # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      # 1. 코드 체크아웃
      - name: Checkout code
        uses: actions/checkout@v7
        with:
            fetch-depth: 0

      # 2. Ruff 설치 + 린트 검사
      - name: Ruff 실행
        uses: astral-sh/ruff-action@v3
        with:
            args: "check"

      # 3. Ruff로 포맷 검사
      - name: Run Ruff Formatter Check
        run: ruff format --check .

      # 4. Python 환경 설정
      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"

      # 5. 애플리케이션 의존성 설치
      - name: Install dependencies
        run: python -m pip install -r requirements-dev.txt

      # 6. 컴파일
      - name: Check Python syntax
        run: python -m compileall .

      # 7. 테스트 실행 중 커버리지 측정
      - name: Run tests with coverage
        run: coverage run -m pytest

      # 8. 커버리지 60% 미만이면 여기서 실패
      - name: Coverage verification
        run: |
          coverage report --fail-under=60
          coverage xml
      - name: Upload coverage report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml
          retention-days: 7

      # Phase 1: semantic-release 실행 환경 구성
      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      # Phase 2: Docker Build 준비
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      # Phase 3: OIDC를 이용한 서비스 계정 인증
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      # Phase 4: gcloud 명령어 사용 준비
      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      # Phase 5: Docker가 GCP 인증 정보를 사용하도록 연결
      - name: Configure Docker authentication
        run: |
            gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
      # Phase 6: 버전 계산 -> image build, push -> Git 태그, Release 생성
      - name: Release AI image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.main.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
  main-notify:
    name: Discord notification (prod release)
    runs-on: ubuntu-latest
    needs: main-ci

    # CI가 실패해도 알림 작업 실행
    if: ${{ always() && github.event_name == 'push' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "AI main CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "AI CI: ${{ needs.main-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.main-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

4-4. Frontend

설계서의 커버리지 기준은 Backend, Chat Server, AI Server에만 적용된다. Frontend CI 내용(Prettier, ESLint, TypeScript 검사, 빌드)은 바뀐 게 없고 파일만 dev, prod로 나눴다.

  • 워크플로 이름을 Frontend CI main에서 Frontend CI dev, Frontend CI prod로 바꿈
  • v1에는 pull_request 대상이 develop뿐이었는데, main 대상 PR도 검사하도록 ci-prod.yml에 pr-ci를 추가 (Frontend prod PR Check)
  • 개발 서버 배포(deploy)는 CI에서 빼고 cd-dev.yml로 옮김

ci-dev.yml

name: Frontend CI dev

# develop 브랜치 전용 CI. PR 검증, push 시 개발 이미지 릴리즈까지만 한다.
# 개발 서버 배포는 cd-dev.yml이 이 워크플로가 성공하면 이어서 한다.
on:
  push:
    branches:
      - develop
  pull_request:
    branches:
      - develop

jobs:
  pr-ci:
    name: Frontend dev PR Check
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # PR 검증은 최신 커밋만 검사하므로 얕은 복사(shallow clone) 사용
          fetch-depth: 1

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      # Phase 1: 스타일 검사
      - name: Run Prettier
        run: npm run prettier

      # Phase 2: Lint 검사
      - name: Run ESLint
        run: npm run lint

      # Phase 3: TypeScript 타입 검사
      - name: Typecheck
        run: npm run typecheck

  pr-notify:
    name: Discord notification (dev PR)
    runs-on: ubuntu-latest
    needs: pr-ci
    if: ${{ always() }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Frontend develop PR 검증 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Frontend PR CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]
  push-ci:
    if: github.event_name == 'push'
    name: Frontend CI dev release
    runs-on: ubuntu-latest
    concurrency:
      group: frontend-develop-release
      cancel-in-progress: false

    permissions:
      contents: write   # Git 태그 및 GitHub Release 생성 권한
      id-token: write   # GCP Workload Identity OIDC 인증 권한
      issues: write
      pull-requests: write

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # semantic-release가 커밋 로그와 태그를 추적해야 하므로 전체 히스토리 필요
          fetch-depth: 0

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      # Phase 1: 애플리케이션 빌드
      - name: Build
        run: npm run build

      # Phase 2: Docker Buildx 설정
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      # Phase 3: GCP 인증 (OIDC)
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      # Phase 4: gcloud CLI 준비
      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      # Phase 5: Artifact Registry Docker 인증
      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
      # release.config.develop.json을 .releaserc.json으로 복사해서 쓴다 (main-ci와 같은 방식).
      - name: Release frontend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.develop.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
  push-notify:
    name: Discord notification (dev release)
    runs-on: ubuntu-latest
    needs: push-ci
    if: ${{ always() && github.event_name == 'push' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Frontend Dev push CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Frontend Dev CI: ${{ needs.push-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.push-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인]",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

ci-prod.yml

name: Frontend CI prod

# main 브랜치 전용 CI. PR 검증, push 시 프로덕션 이미지 릴리즈까지만 한다.
# 프로덕션 배포는 cd-prod.yml이 이 워크플로가 성공하면 이어서 한다.
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  pr-ci:
    name: Frontend prod PR Check
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # PR 검증은 최신 커밋만 검사하므로 얕은 복사(shallow clone) 사용
          fetch-depth: 1

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      # Phase 1: 스타일 검사
      - name: Run Prettier
        run: npm run prettier

      # Phase 2: Lint 검사
      - name: Run ESLint
        run: npm run lint

      # Phase 3: TypeScript 타입 검사
      - name: Typecheck
        run: npm run typecheck

  pr-notify:
    name: Discord notification (prod PR)
    runs-on: ubuntu-latest
    needs: pr-ci
    if: ${{ always() }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Frontend main PR 검증 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Frontend PR CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]
  main-ci:
    if: github.event_name == 'push'
    name: Frontend CI prod release
    runs-on: ubuntu-latest
    concurrency:
      group: frontend-main-release
      cancel-in-progress: false

    # 워크플로우 권한 정의
    permissions:
      contents: write      # Git 태그, Release 생성
      id-token: write      # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      # 1. 코드 체크아웃
      - name: Checkout code
        uses: actions/checkout@v7
        with:
            fetch-depth: 0

      # 2. Node.js
      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"

      # 3. 의존성 설치
      - name: Install dependencies
        run: npm ci

      # Phase 1: Prettier
      - name: Run Prettier
        run: npm run prettier

      # Phase 2: Lint (ESlint)
      - name: Run ESlint
        run: npm run lint

      # Phase 3: TypeScript 타입 오류 검사
      - name: Typecheck
        run: npm run typecheck

      # Phase 4: Build
      - name: Build
        run: npm run build

      # Phase 5: Docker Build 준비
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      # Phase 6: OIDC를 이용한 서비스 계정 인증
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      # Phase 7: gcloud 명령어 사용 준비
      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      # Phase 8: Docker가 GCP 인증 정보를 사용하도록 연결
      - name: Configure Docker authentication
        run: |
            gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet
      # Phase 9: 버전 계산 -> image build, push -> Git 태그, Release 생성
      - name: Release frontend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
          # release.config.main.json의 prepareCmd(docker buildx build)가 이 값들을 참조
          VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
        run: |
          cp release.config.main.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
  main-notify:
    name: Discord notification (prod release)
    runs-on: ubuntu-latest
    needs: main-ci

    # CI가 실패해도 알림 작업 실행
    if: ${{ always() && github.event_name == 'push' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Frontend main CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Frontend CI: ${{ needs.main-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.main-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }}
                  }
                ]
              }
            ]

5. 필요한 설정 파일

5-1. Backend, Chat Server의 build.gradle

jacoco 설정은 둘 다 같다. Chat Server는 checkstyle 플러그인도 아직 없어서 plugins에 id 'checkstyle'을 같이 넣고, config/checkstyle/checkstyle.xml을 Backend 파일로 복사한다.

plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = '0.8.13'   // JDK 21을 지원하는 버전
}

// 측정에서 제외할 대상
ext.coverageExcludes = [
    '**/*Application.class',
    '**/config/**',
    '**/dto/**',
    '**/*Request.class',
    '**/*Response.class'
]

tasks.named('test') {
    useJUnitPlatform()
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn tasks.named('test')
    reports {
        xml.required = true
        html.required = true
    }
    classDirectories.setFrom(files(classDirectories.files.collect {
        fileTree(dir: it, exclude: coverageExcludes)
    }))
}

jacocoTestCoverageVerification {
    dependsOn tasks.named('test')
    classDirectories.setFrom(files(classDirectories.files.collect {
        fileTree(dir: it, exclude: coverageExcludes)
    }))
    violationRules {
        rule {
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.60
            }
        }
    }
}

5-2. AI Server

requirements-dev.txt의 ruff 줄 아래에 추가한다.

coverage>=7,<8

.coveragerc를 저장소 루트에 만든다.

[run]
source = app
omit =
    app/__main__.py
    */migrations/*
    */tests/*

[report]
show_missing = true

릴리즈 설정 파일 두 개를 저장소 루트에 만든다. 기존 .releaserc.json은 삭제한다. 릴리즈 스텝에서 복사해서 쓰기 때문이다.

release.config.main.json (v1의 main 브랜치 .releaserc.json)

{
    "branches": ["main"],
    "tagFormat": "v${version}",
    "plugins": [
      [
        "@semantic-release/commit-analyzer",
        {
          "releaseRules": [
            { "breaking": true, "release": "major" },
            { "type": "feat", "release": "minor" },
            { "release": "patch" }
          ]
        }
      ],
      "@semantic-release/release-notes-generator",
      [
        "@semantic-release/exec",
        {
          "prepareCmd": "docker buildx build --platform linux/amd64 --file Dockerfile --tag \"$IMAGE_BASE:${nextRelease.version}\" --push ."
        }
      ],
      [
        "@semantic-release/github",
        {
          "successComment": false,
          "failComment": false,
          "releasedLabels": false
        }
      ]
    ]
  }

release.config.develop.json (v1의 develop 브랜치 .releaserc.json)

{
  "branches": ["develop"],
  "tagFormat": "dev-v${version}",
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    [
      "@semantic-release/github",
      {
        "successComment": false,
        "failComment": false
      }
    ],
    [
      "@semantic-release/exec",
      {
        "prepareCmd": "docker buildx build --platform linux/amd64 -f Dockerfile -t \"$IMAGE_BASE:dev-${nextRelease.version}\" --push ."
      }
    ]
  ]
}

6. 병합 차단에 지정할 Required status checks

브랜치마다 하나씩만 지정한다. develop 대상 PR에는 dev 검사만, main 대상 PR에는 prod 검사만 생기기 때문에 둘을 한 브랜치에 같이 걸면 없는 검사를 기다리면서 PR이 멈춘다.

저장소 대상 브랜치 job 이름
Backend develop Backend CI dev pull_request
Backend main Backend CI prod pull_request
Chat Server develop Chat CI dev pull_request
Chat Server main Chat CI prod pull_request
AI Server develop AI CI dev pull_request
AI Server main AI CI prod pull_request
Frontend develop Frontend dev PR Check
Frontend main Frontend prod PR Check

2. CD 파이프라인 재설계 및 구현

1. 설계 기준

  • v1 구조는 GitHub Actions에서 main branch에서 CI를 통과하게 되면 단일 VM Blue-green 방식으로 배포했다.
  • v2에서는 MIG를 이용하면 Rolling Update 방식을 선택한다. (4단계 설계 문서 참고)
    • MIG Rolling Update는 MIG를 구성하여 변경된 서비스의 VM을 하나씩 신버전으로 교체한다. 따라서 해당 서비스의 신버전용 VM 전체를 별도로 준비할 필요가 없다.
    • 또한 현재 서비스는 배포 장애 발생 시 반드시 수 초 이내에 복구해야 하는 가용성 요구사항을 두고 있지 않기 때문에 즉시 롤백을 위해 전체 Blue 환경을 유지하는 비용과 운영 복잡성보다 서비스별 Rolling Update의 단순성과 비용 효율성이 더 크다고 판단했다.
  • 개발 서버 배포는 MIG로 하지 않는다. v1처럼 develop VM에 SSH로 접속해서 컨테이너를 교체하고, CI 파일 안에 있던 것을 cd-dev.yml로 분리했다.
  • Chat VM CD
    • 채팅 서버는 사전에 안내한 점검 시간(00:00)에 Recreate 방식으로 배포를 선택했다.
      • GCP Graceful Shutdown으로 처리 중인 메시지 저장과 연결 정리를 마친 뒤 신버전을 실행하고, 헬스 체크를 통과하면 채팅 서비스를 시작하고, 배포 실패 시 이전 버전으로 복구한다.

2. v1 CD와 달라지는 점

구분 v1 v2
배포 대상 Standby VM 1대 서비스별 MIG
배포 방식 Blue-Green Rolling Update (Chat은 Recreate)
새 버전 전달 SSH/SCP로 env 파일 전송, docker compose pull과 up 새 인스턴스 템플릿의 이미지 태그 메타데이터를 바꾸고 VM이 부팅할 때 이미지를 받음
환경변수 GitHub Secrets로 env 파일을 만들어 전송 VM이 부팅할 때 Secret Manager에서 읽음
트래픽 전환 고정 IP와 role 라벨 교체, nginx reload 없음. 로드밸런서가 헬스체크 통과한 VM으로만 보냄
SSH 사용 (IAP 터널) 사용하지 않음
실패 처리 승인 전이면 중단, 승인 후 문제는 수동 교체 중 실패하면 이전 템플릿으로 자동 롤백
서비스 간 동기화 한 VM에 서비스가 같이 있어서 다른 서비스 버전도 맞춤 서비스별로 독립 배포, 동기화 불필요
파일 구성 CD 파일 하나 cd-dev.yml, cd-prod.yml로 분리

3. 서비스별 배포 설정

서비스 MIG 트리거 방식 max-surge max-unavailable
Backend muro-backend-mig (존, 1대) main CI 성공 시 자동 Rolling Update 1 0
AI Server muro-ai-mig (존, 2대) main CI 성공 시 자동 Rolling Update 1 0
Frontend muro-frontend-mig (존, 1대) main CI 성공 시 자동 Rolling Update 1 0
Chat Server muro-chat-mig (존, 1대) 수동 실행만 Recreate 0 1
  • max-unavailable을 0으로 두고 max-surge로 새 VM을 먼저 띄우기 때문에, 교체 중에도 기존 VM이 트래픽을 받는다. 1대짜리 Backend와 Frontend도 새 VM이 헬스체크를 통과한 뒤 기존 VM이 내려가서 끊김이 없다.
  • Chat Server는 서비스 중단을 감수하고 점검 시간에 배포한다. 자동 실행하면 점검 안내와 상관없이 배포되므로 CI 성공에 연결하지 않고 수동 실행만 허용한다. 실행할 때 점검 안내를 했는지 확인을 받는다.
  • MIG 이름과 대수, 위치는 v2 인프라를 만들 때 정해지는 값이라서 워크플로 상단의 env에서 한 번에 바꾸게 했다.
  • cd-dev.yml은 develop CI(ci-dev)가 성공하면 workflow_run으로 실행되고, 수동 실행(workflow_dispatch)도 된다. 같은 서비스의 배포가 겹치지 않게 서비스별 concurrency를 걸었다. 기존 deploy job에는 없어서 develop에 연속으로 push하면 같은 VM에 배포가 겹칠 수 있었다.
  • cd-dev.yml의 알림은 workflow_run 이벤트에 head_commit이 없어서 github.event.workflow_run의 head_commit, head_sha, actor를 쓴다. 수동 실행이면 github.sha와 github.actor를 쓴다.
  • workflow_run으로 실행되는 워크플로는 기본 브랜치(main)에 있는 파일 기준으로 돈다. develop에서 CD 파일을 고쳐도 main에 머지되기 전에는 반영되지 않는다.

4. 저장소별 CD 워크플로

4-1. Backend

  • cd-dev.yml: develop VM에 새 이미지를 배포한다. CI에 있던 deploy job을 그대로 옮겼고 알림만 CD 결과로 따로 보낸다.
  • cd-prod.yml: MIG Rolling Update 배포다. 기존 CD 파일에서 name과 workflow_run.workflows 두 줄만 바뀌었다.

cd-dev.yml

name: Backend CD dev

# develop CI(ci-dev.yml)가 성공하면 자동으로 개발 서버에 배포한다.
# workflow_dispatch는 개발 서버에 다시 배포하고 싶을 때 수동으로 쓴다.
# 주의: workflow_run으로 실행되는 워크플로는 기본 브랜치(main)에 있는 파일 기준으로 실행된다.
on:
  workflow_run:
    workflows: ["Backend CI dev"]
    types: [completed]
    branches: [develop]
  workflow_dispatch:

env:
  ZONE: asia-northeast3-a
  DEV_VM_NAME: mulo-v2-dev-vm   # Terraform이 만든 개발 VM 이름

jobs:
  deploy:
    name: Deploy to Develop VM
    runs-on: ubuntu-latest
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    concurrency:
      group: cd-backend-develop
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      vm: ${{ steps.develop.outputs.vm }}

    steps:
      # WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - uses: google-github-actions/setup-gcloud@v3

      # 1. DEV_VM_NAME 이름의 개발 VM을 찾는다.
      - id: develop
        run: |
          VM=$(gcloud compute instances list \
            --filter="name=${{ env.DEV_VM_NAME }}" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          if [ -z "$VM" ]; then
            echo "${{ env.DEV_VM_NAME }} VM을 찾을 수 없습니다."
            exit 1
          fi
          echo "vm=$VM" >> "$GITHUB_OUTPUT"

      # 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
      #    CD는 이미지를 직접 빌드하지 않고, CI가 이미 push해둔 걸 그대로 가져다 쓴다.
      - id: image
        run: |
          TAG=$(gcloud artifacts docker images list \
            "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
            --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
            --format="value(TAGS)" | cut -d',' -f1)
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"

      # 3. backend.env 파일을 GitHub Secrets 값으로 생성한다.
      #    이 파일은 여기서만 잠깐 만들어지고, 실제 값은 워크플로 로그에 자동으로 마스킹된다.
      - run: |
          cat > backend.env <<ENVEOF
          DB_URL=${{ secrets.DB_URL_Develop }}
          DB_USERNAME=${{ secrets.DB_USERNAME_Develop }}
          DB_PASSWORD=${{ secrets.DB_PASSWORD_Develop }}
          JWT_SECRET=${{ secrets.JWT_SECRET }}
          GCS_BUCKET_NAME=${{ secrets.GCS_BUCKET_NAME_Develop }}
          GCS_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
          KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }}
          KMA_SERVICE_KEY=${{ secrets.KMA_SERVICE_KEY }}
          SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
          SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}
          AI_BASE_URL=${{ secrets.AI_BASE_URL }}
          AI_INTERNAL_TOKEN=${{ secrets.AI_INTERNAL_TOKEN }}
          MULO_SECURITY_ALLOWED_ORIGINS=${{ secrets.MULO_SECURITY_ALLOWED_ORIGINS_Develop }}
          REPORT_OPERATION_TOKEN=${{ secrets.REPORT_OPERATION_TOKEN }}
          # 컨테이너 JVM 기본 시간대가 UTC라, java.sql.Timestamp -> LocalDateTime 변환 시
          # (Timestamp가 상속하는 java.util.Date의 레거시 getter가 JVM 기본시간대를 씀)
          # DB에 정상 저장된 KST 값을 9시간 밀려서 읽는 문제가 있었다. JVM 기본시간대 자체를
          # 앱이 실제 쓰는 Asia/Seoul로 맞춰서 이 변환 단계의 시간대 불일치를 없앤다.
          JAVA_TOOL_OPTIONS=-Duser.timezone=Asia/Seoul
          ENVEOF

      # 4. env 파일을 develop VM으로 전송하고, 새 이미지를 pull해서 backend 컨테이너만 교체한다.
      #    같은 VM 위의 frontend/ai/nginx 컨테이너는 건드리지 않는다.
      - run: |
          gcloud compute scp backend.env "${{ steps.develop.outputs.vm }}:~/app/backend.env" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap

          gcloud compute ssh "${{ steps.develop.outputs.vm }}" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
              set -e
              sudo gcloud auth configure-docker '${{ secrets.GCP_REGION }}-docker.pkg.dev' --quiet
              cd ~/app
              export IMAGE_TAG=${{ steps.image.outputs.tag }}
              export GCP_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose pull backend
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose up -d backend
            "

      # 5. 컨테이너가 healthy 상태가 될 때까지 대기 
      - name: "Wait for backend container to be healthy"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            for i in \$(seq 1 20); do
              status=\$(sudo docker inspect backend --format='{{.State.Health.Status}}')
              if [ \"\$status\" = \"healthy\" ]; then
                echo \"backend healthy (시도 \$i/20)\"
                exit 0
              fi
              echo \"대기 중... (\$i/20) 현재 상태: \$status\"
              sleep 3
            done
            echo \"backend가 60초 안에 healthy가 되지 않았습니다.\"
            exit 1
          "

      # nginx는 컨테이너 DNS를 시작/리로드 시점에만 캐싱하므로, 방금 재생성된
      # backend의 새 IP를 반영하도록 reload한다.
      - name: "Reload nginx to pick up new container IP"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            sudo docker exec nginx nginx -s reload
          "

  notify:
    name: Discord notification
    runs-on: ubuntu-latest
    needs: deploy
    if: ${{ always() && needs.deploy.result != 'skipped' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CD result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Backend Develop 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend Develop 배포: ${{ needs.deploy.result }}",
                "description": ${{ toJSON(github.event.workflow_run.head_commit.message || '수동 실행') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.deploy.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.workflow_run.head_sha || github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.event.workflow_run.actor.login || github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": "develop",
                    "inline": true
                  }
                ]
              }
            ]

cd-prod.yml

name: Backend CD prod

# CI가 main에서 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 특정 태그로 재배포하거나 이전 버전으로 되돌릴 때 수동으로 쓴다.
on:
  workflow_run:
    workflows: ["Backend CI prod"]
    types: [completed]
    branches: [main]
  workflow_dispatch:
    inputs:
      image_tag:
        description: "배포할 이미지 태그 (비우면 가장 최근 태그)"
        required: false
        type: string

env:
  SERVICE: backend
  MIG_NAME: muro-backend-mig
  MIG_FLAG: "--zone=asia-northeast3-a"
  MAX_SURGE: "1"
  MAX_UNAVAILABLE: "0"
  REPLACEMENT_METHOD: substitute

jobs:
  # ============================================================
  # 1단계: 배포할 이미지 태그와 현재 인스턴스 템플릿을 확인한다. (VM은 건드리지 않는다)
  # ============================================================
  prepare:
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      image_tag: ${{ steps.image.outputs.tag }}
      prev_template: ${{ steps.current.outputs.template }}
      new_template: ${{ steps.name.outputs.template }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v3

      # 이미지는 CD가 빌드하지 않고, CI가 push해둔 것을 그대로 쓴다.
      # 수동 실행 때 태그를 넣으면 그 태그를, 비우면 가장 최근 태그를 쓴다.
      - id: image
        env:
          INPUT_TAG: ${{ inputs.image_tag }}
          IMAGE_REPO: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
        run: |
          if [ -n "$INPUT_TAG" ]; then
            TAG="$INPUT_TAG"
          else
            TAG=$(gcloud artifacts docker images list "$IMAGE_REPO" \
              --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
              --format="value(TAGS)" | cut -d',' -f1)
          fi
          if [ -z "$TAG" ]; then
            echo "배포할 이미지 태그를 찾을 수 없습니다."
            exit 1
          fi
          # 해당 태그의 이미지가 실제로 있는지 확인한다.
          gcloud artifacts docker images describe "$IMAGE_REPO:$TAG" > /dev/null
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
      # 롤백할 때 되돌아갈 현재 인스턴스 템플릿 이름
      - id: current
        run: |
          TEMPLATE=$(gcloud compute instance-groups managed describe "$MIG_NAME" $MIG_FLAG \
            --format="value(versions[0].instanceTemplate.basename())")
          if [ -z "$TEMPLATE" ]; then
            echo "$MIG_NAME 의 현재 인스턴스 템플릿을 찾을 수 없습니다."
            exit 1
          fi
          echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
      # 새 템플릿 이름: 서비스-tpl-태그-실행번호 (소문자, 숫자, 하이픈만 가능)
      - id: name
        env:
          TAG: ${{ steps.image.outputs.tag }}
        run: |
          SAFE=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' '-' | sed 's/-*$//')
          echo "template=${SERVICE}-tpl-${SAFE}-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
  # ============================================================
  # 2단계: 새 인스턴스 템플릿을 만들고 MIG를 교체한다.
  # environment: production 에 지정한 승인자가 승인해야 시작한다.
  # ============================================================
  rolling-update:
    needs: prepare
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: cd-backend-production
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write
    env:
      TAG: ${{ needs.prepare.outputs.image_tag }}
      PREV_TEMPLATE: ${{ needs.prepare.outputs.prev_template }}
      NEW_TEMPLATE: ${{ needs.prepare.outputs.new_template }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v3

      # 현재 템플릿을 복사하고 이미지 태그 메타데이터만 바꾼 새 템플릿을 만든다.
      # VM은 부팅할 때 이 태그의 이미지를 받아서 실행한다.
      - name: Create instance template
        run: |
          gcloud compute instance-templates create "$NEW_TEMPLATE" \
            --source-instance-template="$PREV_TEMPLATE" \
            --metadata=app-image-tag="$TAG"
      - id: start
        name: Start rolling update
        run: |
          gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
            --version=template="$NEW_TEMPLATE" \
            --type=proactive \
            --minimal-action=replace \
            --most-disruptive-allowed-action=replace \
            --replacement-method="$REPLACEMENT_METHOD" \
            --max-surge="$MAX_SURGE" \
            --max-unavailable="$MAX_UNAVAILABLE"
      # 모든 VM이 새 템플릿으로 교체되고 헬스체크를 통과할 때까지 기다린다.
      - name: Wait until all instances are updated
        run: |
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --version-target-reached --timeout=900
      - name: Wait until the group is stable
        run: |
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --stable --timeout=300
      # 교체 도중 실패하면 이전 템플릿으로 되돌린다. (교체를 시작한 경우에만)
      - name: Rollback to previous template
        if: ${{ failure() && steps.start.outcome == 'success' }}
        run: |
          gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
            --version=template="$PREV_TEMPLATE" \
            --type=proactive \
            --minimal-action=replace \
            --most-disruptive-allowed-action=replace \
            --replacement-method="$REPLACEMENT_METHOD" \
            --max-surge="$MAX_SURGE" \
            --max-unavailable="$MAX_UNAVAILABLE"
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --version-target-reached --timeout=900
      # 오래된 템플릿은 최근 5개만 남기고 지운다. (사용 중인 템플릿은 삭제되지 않는다)
      - name: Delete old instance templates
        if: ${{ success() }}
        continue-on-error: true
        run: |
          gcloud compute instance-templates list \
            --filter="name~^${SERVICE}-tpl-" \
            --sort-by="~creationTimestamp" \
            --format="value(name)" \
            | tail -n +6 \
            | while read -r T; do
                gcloud compute instance-templates delete "$T" --quiet || true
              done
  # 배포 결과 알림
  notify:
    name: Discord notification
    runs-on: ubuntu-latest
    needs: [prepare, rolling-update]
    if: ${{ always() && needs.prepare.result != 'skipped' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CD result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Backend 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend 배포: ${{ needs.rolling-update.result }}",
                "description": "이미지 태그: ${{ needs.prepare.outputs.image_tag }}",
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.rolling-update.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "실행한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "배포 방식",
                    "value": "Rolling Update",
                    "inline": true
                  }
                ]
              }
            ]

4-2. Chat Server

개발 서버에 배포하지 않아서 cd-dev.yml은 없다. cd-prod.yml은 CI 성공에 연결하지 않고 수동 실행만 하므로 workflow_run 트리거가 없다. 기존 파일에서 name만 바뀌었다.

개발 서버에 Chat을 올릴지는 아직 정해지지 않았다. 올리기로 하면 개발 VM의 compose에 chat 서비스를 추가하고, Backend의 cd-dev.yml을 참고해서 CHAT_DB_URL, CHAT_DB_USERNAME, CHAT_DB_PASSWORD를 env 파일로 넘기는 cd-dev.yml을 만든다.

cd-prod.yml

name: Chat CD prod

# Chat Server는 사전에 안내한 점검 시간에만 배포한다.
# CI 성공으로 자동 실행하지 않고 수동 실행만 허용하며, 점검 안내를 했는지 확인을 받는다.
on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: "배포할 이미지 태그 (비우면 가장 최근 태그)"
        required: false
        type: string
      maintenance_confirmed:
        description: "점검 시간을 사전에 안내했습니다"
        required: true
        type: boolean
        default: false

env:
  SERVICE: chat
  MIG_NAME: muro-chat-mig
  MIG_FLAG: "--zone=asia-northeast3-a"
  MAX_SURGE: "0"
  MAX_UNAVAILABLE: "1"
  REPLACEMENT_METHOD: recreate

jobs:
  # ============================================================
  # 1단계: 배포할 이미지 태그와 현재 인스턴스 템플릿을 확인한다. (VM은 건드리지 않는다)
  # ============================================================
  prepare:
    if: ${{ inputs.maintenance_confirmed == true }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      image_tag: ${{ steps.image.outputs.tag }}
      prev_template: ${{ steps.current.outputs.template }}
      new_template: ${{ steps.name.outputs.template }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v3

      # 이미지는 CD가 빌드하지 않고, CI가 push해둔 것을 그대로 쓴다.
      # 수동 실행 때 태그를 넣으면 그 태그를, 비우면 가장 최근 태그를 쓴다.
      - id: image
        env:
          INPUT_TAG: ${{ inputs.image_tag }}
          IMAGE_REPO: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
        run: |
          if [ -n "$INPUT_TAG" ]; then
            TAG="$INPUT_TAG"
          else
            TAG=$(gcloud artifacts docker images list "$IMAGE_REPO" \
              --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
              --format="value(TAGS)" | cut -d',' -f1)
          fi
          if [ -z "$TAG" ]; then
            echo "배포할 이미지 태그를 찾을 수 없습니다."
            exit 1
          fi
          # 해당 태그의 이미지가 실제로 있는지 확인한다.
          gcloud artifacts docker images describe "$IMAGE_REPO:$TAG" > /dev/null
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
      # 롤백할 때 되돌아갈 현재 인스턴스 템플릿 이름
      - id: current
        run: |
          TEMPLATE=$(gcloud compute instance-groups managed describe "$MIG_NAME" $MIG_FLAG \
            --format="value(versions[0].instanceTemplate.basename())")
          if [ -z "$TEMPLATE" ]; then
            echo "$MIG_NAME 의 현재 인스턴스 템플릿을 찾을 수 없습니다."
            exit 1
          fi
          echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
      # 새 템플릿 이름: 서비스-tpl-태그-실행번호 (소문자, 숫자, 하이픈만 가능)
      - id: name
        env:
          TAG: ${{ steps.image.outputs.tag }}
        run: |
          SAFE=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' '-' | sed 's/-*$//')
          echo "template=${SERVICE}-tpl-${SAFE}-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
  # ============================================================
  # 2단계: 새 인스턴스 템플릿을 만들고 MIG를 교체한다.
  # environment: production 에 지정한 승인자가 승인해야 시작한다.
  # ============================================================
  rolling-update:
    needs: prepare
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: cd-chat-production
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write
    env:
      TAG: ${{ needs.prepare.outputs.image_tag }}
      PREV_TEMPLATE: ${{ needs.prepare.outputs.prev_template }}
      NEW_TEMPLATE: ${{ needs.prepare.outputs.new_template }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v3

      # 현재 템플릿을 복사하고 이미지 태그 메타데이터만 바꾼 새 템플릿을 만든다.
      # VM은 부팅할 때 이 태그의 이미지를 받아서 실행한다.
      - name: Create instance template
        run: |
          gcloud compute instance-templates create "$NEW_TEMPLATE" \
            --source-instance-template="$PREV_TEMPLATE" \
            --metadata=app-image-tag="$TAG"
      - id: start
        name: Start rolling update
        run: |
          gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
            --version=template="$NEW_TEMPLATE" \
            --type=proactive \
            --minimal-action=replace \
            --most-disruptive-allowed-action=replace \
            --replacement-method="$REPLACEMENT_METHOD" \
            --max-surge="$MAX_SURGE" \
            --max-unavailable="$MAX_UNAVAILABLE"
      # 모든 VM이 새 템플릿으로 교체되고 헬스체크를 통과할 때까지 기다린다.
      - name: Wait until all instances are updated
        run: |
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --version-target-reached --timeout=900
      - name: Wait until the group is stable
        run: |
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --stable --timeout=300
      # 교체 도중 실패하면 이전 템플릿으로 되돌린다. (교체를 시작한 경우에만)
      - name: Rollback to previous template
        if: ${{ failure() && steps.start.outcome == 'success' }}
        run: |
          gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
            --version=template="$PREV_TEMPLATE" \
            --type=proactive \
            --minimal-action=replace \
            --most-disruptive-allowed-action=replace \
            --replacement-method="$REPLACEMENT_METHOD" \
            --max-surge="$MAX_SURGE" \
            --max-unavailable="$MAX_UNAVAILABLE"
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --version-target-reached --timeout=900
      # 오래된 템플릿은 최근 5개만 남기고 지운다. (사용 중인 템플릿은 삭제되지 않는다)
      - name: Delete old instance templates
        if: ${{ success() }}
        continue-on-error: true
        run: |
          gcloud compute instance-templates list \
            --filter="name~^${SERVICE}-tpl-" \
            --sort-by="~creationTimestamp" \
            --format="value(name)" \
            | tail -n +6 \
            | while read -r T; do
                gcloud compute instance-templates delete "$T" --quiet || true
              done
  # 배포 결과 알림
  notify:
    name: Discord notification
    runs-on: ubuntu-latest
    needs: [prepare, rolling-update]
    if: ${{ always() && needs.prepare.result != 'skipped' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CD result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Chat 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Chat 배포: ${{ needs.rolling-update.result }}",
                "description": "이미지 태그: ${{ needs.prepare.outputs.image_tag }}",
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.rolling-update.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "실행한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "배포 방식",
                    "value": "Recreate (점검 시간)",
                    "inline": true
                  }
                ]
              }
            ]

4-3. AI Server

  • cd-dev.yml: develop VM에 ai 컨테이너만 교체한다. CI의 deploy job을 그대로 옮겼다.
  • cd-prod.yml: 기존 CD 파일에서 name과 workflow_run.workflows 두 줄만 바뀌었다.

cd-dev.yml

name: AI CD dev

# develop CI(ci-dev.yml)가 성공하면 자동으로 개발 서버에 배포한다.
# workflow_dispatch는 개발 서버에 다시 배포하고 싶을 때 수동으로 쓴다.
# 주의: workflow_run으로 실행되는 워크플로는 기본 브랜치(main)에 있는 파일 기준으로 실행된다.
on:
  workflow_run:
    workflows: ["AI CI dev"]
    types: [completed]
    branches: [develop]
  workflow_dispatch:

env:
  ZONE: asia-northeast3-a
  DEV_VM_NAME: mulo-v2-dev-vm   # Terraform이 만든 개발 VM 이름

jobs:
  deploy:
    name: Deploy to Develop VM
    runs-on: ubuntu-latest
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    concurrency:
      group: cd-ai-develop
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      vm: ${{ steps.develop.outputs.vm }}

    steps:
      # WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - uses: google-github-actions/setup-gcloud@v3

      # 1. DEV_VM_NAME 이름의 개발 VM을 찾는다.
      - id: develop
        run: |
          VM=$(gcloud compute instances list \
            --filter="name=${{ env.DEV_VM_NAME }}" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          if [ -z "$VM" ]; then
            echo "${{ env.DEV_VM_NAME }} VM을 찾을 수 없습니다."
            exit 1
          fi
          echo "vm=$VM" >> "$GITHUB_OUTPUT"

      # 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
      - id: image
        run: |
          TAG=$(gcloud artifacts docker images list \
            "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
            --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
            --format="value(TAGS)" | cut -d',' -f1)
          if [ -z "$TAG" ]; then
            echo "조회된 Docker 이미지 태그가 없습니다."
            exit 1
          fi
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"

      # 3. ai.env 파일을 GitHub Secrets 값으로 생성한다.
      - run: |
          cat > ai.env <<ENVEOF
          APP_ENV=${{ secrets.APP_ENV || 'develop' }}
          INTERNAL_TOKEN=${{ secrets.AI_INTERNAL_TOKEN || secrets.INTERNAL_TOKEN }}
          DATABASE_URL=${{ secrets.DATABASE_URL_Develop || secrets.DATABASE_URL }}
          AI_CLIENT_MODE=${{ secrets.AI_CLIENT_MODE }}
          ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
          LLM_MODEL=${{ secrets.LLM_MODEL }}
          OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
          TEXT_EMBEDDING_MODEL=${{ secrets.TEXT_EMBEDDING_MODEL }}
          TEXT_EMBEDDING_DIM=${{ secrets.TEXT_EMBEDDING_DIM }}
          REPLICATE_API_TOKEN=${{ secrets.REPLICATE_API_TOKEN }}
          CLIP_MODEL=${{ secrets.CLIP_MODEL }}
          CLIP_EMBEDDING_DIM=${{ secrets.CLIP_EMBEDDING_DIM }}
          SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
          SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}
          YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }}
          BACKEND_MYSQL_URL=${{ secrets.BACKEND_MYSQL_URL_Develop || secrets.BACKEND_MYSQL_URL }}
          BACKEND_INTERNAL_URL=${{ secrets.BACKEND_INTERNAL_URL_Develop || secrets.BACKEND_INTERNAL_URL }}
          RUN_WORKER=true
          ENVEOF

      # 4. env 파일을 develop VM으로 전송하고, 새 이미지를 pull해서 ai 컨테이너만 교체한다.
      #    같은 VM 위의 backend/frontend/nginx 컨테이너는 건드리지 않는다.
      - run: |
          gcloud compute scp ai.env "${{ steps.develop.outputs.vm }}:~/app/ai.env" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap

          gcloud compute ssh "${{ steps.develop.outputs.vm }}" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
              set -e
              sudo gcloud auth configure-docker '${{ secrets.GCP_REGION }}-docker.pkg.dev' --quiet
              cd ~/app
              export IMAGE_TAG=${{ steps.image.outputs.tag }}
              export GCP_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose pull ai
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose up -d ai
            "

      # 5. 컨테이너가 healthy 상태가 될 때까지 대기
      - name: "Wait for ai container to be healthy"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            for i in \$(seq 1 20); do
              status=\$(sudo docker inspect ai --format='{{.State.Health.Status}}' 2>/dev/null || echo 'unhealthy')
              if [ \"\$status\" = \"healthy\" ]; then
                echo \"ai healthy (시도 \$i/20)\"
                exit 0
              fi
              echo \"대기 중... (\$i/20) 현재 상태: \$status\"
              sleep 3
            done
            echo \"ai가 60초 안에 healthy가 되지 않았습니다.\"
            exit 1
          "

      # nginx는 컨테이너 DNS를 시작/리로드 시점에만 캐싱하므로, 방금 재생성된
      # ai의 새 IP를 반영하도록 reload한다.
      - name: "Reload nginx to pick up new container IP"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            sudo docker exec nginx nginx -s reload
          "

  notify:
    name: Discord notification
    runs-on: ubuntu-latest
    needs: deploy
    if: ${{ always() && needs.deploy.result != 'skipped' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CD result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "AI Develop 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "AI Develop 배포: ${{ needs.deploy.result }}",
                "description": ${{ toJSON(github.event.workflow_run.head_commit.message || '수동 실행') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.deploy.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.workflow_run.head_sha || github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.event.workflow_run.actor.login || github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": "develop",
                    "inline": true
                  }
                ]
              }
            ]

cd-prod.yml

name: AI CD prod

# CI가 main에서 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 특정 태그로 재배포하거나 이전 버전으로 되돌릴 때 수동으로 쓴다.
on:
  workflow_run:
    workflows: ["AI CI prod"]
    types: [completed]
    branches: [main]
  workflow_dispatch:
    inputs:
      image_tag:
        description: "배포할 이미지 태그 (비우면 가장 최근 태그)"
        required: false
        type: string

env:
  SERVICE: ai
  MIG_NAME: muro-ai-mig
  MIG_FLAG: "--zone=asia-northeast3-a"
  MAX_SURGE: "1"
  MAX_UNAVAILABLE: "0"
  REPLACEMENT_METHOD: substitute

jobs:
  # ============================================================
  # 1단계: 배포할 이미지 태그와 현재 인스턴스 템플릿을 확인한다. (VM은 건드리지 않는다)
  # ============================================================
  prepare:
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      image_tag: ${{ steps.image.outputs.tag }}
      prev_template: ${{ steps.current.outputs.template }}
      new_template: ${{ steps.name.outputs.template }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v3

      # 이미지는 CD가 빌드하지 않고, CI가 push해둔 것을 그대로 쓴다.
      # 수동 실행 때 태그를 넣으면 그 태그를, 비우면 가장 최근 태그를 쓴다.
      - id: image
        env:
          INPUT_TAG: ${{ inputs.image_tag }}
          IMAGE_REPO: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
        run: |
          if [ -n "$INPUT_TAG" ]; then
            TAG="$INPUT_TAG"
          else
            TAG=$(gcloud artifacts docker images list "$IMAGE_REPO" \
              --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
              --format="value(TAGS)" | cut -d',' -f1)
          fi
          if [ -z "$TAG" ]; then
            echo "배포할 이미지 태그를 찾을 수 없습니다."
            exit 1
          fi
          # 해당 태그의 이미지가 실제로 있는지 확인한다.
          gcloud artifacts docker images describe "$IMAGE_REPO:$TAG" > /dev/null
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
      # 롤백할 때 되돌아갈 현재 인스턴스 템플릿 이름
      - id: current
        run: |
          TEMPLATE=$(gcloud compute instance-groups managed describe "$MIG_NAME" $MIG_FLAG \
            --format="value(versions[0].instanceTemplate.basename())")
          if [ -z "$TEMPLATE" ]; then
            echo "$MIG_NAME 의 현재 인스턴스 템플릿을 찾을 수 없습니다."
            exit 1
          fi
          echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
      # 새 템플릿 이름: 서비스-tpl-태그-실행번호 (소문자, 숫자, 하이픈만 가능)
      - id: name
        env:
          TAG: ${{ steps.image.outputs.tag }}
        run: |
          SAFE=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' '-' | sed 's/-*$//')
          echo "template=${SERVICE}-tpl-${SAFE}-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
  # ============================================================
  # 2단계: 새 인스턴스 템플릿을 만들고 MIG를 교체한다.
  # environment: production 에 지정한 승인자가 승인해야 시작한다.
  # ============================================================
  rolling-update:
    needs: prepare
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: cd-ai-production
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write
    env:
      TAG: ${{ needs.prepare.outputs.image_tag }}
      PREV_TEMPLATE: ${{ needs.prepare.outputs.prev_template }}
      NEW_TEMPLATE: ${{ needs.prepare.outputs.new_template }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v3

      # 현재 템플릿을 복사하고 이미지 태그 메타데이터만 바꾼 새 템플릿을 만든다.
      # VM은 부팅할 때 이 태그의 이미지를 받아서 실행한다.
      - name: Create instance template
        run: |
          gcloud compute instance-templates create "$NEW_TEMPLATE" \
            --source-instance-template="$PREV_TEMPLATE" \
            --metadata=app-image-tag="$TAG"
      - id: start
        name: Start rolling update
        run: |
          gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
            --version=template="$NEW_TEMPLATE" \
            --type=proactive \
            --minimal-action=replace \
            --most-disruptive-allowed-action=replace \
            --replacement-method="$REPLACEMENT_METHOD" \
            --max-surge="$MAX_SURGE" \
            --max-unavailable="$MAX_UNAVAILABLE"
      # 모든 VM이 새 템플릿으로 교체되고 헬스체크를 통과할 때까지 기다린다.
      - name: Wait until all instances are updated
        run: |
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --version-target-reached --timeout=900
      - name: Wait until the group is stable
        run: |
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --stable --timeout=300
      # 교체 도중 실패하면 이전 템플릿으로 되돌린다. (교체를 시작한 경우에만)
      - name: Rollback to previous template
        if: ${{ failure() && steps.start.outcome == 'success' }}
        run: |
          gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
            --version=template="$PREV_TEMPLATE" \
            --type=proactive \
            --minimal-action=replace \
            --most-disruptive-allowed-action=replace \
            --replacement-method="$REPLACEMENT_METHOD" \
            --max-surge="$MAX_SURGE" \
            --max-unavailable="$MAX_UNAVAILABLE"
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --version-target-reached --timeout=900
      # 오래된 템플릿은 최근 5개만 남기고 지운다. (사용 중인 템플릿은 삭제되지 않는다)
      - name: Delete old instance templates
        if: ${{ success() }}
        continue-on-error: true
        run: |
          gcloud compute instance-templates list \
            --filter="name~^${SERVICE}-tpl-" \
            --sort-by="~creationTimestamp" \
            --format="value(name)" \
            | tail -n +6 \
            | while read -r T; do
                gcloud compute instance-templates delete "$T" --quiet || true
              done
  # 배포 결과 알림
  notify:
    name: Discord notification
    runs-on: ubuntu-latest
    needs: [prepare, rolling-update]
    if: ${{ always() && needs.prepare.result != 'skipped' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CD result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "AI 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "AI 배포: ${{ needs.rolling-update.result }}",
                "description": "이미지 태그: ${{ needs.prepare.outputs.image_tag }}",
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.rolling-update.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "실행한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "배포 방식",
                    "value": "Rolling Update",
                    "inline": true
                  }
                ]
              }
            ]

4-4. Frontend

  • cd-dev.yml: develop VM에 frontend 컨테이너만 교체한다. CI의 deploy job을 그대로 옮겼다.
  • cd-prod.yml: 기존 CD 파일에서 name과 workflow_run.workflows 두 줄만 바뀌었다.

cd-dev.yml

name: Frontend CD dev

# develop CI(ci-dev.yml)가 성공하면 자동으로 개발 서버에 배포한다.
# workflow_dispatch는 개발 서버에 다시 배포하고 싶을 때 수동으로 쓴다.
# 주의: workflow_run으로 실행되는 워크플로는 기본 브랜치(main)에 있는 파일 기준으로 실행된다.
on:
  workflow_run:
    workflows: ["Frontend CI dev"]
    types: [completed]
    branches: [develop]
  workflow_dispatch:

env:
  ZONE: asia-northeast3-a
  DEV_VM_NAME: mulo-v2-dev-vm   # Terraform이 만든 개발 VM 이름

jobs:
  deploy:
    name: Deploy to Develop VM
    runs-on: ubuntu-latest
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    concurrency:
      group: cd-frontend-develop
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      vm: ${{ steps.develop.outputs.vm }}

    steps:
      # WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - uses: google-github-actions/setup-gcloud@v3

      # 1. DEV_VM_NAME 이름의 개발 VM을 찾는다.
      - id: develop
        run: |
          VM=$(gcloud compute instances list \
            --filter="name=${{ env.DEV_VM_NAME }}" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          if [ -z "$VM" ]; then
            echo "${{ env.DEV_VM_NAME }} VM을 찾을 수 없습니다."
            exit 1
          fi
          echo "vm=$VM" >> "$GITHUB_OUTPUT"

      # 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
      - id: image
        run: |
          TAG=$(gcloud artifacts docker images list \
            "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
            --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
            --format="value(TAGS)" | cut -d',' -f1)
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"

      # 3. 빈 env 파일 생성
      - run: touch frontend.env

      # 4. env 파일을 develop VM으로 전송하고, 새 이미지를 pull해서 frontend 컨테이너만 교체한다.
      #    같은 VM 위의 backend/ai/nginx 컨테이너는 건드리지 않는다.
      - run: |
          gcloud compute scp frontend.env "${{ steps.develop.outputs.vm }}:~/app/frontend.env" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap

          gcloud compute ssh "${{ steps.develop.outputs.vm }}" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
              set -e
              sudo gcloud auth configure-docker '${{ secrets.GCP_REGION }}-docker.pkg.dev' --quiet
              cd ~/app
              export IMAGE_TAG=${{ steps.image.outputs.tag }}
              export GCP_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose pull frontend
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose up -d frontend
            "

      # 5. 컨테이너가 healthy 상태가 될 때까지 대기
      - name: "Wait for frontend container to be healthy"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            for i in \$(seq 1 20); do
              status=\$(sudo docker inspect frontend --format='{{.State.Health.Status}}' 2>/dev/null || echo 'unhealthy')
              if [ \"\$status\" = \"healthy\" ]; then
                echo \"frontend healthy (시도 \$i/20)\"
                exit 0
              fi
              echo \"대기 중... (\$i/20) 현재 상태: \$status\"
              sleep 3
            done
            echo \"frontend가 60초 안에 healthy가 되지 않았습니다.\"
            exit 1
          "

      # 6. nginx는 컨테이너 DNS를 시작/리로드 시점에만 캐싱하므로, 방금 재생성된
      #    frontend의 새 IP를 반영하도록 reload한다. 
      - name: "Reload nginx to pick up new container IP"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            sudo docker exec nginx nginx -s reload
          "

  notify:
    name: Discord notification
    runs-on: ubuntu-latest
    needs: deploy
    if: ${{ always() && needs.deploy.result != 'skipped' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CD result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Frontend Develop 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Frontend Develop 배포: ${{ needs.deploy.result }}",
                "description": ${{ toJSON(github.event.workflow_run.head_commit.message || '수동 실행') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.deploy.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.workflow_run.head_sha || github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.event.workflow_run.actor.login || github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": "develop",
                    "inline": true
                  }
                ]
              }
            ]

cd-prod.yml

name: Frontend CD prod

# CI가 main에서 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 특정 태그로 재배포하거나 이전 버전으로 되돌릴 때 수동으로 쓴다.
on:
  workflow_run:
    workflows: ["Frontend CI prod"]
    types: [completed]
    branches: [main]
  workflow_dispatch:
    inputs:
      image_tag:
        description: "배포할 이미지 태그 (비우면 가장 최근 태그)"
        required: false
        type: string

env:
  SERVICE: frontend
  MIG_NAME: muro-frontend-mig
  MIG_FLAG: "--zone=asia-northeast3-a"
  MAX_SURGE: "1"
  MAX_UNAVAILABLE: "0"
  REPLACEMENT_METHOD: substitute

jobs:
  # ============================================================
  # 1단계: 배포할 이미지 태그와 현재 인스턴스 템플릿을 확인한다. (VM은 건드리지 않는다)
  # ============================================================
  prepare:
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      image_tag: ${{ steps.image.outputs.tag }}
      prev_template: ${{ steps.current.outputs.template }}
      new_template: ${{ steps.name.outputs.template }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v3

      # 이미지는 CD가 빌드하지 않고, CI가 push해둔 것을 그대로 쓴다.
      # 수동 실행 때 태그를 넣으면 그 태그를, 비우면 가장 최근 태그를 쓴다.
      - id: image
        env:
          INPUT_TAG: ${{ inputs.image_tag }}
          IMAGE_REPO: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
        run: |
          if [ -n "$INPUT_TAG" ]; then
            TAG="$INPUT_TAG"
          else
            TAG=$(gcloud artifacts docker images list "$IMAGE_REPO" \
              --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
              --format="value(TAGS)" | cut -d',' -f1)
          fi
          if [ -z "$TAG" ]; then
            echo "배포할 이미지 태그를 찾을 수 없습니다."
            exit 1
          fi
          # 해당 태그의 이미지가 실제로 있는지 확인한다.
          gcloud artifacts docker images describe "$IMAGE_REPO:$TAG" > /dev/null
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
      # 롤백할 때 되돌아갈 현재 인스턴스 템플릿 이름
      - id: current
        run: |
          TEMPLATE=$(gcloud compute instance-groups managed describe "$MIG_NAME" $MIG_FLAG \
            --format="value(versions[0].instanceTemplate.basename())")
          if [ -z "$TEMPLATE" ]; then
            echo "$MIG_NAME 의 현재 인스턴스 템플릿을 찾을 수 없습니다."
            exit 1
          fi
          echo "template=$TEMPLATE" >> "$GITHUB_OUTPUT"
      # 새 템플릿 이름: 서비스-tpl-태그-실행번호 (소문자, 숫자, 하이픈만 가능)
      - id: name
        env:
          TAG: ${{ steps.image.outputs.tag }}
        run: |
          SAFE=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' '-' | sed 's/-*$//')
          echo "template=${SERVICE}-tpl-${SAFE}-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
  # ============================================================
  # 2단계: 새 인스턴스 템플릿을 만들고 MIG를 교체한다.
  # environment: production 에 지정한 승인자가 승인해야 시작한다.
  # ============================================================
  rolling-update:
    needs: prepare
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: cd-frontend-production
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write
    env:
      TAG: ${{ needs.prepare.outputs.image_tag }}
      PREV_TEMPLATE: ${{ needs.prepare.outputs.prev_template }}
      NEW_TEMPLATE: ${{ needs.prepare.outputs.new_template }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v3

      # 현재 템플릿을 복사하고 이미지 태그 메타데이터만 바꾼 새 템플릿을 만든다.
      # VM은 부팅할 때 이 태그의 이미지를 받아서 실행한다.
      - name: Create instance template
        run: |
          gcloud compute instance-templates create "$NEW_TEMPLATE" \
            --source-instance-template="$PREV_TEMPLATE" \
            --metadata=app-image-tag="$TAG"
      - id: start
        name: Start rolling update
        run: |
          gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
            --version=template="$NEW_TEMPLATE" \
            --type=proactive \
            --minimal-action=replace \
            --most-disruptive-allowed-action=replace \
            --replacement-method="$REPLACEMENT_METHOD" \
            --max-surge="$MAX_SURGE" \
            --max-unavailable="$MAX_UNAVAILABLE"
      # 모든 VM이 새 템플릿으로 교체되고 헬스체크를 통과할 때까지 기다린다.
      - name: Wait until all instances are updated
        run: |
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --version-target-reached --timeout=900
      - name: Wait until the group is stable
        run: |
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --stable --timeout=300
      # 교체 도중 실패하면 이전 템플릿으로 되돌린다. (교체를 시작한 경우에만)
      - name: Rollback to previous template
        if: ${{ failure() && steps.start.outcome == 'success' }}
        run: |
          gcloud compute instance-groups managed rolling-action start-update "$MIG_NAME" $MIG_FLAG \
            --version=template="$PREV_TEMPLATE" \
            --type=proactive \
            --minimal-action=replace \
            --most-disruptive-allowed-action=replace \
            --replacement-method="$REPLACEMENT_METHOD" \
            --max-surge="$MAX_SURGE" \
            --max-unavailable="$MAX_UNAVAILABLE"
          gcloud compute instance-groups managed wait-until "$MIG_NAME" $MIG_FLAG \
            --version-target-reached --timeout=900
      # 오래된 템플릿은 최근 5개만 남기고 지운다. (사용 중인 템플릿은 삭제되지 않는다)
      - name: Delete old instance templates
        if: ${{ success() }}
        continue-on-error: true
        run: |
          gcloud compute instance-templates list \
            --filter="name~^${SERVICE}-tpl-" \
            --sort-by="~creationTimestamp" \
            --format="value(name)" \
            | tail -n +6 \
            | while read -r T; do
                gcloud compute instance-templates delete "$T" --quiet || true
              done
  # 배포 결과 알림
  notify:
    name: Discord notification
    runs-on: ubuntu-latest
    needs: [prepare, rolling-update]
    if: ${{ always() && needs.prepare.result != 'skipped' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CD result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Frontend 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Frontend 배포: ${{ needs.rolling-update.result }}",
                "description": "이미지 태그: ${{ needs.prepare.outputs.image_tag }}",
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.rolling-update.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "실행한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "배포 방식",
                    "value": "Rolling Update",
                    "inline": true
                  }
                ]
              }
            ]

3. VM 템플릿과 부팅 스크립트

CD는 인스턴스 템플릿의 app-image-tag 메타데이터만 바꾼다. 나머지는 템플릿에 미리 들어 있어야 하고, 이 부분은 v2 인프라 개발 때 만든다. 아래는 CD와 맞물리는 부분을 보여주기 위한 예시다.

템플릿에 넣을 메타데이터는 다음과 같다.

키 값 누가 바꾸나
app-image 이미지 저장소 경로 (태그 제외) 인프라 (고정)
app-image-tag 이미지 태그 CD (배포마다)
app-env-secret env 내용이 들어 있는 Secret Manager 시크릿 이름 인프라 (고정)
app-port 컨테이너 포트 인프라 (고정)

부팅 스크립트 예시는 다음과 같다. Docker와 Google Cloud CLI가 설치된 이미지를 쓴다고 가정한다. (v1의 startup-script.sh는 첫 부팅 때 설치까지 하는데, MIG에서는 VM이 자주 새로 만들어지므로 설치를 포함한 커스텀 이미지를 쓰는 편이 부팅이 빠르다)

#!/bin/bash
set -euo pipefail

md() { curl -fs -H "Metadata-Flavor: Google" \
  "<http://metadata.google.internal/computeMetadata/v1/instance/attributes/$1>"; }

IMAGE=$(md app-image)
TAG=$(md app-image-tag)
SECRET=$(md app-env-secret)
PORT=$(md app-port)

gcloud auth configure-docker asia-northeast3-docker.pkg.dev --quiet

# 환경변수는 Secret Manager에서 읽는다
mkdir -p /opt/app
gcloud secrets versions access latest --secret="$SECRET" > /opt/app/app.env
chmod 600 /opt/app/app.env

docker pull "$IMAGE:$TAG"
docker rm -f app 2>/dev/null || true
docker run -d --name app --restart unless-stopped \
  --env-file /opt/app/app.env \
  -p "$PORT:$PORT" \
  --stop-timeout 60 \
  "$IMAGE:$TAG"

종료 스크립트는 다음과 같다. VM이 내려갈 때 컨테이너에 종료 신호를 보내고 최대 60초를 기다린다. 애플리케이션은 종료 신호를 받으면 처리 중인 요청과 채팅 메시지 저장을 마치고 종료해야 한다(Graceful Shutdown).

#!/bin/bash
docker stop -t 60 app || true

4. 환경변수와 Secret Manager

v1에서는 CD가 GitHub 시크릿으로 backend.env, ai.env를 만들어서 SSH로 VM에 보냈다. v2의 MIG는 이 방식을 쓸 수 없어서 Secret Manager를 도입한다.

도입이 필요한 이유

  • 오토힐링, 롤링 업데이트, VM 교체로 새 VM이 CD와 상관없이 만들어진다. 새 VM에는 아무도 env 파일을 보내주지 않으므로, VM이 부팅할 때 스스로 값을 가져올 곳이 필요하다.
  • 인스턴스 템플릿 메타데이터에 값을 넣는 방법도 있지만, 콘솔과 API에서 값이 그대로 보이고 VM 안에서 누구나 읽을 수 있어서 DB 비밀번호와 API 키에는 쓰지 않는다.
  • Secret Manager는 권한으로 접근을 제한하고, 버전 관리와 접근 기록이 남는다. 시크릿이 몇 개 수준이라 비용은 월 $1 안쪽이다.

서비스마다 시크릿을 하나씩 만들고, 그 서비스의 런타임 환경변수 전체를 env 파일 형식으로 넣는다. 부팅 스크립트가 한 번만 읽으면 되고, 설정값 변경도 같은 절차로 관리할 수 있다. 템플릿 메타데이터의 app-env-secret에 시크릿 이름을 적는다.

옮길 값은 v1 운영 CD가 VM에 주입하던 런타임 환경변수 전부다.

서비스 민감한 값 설정값
Backend DB_PASSWORD, DB_USERNAME, DB_URL, JWT_SECRET, SENTRY_DSN, KAKAO_REST_API_KEY, KMA_SERVICE_KEY, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, AI_INTERNAL_TOKEN, REPORT_OPERATION_TOKEN GCS_BUCKET_NAME, GCS_PROJECT_ID, AI_BASE_URL, MULO_SECURITY_ALLOWED_ORIGINS, JAVA_TOOL_OPTIONS
AI Server DATABASE_URL, INTERNAL_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY, REPLICATE_API_TOKEN, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, YOUTUBE_API_KEY, BACKEND_MYSQL_URL APP_ENV, AI_CLIENT_MODE, LLM_MODEL, TEXT_EMBEDDING_MODEL, TEXT_EMBEDDING_DIM, CLIP_MODEL, CLIP_EMBEDDING_DIM, BACKEND_INTERNAL_URL
AI Server 신규 RunPod 접속 키, Backend DB 읽기 전용 계정 정보 RunPod 주소
Frontend 없음 Frontend는 env 파일이 비어 있고, Sentry 값은 빌드 때 이미지에 들어간다
Chat Server CHAT_DB_PASSWORD, CHAT_DB_USERNAME, CHAT_DB_URL (application.yaml 기준, 인증 설정이 추가되면 더함)

GitHub 시크릿에 그대로 남기는 값은 다음과 같다. CI/CD가 쓰는 값이라 VM 런타임에는 필요하지 않다.

구분 값
배포 인증 WORKLOAD_IDENTITY_PROVIDER, SERVICE_ACCOUNT, GCP_PROJECT_ID, GCP_REGION, PROJECT_REPOSITORY, IMAGE_NAME
릴리즈와 알림 GIT_TOKEN, DISCORD_WEBHOOK
빌드 때 이미지에 들어가는 값 VITE_SENTRY_DSN, SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT
v2에서 사라지는 값 MURO_PROD_IP (v1의 고정 IP 교체용)

개발서버용 시크릿은 GitHub 시크릿 방식을 그대로 쓴다.

5. 필요한 권한과 설정

CD용 서비스 계정에 필요한 권한은 다음과 같다.

역할 용도
Compute Instance Admin (v1) 인스턴스 템플릿 생성과 삭제, MIG 교체
Service Account User (VM이 쓰는 서비스 계정에 대해) 템플릿에 서비스 계정을 지정해 VM을 만들 때 필요
Artifact Registry Reader 이미지 태그 조회와 확인

VM이 쓰는 서비스 계정(런타임 계정)에는 별도로 다음 권한이 필요하다. CD용 계정이 아니라 VM용 계정이다.

역할 용도
Secret Manager Secret Accessor 부팅할 때 서비스별 env 시크릿 읽기
Artifact Registry Reader 부팅할 때 이미지 받기

Chat 저장소를 새로 쓰기 때문에 GitHub, GCP 쪽에서 아래도 같이 해야 한다.

  • Chat 저장소의 Settings → Secrets and variables → Actions에 위 표의 배포 인증, 릴리즈와 알림 시크릿을 등록한다. 개발용은 _Develop 접미사가 붙은 시크릿(GCP_PROJECT_ID_Develop, WORKLOAD_IDENTITY_PROVIDER_Develop, SERVICE_ACCOUNT_Develop)도 필요하다. IMAGE_NAME은 다른 서비스와 겹치지 않게 정한다. (예: mulo-chat)
  • Settings → Environments에 production을 만들고 승인자를 지정한다. cd-prod.yml의 rolling-update가 이 환경을 쓴다. 다른 서비스 저장소도 마찬가지다.
  • Workload Identity 공급자의 attribute condition이 저장소 이름으로 제한되어 있으면 100-hours-a-week/KTB4-16th-CHAT을 추가한다. 안 하면 인증 단계에서 실패한다.
  • Chat 이미지가 올라가는 Artifact Registry 저장소에 CI용 서비스 계정의 쓰기 권한과 VM 서비스 계정의 읽기 권한이 있어야 한다.
  • MIG 헬스체크 경로가 필요한데 Chat build.gradle에는 actuator가 없다. actuator를 넣거나 헬스체크용 엔드포인트를 만들어야 한다.
⚠️ **GitHub.com Fallback** ⚠️