Engineering Note

[SW Engineering] GitHub Actions + Docker를 활용한 Spring Boot 자동 배포 파이프라인 구축 과정 본문

SW Engineering

[SW Engineering] GitHub Actions + Docker를 활용한 Spring Boot 자동 배포 파이프라인 구축 과정

Software Engineer Kim 2025. 9. 28. 12:16

Spring Boot 프로젝트에서 수동 배포의 번거로움을 해결하고 개발 생산성을 높이기 위한 완전 자동화 CI/CD 파이프라인을 구축하는 실용적 가이드이자 개발 과정에서 실제 적용한 방법을 기록하고 공유하기 위한 글입니다.

 

수동 배포의 한계점

  • 반복적인 수작업: SSH 접속 → 코드 pull → 빌드 → 실행의 반복
  • 배포 시간 소요: 매번 7분 이상의 수동 작업 필요
  • 휴먼 에러: 수동 과정에서 발생하는 실수
  • 개발 집중도 저하: 배포 과정에 신경 써야 하는 부담

 

목표 설정

  • PR 병합 시 자동 배포 실현
  • 배포 시간 대폭 단축
  • 100% 자동화로 휴먼 에러 제거
  • 개발자가 기능 개발에만 집중할 수 있는 환경 구축

 

전체 아키텍처

 

 

 

플로우:

  1. 개발자가 PR 생성 및 병합
  2. GitHub Actions가 자동 빌드 시작
  3. Docker 이미지 생성 및 GCP 서버 배포
  4. Nginx를 통한 서비스 제공

 

 

기술 선택 과정

GCP vs AWS 

기준 GCP AWS
프리티어 정책 3개월 크레딧 한도 내 무제한 사용량별 과금
비용 예측 크레딧 소진까지 안전 갑작스런 요금 발생 위험
데모 프로젝트 적합성 높음 낮음 (비용 부담)
24시간 서비스 운영 크레딧 내에서 가능 비용 부담

선택: GCP (예측 가능한 비용과 안정적인 24시간 서비스 운영)

 

GitHub Actions vs Jenkins

기준 GitHub Actions Jenkins
비용 무료 (퍼블릭 repo) 별도 서버 비용
설정 복잡도 낮음 높음
유지보수 GitHub에서 관리 직접 관리 필요
확장성 클라우드 기반 서버 리소스 제한

 

선택: GitHub Actions (간편함과 비용 효율성)

 

Docker 도입 이유

  • 환경 일관성: 로컬, 개발, 운영 환경 동일화
  • 배포 간소화: 이미지 하나로 모든 의존성 포함
  • 확장성: 새 서버 추가 시 Docker만 설치하면 즉시 배포 가능
  • 롤백 용이성: 이전 이미지로 즉시 복구 가능

 

서버 배포 연습을 하면서 클라우드를 인스턴스를 만들면서 jdk 설치, MySQL 설치 등 프로젝트를 위한 환경을 구축하면서 실수가 일어나기도 하고 서버마다 동일한 환경을 구축하는데 불편함을 겪어서 Docker를 통해 환경을 통일하고 배포를 간소화하기 위해 도입하기로 했습니다.

 

 

단계별 구현 가이드

1단계: 환경 설정

SSH 키 생성 및 등록

# 로컬에서 SSH 키 생성
ssh-keygen -t rsa -f ~/.ssh/gcp-deploy-key -C "ubuntu"

# 공개키를 GCP 서버에 등록
cat ~/.ssh/gcp-deploy-key.pub >> ~/.ssh/authorized_keys

 

공개키 등록 방법은 CLI 통한 방법 말고 GCP를 사용한다면 웹 환경에서 Compute Engine > 메타데이터를 통해서도 SSH 공개키를 등록할 수 있습니다.

 

 

GitHub Secrets 등록

GitHub Repository > Settings → Secrets and variables → Actions에서 등록:

  • SERVER_HOST: Cloud 서버 IP 주소
  • SERVER_USER: 서버 접속 사용자명 (예: ubuntu)
  • SERVER_KEY: 생성한 SSH 개인키 전체 내용

 

 

2단계: Docker 설정

Dockerfile 작성

 

FROM openjdk:17-jdk-slim

# 앱 디렉토리 생성
WORKDIR /app

# JAR 파일 복사
COPY target/shop-0.0.1-SNAPSHOT.jar app.jar

# 포트 노출
EXPOSE 8080

# 애플리케이션 실행
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

 

 

 

docker-compose.yml 작성

version: "3.8"

services:
  mysql:
    image: mysql:9.0
    container_name: shop-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
      MYSQL_DATABASE: shop
      MYSQL_USER: shopuser
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-shoppassword}
    ports:
      - "3307:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 10s
      retries: 5

  app:
    build: .
    container_name: shop-app
    depends_on:
      mysql:
        condition: service_healthy
    ports:
      - "8081:8080"
    volumes:
      - ./uploads:/app/uploads
    environment:
      SPRING_PROFILES_ACTIVE: prod
      JAVA_OPTS: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  nginx:
    image: nginx:alpine
    container_name: shop-nginx
    depends_on:
      app:
        condition: service_healthy
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./uploads:/usr/share/nginx/html/uploads:ro
    restart: unless-stopped

volumes:
  mysql-data:

 

 

3단계: GitHub Actions 워크플로우 구성

.github/workflows/deploy.yml

name: Deploy to GCP on PR Merge

on:
  pull_request:
    types: [closed]
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: github.event.pull_request.merged == true

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.merge_commit_sha }}

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build application
        run: |
          mvn clean package -DskipTests
          echo "JAR size: $(du -h target/shop-0.0.1-SNAPSHOT.jar)"

      - name: Copy files to server
        uses: appleboy/scp-action@v0.1.4
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_KEY }}
          source: "target/shop-0.0.1-SNAPSHOT.jar,docker-compose.yml,Dockerfile,nginx/"
          target: "/home/${{ secrets.SERVER_USER }}/shop-app/"

      - name: Deploy application
        uses: appleboy/ssh-action@v0.1.5
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_KEY }}
          script: |
            set -e
            cd /home/${{ secrets.SERVER_USER }}/shop-app/
            
            echo "=== Starting deployment for PR #${{ github.event.pull_request.number }} ==="
            
            # 필요한 디렉토리 생성
			mkdir -p /home/${{ secrets.SERVER_USER }}/shop-app/uploads/item
            
            # 기존 컨테이너 정리
            docker-compose down --remove-orphans
            docker image prune -f
            
            # 새 버전 배포
            docker-compose up -d --build
            
            # 헬스체크 대기
            echo "Waiting for application to start..."
            for i in {1..12}; do
              if curl -f http://localhost/actuator/health >/dev/null 2>&1; then
                echo "✅ Application is healthy!"
                exit 0
              fi
              echo "Attempt $i/12: Waiting 10 seconds..."
              sleep 10
            done
            
            echo "❌ Application failed to start"
            docker-compose logs app
            exit 1

      - name: Notify deployment status
        if: always()
        run: |
          if [ "${{ job.status }}" == "success" ]; then
            echo "✅ Deployment successful for PR #${{ github.event.pull_request.number }}"
          else
            echo "❌ Deployment failed for PR #${{ github.event.pull_request.number }}"
          fi

 

 

4단계: Nginx 설정

nginx/nginx.conf

events {
    worker_connections 1024;
}

http {
    upstream app {
        server app:8080;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://app;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        # 정적 파일 서빙 (이미지 등)
        location /images/ {
            alias /usr/share/nginx/html/uploads/;
        }
    }
}

 

리버스 프록시로 Nginx를 도입했습니다. 프로젝트에서 사용하는 이미지 파일을 서빙하기 위한 경로도 설정해주었습니다.

 

 

모니터링 및 로그 관리

Docker 애플리케이션 서비스 컨테이너 로그 확인

# 애플리케이션 로그
docker-compose logs app -f

# 모든 서비스 로그
docker-compose logs -f

# 특정 시간 이후 로그
docker-compose logs --since 2h app

 

 

시스템 모니터링

# 컨테이너 상태 확인
docker-compose ps

# 리소스 사용량 확인
docker stats

# 디스크 사용량 확인
df -h
du -sh /var/lib/docker/

 

 

트러블슈팅 가이드

일반적인 문제들

1. 빌드 실패

# 해결: 캐시 정리 후 재빌드
mvn clean package -DskipTests
docker-compose down && docker system prune -f
docker-compose up -d --build

 

 

성과 및 효과

정량적 성과

  • 배포 시간: 7분 → 2분 20초 (67% 단축)
  • 빌드 성공률: 불안정 → 100% 안정화
  • 개발자 개입 시간: 완전 자동화로 0분

정성적 효과

  • 개발자가 배포 걱정 없이 기능 개발에 집중
  • 빠른 피드백 루프로 개발 속도 향상
  • 휴먼 에러 제거로 서비스 안정성 확보

 

결론

GitHub Actions와 Docker를 활용한 자동 배포 파이프라인 구축을 통해 개발 생산성을 크게 향상시킬 수 있었습니다. 특히 수동 작업 제거와 배포 시간 단축은 개발 집중도를 높이는 데 큰 도움이 되었습니다.

이 가이드를 통해 비슷한 환경에서 개발하시는 분들이 효율적인 배포 파이프라인을 구축하는 데 도움이 되기를 바랍니다.

Comments