왜 단순 샤딩은 느린가
CI에서 테스트를 N개 러너로 나눌 때 가장 흔한 방식은 파일 개수나 알파벳 순으로 균등 분할하는 것이다. 하지만 테스트 파일마다 실행 시간은 수십 ms에서 수 분까지 편차가 크다. 통합 테스트 한 개가 단위 테스트 200개보다 오래 걸리는 일이 흔하다. 그 결과 어떤 러너는 30초에 끝나고, 어떤 러너는 8분을 도는 불균형(straggler)이 생긴다. 전체 CI 시간은 항상 가장 느린 러너에 의해 결정되므로, 병렬도를 늘려도 체감 속도가 개선되지 않는다.
핵심 아이디어: 시간으로 나눈다
해결책은 개수가 아니라 과거 실행 시간을 기준으로 분할하는 것이다. 각 테스트의 평균 소요 시간을 기록해 두고, 이를 그리디(greedy)하게 배분하면 러너별 총 시간이 비슷해진다. 정렬 후 항상 가장 덜 찬 샤드에 넣는 방식(LPT, Longest Processing Time)만으로도 균형이 크게 개선된다.
import json, heapq
def shard(timings: dict[str, float], n: int) -> list[list[str]]:
# (총시간, 샤드번호) 최소 힙
heap = [(0.0, i) for i in range(n)]
heapq.heapify(heap)
shards = [[] for _ in range(n)]
# 오래 걸리는 테스트부터 배치 (LPT)
for test, dur in sorted(timings.items(), key=lambda x: -x[1]):
total, idx = heapq.heappop(heap)
shards[idx].append(test)
heapq.heappush(heap, (total + dur, idx))
return shards
timings = json.load(open("timings.json"))
for i, group in enumerate(shard(timings, 4)):
open(f"shard_{i}.txt", "w").write("\n".join(group))
타이밍 데이터 수집
분할의 정확도는 입력 데이터 품질에 달렸다. pytest는 JUnit XML로, 대부분의 러너는 유사 포맷으로 시간을 남긴다. 이를 파싱해 테스트별 최근 실행 시간의 이동 평균을 저장한다. 데이터는 캐시나 아티팩트, 또는 별도 오브젝트 스토리지에 둔다.
# 테스트 실행 시 JUnit 리포트 생성
pytest --junitxml=report.xml
# XML에서 (테스트명, 시간) 추출해 timings.json 갱신
python - <<'PY'
import xml.etree.ElementTree as ET, json, os
prev = json.load(open("timings.json")) if os.path.exists("timings.json") else {}
for tc in ET.parse("report.xml").getroot().iter("testcase"):
key = f"{tc.get('classname')}::{tc.get('name')}"
new = float(tc.get("time", 0))
# 지수 이동 평균으로 노이즈 완화
prev[key] = round(0.7 * prev.get(key, new) + 0.3 * new, 3)
json.dump(prev, open("timings.json", "w"))
PY
CI 파이프라인 연동
GitHub Actions의 matrix로 샤드 인덱스를 넘기고, 각 잡이 자기 몫만 실행하도록 구성한다. 타이밍 파일은 캐시로 공유하고, 매 실행 후 갱신본을 다시 저장한다.
jobs:
test:
strategy:
matrix:
shard: [0, 1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: timings.json
key: test-timings-${{ github.ref_name }}
restore-keys: test-timings-
- run: python make_shards.py --n 4
- run: pytest $(cat shard_${{ matrix.shard }}.txt) --junitxml=report.xml
방식별 비교
| 분할 기준 | 구현 난이도 | 불균형 정도 | 데이터 필요 |
|---|---|---|---|
| 파일 개수 | 매우 낮음 | 큼 | 없음 |
| 알파벳/해시 | 낮음 | 큼 | 없음 |
| 타이밍 기반 LPT | 중간 | 작음 | 과거 실행 시간 |
주의점
첫째, 신규 테스트는 타이밍 기록이 없다. 이 경우 전체 평균값을 기본값으로 부여해 특정 샤드에 몰리지 않게 한다. 둘째, 타이밍은 러너 사양·캐시 상태·네트워크에 따라 흔들리므로 단일 실행값이 아니라 이동 평균을 써야 한다. 셋째, 브랜치별로 캐시 키를 분리하되 restore-keys로 폴백을 두어 새 브랜치도 이전 데이터를 재사용하게 한다.
측정과 한계
도입 효과는 러너별 실제 소요 시간의 표준편차, 그리고 "가장 느린 샤드 ÷ 평균 샤드" 비율로 확인한다. 이 비율이 1에 가까울수록 병렬 자원을 낭비 없이 쓰는 것이다. 다만 타이밍 샤딩은 개별 테스트 하나가 지나치게 오래 걸리는 경우까지 해결하지 못한다. 단일 테스트 시간이 목표 샤드 시간을 초과하면 그 테스트 자체를 쪼개거나 최적화해야 하며, 샤딩은 그 위에서만 균형을 맞춘다는 점을 기억해야 한다.