들어가며
저는 웹 외주 프로젝트에 프론트엔드 개발로 참여하며 팀원과 공유할 테스트용 배포 사이트가 필요했습니다.
좀 더 구체적으로는 다음과 같은 프로세스를 가졌는데요.
develop 브랜치에 개발한 기능을 올리고 EC2 서버에 develop 브랜치를 테스트용으로 배포하여, 팀원과 확인한 후 문제가 없을 시 main 브랜치에 최종적으로 merge하며 진행하였습니다.
(현업에서는 더 구체적이고 체계적인 방식으로 진행하겠지만, 신속한 수정이 필요하고 팀원 구성이 적은 저희는 우선적으로 이렇게 진행하곤했습니다.)
이 때 배포하는 방식은, 말 그대로 '수동 배포 방식'이라고 할 수 있었는데요.
직접 EC2 서버에 접속해서 터미널 명령어로 배포를 위한 명령어를 입력해야했기 때문이에요.
하지만 배포의 빈도가 늘어나면서 다음과 같은 한계점들이 보이기 시작했습니다.
예를 들어,
- 배포할 때마다 EC2 서버에 직접 접속하고, 터미널에 배포를 위한 명령어를 입력해야함.
- 급하게 배포가 이루어지다보면 코드 품질 검사를 넘어가는 경우가 많음.
등이 있습니다.
그렇다보니 이참에 직접 CI/CD 파이프라인을 구축해보고자하는 욕심이 생겼습니다.
Github Actions를 활용해서 자동화를 하면 편리하겠다 생각이 들었어요.
기존 배포 프로세스
먼저 기존에 사용하던 배포 스크립트는 다음과 같습니다.
터미널에서 모든 명령어를 일일이 입력하기 번거롭기 때문에, 아래와 같이 스크립트 형태로 만들어서 스크립트 파일을 실행시키는 방법을 택했었습니다.
# 수정 전 update-and-deploy.sh 전체코드
#!/bin/bash
# 오류 발생 시 스크립트 중단
set -e
echo "Stopping and deleting the existing application..."
pm2 delete my-react-app || true
echo "Pulling latest changes..."
git pull origin develop
echo "Installing dependencies..."
npm ci
echo "Building the project..."
npm run build
echo "Restarting the application..."
pm2 start npm --name "my-react-app" -- start
echo "Cleaning up PM2 processes..."
pm2 save
echo "Deployment complete!"
위 스크립트는 아래와 같이 배포를 진행합니다.
- 실행 중인 애플리케이션을 중지시키기
- pull 명령어로 github에서 최신 코드 가져오기
- 의존성 패키지 설치
- 프로젝트 빌드
- PM2로 애플리케이션 재시작하기
이러한 기존 방식으로 진행할 때 예상되는 여러 문제가 있는데요.
그 중에서도 개선하고싶었던 주요 몇 가지를 설명해보자면 다음과 같습니다.
먼저 서비스 다운 타임이 발생합니다.
현재는 애플리케이션을 완전히 중지했다가 다시 시작하는 방식이기 때문이에요.
특히 npm ci 와 npm build 과정이 길어진다면 서비스 중단 시간이 길어지게 됩니다.
사실상 무중단 배포를 위해서 PM2를 택했던 것인데, 그 기능을 온전히 쓰지 못하고 있었어요.
하지만 당시에 완전히 중지하는 방법을 택했던데에 이유가 없었던 것은 아닙니다.
단순 reload만 했을 때에 새로운 의존성이 제대로 적용되지 않을 수도 있어서, pm2 delete 로 완전히 제거함으로써 정리하고 싶었어요.
하지만 되돌아보니 무중단 배포의 장점을 살리는 방향으로 개선하고 싶어졌습니다.
(사실 이 배포 사이트는 테스트용으로 쓰는 거여서 서비스가 중단된다고 문제가 생길 상황은 아니었지만, 나중에 메인 서비스 쪽 배포를 제가 넘겨받아 다시 구성한다면 이번 구현을 경험삼아 참고할 수 있기에 미리 해결하고싶은 문제였어요.)
또한 배포가 실패했을 때 롤백하는 기능이 없었습니다.
코드 품질 검사 프로세스가 따로 없었기 때문에 오류 있는 코드가 바로 배포될 수도 있었습니다.
마지막으로 제가 항상 직접 EC2 서버에 접속해서 배포해야했습니다.
터미널에 들어가 몇 개 안되는 명령어를 입력하는 것이지만, 그래도 자동화를 이루어보고 싶었습니다.
이러한 이유로, 저는 'develop 브랜치에 push 했을 때 Github Actions workflow를 통해 자동적으로 CI와 CD가 이루어지도록 하기' 라는 목표를 세웠습니다.
CI/CD 자동화 구축하기
GitHub Actions 워크플로우 설정
먼저 Github Actions workflow 를 설정하기위해, 프로젝트의 .github/workflows 폴더를 만들고, 그 안에 develop-deploy.yml 파일을 작성해주었습니다.
develop 브랜치에 push를 하게되면 자동으로 배포하도록요!
# .github/workflows/develop-deploy.yml 전체코드
name: Develop Branch Deploy
on:
push:
branches: [develop]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
- name: Install Dependencies
run: npm ci
- name: Lint Check
run: npm run lint
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build-files
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: build-files
path: dist
- name: Deploy to EC2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd project-directory/
./update-and-deploy.sh
일단 develop 브랜치에 push 가 일어나면, 이 develop-deploy.yml 가 실행되게 됩니다.
작업(jobs)은 크게 build, deploy 로 나뉩니다.
build job에서는 코드 품질 검사와 빌드를 수행하고, deploy job에서는 앞서 검증된 코드를 EC2 서버에 배포하게 됩니다.
이 때, 기존 스크립트에 있던 ci, build 과정은 build job으로 옮기고, deploy job에서는 배포만 맡게하였습니다.
왜냐하면 build 과정을 중복되게 2번 수행할 필요가 없기도하고, build job에서 build 했던 결과물을 deploy job에서 재사용할 수 있기 때문이에요.
여기에서 새로 알게된 개념이 아티팩트(Artifacts)였습니다.
아티팩트(Artifacts) 란?
빌드 과정에서 생성된 파일들을 임시로 저장하고 다른 작업에서 사용할 수 있게 해주는 기능입니다.
Github Actions 에서 ‘파일 보관함’ 같은 역할이에요.
현재 예시에서는 npm run build 실행 시 dist 폴더에 빌드된 파일들이 생기는데 이 dist 폴더를 아티팩트로 업로드해서 deploy 작업을 수행할 때 아티팩트를 다운로드 하고 dist 폴더를 복원하고 재사용합니다.
이 아티팩트 개념을 이용하면 build job 에서 업로드했던 결과를 deploy job에서 재사용할 수 있게 되어서, 빌드 시간을 줄이며 배포시간 또한 줄일 수 있었어요.
다음으로 해주어야 할 작업은 Github repository의 settings에서 secret을 설정해주는 것입니다.
저는 아래와 같은 이름으로 값을 저장해주었어요.
- EC2_HOST: EC2 인스턴스의 퍼블릭 IP 또는 도메인
- EC2_SSH_KEY: EC2 접속을 위한 SSH 프라이빗 키
위 정보들은 민감한 정보이기 때문에, 워크플로우 파일에서 직접 값을 사용하여 커밋하기보다는 github secrets 을 통해 값을 관리해주는 것이 보안상 좋습니다.
참고로, SSH 프라이빗 키를 secret에 저장할 때에는 BEGIN과 END라인을 포함시켜서 전체 내용을 작성하셔야합니다.
저는 키 내용만 작성했다가 제대로 키를 인식하지 못하는 오류를 겪었거든요.
-----BEGIN RSA PRIVATE KEY-----
(키 내용)
-----END RSA PRIVATE KEY-----
CI (Continuous Integration) 구현
위에서 진행한 workflow 에서 CI 에 해당하는 부분을 다시 살펴보겠습니다.
# .github/workflows/develop-deploy.yml
- name: Lint Check
run: npm run lint
이 단계에서는 ESLint를 통해서 코드 품질 검사를 진행하는데요.
코드 스타일과 잠재적인 오류의 존재 여부를 검사합니다.
# .github/workflows/develop-deploy.yml
- name: Build
run: npm run build
이 단계에서는 프로젝트 빌드가 정상적으로 수행되는지 확인합니다.
# .github/workflows/develop-deploy.yml
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build-files
path: dist/
그리고 빌드된 결과물을 Github Actions의 아티팩트를 통해서 저장합니다.
여기서 저장한 결과물은 deploy 단계에서 재사용하게 됩니다.
CD (Continuous Deployment) 구현
CD 는 EC2 서버로 자동 배포, PM2를 이용해서 무중단 배포하는 과정들이 포함됩니다.
먼저 기존 배포 스크립트를 CI/CD에 맞게 개선한 전체 코드(update-and-deploy.sh)는 다음과 같습니다.
# 수정한 update-and-deploy.sh 전체 코드
#!/bin/bash
# 오류 발생 시 스크립트 중단
set -e
# 배포 시작 전 현재 커밋 해시 저장
CURRENT_COMMIT=$(git rev-parse HEAD)
echo "Starting deployment from commit: $CURRENT_COMMIT"
# 함수: 롤백 실행
rollback() {
echo "🔄 Deployment failed! Rolling back to commit: $CURRENT_COMMIT"
git reset --hard $CURRENT_COMMIT
echo "Restarting previous version..."
pm2 reload my-react-app || pm2 start npm --name "my-react-app" -- start
pm2 save
echo "❌ Rollback completed. System restored to previous state."
exit 1
}
# 오류 발생 시 롤백 함수 실행
trap rollback ERR
echo "Pulling latest changes..."
git pull origin develop
echo "Restarting the application with new version..."
pm2 reload my-react-app || pm2 start npm --name "my-react-app" -- start
echo "Saving PM2 process list..."
pm2 save
echo "✅ Deployment completed successfully!"
# 애플리케이션 상태 확인
pm2 list
workflow에서 deploy에 해당하는 부분은 다음과 같습니다.
# .github/workflows/develop-deploy.yml
- name: Deploy to EC2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd project-directory/
./update-and-deploy.sh
EC2 서버로 배포 시, appleboy/ssh-action 을 사용하여 구현하는데요.
(appleboy/ssh-action은 SSH를 통해 원격 서버에 연결하고 명령을 실행하는 Github Action입니다.)
secret 으로 설정했던 EC2_HOST 와 EC2_SSH_KEY 를 사용하여 EC2 서버에 접속해 update-and-deploy 스크립트를 실행시킵니다.
이 스크립트에는 다음과 같은 코드를 포함합니다.
# update-and-deploy.sh
echo "Restarting the application with new version..."
pm2 reload my-react-app || pm2 start npm --name "my-react-app" -- start
PM2의 reload 기능으로 무중단 배포를 하고,
# update-and-deploy.sh
CURRENT_COMMIT=$(git rev-parse HEAD)
rollback() {
echo "🔄 Deployment failed! Rolling back to commit: $CURRENT_COMMIT"
git reset --hard $CURRENT_COMMIT
pm2 reload my-react-app || pm2 start npm --name "my-react-app" -- start
pm2 save
exit 1
}
trap rollback ERR
만약 배포가 실패하면 자동으로 이전 버전으로 되돌리도록 롤백합니다.
여기까지 모두 수행했다면, 직접 브랜치에 push를 해서 Github Actions가 잘 실행되는지 Actions 탭에서 확인할 수 있어요.
모두 성공했다면 위 이미지처럼 초록색 체크 표시가 뜨게 됩니다.
개선된 배포 프로세스
이제 제가 develop 브랜치에 commit 을 push 하면 배포 과정이 자동으로 진행되어, 직접 서버에 접속해 명령어를 실행할 필요가 없어졌습니다.
Github Actions가 자동으로 품질 검사를 하고, 빌드를 수행하며, 검증이 완료되면 EC2 서버에 자동으로 배포되고 PM2를 통해서 무중단으로 서비스가 업데이트 되죠.
만약에 빌드가 실패하면 Github Actions 단계에서 자동으로 프로세스가 중단이 되고, 배포 과정에서 문제가 발생하면 자동으로 롤백 기능이 작동합니다.
또한 코드 품질에 이슈가 생긴다면 ESLint 검사 단계에서 문제를 알려줌으로써 방지할 수 있을 것입니다.
맺음말
CI/CD 파이프라인을 구축하며 develop 브랜치를 관리하기가 좀 더 쉬워졌고, 배포 안정성도 향상되었습니다.
무엇보다 직접 서버에 접속하지 않아도 되어서 스스로 일을 하나 덜어낸 것 같아 뿌듯합니다.
앞으로 더 개선하고 싶은 부분도 존재합니다.
현재는 develop 브랜치에 대해서만 CI/CD 파이프라인이 구축되어 있지만, 추후 main 브랜치 관리 권한을 넘겨받게 되면 동일한 자동화 배포 방식을 적용할 필요가 있을 것 같아요.
또한 PM2의 reload 기능을 통해 무중단 배포의 장점을 활용하도록 수정했지만, 때때로는 의존성 변경에 대한 확실한 반영과 메모리 정리가 필요한 상황에서는 완전한 재시작이 필요할 때도 있을 것 같아 이후에는 하이브리드 방식의 배포 전략을 한번 구현해보고싶습니다.
이번 경험으로 Github Actions를 활용한 CI/CD 파이프라인 자동화가 정말 편리하다는 걸 실감했어요.
개발자로서 이런 자동화 툴을 통해 시간을 절약하고, 실수를 줄일 수 있다는 점이 유용하다고 생각했습니다.
앞으로도 다양한 프로젝트에서 이런 배포 파이프라인을 잘 활용할 수 있을 것 같다는 자신감이 생겼고, 좋은 자산이 될 것 같아요.