"완벽한 배포 시스템은 하루아침에 만들어지지 않는다"

들어가며

개인 프로젝트를 진행하다 보면 "빠른 배포"가 우선순위가 되기 쉽다. 하지만 프로젝트 규모가 커지고 사용자가 늘어날수록, 단순한 속도보다는 안정성과 사용자 경험이 더 중요해진다는 걸 깨닫게 된다.

런치챗 프로젝트를 진행하면서 겪은 배포 시스템의 변화를 돌아보니, 마치 개발 실력의 성장 과정을 보는 것 같았다. 처음엔 "일단 빠르게"였다면, 지금은 "안전하게, 그리고 지속 가능하게"가 되었다.

이 글에서는 런치챗의 배포 시스템이 어떻게 진화했는지, 그리고 각 단계에서 어떤 고민과 결정이 있었는지 정리해보려고 한다.


1단계: GitHub Actions만으로 모든 걸 해결

초기 배포 전략: 병렬 배포

프로젝트 초기에는 GitHub Actions 하나로 모든 CI/CD를 처리했다. 당시의 배포 파일을 보면... 지금 생각해도 꽤 복잡했다. 😅

javascript
name: LunchChat CI/CD Pipeline

on:
  push:
    branches: [main, 'dev/**']

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Build application
        run: ./gradlew build -x test --no-daemon --parallel
      
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          platforms: linux/amd64
          tags: ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE_NAME }}:${{ steps.set-tag.outputs.IMAGE_TAG }}

  # 모니터링 스택 배포
  deploy-monitoring:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy Monitoring Stack
        uses: appleboy/ssh-action@v1.0.0
        with:
          script: |
            # 모니터링 스택 배포 로직

  # 서버1과 서버2를 병렬로 배포
  deploy-server1:
    needs: [build-and-push, deploy-monitoring]
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Server-1
        uses: appleboy/ssh-action@v1.0.0
        with:
          script: |
            export SERVER_NAME="Server-1"
            export INCLUDE_DB_MIGRATION="true"
            # ... 배포 스크립트

  deploy-server2:
    needs: [build-and-push, deploy-monitoring]  # 병렬로 실행!
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Server-2
        uses: appleboy/ssh-action@v1.0.0
        with:
          script: |
            export SERVER_NAME="Server-2"
            export INCLUDE_DB_MIGRATION="false"
            # ... 배포 스크립트

병렬 배포의 문제점

처음에는 "배포 속도가 빠르면 좋은 거 아닌가?"라고 생각했다. 두 서버에 동시에 배포하니까 시간도 절약되고, 뭔가 효율적으로 보였다.

하지만 실제로 운영해보니 문제가 한둘이 아니었다!

1. 사용자 경험 최악

  • 두 서버가 동시에 내려가는 순간이 발생
  • 로드밸런서가 있어도 잠깐의 다운타임은 피할 수 없었음
  • 프론트엔드들이 서버 API 연동 작업을 진행할 때 다운 시간동안 연결이 종료됨

2. 롤백이 복잡

  • 한 서버는 성공하고 다른 서버는 실패하면?
  • 상태 불일치로 인한 예상치 못한 버그들
  • 어떤 서버가 최신 상태인지 파악하기 어려움

3. 데이터베이스 마이그레이션의 딜레마

  • 두 서버 중 어느 걸 먼저 실행할지 애매
  • 동시 마이그레이션 시도로 인한 락 충돌

2단계: 롤링 배포로의 전환

찰나의 순간 깨달음..

어느 날 배포로 인해서 다운 타임이 지속적으로 생기는 문제로 인해서 "서버가 동작하지 않는 시간이 길어서 불편하다"라는 피드백을 연달아 받았다. 그때 깨달았다. 속도보다 중요한 건 서비스의 연속성이구나.

그래서 배포 전략을 롤링 배포로 바꾸기로 했다.

javascript
deploy-server1:
  needs: [build-and-push, deploy-monitoring]
  runs-on: ubuntu-latest
  steps:
    - name: Deploy to Server-1
      script: |
        export SERVER_NAME="Server-1"
        export INCLUDE_DB_MIGRATION="true"
        
        # 헬스체크 함수
        health_check() {
          local max_attempts=25
          echo "$SERVER_NAME 헬스체크 시작..."
          
          for i in $(seq 1 $max_attempts); do
            if curl -sf -m 5 http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then
              echo "$SERVER_NAME 헬스체크 성공"
              return 0
            fi
            [ $i -eq $max_attempts ] && return 1
            sleep 12
          done
        }

verify-server1:
  needs: deploy-server1
  runs-on: ubuntu-latest
  steps:
    - name: Verify Server-1
      # 첫 번째 서버 검증

deploy-server2:
  needs: [build-and-push, verify-server1]  # 첫 번째 서버 검증 후 실행
  runs-on: ubuntu-latest
  steps:
    - name: Deploy to Server-2
      script: |
        export INCLUDE_DB_MIGRATION="false"  # 이미 첫 번째 서버에서 완료
        # ... 배포 로직

롤링 배포의 장점

1. 무중단 서비스

  • 한 서버씩 순차 배포하니 항상 최소 하나의 서버는 살아있음
  • 사용자는 배포가 진행 중인지도 모름

2. 안전한 배포

  • 첫 번째 서버 배포 후 충분한 검증
  • 문제가 있으면 두 번째 서버 배포 전에 중단 가능

3. 명확한 마이그레이션

  • 첫 번째 서버에서만 DB 마이그레이션 실행
  • 두 번째 서버는 마이그레이션 스킵으로 충돌 방지

3단계: CI/CD 분리와 AWX 도입

왜 AWX를 도입했을까?

GitHub Actions만으로도 충분히 잘 돌아가던 시스템을 왜 바꿨을까? 몇 가지 한계를 느꼈기 때문이다!

Terraform VS Ansible

Terraform은 인프라 프로비저닝에 특화된 도구이지만, 현재 프로젝트는 이미 프로비저닝된 EC2 인스턴스들에서 구성 관리만 필요한 상황입니다.

따라서 Configuration Management에 최적화된 Ansible/AWX가 현재 요구사항에 적합하다고 판단했습니다.

1. 복잡한 배포 스크립트 관리

  • deploy.yml 파일이 390줄이 넘어감
  • 환경변수와 스크립트가 뒤섞여서 가독성 최악
  • 배포 로직 수정할 때마다 Git 커밋이 필요

2. 시크릿 관리의 한계

  • GitHub Secrets에 민감한 정보들이 산재
  • 환경별로 다른 설정 관리가 복잡

3. 배포 권한 제어

  • 개발자 모두가 main 브랜치에 푸시하면 자동 배포
  • 배포 권한을 세밀하게 제어하기 어려움

새로운 아키텍처: GitHub Actions + AWX

결국 관심사의 분리를 적용하기로 했다:

  • CI (GitHub Actions): 코드 빌드, 테스트, 이미지 생성
  • CD (AWX): 실제 배포와 인프라 관리

GitHub Actions는 심플하게

javascript
name: LunchChat CI/CD Pipeline with AWX

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.set-tag.outputs.IMAGE_TAG }}
    steps:
      - name: Build application with Gradle
        run: ./gradlew build -x test
        
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          tags: |
            ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE_NAME }}:latest
            ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE_NAME }}:${{ github.sha }}

  deploy-app-servers:
    needs: [build-and-push, deploy-monitoring]
    runs-on: ubuntu-latest
    steps:
      - name: Install and configure AWX CLI
        run: |
          pip install awxkit
          export TOWER_HOST=${{ secrets.AWX_SERVER_URL }}
          export TOWER_TOKEN=${{ secrets.AWX_TOKEN }}
          awx config --tower_verify_ssl false
          
          EXTRA_VARS='{
            "image_tag": "${{ needs.build-and-push.outputs.image-tag }}",
            "docker_registry": "${{ env.DOCKER_REGISTRY }}",
            "docker_image_name": "${{ env.DOCKER_IMAGE_NAME }}"
          }'
          
          awx job_templates launch "Deploy LunchChat App" \
            --extra-vars "$EXTRA_VARS" \
            --limit "server1" --monitor

AWX로 이관된 배포 로직

Ansible Playbook (deploy-app.yml)

javascript
- name: Deploy LunchChat Application
  hosts: app_server
  become: yes
  gather_facts: no
  serial: 1  
# 롤링 배포의 핵심!

  vars:
    app_dir: "/home/ubuntu/lunchchat-app"
  
  tasks:
    - name: Create docker-compose.yml from template
      template:
        src: templates/docker-compose.app.yml.j2
        dest: "{{ app_dir }}/docker-compose.yml"
    
    - name: Pull the new image and start services
      community.docker.docker_compose_v2:
        project_src: "{{ app_dir }}"
        pull: "always"
        state: present
        recreate: always
    
    - name: Wait for container to start
      pause:
        seconds: 55
    
    - name: Wait for application to be healthy
      uri:
        url: http://localhost:8080/actuator/health
        status_code: 200
        timeout: 10
      retries: 30
      delay: 20
      register: health_check
      until: health_check.status == 200
    
    - name: Clean up unused Docker images
      docker_prune:
        images: true
        images_filters:
          dangling: false
          until: "24h"

Docker Compose 템플릿

javascript
services:
  backend:
    container_name: lunchchat-backend
    image: "{{ docker_registry }}/{{ docker_image_name }}:{{ image_tag }}"
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms512m -Xmx1024m -XX:+UseG1GC -XX:MaxGCPauseMillis=200
      - DB_URL={{ db_url }}
      - REDIS_HOST={{ redis_host }}
      # ... 기타 환경변수들
    restart: unless-stopped
    volumes:
      - ./logs:/var/log/lunchchat
      - ./firebase-service-account-key.json:/app/firebase-service-account-key.json:ro

특별한 점: serial: 1의 장점

Ansible의 serial: 1 옵션이 롤링 배포의 핵심

  • 여러 서버가 있어도 한 번에 하나씩만 배포
  • 자동으로 헬스체크 후 다음 서버로 진행
  • 중간에 실패하면 나머지 서버 배포 중단

📊 성능 데이터로 보는 배포 전략 비교

실제 GitHub Actions 워크플로우 실행 데이터를 분석해보니 흥미로운 결과를 얻을 수 있었다.

배포 시간 분석

현재 AWX 기반 롤링 배포 (2025년 8월 데이터)

javascript
평균 배포 시간: 약 7-8분 (372-555초)

세부 단계별 시간:
├── Build & Push: ~2분 (119초)
├── Deploy Monitoring: ~1분 (55초)
└── Deploy App Servers: ~3분 (189초)

이전 GitHub Actions 병렬 배포 추정치

javascript
예상 배포 시간: 약 4-5분

세부 단계별 시간:
├── Build & Push: ~2분
├── Deploy Monitoring: ~1분
└── Deploy Server1 & Server2: ~2분 (병렬)

트레이드오프

요소병렬 배포롤링 배포승자
배포 속도⭐⭐⭐⭐⭐ (4-5분)⭐⭐⭐ (7-8분)병렬 배포
서비스 가용성⭐⭐ (다운타임 발생)⭐⭐⭐⭐⭐ (무중단)롤링 배포
배포 안정성⭐⭐ (실패 시 복구 복잡)⭐⭐⭐⭐⭐ (점진적 검증)롤링 배포
리소스 사용량⭐⭐⭐⭐ (동시 배포로 효율적)⭐⭐⭐ (순차적으로 더 많은 시간)병렬 배포
사용자 경험⭐⭐ (서비스 중단 인지)⭐⭐⭐⭐⭐ (중단 인지 못함)롤링 배포
문제 진단⭐⭐⭐ (복잡한 상태 추적)⭐⭐⭐⭐ (단계별 명확한 로그)롤링 배포

실제 운영에서 체감한 차이

수치로만 보면 병렬 배포가 3-4분 정도 빠르다.

하지만...

"3-4분 더 걸리더라도 안전하게!"

  • 서비스 임팩트 계산
    • 병렬 배포 실패 시 평균 복구 시간: 15-30분
    • 롤링 배포는 최악의 경우에도 50% 용량 유지
    • 3분 vs 15분, 어느 쪽이 더 효율적인가?

흥미로운 발견

AWX 도입 후 예상치 못한 성능 개선이 있었다!

1. 캐시 효율성 향상

javascript
# GitHub Actions (이전)
- 매번 새로운 러너에서 실행
- Docker 레이어 캐시 제한적
- Gradle 캐시도 불안정

# AWX + Ansible (현재)
- 동일한 서버에서 실행
- Docker 레이어 캐시 최적화
- 로컬 Gradle 캐시 활용

2. 네트워크 레이턴시 감소

  • GitHub Actions: 한국 → GitHub 서버 → AWS(EC2)
  • AWX: AWS 내부에서 직접 통신
  • 결과: 실제 배포 단계는 오히려 더 빨라짐

성과와 배운 점

성과 지표

1. 다운타임 제로

  • 롤링 배포 도입 후 무중단 배포 달성
  • 사용자 불만 제로 (3개월간 연속 달성)

2. 배포 안정성 향상

  • 배포 실패율 90% 감소 (실제 GitHub Actions 데이터 기준)
  • 롤백 시간 5분 → 1분으로 단축
  • 헬스체크 자동화로 인한 조기 문제 발견

3. 운영 효율성

  • AWX 웹 UI로 팀원들도 배포 상황 모니터링 가능
  • AWX에서의 배포를 통하여 t2.micro 2대 서버의 배포 히스토리와 로그 중앙 관리

회고..

1. 관심사의 분리가 정답

CI와 CD를 분리하니 각각의 책임이 명확해졌다. GitHub Actions는 코드 품질에, AWX는 인프라 관리에 집중할 수 있게 되었다.

2. 사용자 경험이 최우선

아무리 개발자에게 편한 시스템이라도 사용자에게 불편을 주면 안 된다. 배포 시간이 조금 더 걸리더라도 무중단 서비스가 훨씬 중요하다.

3. 점진적 개선의 힘

한 번에 완벽한 시스템을 만들려고 하지 말고, 문제가 생길 때마다 하나씩 개선해나가는 게 현실적이다.


마무리

런치챗 프로젝트를 통해 배포 시스템의 중요성을 뼈저리게 느꼈다. 단순히 "돌아가게만 하면 되지"에서 시작해서 "사용자가 불편하지 않게"까지 왔다면, 이제는 "더 안전하고 효율적으로"를 고민하고 있다.

개발자로서 기술적인 성취도 중요하지만, 결국 내가 만드는 서비스를 사용하는 사용자 경험이 가장 중요하다는 걸 다시 한 번 깨달았다. 앞으로도 런치챗이 더 많은 사람들에게 안정적인 서비스를 제공할 수 있도록, 배포 시스템을 계속 발전시켜나갈 예정이다.