왜 단일 잡(job) 테스트가 병목이 되는가
테스트 스위트가 커질수록 CI 파이프라인의 총 실행 시간은 선형으로 늘어난다. 하나의 잡에서 수천 개의 테스트를 순차 실행하면, PR 하나를 검증하는 데 15~20분이 걸리는 상황이 흔하다. 문제는 시간 자체가 아니라 피드백 루프다. 개발자가 푸시하고 결과를 기다리는 동안 컨텍스트 전환이 일어나고, 하루에 처리하는 PR 수가 줄어든다.
더 큰 문제는 GitHub Actions의 러너 한 대가 보통 2~4 vCPU에 불과하다는 점이다. 테스트 프레임워크가 병렬 실행을 지원하더라도 단일 러너의 코어 수가 상한이 된다. 반면 GitHub Actions는 잡 단위로 러너를 여러 대 붙일 수 있다. 즉, 테스트를 잡 여러 개로 쪼개면 물리적으로 더 많은 CPU를 동시에 쓸 수 있다.
매트릭스 전략의 기본 구조
strategy.matrix는 원래 여러 버전·OS 조합을 테스트하기 위한 기능이지만, 테스트 샤딩(sharding)에도 그대로 활용할 수 있다. 아래는 테스트를 4개 샤드로 나눠 병렬 실행하는 최소 구성이다.
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Run shard ${{ matrix.shard }}
run: npx jest --shard=${{ matrix.shard }}/4
fail-fast: false가 핵심이다. 기본값 true면 한 샤드가 실패하는 순간 나머지 샤드가 취소되어, 다른 샤드의 실패를 한 번에 확인하지 못한다. 테스트 목적에서는 모든 샤드를 끝까지 돌리는 편이 낫다.
테스트 프레임워크별 샤딩 방법
프레임워크마다 샤드 지원 방식이 다르다. Jest와 Playwright는 --shard 플래그를 내장한다. pytest는 pytest-split 플러그인으로 실행 시간 기반 균등 분할이 가능하다.
strategy:
matrix:
group: [1, 2, 3, 4]
steps:
- run: pip install -r requirements.txt pytest-split
- run: pytest --splits 4 --group ${{ matrix.group }} --durations-path .test_durations
pytest-split은 단순히 개수로 나누지 않고 .test_durations 파일에 기록된 과거 실행 시간을 기준으로 그룹의 부하를 맞춘다. 이 파일을 리포지토리에 커밋하거나 캐시에 저장해두면 샤드 간 실행 시간 편차가 크게 줄어든다.
동적 매트릭스로 샤드 수 자동 결정
샤드 수를 하드코딩하면 테스트가 늘어날 때마다 수정해야 한다. 앞선 잡에서 샤드 배열을 JSON으로 만들어 outputs로 넘기면, 뒤 잡의 매트릭스를 동적으로 구성할 수 있다.
jobs:
setup:
runs-on: ubuntu-latest
outputs:
shards: ${{ steps.gen.outputs.shards }}
steps:
- id: gen
run: echo "shards=$(python -c 'import json;print(json.dumps(list(range(1,5))))')" >> "$GITHUB_OUTPUT"
test:
needs: setup
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: ${{ fromJson(needs.setup.outputs.shards) }}
steps:
- uses: actions/checkout@v4
- run: pytest --splits 4 --group ${{ matrix.shard }}
결과 집계와 커버리지 병합
샤딩의 함정은 각 샤드가 부분 커버리지 리포트만 생성한다는 점이다. 이를 합치지 않으면 커버리지 수치가 잘못 나온다. 각 샤드가 아티팩트를 업로드하고, 마지막 집계 잡에서 병합한다.
coverage:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: coverage-*
merge-multiple: true
- run: |
coverage combine
coverage report --fail-under=80
각 test 잡에서는 coverage-${{ matrix.shard }} 이름으로 .coverage.* 파일을 업로드해야 한다. coverage combine이 이들을 하나로 합쳐 전체 기준을 검사한다.
비용과 속도의 트레이드오프
샤드를 무한정 늘린다고 계속 빨라지지는 않는다. 각 샤드는 체크아웃, 의존성 설치, 환경 준비라는 고정 오버헤드를 반복한다. 테스트 실행 시간이 3분인데 셋업이 2분이면, 샤드를 8개로 쪼개도 잡당 최소 2분은 깔린다.
| 샤드 수 | 테스트 실행 | 셋업 오버헤드 | 실제 벽시계 시간 |
|---|---|---|---|
| 1 | 16분 | 2분 | 18분 |
| 4 | 4분 | 2분 | 6분 |
| 8 | 2분 | 2분 | 4분 |
| 16 | 1분 | 2분 | 3분 |
동시 실행 러너가 늘면 과금되는 분(minute) 총합도 늘어난다. 위 표에서 16 샤드는 벽시계로 3분이지만 과금은 16 × 3 = 48분이다. 셋업 오버헤드가 지배적인 구간부터는 비용만 늘고 이득이 없으므로, 4~8 샤드에서 균형점을 찾는 경우가 많다.
실무 적용 시 주의점
몇 가지 함정이 반복적으로 나타난다. 첫째, 테스트 간 순서 의존성이 있으면 샤딩 후 무작위로 깨진다. 이는 원래 숨어 있던 버그이므로 격리해서 고쳐야 한다. 둘째, 공유 리소스(같은 DB, 같은 포트)를 쓰는 테스트는 샤드마다 격리된 인스턴스를 띄우거나 스키마를 분리해야 충돌하지 않는다. 셋째, 브랜치 보호 규칙의 필수 체크(required checks)에 개별 샤드 잡 이름을 넣으면 샤드 수를 바꿀 때마다 규칙을 수정해야 하므로, 앞의 집계 잡 하나만 필수 체크로 지정하는 편이 유지보수에 유리하다.
마지막으로, 균등 분할을 맹신하지 말자. 실행 시간 기반 분할을 쓰더라도 duration 데이터가 오래되면 편차가 커진다. CI 대시보드에서 샤드별 실행 시간을 주기적으로 확인하고, 편차가 벌어지면 duration 캐시를 갱신하는 것이 안정적인 파이프라인을 유지하는 방법이다.