pytest 병렬 실행, pytest-split 잡 개수 정하는 법

3줄 요약
- 테스트를 잡으로 쪼개면 파이프라인이 빨라지지만, 어느 지점부터는 벽시계가 거의 안 줄고 청구 분만 늘어납니다.
- 200개·232초짜리 스위트를 실측한 결과 잡 4개에서 벽시계 120초, 8개에서 90초였고 청구는 8분에서 16분으로 정확히 2배였습니다. 잡 고정비를 60초로 잡았을 때입니다.
- 잡 수 상한을
전체 테스트 시간 ÷ 잡 고정비로 먼저 계산하시고, 그 값을 넘기지 않는 선에서--splitting-algorithm least_duration을 붙이시면 됩니다.
작년에 회귀 스위트가 12분을 넘기면서 팀에서 나온 첫 제안은 "잡을 늘리자"였습니다. 4개로 쪼갰더니 5분대로 내려왔고, 다들 만족했습니다. 그래서 다음 달에 8개로 늘렸는데 이번에는 4분 30초가 나왔습니다. 30초를 줄이려고 러너를 두 배로 쓴 셈이었고, 그달 Actions 사용 분은 눈에 띄게 뛰었습니다. 그때는 왜 그런지 설명하지 못했습니다.
선택지는 세 갈래입니다. 잡을 계속 늘리거나, 잡 안에서 pytest-xdist로 프로세스를 늘리거나, 쪼갤 수 있는 상한을 미리 계산해 두고 그 안에서만 움직이는 것입니다. 앞의 둘은 이미 많이 다뤄졌지만 세 번째의 계산식을 실제 숫자로 확인한 글은 잘 없었습니다.
결론부터 적겠습니다. 잡 수에는 상한이 세 개 있고, 가장 먼저 걸리는 것은 러너 대수도 테스트 개수도 아니라 잡 하나가 테스트를 시작하기까지 쓰는 고정비입니다. 이 프로필에서는 그 값이 잡 4개였습니다.
1. 잡을 늘려도 파이프라인이 더 안 빨라지는 지점이 있습니다
실험 스위트를 이렇게 만들었습니다
실제 스위트의 시간 분포는 고르지 않습니다. 짧은 단위 테스트가 압도적으로 많고, 소수의 E2E가 전체의 3분의 1 이상을 먹습니다. 그 모양을 흉내 내되 반복 실행이 가능하도록 10분의 1로 축소했습니다. 테스트 200개, 총 23.21초, 최장 2.008초입니다. 실제 규모로는 232초짜리 스위트에 20초 E2E가 네 개 섞인 셈입니다.
# tests/ 아래에 편향된 시간 분포를 가진 스위트를 만듭니다.
# (E2E 4개 20초, 통합 16개 5초, 서비스 60개 1초, 단위 120개 0.05초)를 1/10로 축소했습니다.
profile = [("e2e", 4, 2.0), ("integration", 16, 0.5),
("service", 60, 0.1), ("unit", 120, 0.005)]
for name, count, dur in profile:
lines = ["import time\n\n"]
for i in range(count):
lines.append(f"def test_{name}_{i:03d}():\n time.sleep({dur})\n\n")
open(f"tests/test_{name}.py", "w").writelines(lines)
pytest --store-durations로 시간표를 저장하면 .test_durations에 200개 항목이 기록됩니다. 합계 23.210초, 최댓값 2.008초, 상위 네 개의 합이 8.023초였습니다. 상위 2%가 전체의 34.6%를 차지하는 구조입니다.
잡 수를 늘려 가며 벽시계를 쟀습니다
각 그룹을 별도 프로세스로 돌리고, 그중 가장 오래 걸린 그룹의 시간을 그 잡 수의 벽시계로 봤습니다. 실제 CI에서 모든 잡이 동시에 뜨면 파이프라인이 끝나는 시각은 가장 느린 잡이 결정하기 때문입니다. 아래는 least_duration 알고리즘 기준이고, 괄호 안이 실제 규모 환산값입니다.
| 잡 수 | 측정 makespan | 실제 규모 환산 | 직전 대비 절감 |
|---|---|---|---|
| 1 | 23.40초 | 234.0초 | — |
| 2 | 11.82초 | 118.2초 | 115.8초 |
| 3 | 7.92초 | 79.2초 | 39.0초 |
| 4 | 5.97초 | 59.7초 | 19.5초 |
| 6 | 4.01초 | 40.1초 | 19.6초 |
| 8 | 3.04초 | 30.4초 | 9.7초 |
| 12 | 2.12초 | 21.2초 | 9.2초 |
| 16 | 2.12초 | 21.2초 | 0.0초 |
마지막 두 줄이 이 글의 출발점입니다. 잡을 12개에서 16개로 늘렸는데 벽시계가 소수점 둘째 자리까지 똑같이 나왔습니다. 우연이 아닙니다. 가장 긴 테스트가 2.008초이므로, 그 테스트가 들어간 그룹은 무슨 수를 써도 2.008초 아래로 내려갈 수 없습니다. 잡을 아무리 늘려도 스위트는 최장 테스트 하나보다 빨라지지 않습니다.
2. 기본 알고리즘이 긴 테스트 앞에서 무너집니다
경계를 넘고 나서야 그룹을 바꿉니다
pytest-split의 기본 알고리즘은 duration_based_chunks입니다. 테스트 순서를 그대로 유지한 채 앞에서부터 잘라 나가는데, 자르는 판정이 "이번 그룹이 목표 시간을 이미 넘겼는가"입니다. 넘긴 것을 확인한 다음에야 다음 그룹으로 넘어가므로, 각 그룹은 목표 시간을 최대 한 테스트만큼 초과합니다. 짧은 테스트만 있으면 무해하지만 20초짜리 E2E가 섞여 있으면 얘기가 달라집니다.
# pytest_split/algorithms.py 의 DurationBasedChunksAlgorithm 핵심부
time_per_group = sum(map(itemgetter(1), items_with_durations)) / splits
group_idx = 0
for item, item_duration in items_with_durations:
if duration[group_idx] >= time_per_group: # 이미 넘긴 뒤에 그룹을 바꿉니다
group_idx += 1
selected[group_idx].append(item)
duration[group_idx] += item_duration # 초과분은 그대로 그룹에 남습니다
잡 8개로 돌렸을 때 그룹별 실측은 이랬습니다. 4.11 / 4.13 / 3.15 / 3.15 / 3.09 / 3.07 / 3.08 / 0.44초. 목표는 그룹당 2.90초였는데 앞의 두 그룹이 4.1초를 넘겼습니다. 2초짜리 E2E가 두 개씩 붙어 버렸기 때문입니다. 반대로 마지막 그룹은 테스트 48개를 받고도 0.34초 만에 끝났습니다. 같은 파이프라인 안에서 가장 무거운 잡과 가장 가벼운 잡이 열 배 가까이 벌어진 것입니다.
알고리즘을 바꾸면 3.04초까지 내려갑니다
least_duration은 긴 테스트부터 꺼내 현재 합계가 가장 작은 그룹에 넣습니다. 최장 처리 시간 우선 배치에 해당하고, 그룹당 8개 안팎으로 균등하게 갈립니다. 같은 조건에서 두 알고리즘을 나란히 돌린 결과입니다.
| 잡 수 | duration_based_chunks | least_duration | 차이 |
|---|---|---|---|
| 2 | 12.19초 | 11.82초 | 3.1% |
| 3 | 8.22초 | 7.92초 | 3.8% |
| 4 | 6.18초 | 5.97초 | 3.5% |
| 6 | 4.15초 | 4.01초 | 3.5% |
| 8 | 4.13초 | 3.04초 | 35.9% |
잡 6개까지는 차이가 3%대여서 굳이 신경 쓸 이유가 없습니다. 그런데 8개에서 갑자기 36%로 벌어집니다. 기본 알고리즘은 잡을 6개에서 8개로 늘려도 4.15초에서 4.13초로 0.02초밖에 못 줄였습니다. 러너를 두 개 더 쓰고 얻은 것이 0.02초입니다.
제 해석으로는, 여기가 "잡을 늘렸는데 왜 안 빨라지지"라는 체감이 실제로 발생하는 지점입니다. 원인이 러너에 있다고 생각하기 쉽지만 실은 옵션 한 줄입니다. 공식 pytest-split README의 알고리즘 비교표에도 least_duration이 분할 품질에서 낫다고 적혀 있는데, 기본값이 아니라서 대부분 그냥 지나칩니다. 잡을 6개 넘게 쓸 계획이라면 --splitting-algorithm least_duration을 먼저 붙이시는 편이 낫습니다.
3. 세 가지 상한이 잡 수를 결정합니다
첫째, 하드 플로어는 최장 테스트가 정합니다
전체 시간을 T, 가장 긴 테스트 하나를 t_max라고 하면 잡을 아무리 늘려도 벽시계는 t_max 아래로 못 갑니다. 그러니 의미 있는 잡 수의 절대 상한은 T 나누기 t_max입니다. 이 스위트에서는 232.1을 20.08로 나눈 11.6, 올려서 12입니다. 앞의 표에서 12와 16이 같은 값이 나온 이유가 이것입니다.
이 값을 아는 순간 할 일이 하나 정해집니다. 잡을 더 늘리는 대신 최장 테스트를 쪼개는 것입니다. 20초짜리 E2E 하나를 10초짜리 둘로 나누면 상한이 12에서 23으로 올라갑니다. 잡을 늘려도 안 빨라진다면 러너가 아니라 그 테스트를 봐야 합니다.
둘째, 실무 상한은 잡 고정비가 정합니다
잡 하나는 테스트를 시작하기 전에 러너 배정, 체크아웃, 언어 설치, 의존성 복원을 거칩니다. 이 시간을 O라고 하면 잡 하나의 벽시계는 O 더하기 그룹 실행 시간입니다. O는 잡을 늘려도 줄지 않고, 오히려 잡마다 한 번씩 반복됩니다.
그러면 자연스러운 기준선이 나옵니다. 그룹 실행 시간이 O와 같아지는 지점, 즉 잡 수가 T 나누기 O가 되는 지점입니다. 그보다 더 쪼개면 잡이 테스트를 도는 시간보다 준비하는 시간이 더 길어집니다. 232.1초 스위트에 고정비 60초를 가정하면 3.87, 반올려서 4개입니다.
O를 모르시면 실제 워크플로 로그의 스텝별 소요를 더하면 됩니다. 참고로 이 실험 환경에서 파이썬 가상환경 생성이 1.10초, pytest와 pytest-split 설치가 1.74초였고, pytest 자체의 기동과 수집만으로도 매 호출 0.077초가 들었습니다. 실제 프로젝트에서는 의존성이 수십 개이고 캐시 복원과 체크아웃이 붙으니 40초에서 90초 사이가 흔합니다.
셋째, 청구 상한은 분 단위 올림이 정합니다
GitHub는 잡별 사용 시간을 분 단위로 올려서 청구합니다. Actions runner pricing 문서에 "GitHub rounds the minutes and partial minutes each job uses up to the nearest whole minute"라고 명시돼 있고, Linux 2코어 표준 러너는 분당 0.006달러입니다. 그러니 잡을 쪼갤수록 올림 손실이 잡 수만큼 곱해집니다.
고정비 60초를 가정하고 위 실측값을 넣어 계산한 표입니다.
| 잡 수 | 그룹 시간 | 잡 벽시계 | 직전 대비 절감 | 청구 분 | 비용 | 절감 1분당 추가 청구 |
|---|---|---|---|---|---|---|
| 1 | 234.0초 | 294.0초 | — | 5분 | $0.030 | — |
| 2 | 118.2초 | 178.2초 | 115.8초 | 6분 | $0.036 | 0.5배 |
| 3 | 79.2초 | 139.2초 | 39.0초 | 9분 | $0.054 | 4.6배 |
| 4 | 59.7초 | 119.7초 | 19.5초 | 8분 | $0.048 | — |
| 6 | 40.1초 | 100.1초 | 19.6초 | 12분 | $0.072 | 12.2배 |
| 8 | 30.4초 | 90.4초 | 9.7초 | 16분 | $0.096 | 24.7배 |
| 12 | 21.2초 | 81.2초 | 9.2초 | 24분 | $0.144 | 52.2배 |
| 16 | 21.2초 | 81.2초 | 0.0초 | 32분 | $0.192 | ∞ |
두 가지가 눈에 띕니다. 하나는 잡 4개가 3개보다 싸다는 점입니다. 3개일 때는 잡 벽시계가 139.2초라 2분을 넘겨 3분으로 올라가고, 4개일 때는 119.7초로 2분 안에 들어옵니다. 올림 때문에 잡을 하나 더 늘렸더니 청구가 9분에서 8분으로 내려간 것입니다. 이런 구간이 있다는 것을 모르면 "3개가 4개보다 쌀 것"이라는 직관으로 잘못된 선택을 하게 됩니다.
다른 하나는 4개를 넘어선 뒤의 기울기입니다. 4개에서 8개로 가면 벽시계는 119.7초에서 90.4초로 24.5% 줄지만 청구는 8분에서 16분으로 정확히 두 배가 됩니다. 8개에서 16개로 가면 벽시계는 90.4초에서 81.2초로 10% 줄고 청구는 다시 두 배입니다. T 나누기 O로 계산한 4가 표의 무릎과 정확히 겹칩니다.
한 가지 더 있습니다. 잡을 늘려도 동시에 뜨지 않으면 벽시계는 전혀 줄지 않습니다. GitHub 표준 러너의 동시 실행 잡 수는 Free 20개, Pro 40개, Team 60개입니다. 매트릭스 하나가 만들 수 있는 잡은 워크플로 실행당 256개가 상한입니다. 소규모 팀에서 이 한도에 걸릴 일은 드물지만, 워크플로 여러 개가 같은 시각에 뜨면 조직 전체 동시 실행 한도를 나눠 쓰게 되므로 계산에 넣으셔야 합니다.
4. 실제로 걸린 함정 세 가지
첫 번째 함정 — 시간표가 낡으면 균형이 조용히 깨집니다
pytest-split은 .test_durations에 없는 테스트를 만나면 저장된 테스트들의 평균값으로 추정합니다. 이 스위트에서 그 값은 0.116초였습니다. 즉 어제 추가한 20초짜리 E2E가 0.116초짜리로 취급됩니다.
확인해 봤습니다. 시간표를 갱신하지 않은 채 2초짜리 신규 테스트 4개를 추가하고 잡 6개로 돌렸더니 그룹별 시간이 5.97 / 5.98 / 4.09 / 4.10 / 5.97 / 5.97초로 나왔습니다. 추가 전에는 3.98에서 4.01초 사이로 거의 완벽하게 균등했는데, 최대와 최소가 1.46배로 벌어졌습니다. 신규 테스트가 들어간 그룹만 2초씩 무거워졌기 때문입니다.
문제는 이게 실패로 보이지 않는다는 점입니다. 테스트는 다 통과하고 로그에 경고도 없으며, 다만 파이프라인이 조금씩 느려집니다. 스프린트마다 테스트가 늘어나는 팀이라면 몇 달 뒤에는 분할이 사실상 무의미해집니다. 갱신을 사람의 기억에 맡기지 마시고, 야간 스케줄 워크플로에서 --store-durations를 돌려 커밋하도록 붙여 두시는 편이 안전합니다.
두 번째 함정 — 빈 그룹은 잡을 실패시킵니다
--splits 값을 테스트 수보다 크게 잡거나, 필터를 걸어 수집된 테스트가 줄어들면 어떤 그룹은 테스트를 하나도 못 받습니다. 이때 pytest는 통과가 아니라 종료 코드 5를 냅니다. NO_TESTS_COLLECTED이고 CI에서는 잡 실패로 표시됩니다.
# 테스트 3개짜리 디렉터리를 5개로 쪼갠 경우 (실측)
pytest tiny -q --splits 5 --group 1 # 1 passed, 2 deselected → exit 0
pytest tiny -q --splits 5 --group 5 # 3 deselected → exit 5 ← 잡 실패
-m smoke 같은 마커 필터와 분할을 함께 쓰는 워크플로에서 특히 잘 납니다. 평소에는 멀쩡하다가 마커가 붙은 테스트가 줄어든 날 갑자기 빨간 잡이 뜹니다. 원인이 테스트가 아니라 분할이라는 것을 알아내는 데 시간이 걸립니다. 잡 스텝에 || [ $? -eq 5 ]를 붙여 넘길지, 아니면 빈 그룹 자체를 막을지는 팀에서 정하시면 되는데, 저는 넘기는 쪽보다 --splits 값을 테스트 수와 함께 검증하는 쪽을 권합니다. 넘기기 시작하면 진짜로 아무 테스트도 안 도는 날을 못 잡습니다.
가장 비싼 함정 — 무작위 순서 플러그인과 섞으면 테스트가 사라집니다
pytest-randomly나 pytest-random-order를 쓰고 계시다면 기본 알고리즘과 절대 같이 쓰면 안 됩니다. 그룹 선택이 무작위화 이후에 일어나기 때문에, 잡마다 순서가 달라지면 어떤 테스트는 여러 그룹에서 중복 실행되고 어떤 테스트는 어느 그룹에도 들어가지 않습니다. 공식 README가 명시적으로 경고하는 조합입니다.
이것이 가장 비싼 이유는 실패하지 않기 때문입니다. 파이프라인은 초록이고, 통과 개수만 조금 다르고, 정작 실행되지 않은 테스트가 무엇인지는 아무도 모릅니다. 회귀 스위트를 믿고 배포했는데 그 회귀 스위트의 일부가 그날 아예 안 돌았을 수 있다는 뜻입니다. 해결은 두 가지입니다. least_duration으로 바꾸시거나(0.8.0에서 시작 순서와 무관하게 결정론적으로 갈라지도록 고쳐졌습니다), 셸에서 시드를 하나 만들어 모든 그룹에 같은 값을 넘기시면 됩니다.
5. 도입 난이도와 유지보수 비용
옵션 두 개로 끝나는 도구라 도입 자체는 쉽습니다. 비용은 도구가 아니라 시간표 파일 관리에서 발생합니다.
| 항목 | 평가 | 근거 |
|---|---|---|
| 도입 난이도 | 낮음 | 설치 후 --store-durations 한 번, 워크플로에 --splits/--group 두 줄. 프로덕션 코드 변경 없음 |
| 유지보수 비용 | 중간 | .test_durations를 저장소에 두고 주기적으로 갱신해야 함. 갱신을 놓치면 실패 없이 균형만 나빠짐 |
| 팀 학습곡선 | 낮음 | 옵션이 4개뿐. 다만 세 가지 상한과 알고리즘 차이는 한 번 공유해 두어야 함 |
| 되돌리기 | 매우 쉬움 | 워크플로에서 옵션을 빼면 원상복구. 테스트 코드에 흔적이 남지 않음 |
| 대안 대비 | xdist와 병행 가능 | 잡 간 분할은 pytest-split, 잡 내부 병렬은 xdist. 순서 의존이 있는 스위트에는 xdist가 부적합 |
버전은 2026-02-03에 나온 0.11.0이 최신이며, 이 릴리스에서 pytest 9.x와 Python 3.14 지원이 추가되고 Python 3.8·3.9 지원이 빠졌습니다. 변경 이력을 보면 기능 추가보다 런타임 호환 유지가 중심이라 갑작스러운 동작 변경을 걱정할 도구는 아닙니다.
워크플로에 넣는 모양
# .github/workflows/test.yml — 잡 수는 T ÷ O 로 계산한 값으로 고정합니다
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # 한 그룹이 깨져도 나머지 리포트를 남깁니다
matrix:
group: [1, 2, 3, 4] # 232초 ÷ 고정비 60초 = 3.87 → 4
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip # 고정비 O를 줄이면 상한 자체가 올라갑니다
- run: pip install -r requirements.txt
- name: 테스트 그룹 실행
run: |
pytest --splits 4 --group ${{ matrix.group }} \
--splitting-algorithm least_duration \
--junitxml=report-${{ matrix.group }}.xml
cache: pip을 강조해 두는 이유가 있습니다. 고정비 O를 줄이면 잡 수 상한 T 나누기 O가 그만큼 올라갑니다. 고정비가 90초면 상한이 2.6개지만 30초까지 줄이면 7.7개가 됩니다. 잡을 더 쪼개고 싶으시면 러너를 늘리기 전에 캐시부터 손보시는 편이 순서상 맞습니다.
6. 권할 만한 경우와 권하지 않는 경우
권할 만한 경우
전체 스위트가 5분을 넘고, 가장 긴 테스트가 전체의 10% 미만이며, 테스트 사이에 순서 의존이 있어 pytest-xdist를 못 쓰는 팀입니다. 이 조합이 pytest-split이 만들어진 이유이기도 합니다. QA 4~6명 규모에서 회귀 스위트가 막 무거워지기 시작한 시점이라면 도입 비용 대비 효과가 분명합니다.
권하지 않는 경우
전체가 2분 이내라면 잡 고정비가 테스트 시간과 비슷하거나 더 큽니다. 쪼개는 순간 T 나누기 O가 1 근처가 되므로 얻을 것이 없습니다. 최장 테스트 하나가 전체의 3분의 1 이상을 차지하는 경우도 마찬가지입니다. 하드 플로어가 3 언저리라 잡을 늘릴 여지 자체가 없고, 먼저 할 일은 그 테스트를 쪼개는 것입니다. 무작위 순서 플러그인을 쓰면서 알고리즘을 바꿀 계획이 없다면 도입하지 않는 편이 낫습니다. 조용히 안 도는 테스트가 생기는 쪽이 느린 파이프라인보다 훨씬 위험합니다.
오늘 저장소에서 바로 해볼 것
아래 세 줄이면 우리 스위트의 상한 두 개가 나옵니다.
# 1) 시간표를 만들고 T와 t_max를 뽑습니다
pytest --store-durations
python3 - <<'PY'
import json, math
d = json.load(open(".test_durations"))
T, tmax = sum(d.values()), max(d.values())
O = float(input("잡 고정비(초, 워크플로 로그의 체크아웃+설치 합계): ") or 60)
print(f"전체 {T:.1f}초 / 최장 테스트 {tmax:.1f}초")
print(f"하드 플로어(이보다 늘려도 안 빨라짐) : {math.ceil(T/tmax)}개")
print(f"실무 상한(T÷O, 여기까지만 쪼갤 것) : {max(1, round(T/O))}개")
top = sorted(d.items(), key=lambda x: -x[1])[:5]
print("상위 5개:", *[f" {v:6.2f}초 {k}" for k, v in top], sep="\n")
PY
실무 상한이 2 이하로 나오면 분할이 아니라 다른 곳을 봐야 한다는 신호입니다. 하드 플로어가 실무 상한보다 작게 나오면 최장 테스트를 먼저 쪼개셔야 합니다. 두 값이 모두 4 이상이면 그 사이에서 고르시면 되고, 저라면 작은 쪽을 택하겠습니다. 늘리는 것은 언제든 쉽지만, 이미 늘려 놓은 잡을 줄이자는 제안은 팀에서 잘 통과되지 않습니다.
병렬 수를 늘릴 때 인프라 쪽 상한은 Selenium Grid 병렬 세션 수를 정하는 글에서 계산해 봤습니다.
댓글
댓글 쓰기