
이번에 백엔드 프로젝트에 CI/CD를 붙였다. 목표는 거창한 배포 체계를 만드는 게 아니라, 지금 내가 운영 중인 구조에 맞는 최소 자동화를 먼저 만드는 것이었다.
현재 이 프로젝트는 EC2 한 대에서 Docker Compose로 api, worker, db, redis를 같이 운영하는 형태다. 업로드 파일은 S3를 사용하고 있고, 백엔드는 FastAPI 기반이다. 그래서 이번 작업도 이 구조를 크게 바꾸지 않는 선에서, 코드 푸시 -> 검증 -> 배포 흐름을 자동화하는 데 집중했다.
최종적으로는 GitHub Actions + EC2 self-hosted runner 조합으로 정리했다.
왜 CI/CD를 붙이려고 했나
처음에는 서버에 직접 접속해서 배포했다.
git pulldocker compose up -d --build- 컨테이너 상태 확인
/health확인
이 방식은 처음에는 괜찮다. 그런데 몇 번 반복되면 금방 불편해진다. 배포 순서를 계속 기억해야 하고, 사람이 직접 치는 명령이 매번 조금씩 달라질 수 있고, 코드가 깨진 상태로도 배포할 수 있다.
작은 프로젝트라고 해도 배포 과정이 머릿속에만 남아 있으면 결국 다시 확인하게 되고, 그게 쌓이면 운영 피로도가 커진다. 그래서 이번에는 최소한의 자동화라도 먼저 붙여두는 쪽이 낫겠다고 판단했다.
처음엔 SSH 방식으로 붙였다
처음에는 GitHub Actions에서 SSH로 EC2에 접속해서 배포하는 방식으로 시작했다. 이 방식은 생각보다 금방 붙는다. GitHub Secrets에 서버 접속 정보를 넣고, Actions에서 SSH 액션으로 서버에 접속한 뒤 배포 스크립트를 실행하면 된다.
실제로 이 방식으로 한 번은 배포를 성공시켰다. 그런데 붙이고 나니 불편한 점이 명확하게 보였다.
첫 번째는 SSH 접속 정보를 GitHub Secrets로 관리해야 한다는 점이었다.
로컬에 있는 .pem 키 파일 내용을 GitHub에 넣어야 했고, 이것부터가 썩 마음에 들지는 않았다.
두 번째는 보안그룹 문제였다.
내 로컬에서 EC2에 접속할 때는 내 IP만 열어두면 되는데, GitHub-hosted runner는 외부에서 들어오기 때문에 EC2 보안그룹에서 22/tcp를 열어줘야 했다. 실제로 배포 중에 i/o timeout 에러가 났고, 원인은 SSH 포트 접근 차단이었다.
테스트를 위해 잠깐 22/tcp를 넓게 열고 확인할 수는 있었지만, 이 방식은 계속 가져가고 싶지 않았다.
지금 프로젝트 구조에서는 굳이 외부 러너가 EC2에 SSH로 들어와야 할 이유가 크지 않다고 느꼈다.
그래서 self-hosted runner로 바꿨다
이번에 선택한 방식은 EC2에 GitHub Actions runner를 직접 설치하는 방법이다.
즉 GitHub가 제공하는 기본 러너가 배포를 수행하는 게 아니라, 내가 운영 중인 EC2 서버가 직접 GitHub Actions job을 수행하게 만든 것이다. 이게 self-hosted runner 방식이다.
배포 흐름으로 보면 차이는 이렇다.
기존 방식:
GitHub Actions -> SSH로 EC2 접속 -> 배포
바꾼 방식:
GitHub Actions -> EC2에 설치한 runner가 서버 내부에서 직접 배포
이 방식으로 바꾸고 나니 구조가 훨씬 자연스러워졌다.
어차피 이 프로젝트의 배포는 결국 EC2 안에서 git pull하고 docker compose up -d --build를 돌리는 작업이다. 그렇다면 그 작업도 서버 안에서 직접 실행되게 하는 편이 더 단순했다.
이번에 구성한 최소 CI/CD
최종적으로는 두 단계로 나눴다.
먼저 CI에서는 PR과 main 브랜치 push 시 아래를 확인하게 했다.
ruff check .python -m compileall app alembicdocker build
처음에는 lint 이슈가 누적되어 있어서 ruff가 바로 통과하지 않았다.
그래서 먼저 lint 기준선을 정리하는 작업을 따로 하고, 그 다음에 CI에 ruff를 넣었다. 이건 생각보다 중요했다. CI는 “지금부터 깨지는 걸 막는 기준선”이어야지, 처음부터 항상 실패하는 상태면 의미가 없기 때문이다.
그 다음 CD는 main 브랜치 기준으로 CI가 성공했을 때만 실행되게 했다.
배포 job은 EC2의 self-hosted runner가 수행하고, 실제 배포는 서버에 있는 스크립트 하나로 통일했다.
이 스크립트는 내부에서 다음 순서로 동작한다.
git fetchgit checkout maingit pull --ff-onlydocker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --builddocker compose ps/health체크
이렇게 해두니 장점이 분명했다. 배포 로직이 GitHub Actions YAML 안에 흩어지지 않고, 서버에서 수동으로 검증할 때도 같은 스크립트를 그대로 재사용할 수 있었다.
구축하면서 실제로 겪은 문제들
이번 작업에서 좋았던 점은 “직접 부딪혀보면서 구조를 정리했다”는 점이다. 중간에 몇 가지 문제를 꽤 명확하게 겪었다.
1. lint 기준선 문제
처음 CI에 ruff를 바로 넣으려 했는데, 기존 코드에 누적된 이슈가 있어서 통과하지 않았다.
자동 수정 가능한 건 먼저 정리하고, 남는 부분은 직접 맞추는 식으로 기준선을 잡았다. 이 과정을 먼저 하지 않았으면 CI는 계속 실패만 했을 것이다.
2. GitHub Secrets 누락
SSH 방식으로 붙일 때는 missing server host 같은 에러도 만났다.
원인은 단순했다. GitHub Secrets에 필요한 값이 없거나 이름이 맞지 않았다. 이런 문제는 금방 고칠 수 있지만, 결국 “SSH 정보 관리”가 계속 필요하다는 점을 보여줬다.
3. 보안그룹 때문에 SSH timeout
그 다음으로는 dial tcp ... i/o timeout 에러가 났다.
이건 애플리케이션 문제가 아니라 GitHub-hosted runner가 EC2의 SSH 포트에 접근하지 못해서 생긴 문제였다. 이걸 겪고 나서, 지금 구조에는 self-hosted runner가 더 잘 맞는다는 판단이 더 분명해졌다.
4. self-hosted runner 라벨 불일치
EC2에 runner를 설치한 뒤에도 바로 끝나지는 않았다. 워크플로에서 요구한 라벨과 실제 runner 라벨이 달라서 job이 계속 대기 상태에 있었다. 결국 워크플로 라벨과 runner 라벨을 맞추는 식으로 해결했다.
이건 작은 문제 같지만, self-hosted runner를 처음 붙일 때 꽤 흔하게 나올 수 있는 부분이었다.
self-hosted runner 방식을 쓴 이유
이번 글에서 가장 핵심적인 부분은 이거다. 왜 굳이 self-hosted runner로 갔느냐.
이유는 어렵지 않다.
첫째, 지금 운영 구조와 잘 맞았다. 이 프로젝트는 EC2 한 대에서 Compose로 서비스를 운영하고 있다. 배포도 결국 서버 안에서 끝나는 작업이다. 그렇다면 GitHub Actions도 그 서버 안에서 직접 실행되게 하는 쪽이 더 자연스러웠다.
둘째, SSH 기반 배포보다 단순했다. 한 번 붙이고 나면 GitHub-hosted runner가 EC2에 SSH로 들어올 필요가 없기 때문에, GitHub용 SSH secret을 더 이상 유지하지 않아도 됐다. 테스트 때문에 잠깐 열었던 SSH 보안그룹 규칙도 다시 닫을 수 있었다.
셋째, 지금 단계에서 필요한 만큼만 자동화할 수 있었다. 이번에 만들고 싶었던 건 “지금 프로젝트에서 당장 쓸 수 있는 최소 자동배포”였다. self-hosted runner는 그 목적에 맞게 충분히 실용적이었다.
지금 최종 상태
현재는 다음 흐름으로 동작한다.
- 코드 푸시
- GitHub Actions CI 실행
- CI 성공 시 Deploy 워크플로 실행
- EC2 self-hosted runner가 배포 job 수행
- Docker Compose 재기동
/health확인
즉, 배포가 이제는 “서버 들어가서 수동으로 명령 치는 일”이 아니라, 저장소에 정의된 워크플로와 스크립트로 고정됐다.
이 정도면 내가 원했던 최소 목표는 달성한 셈이다. 구조를 과하게 키우지 않으면서도, 지금 프로젝트에서 반복적으로 하게 될 배포 과정을 자동화할 수 있었다.
마무리
이번 작업을 하면서 느낀 건, 작은 프로젝트일수록 오히려 배포 과정을 빨리 고정해두는 게 좋다는 점이었다. 서비스가 작을 때는 대충 수동으로 해도 돌아간다. 그런데 그게 익숙해지면 나중에 더 바꾸기 귀찮아진다.
이번에 구축한 GitHub Actions + EC2 self-hosted runner 조합은 적어도 지금 프로젝트에는 잘 맞았다.
내가 실제로 운영 중인 구조를 크게 바꾸지 않았고, 직접 겪은 문제들을 하나씩 정리하면서 최종 형태를 만들 수 있었다.
다음에는 여기에 배포 실패 알림이나 운영 체크리스트 같은 걸 조금씩 더 얹어볼 생각이다. 하지만 현재 기준에서는, 이 정도만으로도 충분히 실용적인 첫 번째 CI/CD라고 느꼈다.