본문으로 건너뛰기

CI/CD 파이프라인 연동 — GitHub Actions·Jenkins로 빌드·배포·헬스체크·트래픽 전환 자동화

현대적인 웹 서비스 운영에서 수동 배포는 사람의 실수를 유발하고, 배포 시간을 늘리며, 야간/주말 배포를 어렵게 만드는 주요 원인입니다. CI/CD(Continuous Integration/Continuous Delivery) 파이프라인은 이러한 문제를 해결하여 코드 변경이 자동으로 빌드, 테스트, 배포, 검증까지 이어지도록 합니다. 이 챕터에서는 GitHub Actions와 Jenkins를 활용해 Nginx + Tomcat 스택에 완전 자동화된 CI/CD 파이프라인을 구축하는 방법을 다룹니다.

CI/CD 파이프라인 단계 개요

완전한 파이프라인은 아래 6단계로 구성됩니다.

Source → Build → Test → Deploy → Verify → Traffic Switch
│ │ │ │ │ │
Git Maven JUnit WAR Health Nginx
Push Build Test Copy Check Reload
단계설명도구실패 시
Source코드 변경 감지GitHub/GitLab파이프라인 미실행
Build소스 컴파일 및 패키징Maven/Gradle즉시 중단, 알림
Test단위/통합 테스트JUnit, Testcontainers즉시 중단, 알림
Deploy서버에 WAR 배포SSH/SCP, Ansible즉시 중단, 알림
Verify헬스체크 및 스모크 테스트curl, Newman자동 롤백
Traffic Switch로드밸런서 트래픽 전환Nginx, HAProxy수동 개입 필요

GitHub Actions 워크플로우

기본 CI/CD 워크플로우

# .github/workflows/deploy.yml
name: Build and Deploy to Production

on:
push:
branches:
- main # main 브랜치 푸시 시 프로덕션 배포
- staging # staging 브랜치 푸시 시 스테이징 배포
pull_request:
branches:
- main
types: [opened, synchronize] # PR 시 빌드·테스트만 실행

env:
JAVA_VERSION: '17'
MAVEN_OPTS: '-Xmx1g'

jobs:
# ================================================================
# Job 1: 빌드 및 테스트
# ================================================================
build-and-test:
name: Build and Test
runs-on: ubuntu-latest

steps:
- name: 소스 코드 체크아웃
uses: actions/checkout@v4

- name: Java ${{ env.JAVA_VERSION }} 설정
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: 'temurin'
cache: 'maven'

- name: Maven 빌드 및 테스트
run: mvn clean package -B --no-transfer-progress

- name: 테스트 결과 업로드
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: target/surefire-reports/

- name: WAR 파일 아티팩트 업로드
uses: actions/upload-artifact@v4
with:
name: app-war
path: target/*.war
retention-days: 7

# ================================================================
# Job 2: 스테이징 배포 (staging 브랜치)
# ================================================================
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build-and-test
if: github.ref == 'refs/heads/staging' && github.event_name == 'push'
environment:
name: staging
url: https://staging.example.com

steps:
- name: WAR 파일 다운로드
uses: actions/download-artifact@v4
with:
name: app-war
path: ./artifacts

- name: SSH 키 설정
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STAGING_SSH_KEY }}

- name: 스테이징 서버 배포
run: |
# 1. Nginx에서 노드 제거 (드레이닝)
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.STAGING_HOST }} \
"sudo sed -i 's|server 127.0.0.1:8080 weight=1;|server 127.0.0.1:8080 down;|g' \
/etc/nginx/conf.d/upstream.conf && sudo nginx -s reload"

echo "드레이닝 대기 중 (20초)..."
sleep 20

# 2. WAR 파일 전송
scp ./artifacts/*.war \
deploy@${{ secrets.STAGING_HOST }}:/opt/tomcat/webapps/ROOT.war

# 3. Tomcat 재시작
ssh deploy@${{ secrets.STAGING_HOST }} \
"sudo systemctl restart tomcat"

- name: 헬스체크
run: |
MAX_RETRIES=12
RETRY_INTERVAL=10
HEALTH_URL="http://${{ secrets.STAGING_HOST }}:8080/health"

for i in $(seq 1 $MAX_RETRIES); do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL || echo "000")
echo "헬스체크 시도 $i/$MAX_RETRIES: HTTP $HTTP_STATUS"
if [ "$HTTP_STATUS" == "200" ]; then
echo "헬스체크 성공!"
break
fi
if [ $i -eq $MAX_RETRIES ]; then
echo "헬스체크 실패! 배포 실패"
exit 1
fi
sleep $RETRY_INTERVAL
done

- name: Nginx upstream 복구
run: |
ssh deploy@${{ secrets.STAGING_HOST }} \
"sudo sed -i 's|server 127.0.0.1:8080 down;|server 127.0.0.1:8080 weight=1;|g' \
/etc/nginx/conf.d/upstream.conf && sudo nginx -s reload"
echo "스테이징 배포 완료!"

# ================================================================
# Job 3: 프로덕션 Blue-Green 배포 (main 브랜치)
# ================================================================
deploy-production:
name: Deploy to Production (Blue-Green)
runs-on: ubuntu-latest
needs: build-and-test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: https://example.com

steps:
- name: 소스 코드 체크아웃
uses: actions/checkout@v4

- name: WAR 파일 다운로드
uses: actions/download-artifact@v4
with:
name: app-war
path: ./artifacts

- name: SSH 키 설정
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PROD_SSH_KEY }}

- name: 현재 Active 환경 확인
id: check-active
run: |
ACTIVE=$(ssh -o StrictHostKeyChecking=no deploy@${{ secrets.PROD_HOST }} \
"cat /etc/nginx/current_env || echo blue")
echo "active=$ACTIVE" >> $GITHUB_OUTPUT
if [ "$ACTIVE" == "blue" ]; then
echo "target=green" >> $GITHUB_OUTPUT
echo "active_port=8080" >> $GITHUB_OUTPUT
echo "target_port=8081" >> $GITHUB_OUTPUT
else
echo "target=blue" >> $GITHUB_OUTPUT
echo "active_port=8081" >> $GITHUB_OUTPUT
echo "target_port=8080" >> $GITHUB_OUTPUT
fi
echo "현재 Active: $ACTIVE, 배포 대상: $([ "$ACTIVE" == "blue" ] && echo green || echo blue)"

- name: 비활성 환경에 WAR 배포
run: |
TARGET_PORT=${{ steps.check-active.outputs.target_port }}
# WAR 파일 전송
scp ./artifacts/*.war \
deploy@${{ secrets.PROD_HOST }}:/opt/tomcat-${{ steps.check-active.outputs.target }}/webapps/ROOT.war
# Tomcat 재시작
ssh deploy@${{ secrets.PROD_HOST }} \
"sudo systemctl restart tomcat-${{ steps.check-active.outputs.target }}"

- name: 비활성 환경 헬스체크
run: |
TARGET_PORT=${{ steps.check-active.outputs.target_port }}
HEALTH_URL="http://${{ secrets.PROD_HOST }}:${TARGET_PORT}/health"

for i in $(seq 1 18); do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL || echo "000")
echo "헬스체크 시도 $i/18: HTTP $HTTP_STATUS"
if [ "$HTTP_STATUS" == "200" ]; then
echo "헬스체크 성공!"
exit 0
fi
if [ $i -eq 18 ]; then
echo "헬스체크 실패! 트래픽 전환 취소"
exit 1
fi
sleep 10
done

- name: 트래픽 전환 (Blue-Green Swap)
run: |
TARGET_ENV=${{ steps.check-active.outputs.target }}
TARGET_PORT=${{ steps.check-active.outputs.target_port }}

ssh deploy@${{ secrets.PROD_HOST }} "
# Nginx upstream을 새 환경으로 전환
sudo sed -i 's|proxy_pass http://tomcat_.*|proxy_pass http://tomcat_${TARGET_ENV};|g' \
/etc/nginx/conf.d/app.conf
sudo nginx -t && sudo nginx -s reload

# 현재 환경 기록
echo '$TARGET_ENV' | sudo tee /etc/nginx/current_env
"
echo "트래픽 전환 완료: ${{ steps.check-active.outputs.active }} → $TARGET_ENV"

- name: 배포 성공 Slack 알림
if: success()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '{
"text": "✅ 프로덕션 배포 성공",
"attachments": [{
"color": "good",
"fields": [
{"title": "환경", "value": "${{ steps.check-active.outputs.target }}", "short": true},
{"title": "커밋", "value": "${{ github.sha }}", "short": true},
{"title": "배포자", "value": "${{ github.actor }}", "short": true},
{"title": "브랜치", "value": "${{ github.ref_name }}", "short": true}
]
}]
}'

- name: 배포 실패 Slack 알림
if: failure()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '{
"text": "❌ 프로덕션 배포 실패",
"attachments": [{
"color": "danger",
"fields": [
{"title": "커밋", "value": "${{ github.sha }}", "short": true},
{"title": "배포자", "value": "${{ github.actor }}", "short": true},
{"title": "워크플로우", "value": "${{ github.workflow }}", "short": true}
]
}]
}'

Jenkins Pipeline (Jenkinsfile)

Declarative Pipeline 전체 예시

// Jenkinsfile
pipeline {
agent any

// 파이프라인 전역 옵션
options {
buildDiscarder(logRotator(numToKeepStr: '10'))
timeout(time: 30, unit: 'MINUTES')
disableConcurrentBuilds()
}

// 환경 변수
environment {
JAVA_HOME = tool 'JDK-17'
MAVEN_HOME = tool 'Maven-3.9'
PATH = "${JAVA_HOME}/bin:${MAVEN_HOME}/bin:${env.PATH}"

// Jenkins Credentials에서 SSH 키 로드
PROD_SSH_KEY = credentials('prod-ssh-key')
STAGING_SSH_KEY = credentials('staging-ssh-key')
SLACK_WEBHOOK = credentials('slack-webhook-url')

// 서버 주소
PROD_HOST = '192.168.1.10'
STAGING_HOST = '192.168.1.20'
TOMCAT_HOME = '/opt/tomcat'
}

// 브랜치별 파라미터
parameters {
choice(
name: 'DEPLOY_ENV',
choices: ['auto', 'staging', 'production'],
description: '배포 환경 선택 (auto: 브랜치 기반 자동 결정)'
)
booleanParam(
name: 'SKIP_TESTS',
defaultValue: false,
description: '테스트 건너뛰기 (긴급 배포 시만 사용)'
)
}

stages {
// ============================================================
// Stage 1: 소스 코드 체크아웃
// ============================================================
stage('Checkout') {
steps {
checkout scm
script {
env.GIT_COMMIT_MSG = sh(
script: 'git log -1 --format="%s"',
returnStdout: true
).trim()
env.GIT_AUTHOR = sh(
script: 'git log -1 --format="%an"',
returnStdout: true
).trim()
echo "커밋: ${env.GIT_COMMIT_MSG} by ${env.GIT_AUTHOR}"
}
}
}

// ============================================================
// Stage 2: 빌드
// ============================================================
stage('Build') {
steps {
sh '''
mvn clean package \
-DskipTests=${SKIP_TESTS} \
-B \
--no-transfer-progress \
-Pproduction
'''
}
post {
always {
junit '**/target/surefire-reports/*.xml'
archiveArtifacts artifacts: 'target/*.war', fingerprint: true
}
}
}

// ============================================================
// Stage 3: 테스트 (선택적 건너뛰기)
// ============================================================
stage('Test') {
when {
not { expression { params.SKIP_TESTS } }
}
steps {
sh 'mvn verify -B --no-transfer-progress'
}
}

// ============================================================
// Stage 4: 스테이징 배포
// ============================================================
stage('Deploy to Staging') {
when {
anyOf {
branch 'develop'
branch 'staging'
expression { params.DEPLOY_ENV == 'staging' }
}
}
steps {
script {
deployToServer(
host: env.STAGING_HOST,
sshKeyFile: env.STAGING_SSH_KEY,
warFile: 'target/app.war',
healthUrl: "http://${env.STAGING_HOST}:8080/health"
)
}
}
}

// ============================================================
// Stage 5: 프로덕션 배포 승인
// ============================================================
stage('Production Approval') {
when {
anyOf {
branch 'main'
expression { params.DEPLOY_ENV == 'production' }
}
}
steps {
timeout(time: 10, unit: 'MINUTES') {
input message: '프로덕션에 배포하시겠습니까?',
ok: '배포 승인',
submitter: 'devops,admin',
parameters: [
booleanParam(
name: 'BLUE_GREEN',
defaultValue: true,
description: 'Blue-Green 배포 사용'
)
]
}
}
}

// ============================================================
// Stage 6: 프로덕션 배포
// ============================================================
stage('Deploy to Production') {
when {
anyOf {
branch 'main'
expression { params.DEPLOY_ENV == 'production' }
}
}
steps {
script {
deployToServer(
host: env.PROD_HOST,
sshKeyFile: env.PROD_SSH_KEY,
warFile: 'target/app.war',
healthUrl: "http://${env.PROD_HOST}:8080/health",
nginxConf: '/etc/nginx/conf.d/upstream.conf'
)
}
}
}
}

// 파이프라인 완료 후 알림
post {
success {
script {
slackNotify(
status: 'SUCCESS',
color: 'good',
message: "✅ ${env.JOB_NAME} #${env.BUILD_NUMBER} 배포 성공\n커밋: ${env.GIT_COMMIT_MSG}"
)
}
}
failure {
script {
slackNotify(
status: 'FAILURE',
color: 'danger',
message: "❌ ${env.JOB_NAME} #${env.BUILD_NUMBER} 배포 실패\n빌드 로그: ${env.BUILD_URL}"
)
}
}
always {
cleanWs()
}
}
}

// ================================================================
// 공통 배포 함수
// ================================================================
def deployToServer(Map config) {
def host = config.host
def warFile = config.warFile
def healthUrl = config.healthUrl
def nginxConf = config.nginxConf ?: '/etc/nginx/conf.d/upstream.conf'

// 1. Nginx에서 노드 제거
sh """
ssh -i ${config.sshKeyFile} -o StrictHostKeyChecking=no deploy@${host} \
"sudo sed -i 's|server 127.0.0.1:8080 weight=1;|server 127.0.0.1:8080 down;|g' \
${nginxConf} && sudo nginx -s reload"
"""
echo "Nginx에서 노드 제거 완료, 드레이닝 대기 (20초)..."
sleep(20)

// 2. WAR 파일 배포
sh """
scp -i ${config.sshKeyFile} -o StrictHostKeyChecking=no \
${warFile} deploy@${host}:/opt/tomcat/webapps/ROOT.war
ssh -i ${config.sshKeyFile} -o StrictHostKeyChecking=no deploy@${host} \
"sudo systemctl restart tomcat"
"""

// 3. 헬스체크
retry(12) {
sleep(10)
def status = sh(
script: "curl -s -o /dev/null -w '%{http_code}' ${healthUrl}",
returnStdout: true
).trim()
if (status != '200') {
error("헬스체크 실패: HTTP ${status}")
}
echo "헬스체크 성공: HTTP ${status}"
}

// 4. Nginx upstream 복구
sh """
ssh -i ${config.sshKeyFile} -o StrictHostKeyChecking=no deploy@${host} \
"sudo sed -i 's|server 127.0.0.1:8080 down;|server 127.0.0.1:8080 weight=1;|g' \
${nginxConf} && sudo nginx -s reload"
"""
echo "배포 완료!"
}

def slackNotify(Map config) {
sh """
curl -X POST ${SLACK_WEBHOOK} \
-H 'Content-Type: application/json' \
-d '{"text": "${config.message}"}'
"""
}

배포 헬스체크 스크립트

#!/bin/bash
# health-check.sh — N회 재시도 헬스체크

HEALTH_URL="${1:-http://localhost:8080/health}"
MAX_RETRIES="${2:-12}"
RETRY_INTERVAL="${3:-10}"
EXPECTED_STATUS="${4:-200}"

echo "=== 헬스체크 시작 ==="
echo "URL: $HEALTH_URL"
echo "최대 재시도: $MAX_RETRIES회 (간격: ${RETRY_INTERVAL}초)"

for i in $(seq 1 $MAX_RETRIES); do
HTTP_STATUS=$(curl -s \
--connect-timeout 5 \
--max-time 10 \
-o /tmp/health_response.txt \
-w "%{http_code}" \
$HEALTH_URL 2>/dev/null || echo "000")

TIMESTAMP=$(date '+%H:%M:%S')

if [ "$HTTP_STATUS" == "$EXPECTED_STATUS" ]; then
RESPONSE=$(cat /tmp/health_response.txt)
echo "[$TIMESTAMP] ✅ 헬스체크 성공! (HTTP $HTTP_STATUS)"
echo "응답: $RESPONSE"
exit 0
else
echo "[$TIMESTAMP] ⏳ 시도 $i/$MAX_RETRIES: HTTP $HTTP_STATUS (대기 중...)"
fi

if [ $i -lt $MAX_RETRIES ]; then
sleep $RETRY_INTERVAL
fi
done

echo "❌ 헬스체크 최종 실패 (${MAX_RETRIES}회 시도 후 응답 없음)"
echo "마지막 응답:"
cat /tmp/health_response.txt
exit 1

롤백 자동화

헬스체크 실패 시 자동으로 이전 버전을 복원하는 스크립트입니다.

#!/bin/bash
# auto-rollback.sh — 헬스체크 실패 시 자동 롤백

TOMCAT_HOME="/opt/tomcat"
BACKUP_DIR="/opt/tomcat/backup"
CURRENT_WAR="${TOMCAT_HOME}/webapps/ROOT.war"
HEALTH_URL="http://localhost:8080/health"
NGINX_CONF="/etc/nginx/conf.d/upstream.conf"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }

# 배포 전 현재 WAR 백업
backup_current() {
BACKUP_FILE="${BACKUP_DIR}/ROOT.war.$(date '+%Y%m%d_%H%M%S')"
cp $CURRENT_WAR $BACKUP_FILE
# 최신 5개만 유지
ls -t ${BACKUP_DIR}/ROOT.war.* | tail -n +6 | xargs rm -f 2>/dev/null || true
log "현재 WAR 백업 완료: $BACKUP_FILE"
echo $BACKUP_FILE
}

# 롤백 실행
perform_rollback() {
local PREV_WAR=$(ls -t ${BACKUP_DIR}/ROOT.war.* 2>/dev/null | head -1)

if [ -z "$PREV_WAR" ]; then
log "롤백 실패: 백업 파일이 없습니다"
exit 1
fi

log "롤백 시작: $PREV_WAR"

# 1. Nginx에서 노드 제거
sed -i 's|server 127.0.0.1:8080 weight=1;|server 127.0.0.1:8080 down;|g' $NGINX_CONF
nginx -s reload
sleep 15

# 2. 이전 WAR 복원
cp $PREV_WAR $CURRENT_WAR
systemctl restart tomcat

# 3. 롤백 후 헬스체크
log "롤백 후 헬스체크..."
for i in $(seq 1 12); do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL || echo "000")
if [ "$HTTP_STATUS" == "200" ]; then
log "롤백 성공! 서비스 정상 복구"
sed -i 's|server 127.0.0.1:8080 down;|server 127.0.0.1:8080 weight=1;|g' $NGINX_CONF
nginx -s reload
return 0
fi
sleep 10
done

log "롤백 후에도 헬스체크 실패! 즉시 수동 대응 필요"
return 1
}

# 메인 배포 + 롤백 로직
BACKUP_FILE=$(backup_current)
log "새 버전 배포 중..."
cp /deploy/app.war $CURRENT_WAR
systemctl restart tomcat

# 헬스체크 실행
bash health-check.sh $HEALTH_URL 12 10
if [ $? -ne 0 ]; then
log "배포 실패 감지! 자동 롤백 시작..."
perform_rollback
fi

환경별 분기

# GitHub Actions에서 환경별 분기 처리
# .github/workflows/deploy.yml (환경별 분기 부분)

jobs:
determine-environment:
runs-on: ubuntu-latest
outputs:
environment: ${{ steps.set-env.outputs.environment }}
should-deploy: ${{ steps.set-env.outputs.should-deploy }}
steps:
- name: 배포 환경 결정
id: set-env
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "environment=production" >> $GITHUB_OUTPUT
echo "should-deploy=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == "refs/heads/staging" ]]; then
echo "environment=staging" >> $GITHUB_OUTPUT
echo "should-deploy=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then
echo "environment=development" >> $GITHUB_OUTPUT
echo "should-deploy=true" >> $GITHUB_OUTPUT
else
echo "environment=none" >> $GITHUB_OUTPUT
echo "should-deploy=false" >> $GITHUB_OUTPUT
fi

SSH 배포 시 Secrets 관리

GitHub Secrets 설정

# GitHub CLI로 시크릿 등록
gh secret set PROD_SSH_KEY < ~/.ssh/id_rsa_prod
gh secret set STAGING_SSH_KEY < ~/.ssh/id_rsa_staging
gh secret set SLACK_WEBHOOK_URL --body "https://hooks.slack.com/services/xxx/yyy/zzz"
gh secret set PROD_HOST --body "192.168.1.10"

SSH 키 생성 및 서버 등록

# 배포 전용 SSH 키 생성 (passphrase 없이)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/id_ed25519_deploy -N ""

# 서버에 공개키 등록
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub deploy@192.168.1.10

# GitHub Secrets에 개인키 등록
cat ~/.ssh/id_ed25519_deploy | gh secret set PROD_SSH_KEY

# 서버의 deploy 사용자 sudo 권한 최소화 (필요한 명령만 허용)
# /etc/sudoers.d/deploy 파일
# deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart tomcat, /usr/sbin/nginx -s reload, /usr/sbin/nginx -t

Docker 기반 CI/CD

# .github/workflows/docker-deploy.yml
name: Docker Build and Deploy

on:
push:
branches: [main]

jobs:
docker-build-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Docker Buildx 설정
uses: docker/setup-buildx-action@v3

- name: Docker Hub 로그인
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: 이미지 빌드 및 Push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/myapp:latest
${{ secrets.DOCKERHUB_USERNAME }}/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
needs: docker-build-push
runs-on: ubuntu-latest
steps:
- name: SSH로 서버에서 새 이미지 Pull 및 재시작
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.PROD_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: |
# 새 이미지 Pull
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/myapp:latest

# 무중단 재시작 (docker compose)
cd /opt/app
docker compose pull tomcat
docker compose up -d --no-deps tomcat

# 헬스체크
sleep 15
for i in $(seq 1 10); do
if curl -sf http://localhost:8080/health; then
echo "배포 성공!"
exit 0
fi
sleep 10
done
echo "배포 실패!" && exit 1

CI/CD 파이프라인을 구축하면 개발자는 코드 변경에만 집중하고, 빌드·테스트·배포·검증은 자동으로 처리됩니다. 처음에는 설정이 복잡해 보이지만, 한 번 구축하면 팀 전체의 배포 효율과 안정성이 크게 향상됩니다.