Github Actions

git push 하나로 완성되는 나만의 배포 파이프라인 구축기

왜 CI/CD를 구축해야만 했나?

솔직히 처음에는 필요성을 크게 느끼지 못했다. 서버에 SSH로 접속해서 git pull 받고, docker compose restart 하는 과정이 몇 분 걸리지 않았으니까. 하지만 프로젝트가 늘어나고 코드 수정이 잦아지면서 이 수작업은 점점 귀찮고 비효율적으로 느껴졌다. 명령어 오타라도 나는 날엔 하루종일 그것만 보고있고 배포하는 동안에는 다른 작업을 할 수도 없었다.

매번 5분 넘게 걸리던 이 수작업을 없애고, 오직 코드에만 집중할 수 있는 환경을 만들고 싶었다. git push라는 익숙한 명령어 하나로, 테스트부터 배포까지 알아서 다 해주는 그런 환경 말이다.

빌드와 실행 환경의 완전한 분리

CI/CD를 구축하기로 마음먹고 가장 중요하게 생각한 원칙이 하나 있다.

"서버는 코드를 빌드하는 곳이 아니라, 완성된 결과물을 실행만 하는 곳이어야 한다."

과거에는 서버에서 직접 소스 코드를 git pull 받아 npm install이나 mvn package 같은 빌드 명령어를 실행했다. 하지만 이 방식은 서버의 상태나 설치된 라이브러리 버전에 따라 빌드가 실패하는, 소위 "내 컴퓨터에선 됐는데..." 문제의 원인이 되곤 했다.

그래서 이번에는 GitHub Actions의 가상 머신에서 모든 빌드 과정을 끝내고, 완제품인 Docker 이미지를 만들어 Docker Hub에 올리는 방식을 채택했다. 서버는 그저 Docker Hub에서 검증된 이미지를 내려받아 실행만 하면 된다. 마치 공장에서 완제품을 만들어 현장에서는 조립만 하는 것처럼, 어떤 환경에서든 동일하게 작동하는 불변적인(Immutable) 배포 단위를 확보하여 안정성을 극대화했다.

GitHub Actions 워크플로우 상세

우선 'leafy' 프로젝트.github/workflows/ 디렉토리에 deploy.yml 파일을 만들어 CI/CD 파이프라인을 적용했다. 이 경험을 바탕으로, 추후 'ohgnoy'를 포함한 다른 프로젝트에도 동일한 파이프라인을 확장 적용할 계획이다.

# .github/workflows/deploy.yml

name: CI/CD Pipeline

# 1. 트리거 조건: main 브랜치에 push가 발생하면 실행
on:
  push:
    branches: [ "main" ]

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout a repository
        uses: actions/checkout@v3

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

      # Docker Hub 로그인 (Secrets 사용)
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
          
      # 2. 빌드 & 푸시: Multi-stage build와 Layer Cache를 활용
      - name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/leafy:latest
          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/leafy:buildcache
          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/leafy:buildcache,mode=max

  deploy:
    # 3. 배포: build-and-push 작업이 성공해야만 실행
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            # GitHub Secrets의 내용을 .env 파일로 생성
            echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" > .env
            echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env
            
            # 최신 이미지를 pull 받고, 변경된 컨테이너만 재시작
            docker compose pull
            docker compose up -d
            
            # 불필요한 이전 이미지 정리
            docker image prune -f

파이프라인의 주요 단계

  1. 트리거: main 브랜치에 코드가 push되면 파이프라인이 자동으로 시작된다.

  2. 빌드 및 푸시 (Build & Push):

    • Multi-stage Build: Dockerfile 내에서 빌드용 환경과 실행용 환경을 분리했다. 최종 이미지에는 불필요한 개발 도구 없이 오직 실행에 필요한 파일만 담아 용량을 줄이고 보안을 강화했다.
    • Layer Caching: 코드 변경이 없는 Docker 이미지 레이어는 캐시에서 가져와 재사용했다. 이를 통해 빌드 시간을 평균 50% 이상 단축시킬 수 있었다.
  3. 배포 (Deploy):

    • 빌드가 성공하면, SSH로 개인 서버에 안전하게 접속한다.
    • GitHub Secrets에 저장해 둔 민감한 정보(DB 접속 주소 등)를 서버에 .env 파일로 동적으로 생성하여, 코드에 하드코딩하는 것을 방지했다.
    • docker compose pull로 방금 Docker Hub에 올라간 최신 이미지를 내려받고, docker compose up -d로 서비스 전체를 중단 없이(zero-downtime) 업데이트한다.

삽질의 기록: 주요 디버깅 과정

파이프라인 구축은 결코 순탄치 않았다. 수많은 에러 메시지들이 나를 반겨주었다. 그중 가장 기억에 남는 세 가지 삽질의 기록이다.

1. ssh: no key found

2. sudo: a password is required

3. Git 인증 Authentication failed

결론 및 다음 단계

포트 충돌이라는 작은 문제에서 시작된 여정은, Nginx 리버스 프록시, Docker Compose, 그리고 마침내 GitHub Actions를 이용한 완전 자동화 배포 파이프라인 구축으로 이어졌다. 특히 CI/CD를 구축하며 겪었던 수많은 인증과 권한 오류들을 해결하며, 서버 환경과 자동화 스크립트의 작동 원리를 뼛속 깊이 이해하는 계기가 되었다.

여기서 멈추지 않고, 앞으로 아래와 같은 개선 작업을 통해 파이프라인을 더욱 고도화할 계획이다.