작년 10월, 우리 팀에 새 개발자가 합류했을 때의 일이다. 첫 번째 PR을 merge하고 나서 staging 서버에서 이상한 버그가 생겼다. 알고 보니 Python 버전 차이 때문이었다 — 그 개발자는 로컬에서 3.11을 쓰고 있었는데, 서버는 3.9였다. 아주 고전적인 “제 컴퓨터에서는 되는데요” 상황. 두 시간 디버깅 끝에 원인을 찾았을 때는 허탈했다.
그날 이후로 GitHub Actions를 제대로 설정하기로 했다. 이 글은 그 과정에서 내가 배운 것들, 그리고 솔직히 꽤 삽질했던 부분들을 정리한 거다. CI/CD 자체를 처음 접하는 분보다는, 이미 기본 개념은 알지만 Python 프로젝트에 실제로 적용하려는 분들을 대상으로 썼다.
.github/workflows 파일 구조 이해하기
GitHub Actions는 .github/workflows/ 디렉토리에 YAML 파일을 넣으면 자동으로 인식한다. 파일 이름은 크게 상관없지만, 나는 역할별로 ci.yml, deploy.yml, release.yml처럼 분리한다. 한 파일에 다 때려 넣으면 나중에 수정할 때 정말 곤란해진다.
기본 구조는 이렇다:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: 코드 체크아웃
uses: actions/checkout@v4
- name: Python ${{ matrix.python-version }} 설정
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: 의존성 설치
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: 테스트 실행
run: pytest tests/ -v --tb=short
matrix.python-version이 핵심이다. 여러 Python 버전에서 동시에 테스트가 돌아가기 때문에, 위에서 언급한 버전 불일치 문제를 merge 전에 잡을 수 있다. 실제로 이 설정 덕분에 3.10에서만 발생하는 tomllib 관련 버그를 한 번 사전에 발견했다 — tomllib이 3.11에서 표준 라이브러리로 들어왔으니 당연한 거지만, matrix 없었으면 놓쳤을 거다.
한 가지 주의할 점 — on.push.branches와 on.pull_request.branches를 둘 다 설정하면 PR에서 실행이 중복될 수 있다. PR을 열면 push 이벤트도 같이 발생하기 때문이다. 그래서 나는 보통 push는 main만, pull_request는 main과 develop을 지정해서 중복을 줄인다.
테스트, 린팅, 타입 체킹 한 번에 묶기
테스트만 돌리는 건 절반짜리 CI다. 코드 품질 도구들을 함께 묶어야 진짜 가치가 나온다. 우리 팀에서는 ruff(린터 + 포매터), mypy(타입 체커), pytest(테스트) 조합을 쓴다.
lint-and-type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 설정
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: ruff, mypy 설치
run: pip install ruff mypy
- name: 린팅 검사
run: ruff check .
- name: 포매팅 검사 (수정은 하지 않고 확인만)
run: ruff format --check .
- name: 타입 검사
run: mypy src/ --ignore-missing-imports
ruff는 정말 빠르다. flake8 + isort + black을 따로 쓰던 시절과 비교하면 속도 차이가 체감된다. 2024년 초에 전환했는데, lint job 시간이 약 40초에서 8초로 줄었다.
mypy는 솔직히 아직도 설정이 까다롭다. 특히 외부 라이브러리에 type stub이 없을 때. --ignore-missing-imports를 달면 일단 돌아가긴 하는데, 이게 정답인지는 확신이 없다. 팀마다 다르게 접근하는 것 같고, py.typed 마커를 지원하는 라이브러리가 늘어나면서 상황이 조금씩 나아지고 있긴 하다.
의존성 캐싱: 여기서 내가 실수했다
캐싱 없이 쓰면 매번 의존성 설치에 2-3분이 걸린다. CI가 느리면 개발자들이 push를 미루게 된다는 걸 경험으로 배웠다 — 팀원들이 슬랙에서 “CI 왜 이렇게 느려요”라고 물어볼 때쯤 이미 늦은 거다.
그래서 캐싱을 추가했는데, 처음 설정을 잘못했다:
# 이렇게 하면 안 된다
- name: 캐시 설정 (잘못된 예시)
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
문제는 requirements.txt만 해시했다는 점이다. requirements-dev.txt가 변경되어도 캐시가 그대로 남아있어서, dev 의존성이 업데이트되지 않는 버그가 생겼다. 두 시간 동안 왜 테스트가 이상하게 돌아가는지 찾다가 겨우 발견했다. 캐시를 수동으로 지우고 나서야 해결됐을 때 황당함이란.
올바른 방법은 모든 requirements 파일을 포함시키는 것이다:
- name: pip 캐시 설정
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}-
${{ runner.os }}-pip-
hashFiles('**/requirements*.txt') glob을 쓰면 requirements.txt, requirements-dev.txt, requirements-prod.txt 등 모든 파일 변경을 감지한다. restore-keys는 완전히 일치하는 캐시가 없을 때 부분 일치로 폴백하는 용도다.
사실 요즘은 setup-python@v5에 내장 캐싱 기능이 생겨서 수동으로 할 필요가 없다:
- name: Python 설정 (캐싱 포함)
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: 'pip'
cache-dependency-path: '**/requirements*.txt'
훨씬 간결하다. poetry를 쓴다면 cache: 'poetry', pipenv라면 cache: 'pipenv'로 바꾸면 된다. 신규 프로젝트라면 이 방법을 쓰는 게 낫다.
환경별 배포 파이프라인 구성
CI가 통과한 후 자동 배포하기” rel=”nofollow sponsored” target=”_blank”>배포까지 연결하는 부분이다. 우리 팀은 AWS에 배포하는데, staging과 production을 분리해서 관리한다.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches:
- main
- staging
jobs:
deploy:
runs-on: ubuntu-latest
environment:
# main 브랜치면 Production Workloads" rel="nofollow sponsored" target="_blank">production, 아니면 staging 환경 사용
name: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
steps:
- uses: actions/checkout@v4
- name: AWS 자격증명 설정
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: ECR 로그인
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Docker 이미지 빌드 및 푸시
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/my-app:$IMAGE_TAG .
docker push $ECR_REGISTRY/my-app:$IMAGE_TAG
environment 설정이 중요하다. GitHub의 Environment 기능을 활용하면 production 배포하기” rel=”nofollow sponsored” target=”_blank”>배포하기” rel=”nofollow sponsored” target=”_blank”>배포 전에 수동 승인을 요구하도록 설정할 수 있다. Settings > Environments에서 Required reviewers를 추가하면 된다. 팀에 주니어 개발자가 있거나, 실수 한 번이 크게 아프다면 이 설정은 거의 필수라고 생각한다.
secrets는 절대 하드코딩하지 말 것. 당연한 얘기처럼 들리지만, 실제로 GitHub 공개 레포에서 API 키가 그대로 올라가는 걸 본 적이 한두 번이 아니다. GitHub의 Secret scanning이 어느 정도 잡아주긴 하지만 애초에 조심하는 게 낫다.
실제로 내가 쓰는 패턴 — 팀 규모별 추천
2년 가까이 여러 프로젝트에서 운영해보면서 정착한 방식이다.
혼자 또는 2-3명 팀이라면:
단일 ci.yml 파일에 lint + test 합치는 게 관리하기 편하다. Python 버전은 최신 두 개만 (3.11, 3.12 정도) 테스트하면 충분하다. 복잡한 자동 배포보다 수동 배포가 오히려 안전할 때도 있다 — CI가 통과했다는 것만 확인하고 배포는 직접 하는 식으로.
5명 이상 팀이라면:
ci.yml과 deploy.yml은 분리하고, matrix로 Python 버전 3개 이상을 테스트하는 게 좋다. 그리고 이 설정을 꼭 추가하길 권장한다:
# 같은 브랜치의 이전 실행을 자동 취소
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
PR에 연속으로 빠르게 push할 때 이전 CI 실행이 자동으로 취소된다. 월말 비용 청구서 볼 때 체감된다. 놓치기 쉬운 설정인데, 실무에서 꽤 유용하다.
레포가 여러 개로 늘어난다면 (마이크로서비스 구조라면) reusable workflows를 검토할 만하다. 공통 CI 로직을 한 곳에서 관리할 수 있다. 다만 처음부터 필요한 건 아니고, 레포가 5개 이상 생길 때 고민해도 늦지 않는다.
GitHub Actions가 완벽하진 않다. Actions 최신 버전이 breaking change를 조용히 배포한 적도 있었고 (2023년에 actions/checkout@v3 → v4 마이그레이션 때 당황했다), 가끔 원인 모를 인프라 이슈로 실패하기도 한다. 아주 대규모 조직이라면 얘기가 다를 수 있다 — Jenkins나 self-hosted runner로 넘어가는 팀들을 몇 번 봤으니까.
그래도 지금 시점에서 Python 프로젝트 CI/CD 도구로는 진입장벽이 가장 낮고, 무료 tier도 웬만한 소규모 팀엔 충분하다. 무엇보다 GitHub 레포에 바로 붙어있다는 게 편리하다. 별도 서비스를 연동하는 피로감이 없다. 처음에 위에서 공유한 버전 불일치 버그 하나만 막았어도 충분히 가치 있었다.