금요일 오후 4시 반에 그 PR을 머지했다. 팀원들은 퇴근하고 나 혼자 남아서 “별거 아니겠지”라고 생각하며 배포 버튼을 눌렀는데 — 30분 뒤 모니터링 알람이 터졌다. requirements.txt에 있는 패키지 버전이 프로덕션 클라우드 서버” rel=”nofollow sponsored” target=”_blank”>서버의 Python 버전과 충돌했던 거다. 내 맥북에서는 완벽하게 돌아갔다. 로컬에서만.
그 사건 이후로 GitHub Actions를 본격적으로 파기 시작했다. 처음엔 “그냥 테스트 돌리는 거 아니야?”라고 가볍게 생각했는데, 실제로 세팅을 제대로 해보니 고려할 게 생각보다 훨씬 많았다. 이 글은 내가 여러 Python 프로젝트에 직접 적용하면서 겪은 시행착오를 정리한 거다.
.github/workflows 폴더, 뭐부터 넣어야 하나
워크플로우는 .github/workflows/ 폴더 안에 YAML 파일로 작성한다. 파일 이름은 자유롭게 지어도 되는데, 나는 ci.yml과 deploy.yml로 나누는 걸 선호한다. CI 검증과 실제 배포를 분리해두면 나중에 관리하기 훨씬 편하다.
기본적인 Python CI 워크플로우는 이렇게 생겼다:
name: Python CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
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 }}
cache: 'pip'
cache-dependency-path: |
requirements.txt
requirements-dev.txt
- name: 의존성 설치
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: 린팅 검사 (flake8)
run: flake8 . --count --max-line-length=100 --statistics
- name: 타입 체크 (mypy)
run: mypy src/ --ignore-missing-imports
- name: 테스트 실행
run: |
pytest tests/ -v \
--cov=src \
--cov-report=xml \
--cov-report=term-missing \
--cov-fail-under=80
strategy.matrix를 쓰면 Python 3.10, 3.11, 3.12를 병렬로 동시에 테스트할 수 있다. 처음엔 그냥 최신 버전 하나만 돌리다가, 고객사 서버가 3.10을 쓴다는 걸 뒤늦게 알고 황급히 매트릭스를 추가한 기억이 있다. 3개 버전이 병렬로 돌아가니 총 빌드 시간은 크게 늘지 않는다.
--cov-fail-under=80은 커버리지가 80% 미만이면 빌드 자체를 실패시킨다. 개인 프로젝트에선 70%, 팀 프로젝트에선 80%를 기준으로 잡는 편이다.
mypy는 처음 붙였을 때 에러가 200개 넘게 터져나와서 당황했다. --ignore-missing-imports로 외부 라이브러리 관련 에러를 일단 걸러내고, 내 코드에서 나오는 에러부터 하나씩 잡으니 훨씬 수월했다. 한꺼번에 다 잡으려 하면 중간에 포기하게 된다. 경험에서 우러난 조언이다.
캐싱 전략 — 여기서 한 번 크게 낭패를 봤다
솔직히 캐싱 설정이 제일 헷갈렸다. 처음에 actions/cache를 붙여놓고 “이제 빠르겠지”라고 생각했는데, 어느 날 보니 캐시가 전혀 적용이 안 되고 있었다. 한참 들여다봤더니 — hashFiles('requirements.txt')라고만 써놨던 거다. requirements-dev.txt만 수정하면 캐시 키가 안 바뀌니까, 개발용 의존성이 매번 새로 설치되고 있었던 거다.
위 예시처럼 setup-python v5에 내장된 캐싱을 쓰면서 cache-dependency-path에 두 파일을 모두 명시하면 이 문제가 해결된다. 별도로 actions/cache 스텝을 추가할 필요도 없어서 워크플로우가 훨씬 간결해진다.
Poetry를 쓴다면 얘기가 조금 달라진다. cache: 'poetry'로 바꾸면 되는데, Poetry 가상환경 경로가 프로젝트마다 달라서 캐시가 제대로 안 잡히는 경우가 있다. 이럴 때는 poetry config virtualenvs.in-project true로 가상환경을 프로젝트 폴더 안에 만들게 하면 캐싱이 훨씬 안정적으로 된다. 내 경험상 이 설정 하나로 빌드 시간이 평균 4분대에서 2분 이내로 줄었다.
Pipenv는 솔직히 잘 모르겠다. 한 프로젝트에서 써봤는데 캐싱이 예상대로 안 돼서 결국 그냥 requirements.txt로 돌아갔다.
시크릿과 환경 변수 — 실수하기 제일 쉬운 부분
당연한 얘기처럼 들리는데, 생각보다 많은 사람들이 실수한다. API 키나 데이터베이스 접속 정보를 YAML 파일에 직접 하드코딩하는 경우를 몇 번 봤다. 레포에 올라가는 파일이니 절대 안 되는 짓이다.
GitHub 레포 설정에서 Settings → Secrets and variables → Actions로 이동해서 시크릿을 등록하고, 워크플로우에서는 ${{ secrets.SECRET_NAME }}으로 참조한다:
- name: 프로덕션 클라우드" rel="nofollow sponsored" target="_blank">프로덕션 배포
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
REDIS_URL: ${{ secrets.REDIS_URL }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: python scripts/deploy.py
환경에 따라 다른 값을 써야 한다면 GitHub Environments 기능이 유용하다. staging이랑 production 환경을 만들고, 각각 다른 시크릿을 등록하면 된다. 워크플로우에서 environment: production을 명시하면 해당 환경의 시크릿을 자동으로 참조한다.
여기서 주목할 건 Protection rules다. production 환경은 특정 브랜치에서만 배포 가능하게 하거나, 승인자를 지정할 수 있다. 이걸 모르고 한동안 그냥 썼다가 — 팀 신입이 실수로 프로덕션 배포 워크플로우를 트리거했고, 롤백하느라 두 시간을 날렸다. 그 뒤로 Protection rules는 무조건 설정한다.
workflow_dispatch — 수동 트리거는 처음부터 넣어라
이건 나중에 “왜 처음부터 안 넣었지?” 싶었던 기능이다. on 블록에 workflow_dispatch를 추가하면 GitHub UI에서 버튼 하나로 워크플로우를 수동으로 실행할 수 있다.
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
workflow_dispatch:
inputs:
environment:
description: '배포 환경'
required: true
default: 'staging'
type: choice
options:
- staging
- production
처음엔 “push/PR 트리거면 충분하지 않나?”라고 생각했는데, 실제로 써보면 수동 트리거가 필요한 상황이 꽤 많다. 특정 브랜치만 테스트 환경에 올리고 싶을 때, 또는 워크플로우 설정 자체를 디버깅할 때 — push 없이 바로 실행할 수 있으면 훨씬 편하다. 빈 커밋을 억지로 만들어서 push하던 시절이 기억난다.
inputs에 파라미터를 정의하면 실행 시 값을 직접 입력할 수도 있다. 위 예시처럼 environment를 선택하게 하면, 같은 워크플로우로 staging과 production 배포를 깔끔하게 분리할 수 있다.
Codecov로 커버리지 추이 눈에 보이게 만들기
테스트를 돌리는 것 자체도 좋지만, PR마다 커버리지가 얼마나 되는지 바로 보이면 코드리뷰가 훨씬 편해진다. codecov/codecov-action을 붙이면 PR 코멘트로 커버리지 변화를 보여준다.
- name: Codecov 업로드
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: false # Codecov 클라우드 서버" rel="nofollow sponsored" target="_blank">서버 이슈로 빌드 막히는 거 방지
처음에 fail_ci_if_error: true로 해놨다가 Codecov 측 서버 이슈로 배포가 막힌 적이 있다. 외부 서비스에 빌드 성패를 달아두는 건 위험하다. false로 두고, 커버리지 리포트는 참고용으로만 쓰는 게 낫다.
2달 써보고 나서 드는 생각
처음 설정할 때는 “이게 얼마나 도움이 되겠어”라는 반신반의였다. 그런데 실제로 쓰다 보니 체감이 됐다.
금요일 오후에 급하게 push했는데 테스트가 실패해서 머지를 못한 적이 세 번 정도 있었다. 그 세 번 중 두 번은 “별거 아니겠지”라고 생각했던 변경이었는데, 알고 보니 엣지 케이스에서 예외가 터지는 코드였다. CI가 없었으면 그대로 프로덕션에 올라갔을 거다. 맨 처음에 내가 겪은 것처럼.
mypy를 붙이고 나서 타입 관련 버그가 눈에 띄게 줄었다. 정확한 수치를 측정한 건 아니지만, 예전에 종종 터지던 AttributeError나 TypeError 류의 런타임 에러가 PR 단계에서 걸러지는 걸 꽤 여러 번 봤다.
설정을 그대로 가져다 쓴다면, 테스트 경로(tests/)와 소스 경로(src/)는 프로젝트 구조에 맞게 바꿔야 한다. Django 프로젝트라면 pytest --ds=myapp.settings.test처럼 설정 파일도 명시해야 한다.
솔직히 말하면 — 처음부터 완벽하게 세팅하려 하지 마라. 기본 테스트 돌리는 것부터 시작해서, 필요가 생길 때마다 린팅, 타입 체크, 커버리지 리포트를 하나씩 추가하는 게 현실적이다. 내가 권장하는 순서는 이렇다: pytest 기본 실행 → 의존성 캐싱 → flake8 → 커버리지 → mypy. 이 순서대로 하나씩 붙여나가면 각 단계에서 나오는 에러를 처리할 여유가 생긴다.
지금 이 설정 없이 개발하라고 하면 — 못할 것 같다.