- Published on
git bisect run으로 회귀 커밋 10분 추적 자동화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트 어디에서든 성능 저하나 기능 회귀(regression)가 발생하면, 많은 팀이 먼저 하는 일은 “최근 커밋 몇 개를 되돌려보자”입니다. 문제는 이 과정이 대부분 수동 체크아웃 → 빌드 → 실행 → 확인의 반복이라, 30분~반나절이 순식간에 사라진다는 점입니다.
git bisect는 이 반복을 이진 탐색으로 줄여주고, git bisect run은 여기에 자동 테스트 실행을 붙여서 “사람이 판단하는 단계”를 거의 제거합니다. 잘만 만들면 회귀 커밋 추적이 정말로 10분 내외로 떨어집니다(프로젝트 빌드/테스트 시간이 허락하는 범위에서).
이 글에서는 git bisect run을 실전에서 바로 쓸 수 있도록, 스크립트 작성 요령(종료 코드 설계), flaky 테스트 대응, 환경 고정, CI/로컬 활용까지 한 번에 정리합니다.
git bisect run의 핵심: 종료 코드 계약
git bisect run <cmd>는 <cmd>를 각 후보 커밋에서 실행하고, **종료 코드(exit code)**로 해당 커밋이 good/bad인지 판단합니다.
0: good (문제 없음)1~127: bad (문제 있음)125: skip (이 커밋은 판단 불가: 빌드 실패, 테스트 환경 불가, 의존성 깨짐 등)
즉, 우리가 해야 할 일은 “회귀가 재현되면 1, 재현되지 않으면 0, 판단 불가면 125”를 반환하는 커맨드/스크립트를 만드는 것입니다.
10분 내 추적을 만드는 3가지 전제
git bisect run 자체는 마법이 아니고, 속도를 좌우하는 건 결국 테스트 비용입니다. 10분 내 추적을 현실화하려면 아래 중 1~2개는 반드시 챙기는 게 좋습니다.
- 재현 최소 시나리오를 만든다 (통합 테스트 전체 대신, 문제를 찌르는 단일 테스트/스모크 테스트)
- 빌드 캐시/의존성 캐시를 적극 활용한다 (특히 Node/Gradle/Go)
- 환경을 고정한다 (도커/스크립트로 동일한 실행을 보장)
빌드/테스트 캐시가 CI에서 자주 깨진다면, 원인 분석은 별도 주제로도 가치가 있습니다. 캐시가 비정상적으로 미스 나면 bisect 시간이 급격히 늘어나기 때문입니다. 관련해서는 GitHub Actions 캐시 미스 원인 7가지와 해결도 함께 참고하면 좋습니다.
기본 흐름: good/bad 지점 지정 후 자동 실행
가장 기본적인 절차는 다음과 같습니다.
회귀가 발생하는 현재 커밋(보통 HEAD)을 bad로
회귀가 발생하지 않았던 과거 커밋을 good로
git bisect run으로 자동 판정
# 1) bisect 시작
git bisect start
# 2) 현재는 문제 있음(=bad)
git bisect bad
# 3) 과거에 정상 동작하던 커밋 지정(=good)
git bisect good <GOOD_COMMIT_SHA>
# 4) 자동 판정 스크립트 실행
git bisect run ./scripts/bisect-test.sh
끝나면 git bisect가 원인 커밋(최초 bad)을 출력합니다. 작업이 끝났으면 꼭 원복합니다.
git bisect reset
bisect용 스크립트 작성: “재현되면 1”만 확실히
아래는 가장 흔한 형태의 bisect 스크립트 템플릿입니다.
- 빌드 실패나 환경 문제는
125로 skip - 테스트/재현 성공(=문제 재현) 시
1 - 문제 미재현 시
0
#!/usr/bin/env bash
set -euo pipefail
# 예: Node 프로젝트에서 특정 테스트가 실패하면 회귀로 판단
# 요구사항:
# - 정상: exit 0
# - 회귀 재현: exit 1
# - 판단 불가(빌드/설치 실패 등): exit 125
# 1) 의존성 설치(가능하면 캐시/lockfile 기반)
if ! npm ci --silent; then
echo "[bisect] npm ci failed -> skip"
exit 125
fi
# 2) 빌드(선택)
if ! npm run build --silent; then
echo "[bisect] build failed -> skip"
exit 125
fi
# 3) 회귀를 찌르는 최소 테스트만 실행
# 예: 특정 테스트 파일만 실행해 속도 최적화
if npm test --silent -- --runTestsByPath test/regression.spec.ts; then
echo "[bisect] test passed -> good"
exit 0
else
echo "[bisect] test failed -> bad"
exit 1
fi
여기서 중요한 건 테스트가 실패하면 무조건 회귀라고 단정할 수 있느냐입니다. 만약 테스트가 flaky(간헐 실패)라면, bisect 결과가 뒤틀릴 수 있습니다.
flaky 테스트/비결정성 대응: 재시도 + skip 전략
간헐 실패가 있는 테스트를 그대로 bisect에 넣으면, 어떤 커밋은 good인데도 bad로 찍혀서 “엉뚱한 커밋”을 범인으로 지목할 수 있습니다.
실전에서는 다음 중 하나를 권장합니다.
- N회 재시도 후 모두 실패하면 bad, 모두 성공하면 good
- 결과가 섞이면(성공/실패 혼재)
125로 skip
#!/usr/bin/env bash
set -euo pipefail
TRIES=3
pass=0
fail=0
# 준비 단계(설치/빌드)가 실패하면 skip
npm ci --silent || exit 125
npm run build --silent || exit 125
for i in $(seq 1 $TRIES); do
if npm test --silent -- --runTestsByPath test/regression.spec.ts; then
pass=$((pass+1))
else
fail=$((fail+1))
fi
echo "[bisect] try=$i pass=$pass fail=$fail"
done
if [[ $pass -eq $TRIES ]]; then
exit 0
elif [[ $fail -eq $TRIES ]]; then
exit 1
else
echo "[bisect] flaky result -> skip"
exit 125
fi
이 방식은 bisect가 몇 커밋을 건너뛰게 만들 수 있지만, 잘못된 범인 지목을 막는 데 매우 효과적입니다.
“10분”을 만드는 최적화 포인트
git bisect는 대략 log2(N)번 판정합니다. 예를 들어 1,024개 커밋 범위면 약 10번, 8,192개면 약 13번 정도입니다.
즉 총 소요 시간은 대략:
- (한 번 판정 비용) × (판정 횟수)
판정 비용을 줄이려면 아래 순서로 접근하는 게 효율적입니다.
1) 범위를 줄여라: good 커밋을 최대한 최근으로
good 지점이 너무 과거면 탐색 범위가 커집니다. “정상 동작을 확인한 마지막 릴리스 태그”나 “문제 없던 PR 머지 직후” 등 최대한 최근의 good을 지정하세요.
git bisect start
git bisect bad HEAD
git bisect good v1.12.3 # 마지막 정상 릴리스 태그
2) 테스트를 줄여라: 단일 스모크/리그레션 테스트
통합 테스트 전체를 돌리는 대신, 회귀를 가장 빠르게 재현하는 최소 테스트를 만들면 게임이 끝납니다.
이 접근은 “복잡한 상태 불일치” 버그에서 특히 중요합니다. 이벤트 소싱/스냅샷 계열처럼 재현 시나리오가 길어지는 문제는 별도 미니 재현 케이스를 만드는 게 핵심입니다. 비슷한 디버깅 관점은 이벤트 소싱 스냅샷 불일치 버그 추적법에서도 도움됩니다.
3) 환경을 고정해라: 도커로 동일 실행 보장
로컬 환경(특히 Node/Java/DB 의존)에서 커밋마다 실행 결과가 달라지는 경우가 많습니다. bisect는 “커밋만 바뀌고 나머지는 동일”해야 정확해집니다.
도커를 쓰면 간단히 고정할 수 있습니다.
#!/usr/bin/env bash
set -euo pipefail
# 도커 이미지가 준비되어 있다고 가정
# - 소스는 현재 git checkout된 워킹 디렉토리를 마운트
# - 컨테이너 내부에서 빌드/테스트 수행
docker run --rm \
-v "$PWD:/app" \
-w /app \
node:20-bullseye \
bash -lc "npm ci --silent && npm test --silent -- --runTestsByPath test/regression.spec.ts"
# docker run의 종료 코드가 그대로 bisect 판정에 사용됨
도커 기반은 속도가 느릴 수 있으니, “정확성이 최우선인 회귀”나 “팀원 환경 차이가 큰 저장소”에 우선 적용하는 것을 추천합니다.
언어/스택별 실전 예시
Go: 단일 테스트로 회귀 판정
Go는 테스트 실행이 빠르고 의존성 관리가 비교적 안정적이라 bisect 궁합이 좋습니다.
#!/usr/bin/env bash
set -euo pipefail
# 모듈 다운로드가 실패하면 skip
GOMODCACHE="${GOMODCACHE:-}"
go test ./... -run TestRegressionCase -count=1
# go test는 성공=0, 실패=1을 잘 지켜줌
Spring Boot/Gradle: 특정 테스트 클래스만 실행
Gradle 전체 테스트가 오래 걸린다면, 회귀를 재현하는 테스트 클래스만 집어 실행합니다.
#!/usr/bin/env bash
set -euo pipefail
# 빌드 도중 컴파일 자체가 깨지는 커밋은 skip
./gradlew test --tests "com.example.RegressionTest" --no-daemon || exit_code=$?
# gradle 실패가 항상 회귀를 의미하지 않을 수 있어 분기
# 예: 테스트 실패(회귀) vs 컴파일 실패(판단불가)
if [[ ${exit_code:-0} -eq 0 ]]; then
exit 0
fi
# 매우 단순화한 예시: 컴파일 에러 패턴이면 skip 처리
if grep -R "Compilation failed" -n build/reports/tests/test/index.html >/dev/null 2>&1; then
exit 125
fi
exit 1
Gradle 리포트 파싱은 프로젝트마다 달라질 수 있으니, 가능하면 콘솔 로그를 기반으로 더 정확히 분기하거나, 애초에 “컴파일 실패가 나지 않는 범위”로 bisect 범위를 줄이는 게 낫습니다.
자주 하는 실수와 방지 체크리스트
1) 워킹 트리가 더럽다
bisect는 커밋을 계속 바꾸므로, 로컬 변경사항이 있으면 충돌/오염이 발생합니다.
git status --porcelain
# 비어있지 않으면 stash/commit 후 진행
2) 테스트가 외부 상태(DB/캐시/서드파티)에 의존한다
외부 상태가 바뀌면 같은 커밋에서도 결과가 달라집니다. 최소한 아래는 고정하세요.
- 테스트 DB를 매 실행 초기화(또는 in-memory)
- 네트워크 호출은 mock/stub
- 시간/타임존 고정(필요 시)
3) bisect가 끝났는데 “원인 커밋”이 아닌 것 같다
대부분 아래 케이스입니다.
- 테스트가 flaky였다
- “good/bad” 정의가 틀렸다(재현 조건이 애매)
- 범위 내에 **판정 불가 커밋(skip)**이 너무 많다
이 경우, 스크립트의 125 정책을 재검토하고(너무 많이 skip하면 탐색이 왜곡), 재현 테스트를 더 결정적으로 만드는 게 우선입니다.
CI에서 돌릴 수 있을까? (권장 시나리오)
git bisect run은 로컬에서 가장 흔히 쓰지만, 다음 조건이면 CI에서도 유용합니다.
- 재현이 로컬에서 어렵고 CI 환경에서만 재현됨
- 특정 커밋 범위가 커서 로컬에서 오래 걸림
다만 CI는 체크아웃/캐시/권한/시크릿 등 변수가 많아 “판정 불가(125)”가 늘어날 수 있습니다. 특히 캐시가 안정적이지 않으면 bisect가 느려지거나 실패합니다. 앞서 언급한 캐시 점검 글(GitHub Actions 캐시 미스 원인 7가지와 해결)을 같이 보면 시행착오를 줄일 수 있습니다.
마무리: bisect run은 ‘테스트 설계 능력’의 증폭기
git bisect run은 단순히 자동화 옵션이 아니라, 회귀를 빠르게 재현하는 최소 테스트를 가진 팀에게 엄청난 속도를 제공합니다. 반대로 재현이 느리고 비결정적이면, bisect가 오히려 혼란을 키울 수도 있습니다.
정리하면 다음 4가지만 기억해도 실전에서 바로 성과가 납니다.
- good/bad 기준을 명확히 하고 범위를 최대한 줄이기
- bisect 스크립트는 종료 코드(0/1/125) 계약을 정확히 지키기
- flaky면 재시도/skip로 안정화하기
- 빌드/테스트 비용을 줄여 1회 판정 시간을 최소화하기
다음에 “어제까진 되던 게 오늘 깨졌다”를 만나면, 수동 체크아웃 대신 git bisect run부터 켜두는 습관이 회귀 추적 시간을 체감적으로 바꿔줄 것입니다.