왜 전체 커버리지 게이트는 실패하는가
많은 팀이 "전체 커버리지 80% 유지" 같은 게이트를 CI에 건다. 하지만 레거시 코드가 많은 저장소에서 이 방식은 두 가지로 실패한다. 첫째, 이미 72%인 저장소에서 80%를 강제하면 기능 개발과 무관하게 오래된 코드에 테스트를 붙이라는 요구가 되어 PR이 막힌다. 둘째, 반대로 이미 85%인 저장소에서는 새 코드에 테스트를 하나도 안 붙여도 전체 수치가 84.9%로 거의 안 떨어지기 때문에 게이트를 통과한다. 즉 전체 수치는 이번 변경이 안전한지를 알려주지 못한다.
여기서 필요한 것이 변경분 커버리지(diff coverage)다. 이번 PR에서 추가·수정된 라인만 대상으로 커버리지를 계산하고 게이트를 건다. 레거시는 건드리지 않고, 새로 들어오는 코드의 품질만 관리한다.
동작 원리
diff coverage는 두 입력을 교집합한다. (1) 커버리지 리포트(어떤 라인이 실행되었는가), (2) git diff(어떤 라인이 이 브랜치에서 바뀌었는가). 바뀐 라인 중 실행 가능한(executable) 라인이 분모, 그중 테스트로 실행된 라인이 분자가 된다.
커버리지 리포트는 표준 포맷인 Cobertura XML이나 LCOV로 뽑는 것이 핵심이다. 대부분의 diff coverage 도구가 이 포맷을 base 브랜치와 비교해 계산한다.
파이썬 예시: pytest + diff-cover
먼저 Cobertura XML을 생성하고, base 브랜치 대비 변경분만 검사한다.
#!/usr/bin/env bash
set -euo pipefail
# 1) 커버리지 측정 후 Cobertura XML 생성
pytest --cov=src --cov-report=xml:coverage.xml
# 2) origin/main 대비 변경 라인만 검사, 90% 미만이면 exit 1
diff-cover coverage.xml \
--compare-branch=origin/main \
--fail-under=90 \
--html-report=diff-cover.html
--compare-branch는 병합 대상 브랜치를 가리켜야 한다. CI에서는 base 브랜치를 shallow가 아닌 상태로 fetch해 둬야 diff가 정확히 계산된다.
GitHub Actions 통합
PR 이벤트에서 base 브랜치를 받아오는 부분이 함정이다. actions/checkout은 기본이 얕은 체크아웃이라 diff 계산이 깨진다. fetch-depth: 0으로 전체 이력을 받는다.
name: diff-coverage
on: pull_request
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 전체 이력 필요
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest pytest-cov diff-cover
- run: pytest --cov=src --cov-report=xml:coverage.xml
- name: Enforce diff coverage
run: |
diff-cover coverage.xml \
--compare-branch=origin/${{ github.base_ref }} \
--fail-under=90
임계값 설정 전략
임계값은 전체 목표보다 높게 잡는 것이 정석이다. 신규 코드를 엄격히 관리해 시간이 지나며 전체 수치가 목표로 수렴하게 만드는 것이다.
| 대상 | 측정 범위 | 추천 임계값 | 목적 |
|---|---|---|---|
| 전체 커버리지 | 전 코드베이스 | 정보성(게이트 X) | 추세 관찰 |
| 변경분 커버리지 | PR 변경 라인 | 85~95% | 신규 부채 차단 |
단, 100%는 피한다. 방어 코드, 로깅, 예외 분기까지 억지로 테스트하게 되어 의미 없는 테스트가 늘고 개발자가 게이트를 우회하기 시작한다.
주의점
제외 규칙을 명시하라
마이그레이션 스크립트, 자동 생성 코드, DTO 등은 측정에서 빼야 한다. 커버리지 도구 단계에서 제외해야 diff coverage 분모에서도 빠진다.
# pyproject.toml
[tool.coverage.run]
omit = [
"src/migrations/*",
"src/**/generated/*.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
]
Squash 머지와 base 참조
squash 병합을 쓰면 머지 후 base가 재작성되어, 과거 커밋과의 diff 계산이 어긋날 수 있다. 게이트는 어디까지나 PR 시점의 base_ref 기준으로만 판단하도록 고정한다.
분모가 0인 경우
문서·설정만 바꾼 PR은 실행 가능한 변경 라인이 없어 분모가 0이 된다. diff-cover는 이 경우 통과로 처리하지만, 도구에 따라 다르므로 "변경 라인 없음 = 통과" 여부를 CI 로그로 확인해 둔다.
정리
전체 커버리지 게이트는 레거시가 있는 실무에서 거의 항상 잘못된 신호를 준다. 변경분 커버리지는 이번 PR이 들여오는 코드만 책임지게 하므로 도입 저항이 낮고, 부채를 점진적으로 줄인다. 표준 리포트 포맷 생성 → base 브랜치 fetch → 변경 라인 교집합, 이 세 단계만 CI에 고정하면 된다.