Python 애플리케이션을 위한 GitHub Actions 설정: 내가 삽질하며 배운 것들

금요일 오후 4시 반에 그 PR을 머지했다. 팀원들은 퇴근하고 나 혼자 남아서 “별거 아니겠지”라고 생각하며 배포 버튼을 눌렀는데 — 30분 뒤 모니터링 알람이 터졌다. requirements.txt에 있는 패키지 버전이 프로덕션 클라우드 서버” rel=”nofollow sponsored” target=”_blank”>서버의 Python 버전과 충돌했던 거다. 내 맥북에서는 완벽하게 돌아갔다. 로컬에서만.

그 사건 이후로 GitHub Actions를 본격적으로 파기 시작했다. 처음엔 “그냥 테스트 돌리는 거 아니야?”라고 가볍게 생각했는데, 실제로 세팅을 제대로 해보니 고려할 게 생각보다 훨씬 많았다. 이 글은 내가 여러 Python 프로젝트에 직접 적용하면서 겪은 시행착오를 정리한 거다.

.github/workflows 폴더, 뭐부터 넣어야 하나

워크플로우는 .github/workflows/ 폴더 안에 YAML 파일로 작성한다. 파일 이름은 자유롭게 지어도 되는데, 나는 ci.ymldeploy.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를 붙이고 나서 타입 관련 버그가 눈에 띄게 줄었다. 정확한 수치를 측정한 건 아니지만, 예전에 종종 터지던 AttributeErrorTypeError 류의 런타임 에러가 PR 단계에서 걸러지는 걸 꽤 여러 번 봤다.

설정을 그대로 가져다 쓴다면, 테스트 경로(tests/)와 소스 경로(src/)는 프로젝트 구조에 맞게 바꿔야 한다. Django 프로젝트라면 pytest --ds=myapp.settings.test처럼 설정 파일도 명시해야 한다.

솔직히 말하면 — 처음부터 완벽하게 세팅하려 하지 마라. 기본 테스트 돌리는 것부터 시작해서, 필요가 생길 때마다 린팅, 타입 체크, 커버리지 리포트를 하나씩 추가하는 게 현실적이다. 내가 권장하는 순서는 이렇다: pytest 기본 실행 → 의존성 캐싱 → flake8 → 커버리지 → mypy. 이 순서대로 하나씩 붙여나가면 각 단계에서 나오는 에러를 처리할 여유가 생긴다.

지금 이 설정 없이 개발하라고 하면 — 못할 것 같다.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top