[Infra] 시리우스 2편 - CI / CD 동작 과정

Bronze_Yun·2025년 5월 16일
3

Sirius

목록 보기
2/5
post-thumbnail

이전 글에서는 어떤 CI/CD 툴을 사용 할 지, 제가 생각한 CI, CD의 역할은 어떤건지에 대해 간략하게 소개했습니다. 이번엔 제가 구축한 PR, CI, CD에 대해 코드를 통해 자세하게 설명해드리겠습니다.

(참고: ci/cd에서 dev만 설명해드린 이유는 도커 이미지 명, ec2 서버, application.yml의 profile 등 dev에서 prod만 변경을하면되기 때문입니다.)


PR 🤔

PR은 개발 협력에서 매우 중요합니다. 프로젝트는 개인이 아닌 팀으로 이루어져있고 본인이 맡은 역할의 코드가 팀원들의 코드와 합칠 때 문제가 없는 지 사전에 팀원들에게 알리고 검토/승인을 받는 과정입니다. 그래서 저는 PR도 자동화를 통해서 팀원들에게 알리기 전 충분히 자동으로 테스트를 해주고 팀원들이 코드를 보기전 사전 검증을 해주면 좋겠다고 생각했습니다.


pull-request.yml 파일의 workflow 🤔

name: Pull Request

on:
  pull_request:
    branches: [ main, develop ]

permissions:
  checks: write
  contents: read
  pull-requests: write

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          distribution: 'corretto'
          java-version: '17'

      - uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Build with Gradle
        run: ./gradlew build -x test

      - name: Test
        run: ./gradlew test

      - name: add comments to a pull request
        uses: mikepenz/action-junit-report@v3
        if: always()
        with:
          report_paths: |
            */build/test-results/test/TEST-*.xml
            build/test-results/test/TEST-*.xml
          github_token: ${{ secrets.GITHUB_TOKEN }}

      - name: action-slack
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          author_name: serius-be
          fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
          if_mention: failure,cancelled
          channel: '#be-pr'
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.PR_SLACK_WEBHOOK }}
        if: always()

pull-request.yml 파일의 workflow 상세 🤔

on:
  pull_request:
    branches: [ main, develop ]
  • main, develop 브랜치로 pr을 날릴 때 해당 workflow를 실행하는 조건
permissions:
  checks: write
  contents: read
  pull-requests: write
  • checks: write를 통해 테스트 결과를 깃허브에 보고
  • contents: read를 통해 깃허브 레포지토리를 조회
  • pull-requests: write를 통해 pr의 커멘트를 등록
 - name: Checkout
   uses: actions/checkout@v4
  • 레포지토리를 깃허브의 워크플로우 실행환경으로 가져오기 위한 설정
- name: Set up JDK 17
  uses: actions/setup-java@v3
  with:
    distribution: 'corretto'
    java-version: '17'
  • 워크플로우 실행환경에 JDK 17버전을 설치
- uses: actions/cache@v3
  with:
  path: |
       ~/.gradle/caches
       ~/.gradle/wrapper
       key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
  • build.gradle에 다양한 의존성 라이브러리들을 설치하셨을겁니다. 하지만 매번 workflow를 실행하면 의존성 라이브러리를 새로 가져오는 과정이 반복됩니다. 그래서 의존성 라이브러리에 대한 캐시 값을 설정해서 새로 진행 할 workflow에서 의존성 라이브러리가 바뀌지 않으면 캐시에 저장된 의존성 라이브러리를 사용해 빌드 속도가 향상됩니다.
- name: Grant execute permission for gradlew
  run: chmod +x ./gradlew

- name: Build with Gradle
  run: ./gradlew build -x test

- name: Test
  run: ./gradlew test
  • gradle를 통해 빌드하기 위한 실행 권한 부여
  • 테스트 실행을 제외한 코드 컴파일 오류가 없는 지 검사
  • 테스트 코드 검증 및 application-test.yml 설정 정보 검증
- name: add comments to a pull request
  uses: mikepenz/action-junit-report@v3
  if: always()
  with:
     report_paths: |
         */build/test-results/test/TEST-*.xml
         build/test-results/test/TEST-*.xml
     github_token: ${{ secrets.GITHUB_TOKEN }}
  • 테스트 결과물을 pr의 커멘트로 등록하기 위한 설정
  • 깃허브 토큰을 이용하여 pr 커멘트 등록 권한 획득
- name: action-slack
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    author_name: serius-be
    fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
    if_mention: failure,cancelled
    channel: '#be-pr'
  env:
      SLACK_WEBHOOK_URL: ${{ secrets.PR_SLACK_WEBHOOK }}
  if: always()
  • pr을 날렸을 시 Slack의 "#be-pr"이라는 채널에 알림 전송

CI 🤔

CI는 통합한 코드에 문제가 없는 지 테스트 및 빌드를 하고 빌드한 아티펙트를 레지스트리에 푸쉬하는 과정입니다.


ci-dev.yml 파일의 workflow 🤔

name: CI-Dev

on:
  push:
    branches: [ develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
    permissions:
      contents: read
      actions: read
      checks: write
      pull-requests: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.SUBMODULE_TOKEN }}
          submodules: true

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          distribution: 'corretto'
          java-version: '17'

      - uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Build with Gradle
        run: ./gradlew build -x test -Dspring.profiles.active=dev

      - name: Test
        run: ./gradlew test

      - name: Publish test results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: |
            */build/test-results/test/TEST-*.xml
            build/test-results/test/TEST-*.xml
          check_name: "Test Results"
          comment_mode: always

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Set up Docker BuildX
        uses: docker/setup-buildx-action@v3

      - name: Generate version tag
        id: generate_tag
        run: |
          BUILD_DATE=$(date +'%Y%m%d')
          RUN_ID=${{ github.run_id }}
          SHORT_SHA=${GITHUB_SHA::7}
          VERSION="${BUILD_DATE}-${RUN_ID}-${SHORT_SHA}"
          
          echo "VERSION=$VERSION" >> $GITHUB_ENV
          echo "DEV_LATEST_TAG=$VERSION" >> $GITHUB_ENV

      - name: Store build info in Repository Secrets
        env:
          GH_TOKEN: ${{ secrets.PAT_TOKEN }}
        run: |
          echo "${{ env.DEV_LATEST_TAG }}" | gh secret set DEV_LATEST_TAG --repo ${{ github.repository }}

      - name: Build and push
        run: |
          BASE_IMAGE="${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_DEV }}"
          docker buildx build --platform linux/amd64 \
            -t ${BASE_IMAGE}:latest \
            -t ${BASE_IMAGE}:${{ env.VERSION }} \
            --push .

      - name: action-slack
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          author_name: serius-be
          fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
          if_mention: failure,cancelled
          channel: '#be-ci'
          text: |
            *빌드 상태*: ${{ job.status }}
            *이미지 태그*: ${{ env.VERSION }}
            *이미지 URL*: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_DEV }}:${{ env.VERSION }}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK }}
        if: always()

ci-dev.yml 파일의 workflow 상세 🤔

일부분은 pull-request.yml과 동작이 똑같기에 다른점들만 설명하겠습니다~

on:
  push:
    branches: [ develop ]
  • develop 브랜치로 push 될 때 해당 workflow를 실행하는 조건
- name: Create application files
  run: |
    mkdir -p ./src/main/resources
    echo "${{ secrets.APPLICATION }}"  > ./src/main/resources/application.yml
    echo "${{ secrets.APPLICATION_DEV }}"  > ./src/main/resources/application-dev.yml
  • 빌드한 결과물을 레지스트리에 푸쉬를 해야하기에 아티팩트를 생성 할 때 profile과 관계없이 공통적인 설정이 담긴 application.yml, dev profile과 관련된 설정 정보를 포함해서 빌드
- name: Login to Docker Hub
  uses: docker/login-action@v3
  with:
    username: ${{ secrets.DOCKER_USERNAME }}
    password: ${{ secrets.DOCKER_PASSWORD }}
  • 레지스트리(도커 허브)에 아티팩트를 푸시하기위한 인증
- name: Set up Docker BuildX
  uses: docker/setup-buildx-action@v3
  • 깃허브 workflow 실행을 위한 runner에 도커 설정
- name: Generate version tag
  id: generate_tag
  run: |
    BUILD_DATE=$(date +'%Y%m%d')
    RUN_ID=${{ github.run_id }}
    SHORT_SHA=${GITHUB_SHA::7}
    VERSION="${BUILD_DATE}-${RUN_ID}-${SHORT_SHA}"
          
    echo "VERSION=$VERSION" >> $GITHUB_ENV
    echo "DEV_LATEST_TAG=$VERSION" >> $GITHUB_ENV
  • 배포 과정에서 문제가 생겼을 시 롤백 가능성 존재하기에 도커 이미지 버전명 명시
  • CI를 실행한 '년월일' + 깃헙 액션 run 고유 id + 해당 커밋의 마지막 해시코드 7자리로 구성
  • VERSION과 DEV_LATEST_TAG를 같은 workflow 파일의 다른 job에서 사용하기 위해 환경변수 설정
- name: Store build info in Repository Secrets
  env:
    GH_TOKEN: ${{ secrets.PAT_TOKEN }}
  run: |
    echo "${{ env.DEV_LATEST_TAG }}" | gh secret set DEV_LATEST_TAG --repo ${{ github.repository }}
  • CD 파일에서 배포 할 때 어떤 버전명을 배포 할 지 입력하기에 깃허브 secrets에 환경변수 값 DEV_LATEST_TAG를 저장
- name: Build and push
  run: |
    BASE_IMAGE="${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_DEV }}"
    docker buildx build --platform linux/amd64 \
       -t ${BASE_IMAGE}:latest \
       -t ${BASE_IMAGE}:${{ env.VERSION }} \
       --push .
  • 생성한 아티팩트를 linux/amd64를 이용하여 최신버전과 이전에 환경변수 버전을 태깅하여 도커 이미지로 빌드 후 도커 허브에 푸쉬
- name: action-slack
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    author_name: serius-be
    fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
    if_mention: failure,cancelled
    channel: '#be-ci'
    text: |
      *빌드 상태*: ${{ job.status }}
      *이미지 태그*: ${{ env.VERSION }}
      *이미지 URL*: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_DEV }}:${{ env.VERSION }}
  env:
     SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK }}
  if: always()
  • ci를 실행 할 시 Slack의 "#be-ci"이라는 채널에 알림 전송

CD 🤔

CD는 CI에서 생성한 도커 이미지를 서버에 배포 후 실행하는 과정입니다. 배포전략(블루-그린)을 사용할거기에 수동으로 배포합니다. 수동으로 진행 한 이유는 이전글에 자세히 작성해놓았으니 읽고 오시면 좋을것 같습니다~


cd-dev.yml 파일의 workflow 🤔

name: CD-Dev

on:
  workflow_dispatch:
    inputs:
      deployment_type:
        description: '배포 유형'
        required: true
        default: 'latest_successful'
        type: choice
        options:
          - latest_successful
          - specific_version
      specific_image_tag:
        description: '특정 이미지 태그 (배포 유형이 specific_version인 경우에만 사용)'
        required: false
      deploy_message:
        description: '배포 메시지'
        required: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
    permissions:
      contents: read
      actions: read

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Determine image tag
        id: determine_tag
        run: |
          DEPLOY_MESSAGE="${{ github.event.inputs.deploy_message }}"
          DEPLOYMENT_TYPE="${{ github.event.inputs.deployment_type }}"
          
          if [[ "$DEPLOYMENT_TYPE" == "latest_successful" ]]; then
            echo "최신 성공한 CI 빌드 이미지 정보 읽기..."
          
            DOCKER_IMAGE_TAG="${{ secrets.DEV_LATEST_TAG }}"
          
            if [[ -z "$DOCKER_IMAGE_TAG" ]]; then
              echo "::error::저장소 시크릿에서 최신 이미지 태그를 찾을 수 없습니다. CI 워크플로우가 성공적으로 완료되었는지 확인하세요."
              exit 1
            fi
          
            echo "저장소 시크릿에서 이미지 태그 가져옴: $DOCKER_IMAGE_TAG"
          
          elif [[ "$DEPLOYMENT_TYPE" == "specific_version" ]]; then
            DOCKER_IMAGE_TAG="${{ github.event.inputs.specific_image_tag }}"
            if [[ -z "$DOCKER_IMAGE_TAG" ]]; then
              echo "::error::특정 버전 배포 선택 시 이미지 태그를 입력해야 합니다."
              exit 1
            fi
            echo "지정된 이미지 태그 사용: $DOCKER_IMAGE_TAG"
          else
            echo "::error::알 수 없는 배포 유형: $DEPLOYMENT_TYPE"
            exit 1
          fi
          
          echo "DOCKER_IMAGE_TAG=$DOCKER_IMAGE_TAG" >> $GITHUB_ENV
          echo "DEPLOY_MESSAGE=$DEPLOY_MESSAGE" >> $GITHUB_ENV
          echo "DEPLOYMENT_TYPE=$DEPLOYMENT_TYPE" >> $GITHUB_ENV

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Deploy to EC2
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.EC2_DEV_HOST }}
          username: ${{ secrets.EC2_DEV_USERNAME }}
          key: ${{ secrets.EC2_DEV_SSH_KEY }}
          script: |
            DOCKER_IMAGE_TAG=${{ env.DOCKER_IMAGE_TAG }}
            DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }}
            DOCKER_IMAGE=${{ secrets.DOCKER_DEV_IMAGE }}
            
            echo "DOCKER_USERNAME=${DOCKER_USERNAME}" > /app/deploy.env
            echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> /app/deploy.env
            echo "DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG}" >> /app/deploy.env
            
            echo "배포 스크립트 실행"
            cd /app
            chmod +x ./deploy.sh
            ./deploy.sh
            
            if [ $? -eq 0 ]; then
              echo "배포 성공"
              echo "현재 실행 중인 컨테이너:"
              docker ps
            else
              echo "배포 실패"
              exit 1
            fi

      - name: Send Slack notification
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          author_name: serius-be-production
          fields: repo,commit,author,action,eventName,workflow,job,took
          if_mention: always
          mention: here
          channel: '#be-cd'
          text: |
            *배포 상태*: ${{ job.status == 'success' && ':white_check_mark: 성공' || ':x: 실패' }}
            *배포 유형*: ${{ env.DEPLOYMENT_TYPE == 'latest_successful' && '최신 빌드' || '특정 버전 (롤백)' }}
            *이미지 URL*: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}:${{ env.DOCKER_IMAGE_TAG }}
            *배포 메시지*: ${{ env.DEPLOY_MESSAGE }}
            *배포자*: ${{ github.actor }}
            *배포 시간*: $(date +'%Y-%m-%d %H:%M:%S')
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.CD_SLACK_WEBHOOK }}
        if: always()

cd-dev.yml 파일의 workflow 상세 🤔

on:
  workflow_dispatch:
    inputs:
      deployment_type:
        description: '배포 유형'
        required: true
        default: 'latest_successful'
        type: choice
        options:
          - latest_successful
          - specific_version
      specific_image_tag:
        description: '특정 이미지 태그 (배포 유형이 specific_version인 경우에만 사용)'
        required: false
      deploy_message:
        description: '배포 메시지'
        required: false
  • workflow_dispatch를 통해 수동으로 workflow 실행
  • 배포를 할 때 새로운 기능이 추가되어 배포하는 경우 / 새로 배포한 버전에 오류가 생겨 롤백을 해야하는 경우가 존재
  • 배포 유형을 latest_successful과 specific_image_tag 중 택1하여 옵션 선택
  • default는 latest_successfu이고 specific_image_tag일 시 버전명을 입력, 왜 롤백을 해야하는 지 메세지 작성
- name: Determine image tag
  id: determine_tag
  run: |
          DEPLOY_MESSAGE="${{ github.event.inputs.deploy_message }}"
          DEPLOYMENT_TYPE="${{ github.event.inputs.deployment_type }}"
          
          if [[ "$DEPLOYMENT_TYPE" == "latest_successful" ]]; then
            echo "최신 성공한 CI 빌드 이미지 정보 읽기..."
          
            DOCKER_IMAGE_TAG="${{ secrets.DEV_LATEST_TAG }}"
          
            if [[ -z "$DOCKER_IMAGE_TAG" ]]; then
              echo "::error::저장소 시크릿에서 최신 이미지 태그를 찾을 수 없습니다. CI 워크플로우가 성공적으로 완료되었는지 확인하세요."
              exit 1
            fi
          
            echo "저장소 시크릿에서 이미지 태그 가져옴: $DOCKER_IMAGE_TAG"
          
          elif [[ "$DEPLOYMENT_TYPE" == "specific_version" ]]; then
            DOCKER_IMAGE_TAG="${{ github.event.inputs.specific_image_tag }}"
            if [[ -z "$DOCKER_IMAGE_TAG" ]]; then
              echo "::error::특정 버전 배포 선택 시 이미지 태그를 입력해야 합니다."
              exit 1
            fi
            echo "지정된 이미지 태그 사용: $DOCKER_IMAGE_TAG"
          else
            echo "::error::알 수 없는 배포 유형: $DEPLOYMENT_TYPE"
            exit 1
          fi
          
          echo "DOCKER_IMAGE_TAG=$DOCKER_IMAGE_TAG" >> $GITHUB_ENV
          echo "DEPLOY_MESSAGE=$DEPLOY_MESSAGE" >> $GITHUB_ENV
          echo "DEPLOYMENT_TYPE=$DEPLOYMENT_TYPE" >> $GITHUB_ENV
  • DEPLOYMENT_TYPE이 latest_successful이면 CI에서 저장한 버전명이 담긴 secrets에서 DEV_LATEST_TAG를 조회해서 DOCKER_IMAGE_TAG 환경변수에 저장
  • DOCKER_IMAGE_TAG가 존재하지 안하다면 오류를 발생하고 종료
  • DEPLOYMENT_TYPE이 specific_version이면 이전에 입력받은 배포 버전명이 담긴 값을 DOCKER_IMAGE_TAG 환경변수에 저장
  • DOCKER_IMAGE_TAG 값이 없다면 입력하라는 오류와 함께 종료
  • DEPLOYMENT_TYPE이 latest_successful과 specific_version 모두 해당되지 않을 시 오류
  • DOCKER_IMAGE_TAG, DEPLOY_MESSAGE, DEPLOYMENT_TYPE을 환경변수로 설정해서 Slack 알림 메시지에서 사용 할 예정
- name: Deploy to EC2
  uses: appleboy/ssh-action@master
  with:
   host: ${{ secrets.EC2_DEV_HOST }}
   username: ${{ secrets.EC2_DEV_USERNAME }}
   key: ${{ secrets.EC2_DEV_SSH_KEY }}
   script: |
            DOCKER_IMAGE_TAG=${{ env.DOCKER_IMAGE_TAG }}
            DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }}
            DOCKER_IMAGE=${{ secrets.DOCKER_DEV_IMAGE }}
            
            echo "DOCKER_USERNAME=${DOCKER_USERNAME}" > /app/deploy.env
            echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> /app/deploy.env
            echo "DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG}" >> /app/deploy.env
            
            echo "배포 스크립트 실행"
            cd /app
            chmod +x ./deploy.sh
            ./deploy.sh
            
            if [ $? -eq 0 ]; then
              echo "배포 성공"
              echo "현재 실행 중인 컨테이너:"
              docker ps
            else
              echo "배포 실패"
              exit 1
            fi
  • EC2에 배포를 할거기에 인증을 위한 host, username, key를 secrets에 조회해서 설정
  • EC2에서 도커허브에 담긴 도커 이미지를 가져와서 실행하기 위해 /app/deploy.env 파일에 DOCKER_USERNAME, DOCKER_IMAGE, DOCKER_IMAGE_TAG를 저장
  • deploy.sh을 이용하여 deploy.env 파일에 저장된 환경변수를 통해 도커 이미지를 가져와 컨테이너로 구동
  • deploy.sh의 실행 결과가 문제가 없으면 배포 성공, 문제가 생길 시 종료
- name: Send Slack notification
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    author_name: serius-be-production
    fields: repo,commit,author,action,eventName,workflow,job,took
    if_mention: always
    mention: here
    channel: '#be-cd'
    text: |
            *배포 상태*: ${{ job.status == 'success' && ':white_check_mark: 성공' || ':x: 실패' }}
            *배포 유형*: ${{ env.DEPLOYMENT_TYPE == 'latest_successful' && '최신 빌드' || '특정 버전 (롤백)' }}
            *이미지 URL*: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_DEV_IMAGE }}:${{ env.DOCKER_IMAGE_TAG }}
            *배포 메시지*: ${{ env.DEPLOY_MESSAGE }}
            *배포자*: ${{ github.actor }}
            *배포 시간*: $(date +'%Y-%m-%d %H:%M:%S')
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.CD_SLACK_WEBHOOK }}
  if: always()
  • cd를 실행 할 시 Slack의 "#be-cd"이라는 채널에 알림 전송
profile
정답이 꼭 정답이 아니다.

0개의 댓글