작년 이맘때쯤이었다. 팀이 5명으로 늘어났고, 매 PR마다 “테스트 돌렸어요?”라는 질문이 반복되고 있었다. Jenkins를 쓰고 있었는데 — 설정 자체가 고통스럽고, 결국 나만 손댈 수 있는 블랙박스가 되어버렸다. 그래서 GitHub Actions로 마이그레이션을 결정했다.
2주 동안 직접 설정하고 뜯어고치면서 배운 것들을 정리했다. 공식 문서만 보고 따라했을 때와 실제 프로덕션에 붙여봤을 때 사이에는 꽤 큰 간극이 있었다.
Jenkins에서 GitHub Actions로: 기대했던 것과 실제
GitHub Actions의 첫인상은 좋았다. YAML 파일 하나로 설정이 끝나고, UI도 Jenkins보다 훨씬 직관적이다. 빌드 로그도 보기 편하고, PR과 자동 연동되는 것도 매력적이었다.
근데 Python 프로젝트를 처음 연결할 때 황당한 일이 있었다. 공식 starter 예제를 그대로 복사해서 붙여넣었더니 첫 빌드가 바로 ModuleNotFoundError로 터졌다. 원인은 가상환경 경로 문제였다 — 로컬에서는 venv를 직접 activate해서 쓰는데, Actions runner는 그걸 모른다. 사소해 보이지만 팀원들이 처음 접할 때 자주 막히는 지점이다.
Jenkins에서 Groovy 스크립트로 복잡하게 설정했던 것들이 YAML로 훨씬 간결해진 건 맞다. 다만 actions/cache 설정이나 매트릭스 전략 같은 것들은 처음에 생각보다 조건들이 많아서 제대로 동작하기까지 며칠이 걸렸다. “간단하다”는 말을 너무 곧이곧대로 믿었다.
무료 플랜 기준으로 public repo는 무제한이고, private repo는 월 2,000분까지 무료다. 5명짜리 팀에서 적당히 쓰면 사실 돈 낼 일이 거의 없다. Jenkins 서버비까지 합산했을 때와 비교하면 확실한 장점이었다.
첫 번째 워크플로우 파일: 동작하는 것부터 만들기
.github/workflows/ 디렉토리를 만들고 ci.yml 파일을 추가하는 것부터 시작한다. 아래가 내가 실제로 쓰는 기본 구조인데, 공식 예제보다 조금 더 실용적으로 다듬은 버전이다.
name: Python CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-22.04 # latest 대신 버전 고정
steps:
- uses: actions/checkout@v4
- name: Python 설정
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: 의존성 설치
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: 린트 검사 (ruff)
run: |
pip install ruff
ruff check .
- name: 테스트 실행
run: pytest tests/ -v --tb=short
actions/checkout@v4, actions/setup-python@v5처럼 버전을 명시하는 게 중요하다. @latest나 @main을 쓰면 어느 날 갑자기 breaking change가 들어와서 빌드가 터진다. 실제로 2024년 말에 setup-python@v4에서 v5로 올라가면서 캐시 동작 방식이 바뀐 적이 있었는데, 버전 고정을 안 하던 팀들이 꽤 고생했다.
runs-on: ubuntu-latest 대신 ubuntu-22.04를 쓰는 것도 이유가 있다. 2025년부터 ubuntu-latest가 ubuntu-24.04를 가리키기 시작하면서 일부 C 확장 모듈 빌드가 깨지는 경우가 생겼다. 안정성이 중요하다면 구체적인 버전을 명시하는 편이 낫다.
의존성 캐싱으로 빌드 시간을 8분에서 2분으로
초기 설정 직후 평균 빌드 시간이 8분 정도였다. 대부분이 pip install 시간이었다. 캐싱을 적용하고 나서 2분 초반대로 줄었다.
actions/setup-python@v5는 cache 옵션을 내장 지원한다:
- name: Python 설정 (캐시 포함)
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: |
requirements.txt
requirements-dev.txt
cache-dependency-path를 지정하지 않으면 Actions가 requirements.txt 하나만 보고 캐시 키를 만든다. requirements-dev.txt가 바뀌어도 캐시가 그대로라서, 개발 의존성을 추가했는데 빌드에서 ImportError가 나는 황당한 상황이 생긴다. 두 파일을 모두 명시해야 한다 — 이거 처음엔 왜 안 되는지 한참 봤다.
poetry나 uv를 쓴다면 설정이 조금 다르다. 요즘 uv가 대세가 되고 있는데, 별도 action을 쓰는 게 편하다:
- name: uv 설치 및 캐시 설정
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: 의존성 설치
run: uv sync --frozen
솔직히 말하면, uv의 속도가 이 정도일 줄은 몰랐다. 캐시 미스가 발생해도 설치 자체가 pip보다 훨씬 빨라서 캐시 없이도 40초 정도밖에 안 걸렸다. 새 프로젝트라면 처음부터 uv를 쓰는 걸 진지하게 고려해볼 만하다.
테스트 매트릭스 설정하다 뒤통수 맞은 이야기
라이브러리를 만들거나 여러 Python 버전을 지원해야 한다면 매트릭스 전략이 필요하다. 설정 자체는 간단해 보인다:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # 이거 꼭 넣어야 한다
matrix:
python-version: ["3.10", "3.11", "3.12"]
os: [ubuntu-22.04, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- name: Python ${{ matrix.python-version }} 설정
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
- name: 테스트 실행
run: pytest tests/ -v
fail-fast: false를 꼭 넣어야 한다. 기본값이 true인데, 그러면 하나의 매트릭스 조합이 실패했을 때 나머지 빌드가 전부 취소된다. Python 3.10에서 실패했다고 3.12 결과를 못 보는 상황이 생기는 거다.
여기서 예상치 못한 발견이 있었다. Windows runner에서 경로 구분자(\) 문제가 생각보다 자주 터진다. 팀이 macOS와 Linux만 쓰고 있어서 코드 곳곳에 os.path.join 대신 슬래시(/)로 경로를 하드코딩한 부분이 있었는데, Windows 매트릭스를 추가하자마자 바로 드러났다. 기존 코드에서 이런 부분이 7군데나 있었다. 매트릭스 테스트가 없었으면 영원히 몰랐을 것들이다.
그리고 macOS runner는 Linux 대비 빌드 분(minutes)을 10배 소모한다. 무료 플랜을 쓴다면 macOS는 꼭 필요한 경우에만 포함시켜야 한다. 나는 main 브랜치 push 때만 macOS 조합을 실행하고, PR에서는 Linux만 돌리도록 분리했다. 월간 무료 한도를 넉넉하게 쓸 수 있었다.
실제 배포까지: Secrets 관리와 환경 분리
CI가 안정화되면 자연스럽게 CD까지 연결하고 싶어진다. 여기서 가장 중요한 게 secrets 관리와 환경 분리다.
GitHub repository의 Settings → Secrets and variables → Actions에서 secrets를 추가한다. 워크플로우에서는 이렇게 참조한다:
deploy:
needs: test # test job이 성공해야만 실행됨
runs-on: ubuntu-22.04
environment: production # 환경 보호 규칙 적용
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Python 설정
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: 배포 실행
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
API_KEY: ${{ secrets.PROD_API_KEY }}
run: |
pip install -r requirements.txt
python deploy.py
needs: test를 빠뜨리면 테스트 결과와 무관하게 배포가 실행된다. 당연한 것 같지만 실제로 이 부분이 빠진 워크플로우를 여러 번 봤다.
environment: production을 설정하고 나서 GitHub UI에서 해당 환경에 Required reviewers를 1명 이상 지정해두는 걸 강력히 권장한다. 나는 금요일 오후에 이 설정 없이 배포를 자동화했다가 — 테스트는 전부 통과했지만 실제 서비스에서 환경변수 차이로 에러가 났다. 그 이후로는 프로덕션 배포에 반드시 수동 승인 단계를 넣고 있다. 금요일 오후 배포는 진짜 하지 마라.
한 가지 덧붙이면 — ENVIRONMENT: staging 같은 비민감 값도 secrets에 넣는 경우를 종종 보는데, 오히려 불편하다. GitHub는 2023년 업데이트에서 secrets와 variables를 분리했다. 민감하지 않은 환경별 설정은 vars에 넣고, 진짜 비밀 값(API 키, DB URL, 인증서 등)만 secrets에 넣는 게 맞다. 관리하기도 훨씬 쉬워진다.
이제 2달 넘게 이 설정으로 운영하고 있는데, Jenkins 시절보다 확실히 편하다. 팀원 누구나 워크플로우 파일을 읽고 이해할 수 있고, 새로운 검사를 추가하는 것도 파일 하나 수정하면 끝이다.
처음부터 완벽하게 만들려고 하지 마라. 기본 CI부터 돌아가게 만들고, 캐싱을 추가하고, 그 다음에 매트릭스나 CD를 붙이는 순서로 가야 한다. 실제로 나도 처음엔 세 가지를 동시에 설정하려다가 한참 헤맸다 — 뭐가 왜 안 되는지 파악하기가 너무 힘들어진다.
uv를 아직 안 써봤다면 다음 프로젝트에서 한번 시도해볼 만하다. 빌드 시간 단축 효과가 생각보다 크다.