왜 성능 회귀는 늦게 발견되는가
기능 버그는 테스트가 빨간불로 잡아준다. 하지만 응답 지연이 20% 늘거나 메모리 할당이 두 배가 되는 회귀는 대부분 통과한다. 단위 테스트는 "맞는가"만 검증하지 "얼마나 빠른가"는 보지 않기 때문이다. 그래서 성능 저하는 배포 후 대시보드에서, 혹은 사용자 불만으로 뒤늦게 드러난다. 원인 커밋을 찾으려면 수십 개 변경을 되짚어야 하고, 그 사이 다른 회귀가 얹혀 문제가 뒤섞인다.
해법은 명확하다. 성능도 정량 지표로 측정해 PR 단계에서 게이트로 세운다. 다만 벤치마크는 본질적으로 노이즈가 크기 때문에, "느려졌다"를 신뢰 가능한 신호로 만드는 설계가 핵심이다.
측정 대상을 먼저 좁힌다
모든 것을 재려 하면 실패한다. 사용자가 체감하는 핵심 경로 5~10개만 고른다. API p95 지연, 직렬화 처리량, 쿼리 실행 시간처럼 비즈니스에 직결되는 지표가 우선이다. 각 벤치마크는 외부 의존성(네트워크, 실 DB) 없이 결정적으로 동작해야 하며, 워밍업 후 여러 번 반복해 분포를 확보한다.
import pytest
# pytest-benchmark: 워밍업 + 다중 반복으로 분포를 수집한다
def serialize_order(order): ...
def test_serialize_throughput(benchmark):
order = make_fixture_order(items=50)
result = benchmark.pedantic(
serialize_order, args=(order,),
rounds=200, warmup_rounds=20, iterations=5,
)
assert result # 정확성도 함께 확인
노이즈를 이기는 임계값 설계
단일 실행값을 비교하면 CI 러너의 CPU 변동만으로도 오탐이 쏟아진다. 절대 시간(ms) 대신 baseline 대비 상대 변화율을 보고, 중앙값이나 p95 같은 안정된 통계량을 쓴다. 임계값은 측정 환경의 실제 변동폭(예: 반복 실행 시 ±5%)보다 넉넉히 크게 잡아야 한다.
| 전략 | 오탐 | 미탐 | 권장 |
|---|---|---|---|
| 절대 ms 비교 | 많음 | 적음 | 비권장 |
| 고정 % 임계(예 10%) | 보통 | 보통 | 기본값 |
| 표준편차 기반(3σ) | 적음 | 보통 | 지표 안정 시 |
CI 게이트로 연결하기
PR에서는 baseline(대상 브랜치)과 현재 커밋을 같은 러너에서 연달아 측정해 상대 비교한다. 러너 성능이 회차마다 달라도, 같은 잡 안에서 둘을 재면 그 편차가 상쇄된다. 임계 초과 시 exit code로 잡을 실패시킨다.
name: perf-gate
on: [pull_request]
jobs:
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
# baseline 측정
- run: git checkout ${{ github.base_ref }}
- run: pytest bench/ --benchmark-only --benchmark-json=base.json
# 현재 커밋 측정 후 비교
- run: git checkout ${{ github.sha }}
- run: pytest bench/ --benchmark-only --benchmark-json=head.json
- run: python bench/compare.py base.json head.json --threshold 0.10
비교 스크립트는 명확한 신호를 낸다
비교 로직은 지표별로 회귀 여부를 판정하고, 사람이 읽을 수 있는 요약을 남긴다. 개선(빨라짐)은 통과시키되 로그로 드러내 의도치 않은 변화도 인지하게 한다.
import json, sys
def median(stats): return stats["median"]
def main(base_f, head_f, threshold):
base = {b["name"]: b["stats"] for b in json.load(open(base_f))["benchmarks"]}
head = {b["name"]: b["stats"] for b in json.load(open(head_f))["benchmarks"]}
failed = False
for name, h in head.items():
b = base.get(name)
if not b: continue
delta = (median(h) - median(b)) / median(b)
flag = "REGRESS" if delta > threshold else "ok"
if delta > threshold: failed = True
print(f"{flag:8} {name:30} {delta:+.1%}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2], float(sys.argv[3].split("=")[-1]
if "=" in sys.argv[3] else sys.argv[4]))
주의점과 운영 팁
공유형 CI 러너는 이웃 잡의 부하로 변동이 크다. 가능하면 전용 러너나 고정 인스턴스를 쓰고, 그래도 흔들리면 3회 재실행 후 중앙값을 채택한다. 임계값은 처음엔 느슨하게(예 15%) 시작해 오탐이 잡히면 조인다. 게이트를 세운 첫 주는 경고만 내고 병합을 막지 않는 것이 좋다. 팀이 신호를 신뢰하게 된 뒤 blocking으로 전환해야 "또 벤치마크 깨졌네, 그냥 재실행"이라는 무력화를 피할 수 있다.
마지막으로, 벤치마크 자체도 코드다. 측정 대상이 실제 핫패스인지 주기적으로 점검하고, 리팩터링으로 함수가 사라지면 벤치마크도 함께 정리한다. 방치된 벤치마크는 잘못된 안정감만 준다.