Test Impact Analysis, 변경분만 테스트할 때 놓치는 것

3줄 요약
- 합성 스위트(테스트 200개)에서 맵을 만드는 값은 전체 실행 대비 +19.7%이고, 잎 모듈 하나를 고쳤을 때 남는 실행은 25.1%입니다.
- 같은 맵이 conftest 변경·데이터 파일 변경·새 테스트 파일·아무 테스트도 안 닿은 파일 네 자리에서 0개를 고릅니다. 데이터 파일 한 줄을 바꾼 경우 실제로는 40개가 깨졌는데 맵은 "돌릴 것 없음"을 냈습니다.
- 맵에 없는 경로가 변경 목록에 하나라도 섞이면 전체 실행으로 되돌리는 20줄짜리 가드를 먼저 붙이고, 그다음에 선택 실행을 1차 피드백 자리에만 두는 순서입니다.
전체 실행 1.710초. 변경 파일 하나를 골랐을 때 0.430초. 공용 모듈을 고쳤을 때 1.745초. 데이터 파일 한 줄만 고쳤을 때는 선택 0개라 실행 자체가 없습니다. 그리고 그 "돌릴 것 없음"짜리 변경으로 전체를 돌리면 40 failed입니다.
앞의 두 숫자만 보면 붙일 이유가 충분한데요, 뒤의 두 숫자 때문에 하루를 더 써서 재 봤습니다. 변경된 파일에 닿는 테스트만 골라 돌리는 것, 흔히 테스트 임팩트 분석이라고 부르는 그 방식입니다. coverage.py는 7.16.1까지 와 있고 동적 컨텍스트 기능은 5.0부터 있었으니, 별도 도구 없이 설정 파일 한 줄과 파이썬 스무 줄이면 맵이 나옵니다.
재 본 것은 두 가지입니다. 맵을 만드는 데 얼마가 드는가, 그리고 그 맵이 어디서 조용히 빈손을 내미는가.
1. 맵을 만드는 데 드는 값
측정 환경은 Ubuntu 22.04.5 aarch64 4코어 · Python 3.10.12 · pytest 9.1.1 · coverage 7.16.1(PyPI 업로드 2026-09-13T19:08:21Z = KST 09-14 04:08)입니다. 대상은 src/shop 아래 파이썬 파일 8개에 테스트 200개를 붙인 합성 스위트이고, 실제 스위트의 I/O 비용을 흉내 내려고 autouse 픽스처에 5ms 지연을 넣었습니다.
설정은 이게 전부입니다.
# .coveragerc — 동적 컨텍스트를 켜는 데 필요한 것은 이 한 줄입니다
[run]
source = src
dynamic_context = test_function
data_file = .coverage
한 라운드에서 세 조건을 번갈아 한 번씩 돌리고, 그 라운드를 9번 반복해 조건별 최솟값을 취했습니다. 조건별로 몰아서 재지 않은 것은 컨테이너 부하가 시간에 따라 흐르기 때문입니다.
| 조건 | 최솟값 | 중앙값 | 순수 실행 대비 |
|---|---|---|---|
| A · 순수 pytest | 1.710초 | 1.724초 | — |
| B · coverage(컨텍스트 없음) | 1.869초 | 1.902초 | +9.3% |
C · coverage(test_function 컨텍스트) |
2.047초 | 2.067초 | +19.7% |
I/O 스위트와 CPU 스위트에서 값이 갈립니다
같은 스위트에서 픽스처 지연만 빼고 다시 쟀습니다. 명목 1초(200개 × 5ms), 실측 차이로는 1.562초가 사라지자 비율이 완전히 달라집니다.
| 조건 | 지연 5ms 포함 | 지연 제거 |
|---|---|---|
| 순수 pytest | 1.710초 | 0.148초 |
| coverage(컨텍스트 없음) | 1.869초 (+9.3%) | 0.199초 (+34.5%) |
| coverage(컨텍스트) | 2.047초 (+19.7%) | 0.258초 (+74.3%) |
수집 비용은 절대값이 아니라 분모에 달려 있습니다. 이 스위트에서는 지연을 둔 쪽이 20% 남짓, 지연을 뺀 쪽이 74%였습니다. 실제 DB·브라우저 대기가 sleep과 같지는 않으니 비율을 그대로 옮겨 쓰지 마시고 방향만 가져가십시오. 이 값을 CI 러너 요금으로 환산할 생각이라면 잡 수를 늘릴 때 어디서 무릎이 꺾이는지 계산했던 방식을 그대로 쓰면 됩니다.
컨텍스트가 붙는 자리와 안 붙는 자리
맵을 뽑는 코드는 짧습니다. .coverage는 SQLite 파일이고, 컨텍스트별로 질의해 파일 단위로 뒤집으면 끝입니다.
# 컨텍스트별로 조회해 "파일 → 그 파일을 실행한 테스트" 맵을 만듭니다
import coverage, collections, json, os
cov = coverage.Coverage(data_file=".coverage"); cov.load()
d = cov.get_data()
m = collections.defaultdict(set)
for ctx in [c for c in d.measured_contexts() if c]: # 빈 컨텍스트는 뺍니다
d.set_query_context(ctx)
for f in d.measured_files():
if d.lines(f):
m[os.path.relpath(f)].add(ctx)
json.dump({k: sorted(v) for k, v in m.items()}, open("map_file.json", "w"))
산출물은 9,728바이트였고 파일 6개에 파일→테스트 쌍이 400개였습니다. .coverage 자체는 86,016바이트입니다. 200개 스위트에서 이 정도면 저장 비용은 판단 재료가 못 됩니다.
문제는 위 코드의 주석에 적힌 "빈 컨텍스트"입니다. 공식 문서의 Measurement contexts 절은 테스트 러너가 테스트 전과 테스트 사이에 실행하는 코드가 빈 컨텍스트에 기록된다고 적고 있고, 테스트 함수의 정의도 이름이 test로 시작하는 함수라고만 되어 있습니다.
실측으로 확인했습니다. source에 테스트 디렉터리까지 넣고 다시 수집했더니 맵의 파일 수는 11개, 쌍은 600개로 늘었는데 conftest.py에 연결된 테스트는 0개였습니다. conftest 픽스처 본문은 여섯 줄 전부가 빈 컨텍스트에 있었고 어느 테스트 컨텍스트에서도 조회되지 않았습니다. 테스트 파일도 마찬가지로 임포트와 def 줄 41개가 빈 컨텍스트로 가고, 개별 테스트 컨텍스트에는 함수 본문 두 줄만 남습니다.
2. 고른 뒤에 남는 시간
맵이 있으니 이제 고릅니다. 파일 하나를 지목했을 때 몇 개가 딸려 오는지가 절감의 전부입니다.
| 변경한 파일 | 선택 테스트 | 선택 실행 시간 | 전체 대비 |
|---|---|---|---|
src/shop/coupon.py (잎 모듈) |
40개 | 0.430초 | 25.1% |
src/shop/pricing.py (잎 모듈) |
40개 | 0.430초 | 25.1% |
src/shop/util.py (공용 모듈) |
200개 | 1.745초 | 102.0% |
src/shop/report.py (테스트 미도달) |
0개 | — | — |
잎 모듈과 공용 모듈
util.py는 다섯 모듈이 전부 쓰는 금액 정규화 함수 하나짜리 파일입니다. 세 줄입니다. 그런데 그 세 줄을 고치면 200개가 전부 선택되고 실행은 전체의 102.0%가 됩니다. 100%를 넘은 것은 노드 ID 200개를 명령줄로 넘기는 몫이며, 요지는 절감이 0이라는 것입니다. 그날은 맵 수집에 쓴 19.7%만큼 순손해입니다.
여기서 100%가 나온 것은 util.py를 다섯 모듈이 전부 쓰도록 만들었기 때문입니다. 실무에서 같은 자리에 오는 파일은 공통 설정 로더, 로깅 초기화, 시간 유틸, 금액 포맷 정도이고, 그 파일의 팬인이 클수록 선택 비율이 100%에 가까워집니다. 선택 실행의 이득은 스위트 크기가 아니라 의존 그래프의 모양이 결정합니다.
절감식과 실제 환산
전체 실행 시간을 T, 변경 유형별 선택 비율을 r이라 하면 한 번의 PR 실행에서 남는 시간은 (1 − r) × T이고, 맵을 다시 만드는 야간 잡은 러너를 1.197 × T 동안 잡습니다(순증분만 보면 0.197 × T이지만, 안 돌리면 아예 없을 잡이라 점유 시간 전부를 비용으로 셉니다). 실측 비율을 그대로 넣어 T=20분·하루 PR 실행 12회로 환산하면 이렇습니다.
| 변경 유형 | 비중 가정 | 1회 실행 시간 |
|---|---|---|
| 잎 모듈만 | 50% | 5.0분 |
| 공용 모듈 포함 | 30% | 20.4분 |
| 맵 밖 경로 포함(전량 폴백) | 20% | 20.0분 |
| 가중 평균 | — | 12.62분 |
12.62분 × 12회 = 151.4분에 야간 맵 수집 23.9분을 더해 175.4분입니다. 전량 실행 240분 대비 26.9% 절감입니다. 표 첫 줄의 25.1%만 보고 기대했던 75%와는 거리가 멉니다. 비중 가정은 저장소마다 다르니 이 표를 그대로 쓰지 마시고, git log로 파일별 변경 빈도를 뽑아 세 칸의 비중부터 자기 값으로 바꾸십시오.
3. 맵이 0개를 고르는 네 자리
여기가 이 글을 쓴 이유입니다. 선택 비율이 100%로 올라가는 것은 손해일 뿐이지만, 0%로 내려가는 것은 위험입니다. 선택 0개는 CI에서 초록으로 보입니다.
| 변경 종류 | 맵이 고른 수 | 실제 영향 | 왜 |
|---|---|---|---|
conftest.py 픽스처 수정 |
0개 | 200개 전부 | 픽스처는 빈 컨텍스트로 기록 |
data/rates.json 한 줄 |
0개 | 40 failed | 코드가 아니라 맵에 없음 |
| 새 테스트 파일 추가 | 0개 | 새 테스트 미실행 | 맵은 지난 실행의 기록 |
report.py (커버 안 된 파일) |
0개 | 알 수 없음 | 닿은 테스트가 애초에 없음 |
데이터 파일 한 줄이 40개를 깨뜨릴 때
data/rates.json의 골드 등급 할인율을 0.05에서 0.06으로 바꿨습니다. 코드는 한 글자도 건드리지 않았습니다. 맵은 이 경로를 모르니 선택 0개, 전체를 돌리면 40 failed, 160 passed입니다.
픽스처 JSON, 스냅샷 파일, 마이그레이션 SQL, OpenAPI 스펙, 번역 리소스. 이 목록에 해당하는 파일이 저장소에 하나도 없는 팀은 드뭅니다.
그리고 이 구멍은 어서션이 꼼꼼할수록 넓어집니다. 처음 만든 스위트는 payable <= subtotal 같은 느슨한 검증이라 할인율을 바꿔도 40개가 전부 통과했습니다. 기대값을 정확히 박아 넣자 같은 한 줄에 40개가 무너졌습니다. 좋은 테스트일수록 데이터에 민감하고, 선택 실행은 그 민감함을 못 봅니다.
아무 테스트도 안 닿은 파일이 가장 조용합니다
report.py는 맵에 아예 등장하지 않습니다. 커버리지가 0이니 당연합니다. 그런데 선택 실행 관점에서 이 파일은 "돌릴 테스트가 없음"과 "안전함"이 구별되지 않습니다. 커버리지가 낮은 파일일수록 선택 실행이 조용히 아무것도 안 돌릴 여지가 커집니다. 여기서 본 것은 report.py 한 건이지만, 구조상 커버리지 0인 파일은 전부 같은 자리에 놓입니다. 초록이 실제로 무엇을 뜻하는지 헷갈리는 구조는 매트릭스 잡이 취소되고도 성공으로 읽히던 경우와 같은 계열입니다.
줄 단위로 좁히면 더 나빠집니다
파일 단위 대신 줄 단위 맵을 쓰면 선택을 더 줄일 수 있을 것 같은데, 두 가지가 걸립니다.
coupon.py의 줄 단위 맵은 3·4·9번 줄이었습니다. 파일 맨 위에 주석 세 줄을 넣자 실제 코드 줄이 전부 3씩 밀렸습니다. diff의 줄 번호를 맵의 줄 번호로 옮기는 매핑 없이는 줄 단위 선택이 첫 포맷팅 커밋에서 무효가 됩니다.
다른 하나는 조건 분기입니다. pricing.py의 맵에 기록된 줄은 5·8·9·12·13·14·16이었는데, 같은 스위트를 환경변수 FEATURE_ROUNDING을 켜고 수집하면 1·2·4·5·7·8·9·11·12·13·14·15·16이 나옵니다. 15번 줄, 즉 플래그가 켜졌을 때만 도는 코드가 기본 수집에서는 맵에 없습니다. 맵을 수집한 환경과 CI가 도는 환경의 플래그가 다르면 그 차이만큼 통째로 사각지대입니다.
4. testmon과 갈리는 지점
같은 일을 하는 기성 도구가 있습니다. pytest-testmon 2.2.0(PyPI 업로드 2025-12-01T07:30:23Z = KST 12-01 16:30)은 변경된 파일과 메서드로 영향 범위를 판정한다고 패키지 메타데이터에 적혀 있습니다. 같은 스위트에 같은 변경을 넣고 나란히 돌렸습니다. 아래 시간은 pytest가 스스로 보고한 값입니다.
| 변경 | 직접 만든 맵 | testmon 2.2.0 |
|---|---|---|
소스 1파일 (MAX 값 변경) |
40개 · 0.32s | 40개 · 0.37s |
conftest.py 지연 5ms→6ms |
0개 | 200개 · 2.23s |
rates.json 한 줄 |
0개 | 0개 |
| 새 테스트 파일 추가 | 0개 | 1개 · 0.02s |
| 주석 3줄만 추가 | 40개 · 0.32s | 0개 |
testmon은 네 칸 중 세 칸을 메웁니다. conftest를 바꾸면 전량으로 돌아가고, 새 테스트를 알아서 잡고, 동작이 같은 포맷팅 변경은 건너뜁니다. 최초 수집은 1.861초에 DB 135,168바이트였고 변경이 없으면 0.134초에 no tests ran으로 끝납니다.
주석 3줄 칸이 두 방식의 성격을 그대로 보여 줍니다. 파일 단위 맵은 경로만 보므로 동작이 바뀌지 않은 포맷팅에도 40개를 돌리고, testmon은 코드를 보므로 0개를 냅니다. 대신 그 판정을 팀이 직접 열어 볼 수 없습니다. 어느 쪽이 나은지는 스위트가 아니라 팀이 그 판정을 얼마나 신뢰할 수 있는지에 달렸습니다.
testmon이 못 메우는 칸
데이터 파일 칸은 그대로 비어 있습니다. 코드를 보는 도구라 JSON 한 줄은 어느 쪽 방식으로도 안 잡힙니다. 이 칸만큼은 도구를 바꿔서 해결되지 않고, 변경 경로 목록을 직접 보는 가드가 있어야 합니다.
부수적으로 하나. --testmon을 -p no:cacheprovider와 같이 주면 testmon/configure.py 78행의 options["lf"]에서 KeyError: 'lf'가 나면서 INTERNALERROR로 죽습니다. 캐시 플러그인이 꺼져 있으면 --lf 옵션이 등록되지 않기 때문인데, 패키지의 선언된 요구사항은 pytest<10,>=5라 버전만 보고는 예측할 수 없습니다. CI에서 캐시 플러그인을 끄는 관행이 있다면 먼저 확인해 보십시오.
| 항목 | 직접 만든 맵 | pytest-testmon |
|---|---|---|
| 도입 난이도 | 설정 한 줄 + 선택 스크립트 20줄 | 플러그인 설치 한 줄 |
| 유지보수 비용 | 맵 갱신 잡과 가드 스크립트를 직접 관리 | DB 파일 보관·공유 정책만 |
| 팀 학습곡선 | 맵 구조를 알아야 디버깅 가능 | 옵션 하나, 대신 내부가 블랙박스 |
| 데이터 파일 변경 | 미탐지 | 미탐지 |
| 플래그 의존 코드 | 수집 환경에 종속 | 코드 기준이라 무관 |
5. 게이트는 그대로 두고 1차 피드백만 바꾸기
먼저 붙일 것은 선택 스크립트가 아니라 가드입니다. 변경 파일 목록에 맵이 모르는 경로가 하나라도 있으면 선택을 포기하고 전량으로 넘어가는 20줄이면 됩니다.
# tia_select.py — 맵에 없는 경로가 섞이면 exit 2 로 전체 실행을 요구합니다
import json, sys
MAP = json.load(open("map_file.json"))
changed = [l.strip() for l in sys.stdin if l.strip()]
unknown = [f for f in changed if f not in MAP]
if unknown: # 데이터 파일·conftest·새 테스트가 여기 걸립니다
print("FALLBACK 전체 실행 · 맵에 없는 변경 파일: " + ", ".join(unknown))
sys.exit(2)
sel = sorted({c for f in changed for c in MAP[f]})
print("\n".join(f"tests/{c.rpartition('.')[0]}.py::{c.rpartition('.')[2]}" for c in sel))
실제로 걸어 보면 앞의 네 자리가 전부 폴백으로 빠집니다. 데이터 파일도, conftest도, 새 테스트 파일도, 커버되지 않은 소스 파일도 exit 2입니다.
# CI 스텝 — exit 2 면 전량, 0 이면 고른 것만 돌립니다
git diff --name-only "origin/main...HEAD" > changed.txt
if python3 tia_select.py < changed.txt > selected.txt; then
pytest -q $(tr '\n' ' ' < selected.txt) # 고른 것만
else
cat selected.txt # 폴백 사유를 로그에 남깁니다
pytest -q # 전량
fi
멈출 자리
저는 이걸 머지 게이트에 넣을 생각은 없고요, PR 푸시 직후의 1차 피드백 잡에만 붙였습니다. 게이트는 전량 실행 그대로 둡니다. 선택 실행이 틀렸을 때 치르는 값이 초록으로 통과한 머지이고, 그건 앞의 비중을 가정했을 때 나오는 26.9%짜리 절감과 바꿀 물건이 아니거든요.
맵 갱신은 야간 잡 하나로 충분합니다. 맵이 하루 낡으면 그날 추가된 테스트가 선택에서 빠지는데요, 그건 가드가 exit 2로 잡아 주니 낡음이 곧 누락은 아닙니다.
시작 전에 세 가지만 세어 보시면 됩니다. 저장소의 파일별 변경 빈도 상위 10개에 공용 모듈이 몇 개나 있는지, 최근 50개 PR 중 데이터 파일이나 conftest를 건드린 비율이 얼마인지, 그리고 스위트가 I/O 바운드인지 CPU 바운드인지. 세 번째가 CPU 쪽이면 수집 비용이 74.3%까지 올라가니 야간 맵 갱신 자체가 부담이 됩니다.
□ map_file.json 에 없는 경로가 변경 목록에 있으면 exit 2 로 전량 폴백하는가
□ 맵 수집 실행의 환경변수·피처 플래그가 CI 실행과 같은가
□ 선택 실행 잡이 머지 게이트가 아니라 1차 피드백 자리에 있는가
□ 커버리지 0%인 파일 목록을 따로 뽑아 두었는가 (선택 0개와 구별되지 않습니다)
□ 맵 갱신 잡의 실패가 파이프라인 실패로 올라오는가
댓글
댓글 쓰기