목차
- 수동 배포 3개월의 기록
- GitHub Actions 워크플로우 파일 해부
- 테스트 자동화 — GitHub Actions CI/CD의 첫 단계
- Docker 빌드에서 레지스트리 푸시까지
- EC2 자동 배포 연결하기
- 워크플로우 디버깅
- 파이프라인 완성 후 달라진 것
화요일 저녁 8시, EC2에 수동 배포를 하다가 사고가 터졌다. GitHub Actions CI/CD 파이프라인을 구축하게 된 건 그날 밤의 직접적인 결과다.
FastAPI로 만든 사이드 프로젝트를 올리는 중이었는데, scp로 파일 전송하고 ssh 접속해서 pip install -r requirements.txt를 치는 익숙한 루틴을 반복하고 있었다. 문제는 requirements.txt에 새로 추가한 httpx를 빼먹은 거였다. 서버에서 systemctl restart myapp을 치자마자 ModuleNotFoundError: No module named 'httpx'가 떴고, 이미 이전 코드는 덮어씌운 상태라 롤백도 쉽지 않았다. 새벽 1시까지 3시간을 날렸다.
수동 배포 3개월의 기록
매주 반복한 루틴
혼자 하는 프로젝트에 CI/CD는 과하다고 생각했다. 배포 대상이 EC2 하나고, 코드베이스도 크지 않으니 터미널에서 커맨드 몇 줄이면 끝나는 일이라고 믿었다.
실제 루틴은 이랬다. 로컬에서 테스트 돌리고, git push 하고, EC2에 ssh 접속해서 git pull 받고, pip install -r requirements.txt 실행하고, sudo systemctl restart myapp 치고, journalctl -u myapp -f로 로그 확인. 글로 쓰면 6단계다. 매번 빠짐없이 하면 되는데, 한두 개씩 빼먹는 게 문제였다.
3개월간 사고 기록
requirements.txt 누락 3회, .env 파일 동기화 실수 2회, 이전 프로세스를 안 죽이고 새로 띄워서 포트 충돌 1회, 테스트 안 돌리고 올려서 런타임 에러 2회. 복구 시간은 짧으면 20분, 길면 3시간이었다. "어제 배포한 거 안 되는데요"라는 메시지를 월요일 아침에 받을 때의 그 기분은 경험해본 사람만 안다.
CI/CD가 과하다는 건 착각이었다. 수동 배포에서 실수할 확률이 0%가 아닌 이상, 자동화는 프로젝트 규모와 상관없다.
GitHub Actions 워크플로우 파일 해부
.github/workflows/ 디렉토리에 YAML 파일 하나를 넣으면 된다. 가장 기본적인 구조를 먼저 보자.
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/ -v
이 파일 하나를 main 브랜치에 푸시하면 GitHub이 알아서 우분투 가상 머신을 띄우고, 코드를 체크아웃하고, Python을 설치하고, 테스트를 실행한다. GitHub Actions CI/CD의 최소 단위가 바로 이거다.
트리거 이벤트 설정
on 블록이 "언제 이 워크플로우를 실행할 것인가"를 결정한다. push와 pull_request가 가장 흔한 조합인데, schedule로 cron 표현식을 넣으면 정기 실행도 되고, workflow_dispatch를 추가하면 GitHub UI에서 수동 트리거 버튼이 생긴다.
처음에는 branches 필터를 안 걸었다. 모든 브랜치에서 워크플로우가 도는 바람에 Actions 사용 시간을 불필요하게 소모했다. feature 브랜치 푸시할 때마다 배포까지 트리거되니까 반드시 필터를 걸어야 한다.
Job과 Step의 차이
jobs 안에 여러 Job을 정의할 수 있고, 각 Job은 독립된 가상 머신에서 돌아간다. 이게 핵심 포인트다. test Job에서 설치한 패키지가 build Job에는 없다. Job 간 데이터를 넘기려면 artifacts나 outputs를 써야 한다.
반면 steps는 같은 Job 안에서 순서대로 실행되며 파일 시스템을 공유한다. 이 차이를 처음에 몰라서 test Job에서 빌드한 결과물을 deploy Job에서 쓰려다가 "파일이 없다"는 에러를 만났다.
Job 간 의존 관계는 needs 키워드로 지정한다. needs: test를 넣으면 test가 실패했을 때 다음 Job은 아예 실행되지 않는다. 테스트 → 빌드 → 배포 순서를 강제하려면 각 Job에 needs를 체이닝하면 된다. 테스트 실패한 코드가 프로덕션에 올라가는 사고를 구조적으로 차단할 수 있다.
테스트 자동화 — GitHub Actions CI/CD의 첫 단계
빌드나 배포보다 테스트 자동화를 먼저 잡는 게 맞다. 테스트가 통과하지 않으면 뒤의 모든 단계가 무의미하기 때문이다.
Python 프로젝트 테스트 워크플로우
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip' # pip 캐시로 설치 시간 단축
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run linter
run: ruff check .
- name: Run tests with coverage
run: pytest tests/ -v --cov=app --cov-report=xml
strategy.matrix를 쓰면 여러 Python 버전에서 동시에 테스트가 돌아간다. 처음에는 3.12만 넣었다가, 동료가 3.11 환경에서 타입 힌트 관련 이슈를 발견해서 매트릭스에 추가했다. actions/setup-python@v5의 cache: 'pip' 옵션을 켜면 pip 패키지를 캐싱해서 설치 시간이 체감상 절반 이하로 줄어든다. (출처: actions/setup-python v5 README)
린팅은 ruff를 쓴다. Rust로 작성되어 있어서 flake8보다 속도가 압도적이고, 린트에서 걸리면 테스트까지 안 가고 바로 실패한다. 빠른 피드백이 가능하다는 뜻이다.
CI 도구 선택 — 왜 GitHub Actions인가
GitHub Actions를 고른 건 소거법이었다.
| 항목 | GitHub Actions | Jenkins | CircleCI |
|---|---|---|---|
| 초기 세팅 | YAML 파일 하나 | 서버 설치 + 플러그인 | 계정 연동 + YAML |
| 비용 (퍼블릭 레포) | 무료 | 서버 비용 별도 | 월 6,000분 무료 |
| 비용 (프라이빗 레포) | 월 2,000분 무료 | 서버 비용 별도 | 월 6,000분 무료 |
| GitHub 연동 | 네이티브 | 플러그인 필요 | OAuth 연동 |
| 러닝커브 | 낮음 | 높음 | 중간 |
Jenkins는 유연하지만 서버를 직접 관리해야 한다는 게 문제였다. CI/CD 서버를 관리하려고 CI/CD를 도입하는 건 본말전도다. CircleCI도 괜찮은 도구인데, 이미 코드가 GitHub에 있으니 같은 플랫폼 안에서 해결하는 게 맥락 전환 비용이 적었다. 퍼블릭 레포에서 GitHub Actions가 완전 무료라는 것도 결정적이었다. (출처: GitHub Actions billing, 2026년 4월 기준)
Docker 빌드에서 레지스트리 푸시까지
테스트를 통과한 다음은 Docker 이미지를 빌드해서 레지스트리에 올리는 단계다. 여기서 처음으로 제대로 헤맸다.
처음 만난 인증 에러
Docker Hub에 이미지를 푸시하는 Job을 만들었는데, 이런 에러가 나왔다.
Error: Username and password required
Error: Process completed with exit code 1.
워크플로우 파일에는 ${{ secrets.DOCKER_USERNAME }}과 ${{ secrets.DOCKER_PASSWORD }}를 분명히 넣었다. 30분 동안 YAML 문법만 뒤졌는데, 문제는 거기가 아니었다. Repository Settings → Secrets and variables → Actions에 시크릿을 등록하지 않은 것이다.
GitHub Actions에서 시크릿은 코드에 적는 게 아니라 GitHub UI에서 따로 등록해야 한다. 이름은 워크플로우에서 참조하는 것과 정확히 일치해야 하고, 대소문자가 다르면 빈 문자열이 들어간다. 에러 메시지도 안 뜨고 그냥 빈 값이 들어가니까 디버깅이 더 어렵다. 이건 공식 문서에서도 강조하지 않는 부분이다.
빌드와 푸시 워크플로우
시크릿 등록 후 정상 동작하는 워크플로우는 이렇다.
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # main 브랜치에서만 실행
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
myuser/myapp:latest
myuser/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
docker/build-push-action@v6의 cache-from과 cache-to가 빌드 시간을 좌우한다. type=gha는 GitHub Actions 캐시 저장소를 사용한다는 뜻인데, 이걸 켜면 Docker 레이어 캐싱이 적용된다. 내 프로젝트 기준으로 첫 빌드가 4분 20초, 캐시 적용 후 1분 10초로 줄었다. 의존성 변경이 없으면 50초 안팎까지 내려간다.
if: github.ref == 'refs/heads/main' 조건을 빼먹으면 PR 단계에서도 이미지가 빌드되어 푸시된다. Actions 무료 분수만 낭비되는 셈이다.
태그에 ${{ github.sha }}를 넣는 이유는 롤백이다. latest만 있으면 이전 버전으로 돌아갈 방법이 없다. 커밋 해시를 태그로 같이 찍어두면 특정 시점의 이미지를 정확히 지정할 수 있다.
GHCR이라는 대안
Docker Hub 대신 GitHub Container Registry(GHCR)를 쓰는 방법도 있다. docker/login-action@v3에서 registry를 ghcr.io로, username을 ${{ github.actor }}로, password를 ${{ secrets.GITHUB_TOKEN }}으로 설정하면 된다. GITHUB_TOKEN은 워크플로우 실행 시 자동 생성되는 토큰이라 별도 시크릿 등록이 필요 없다. 다만 GHCR의 프라이빗 이미지 저장 용량은 GitHub 플랜에 따라 제한이 있으니 확인이 필요하다. (출처: GitHub Packages 문서)
EC2 자동 배포 연결하기
결론부터 말하면, 소규모 프로젝트에서 EC2 배포를 자동화하는 가장 간단한 방법은 appleboy/ssh-action으로 SSH 명령을 보내는 거다. ECS나 EKS 같은 컨테이너 오케스트레이션까지 갈 필요 없이, Docker 이미지를 pull 받고 컨테이너를 재시작하는 스크립트 하나면 충분하다.
SSH 키와 시크릿 등록
EC2에 SSH로 접속하려면 세 가지 시크릿이 필요하다. EC2_HOST(퍼블릭 IP 또는 도메인), EC2_USERNAME(보통 ubuntu 또는 ec2-user), EC2_SSH_KEY(.pem 파일 전체 내용).
.pem 파일을 시크릿에 붙여넣을 때 앞뒤 공백이나 개행이 들어가면 인증이 실패한다. macOS 기준 cat ~/.ssh/my-key.pem | pbcopy로 정확히 복사하는 게 안전하다.
배포 Job 작성
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to EC2
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
# 최신 이미지 받기
docker pull myuser/myapp:latest
# 기존 컨테이너 중지 및 삭제
docker stop myapp || true
docker rm myapp || true
# 새 컨테이너 실행
docker run -d \
--name myapp \
-p 8000:8000 \
--env-file /home/ubuntu/.env \
myuser/myapp:latest
# 헬스체크 — 앱이 실제로 응답하는지 확인
sleep 5
curl -f http://localhost:8000/health || exit 1
docker stop myapp || true의 || true가 없으면 컨테이너가 존재하지 않는 상태에서 에러가 나고 전체 파이프라인이 멈춘다. 처음 배포 때 "No such container" 에러로 워크플로우가 실패한 적이 있다.
마지막 curl -f는 배포 후 헬스체크다. 컨테이너가 떴는데 앱이 실제로 응답하지 않는 경우를 잡아낸다. sleep 5는 앱 시작 대기 시간인데, FastAPI + uvicorn이면 5초로 충분하다. JVM 기반이면 15~30초는 줘야 한다.
이 구조에는 한계가 있다. docker stop → docker run 사이에 다운타임이 발생한다. 트래픽이 있는 서비스라면 blue-green 배포나 rolling update가 필요한데, 그건 Docker Compose나 Kubernetes 영역이다. 사이드 프로젝트 수준에서는 이 정도면 충분했다.
워크플로우 디버깅
nektos/act를 설치하면 로컬에서 워크플로우를 돌려볼 수 있다. brew install act 후 act push --secret-file .secrets를 실행하면 된다. 매번 git push해서 결과를 확인하는 사이클이 사라지고, YAML 문법 에러나 step 순서 문제를 로컬에서 미리 잡을 수 있다. 다만 docker/build-push-action 같은 Docker-in-Docker 계열은 로컬에서 재현이 안 되는 경우가 있다. GitHub UI의 Actions 탭에서 ACTIONS_RUNNER_DEBUG 시크릿을 true로 설정하면 더 상세한 디버그 로그가 나온다. (출처: GitHub Actions 디버깅 문서)
파이프라인 완성 후 달라진 것
concurrency 설정
운영하면서 바로 추가한 설정이 concurrency다.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
같은 브랜치에서 이전 워크플로우가 아직 돌고 있는데 새 푸시가 들어오면, 이전 실행을 자동으로 취소한다. 이 설정이 없으면 빠르게 연속 푸시했을 때 배포가 꼬인다. latest 태그로 이미지를 푸시하는 구조에서 두 워크플로우가 거의 동시에 돌면 어떤 버전이 최종인지 보장할 수 없기 때문이다.
체감 변화
최종 파이프라인의 흐름은 이렇다. main에 PR을 올리면 테스트만 돌고, PR이 머지되면 테스트 → Docker 빌드 → EC2 배포가 순서대로 실행된다. 전체 소요 시간은 캐시가 적용된 상태에서 3분 30초 정도. 수동으로 할 때 최소 10분, 실수하면 1시간 이상 걸리던 것과 비교하면 차이가 크다.
코드를 고치는 것과 배포하는 것 사이의 마찰이 사라지니까 오히려 더 자주, 더 작은 단위로 배포하게 된다. Slack 알림 연동(slackapi/slack-github-action@v2)이나 PR에 테스트 결과 코멘트를 남기는 것도 가능한데, 이건 나중에 별도로 정리할 내용이다. 다음에는 이 GitHub Actions CI/CD 파이프라인에 ArgoCD를 붙여서 GitOps 방식의 배포를 실험해볼 생각이다.
관련 글
- GitHub Actions 자체 호스팅 러너 설정 — EC2에서 CI 비용 75% 줄인 과정 – GitHub Actions 무료 러너가 느려서 EC2에 자체 호스팅 러너를 구축했다. 월 $380이던 비용이 $95까지 내려간 과정과, 그…
- Terraform 상태 파일 충돌 해결 — S3 백엔드 락 설정부터 force-unlock까지 – 팀에서 동시에 terraform apply를 실행해 상태 파일이 꼬인 경험에서 출발한 글이다. S3 백엔드 + DynamoDB 락 설정, …
- 쿠버네티스 비용 최적화 방법 — Spot·HPA·Karpenter로 월 33% 절감한 과정 – EKS 클러스터 월 비용이 $4,200을 찍었다. Spot 인스턴스를 성급하게 적용했다가 40분 장애를 겪고, Karpenter와 HPA를…