git bisect run으로 '어느 커밋에서 깨졌는지' 찾기

커밋 64개에서 git bisect run으로 회귀를 찾은 실측. 확인 41번이 8번으로, 빌드 안 되는 커밋은 종료 코드 125로 건너뛰고, 1 ms→613 ms 성능 저하도 같은 방법으로 이분 탐색한다.

시리즈: 테스트·디버깅 강좌 6/6 (고급). 지난달에는 됐는데 지금은 안 되고, 그 사이에 커밋이 수십 개 쌓인 개발자를 위한 글입니다. 이 강좌를 마치면 실패하는 확인을 스크립트로 만들어 git bisect run에게 첫 나쁜 커밋을 찾게 하고, 빌드되지 않는 커밋을 처리하고, 성능 저하도 같은 방법으로 쫓을 수 있습니다.

장애 대응 회고에서 자주 나오는 질문이 있습니다. "그래서 언제부터 이랬나요?" 고객 문의는 이렇습니다. 3.33달러 상품에 10% 할인을 적용하면 3.00이 아니라 2.99로 표시된다. 지난 릴리스에서는 정상이었다. 그 뒤로 커밋 63개가 들어갔고 대부분은 무해합니다. diff를 전부 읽으면 오후가 다 가고, 커밋을 하나씩 체크아웃해 확인하는 것도 만만치 않습니다. git bisect는 히스토리를 이분 탐색하므로 확인이 log2(N)번 정도면 되고, git bisect run은 종료 코드를 돌려주는 스크립트만 있으면 확인을 대신 해 줍니다. 신입 온보딩에서 git을 "기록하는 도구"로 배웠다면, 여기서 "기록을 검색하는 도구"이기도 하다는 걸 체험해 보세요. 커밋이 정확히 64개인 일회용 저장소를 만들고, 문제 세 가지를 숨기고, 각 탐색에 든 횟수와 시간을 쟀습니다.

환경: Git 2.54.0 (Apple Git-157), Python 3.9.6, /bin/sh, Apple Silicon Mac (M1 Pro). 모든 커밋은 데모용 이름과 고정한 UTC 시각으로 만들기 때문에, 같은 Git 버전으로 아래 생성 스크립트를 돌리면 글과 같은 커밋 해시가 나올 것입니다. 경로는 ~/demo/…로 줄였습니다. 이전 강좌: flaky 테스트.

연습용 저장소: 문제 세 개를 숨긴 커밋 64개

생성 스크립트는 작은 cart.py(함수 2개)를 만들고 64번 커밋합니다. 대부분의 커밋은 version.py와 변경 기록만 고치고, 특별한 것은 네 개입니다.

  • 31 "Show totals with money() helper": from fmt import money를 넣었지만 fmt.py는 아직 없다. 31~34는 import조차 안 된다.
  • 35 "Add fmt.money": 빠진 파일을 추가.
  • 41 "Simplify discount rounding": 진짜 버그. round(x, 2)가 버림으로 바뀐다.
  • 52 "Make unique_skus easier to read": 성능 회귀. set이 리스트 검색으로 바뀐다.
#!/bin/sh
# make_repo.sh <dir>: a throwaway repo with 64 commits (macOS sed syntax)
set -eu
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export GIT_AUTHOR_NAME=demo GIT_AUTHOR_EMAIL=demo@example.com
export GIT_COMMITTER_NAME=demo GIT_COMMITTER_EMAIL=demo@example.com
rm -rf "$1"; mkdir -p "$1"; cd "$1"; git init -q -b main
cat > cart.py <<'PY'
from version import VERSION

def total(prices, discount_pct=0):
    """Sum prices (in dollars) and apply a percentage discount, rounded to cents."""
    subtotal = sum(prices)
    return round(subtotal * (100 - discount_pct) / 100, 2)

def unique_skus(skus):
    """Keep the first occurrence of each SKU, in order."""
    seen, out = set(), []
    for s in skus:
        if s not in seen:
            seen.add(s)
            out.append(s)
    return out
PY
i=1
while [ $i -le 64 ]; do
  echo "VERSION = $i" > version.py
  echo "- change $i" >> CHANGELOG.md
  case $i in
    31) sed -i '' 's/^from version import VERSION$/&\nfrom fmt import money  # added in 31/' cart.py ;;
    35) printf 'def money(x):\n    return f"${x:.2f}"\n' > fmt.py ;;
    41) sed -i '' 's/return round(subtotal \* (100 - discount_pct) \/ 100, 2)/return int(subtotal * (100 - discount_pct)) \/ 100/' cart.py ;;
    52) sed -i '' -e 's/seen, out = set(), \[\]/out = []/' -e 's/if s not in seen:/if s not in out:/' -e '/seen.add(s)/d' cart.py ;;
  esac
  case $i in
    1) msg="Add cart with total() and unique_skus()" ;;  31) msg="Show totals with money() helper" ;;
    35) msg="Add fmt.money" ;;  41) msg="Simplify discount rounding" ;;
    52) msg="Make unique_skus easier to read" ;;  *) msg="Update changelog ($i)" ;;
  esac
  ts=$(( 1793836800 + i * 3600 ))   # 2026-11-05 00:00 UTC + i hours
  git add -A
  GIT_AUTHOR_DATE="@$ts +0000" GIT_COMMITTER_DATE="@$ts +0000" git commit -q -m "$msg"
  i=$((i + 1))
done

GIT_CONFIG_GLOBAL=/dev/null은 내 Git 설정(훅, 서명, 별칭)을 실험에서 빼고, 고정한 시각은 해시를 재현 가능하게 합니다. git bisect reset으로 돌아오는 법을 익히기 전에는 소중한 저장소에서 bisect를 연습하지 마세요.

테스트 스크립트: 인터페이스는 종료 코드뿐

git bisect run은 커밋을 체크아웃하고 명령을 실행한 뒤 종료 코드만 읽습니다. 이번 확인 스크립트입니다.

#!/bin/sh
# check_total.sh: lives OUTSIDE the repo, so checking out old commits cannot change it.
# exit 0 = good, 1 = bad, 125 = cannot test this commit (skip)
python3 -c "import cart" 2>/dev/null || exit 125
python3 - <<'PY'
import sys, cart
got = cart.total([3.33], discount_pct=10)
sys.exit(0 if got == 3.0 else 1)
PY
종료 코드 git bisect run에서의 의미 흔한 원인
0 good 확인 통과
1~127(125 제외) bad assertion 실패, 테스트 러너가 실패 보고
125 건너뛰기: 이 커밋은 테스트할 수 없음 빌드 실패, 의존성 누락
126, 127 bad. 단, Git 2.54는 먼저 good 커밋에서 재확인 권한 없음, 명령을 찾을 수 없음(스크립트 오타)
128 이상 bisect 중단 스크립트 자체가 죽었거나 강제 종료됨

표에서 규칙 두 개가 나옵니다. 스크립트는 저장소 밖(또는 추적되지 않는 파일)에 둘 것. 그러지 않으면 옛 커밋을 체크아웃하는 순간 탐색 도중에 스크립트가 바뀌거나 사라집니다. 그리고 "테스트할 수 없다"가 무엇인지 의식적으로 정해서, 빌드 문제에는 1이 아니라 125를 돌려줄 것.

흐름도. 좋은 커밋과 나쁜 커밋을 하나씩 지정해 시작한다. Git이 남은 범위의 가운데를 체크아웃하고 스크립트를 실행한다. 종료 코드 0은 good, 125를 뺀 1~127은 bad, 125는 건너뛰기, 128 이상은 중단. good, bad, skip 모두 범위를 좁히고 다음 체크아웃으로 돌아가 첫 나쁜 커밋 하나가 남을 때까지 반복한다. 126과 127이면 Git 2.54는 알려진 good 커밋에서 다시 확인하고, 거기서도 실패하면 bogus exit code 오류로 멈춘다.
git bisect run이 스크립트의 종료 코드를 어떻게 다루는가. Git 2.54.0에서 확인.

탐색: 8번, 0.49초

최신 커밋을 bad, 첫 커밋을 good으로 지정하고 스크립트를 넘겼습니다.

$ git bisect start main 0129d59
$ git bisect run ../check_total.sh
Bisecting: 31 revisions left to test after this (roughly 5 steps)
[b8afa63c5781266c73dee632f2e57ff2645db3ec] Update changelog (32)
running '../check_total.sh'
Bisecting: 30 revisions left to test after this (roughly 5 steps)
[22d5c50674ffd3cfd696345963e7b32e8d9e3442] Update changelog (33)
running '../check_total.sh'
Bisecting: 30 revisions left to test after this (roughly 5 steps)
[2faf604004b11123e9c19505a110323dd390ec8f] Update changelog (40)
running '../check_total.sh'
Bisecting: 11 revisions left to test after this (roughly 4 steps)
[6ca4e3b711ecad8482f445fdd802702555ed2990] Make unique_skus easier to read
…
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[497854fdc48159eedda284b046a4363da0bb985b] Simplify discount rounding
running '../check_total.sh'
497854fdc48159eedda284b046a4363da0bb985b is the first bad commit
commit 497854fdc48159eedda284b046a4363da0bb985b
Author: demo <demo@example.com>
Date:   Fri Nov 6 17:00:00 2026 +0000

    Simplify discount rounding
…
bisect found first bad commit

이야기로 읽어 봅시다. Git이 처음 고른 커밋 32는 정확히 가운데지만 빌드가 안 되는 구간이라 스크립트가 125로 끝났습니다. 남은 개수는 31에서 30으로 줄었을 뿐입니다. 건너뛴 커밋은 후보에서 빠질 뿐 범위를 반으로 줄이지 않습니다. 다음 33도 빌드가 안 됐고, Git은 고장 난 구간을 피해 40(good), 52(bad), 46, 43, 42, 41(모두 bad)로 갔습니다. 스크립트는 8번 돌았고 그중 2번이 건너뛰기였습니다. 마지막으로:

git bisect log > ~/demo/bisect.log   # 판단 기록. git bisect replay로 재생 가능
git bisect reset                     # 시작 전의 main으로 돌아간다

범인 커밋의 diff는 두 줄로, round(…, 2)가 int(…) / 100으로 바뀌어 있었습니다. 3.33 × 90 = 299.7을 버려서 299, 즉 2.99달러입니다.

모든 커밋을 차례로 확인하는 방법과 비교. 오래된 것부터 하나씩 체크아웃하다 종료 코드 1이 나오면 멈추는 루프는 체크아웃 41번, 2.98~3.07초(3회 측정). git bisect run은 8번, 0.47~0.51초였습니다. 초보다 비율이 중요합니다. 이번 확인은 한 번에 약 60 ms지만, 빌드와 테스트에 5분이 걸린다면 41번은 3시간이 넘고 8번은 40분입니다.

단계 수와 log2. "log2"라는 주장을 확인하려고 VERSION >= K면 bad로 보는 다른 스크립트로 범인 위치 K = 2~64를 모두, 건너뛰기 없이 bisect했습니다. 62가지가 6번, 1가지가 5번(평균 5.98), 63가지 모두 정답. log2(64) = 6입니다. 히스토리가 128개로 두 배가 돼도 늘어나는 건 1번이지 64번이 아닙니다.

막대그래프. 오래된 것부터 모든 커밋 검사: 41회. 테스트할 수 없는 커밋 2개를 건너뛴 git bisect run: 8회. 건너뛰기 없는 git bisect run, 가능한 범인 위치 63가지 평균: 5.98회(62가지가 6회, 1가지가 5회). log2(64)는 6.
커밋 64개에서 범인을 찾기까지 테스트 스크립트가 실행된 횟수(Git 2.54.0, Apple Silicon Mac (M1 Pro)). 소요 시간: 선형 탐색 2.98~3.07초, bisect 0.47~0.51초(각 3회).

테스트할 수 없는 커밋 건너뛰기, 그리고 안 했을 때

흔한 실수 → 실제로 일어나는 일 → 해결. || exit 125 줄을 지우면 import 오류는 그냥 Python 실패, 즉 종료 코드 1이 됩니다. 같은 저장소, 같은 버그에서:

ModuleNotFoundError: No module named 'fmt'
…
dda1d4a1afa7cc00da8fadaa54ddf6d2fe25b05c is the first bad commit
    Show totals with money() helper

Git은 자신 있게 커밋 31을 범인으로 지목합니다. 빌드를 깨긴 했지만 반올림은 건드리지 않은 커밋입니다. 해결은 125 줄입니다. "이 커밋은 판정할 수 없다"와 "이 커밋은 나쁘다"는 다릅니다.

건너뛰기에도 한계가 있습니다. 범인이 건너뛴 커밋 바로 옆에 있으면 Git은 더 좁히지 못합니다. 버그가 빌드 안 되는 구간 안의 커밋 33에서 들어왔다고 가정해 봤습니다.

There are only 'skip'ped commits left to test.
The first bad commit could be any of:
22d5c50674ffd3cfd696345963e7b32e8d9e3442
b8afa63c5781266c73dee632f2e57ff2645db3ec
611340bd60d10b72a26966fce29c32a0923c4425
dda1d4a1afa7cc00da8fadaa54ddf6d2fe25b05c
14e91fd0044a40f3cf34876f640bdad31c3e041e
We cannot bisect more!
error: bisect run cannot continue any more

그래도 63개가 5개로 줄었으니 쓸모가 있습니다. 더 좁히려면 고장 난 커밋을 테스트 가능하게 만들고(예: 스크립트가 빠진 fmt.py를 임시로 만든다) 다시 돌립니다.

실제로 빠진 스크립트 함정 두 개. 첫째, 오타 pyhton3은 모든 커밋에서 종료 코드 127을 냅니다. Git 2.54는 첫 커밋을 범인으로 몰지 않고, good 커밋에서 스크립트를 다시 돌린 뒤 error: bogus exit code 127 for good revision으로 멈췄습니다. 예전 Git은 127을 그냥 bad로 셌으니 자기 버전의 git help bisect를 확인하세요. 종료 코드 129는 error: bisect run failed: exit code 129 from '../check_129.sh' is < 0 or >= 128로 멈췄습니다. 둘째, 저장소 밖에 둔 Python 스크립트(python3 ../bench_skus.py)가 모든 커밋에서 ModuleNotFoundError: No module named 'cart'로 실패했습니다. Python이 sys.path에 넣는 건 현재 디렉터리가 아니라 스크립트가 있는 디렉터리이기 때문이고, PYTHONPATH=. python3 ../bench_skus.py로 고쳤습니다. 앞에 || exit 125가 있었다면 모든 커밋이 건너뛰어졌을 것입니다. bisect run 전에 good 커밋과 bad 커밋 하나씩에서 스크립트를 손으로 돌려 보세요.

성능 회귀를 bisect하기

정확성 확인은 종료 코드로 바꾸기 쉽습니다. 속도에는 기준값이 필요하고, 기준값에는 먼저 측정이 필요합니다.

# bench_skus.py: time cart.unique_skus on fixed data, print the best of 5 in ms
import random, time, cart
rng = random.Random(42)
skus = [f"SKU-{rng.randrange(10_000):05d}" for _ in range(20_000)]
best = min(
    (lambda t0: (cart.unique_skus(skus), time.perf_counter() - t0)[1])(time.perf_counter())
    for _ in range(5)
)
print(f"{best * 1000:.2f}")

커밋 51에서 10번 재면 0.98~1.01 ms(중앙값 0.99), 커밋 52에서는 612.65~613.58 ms. 리스트 검색 때문에 20,000개에 대해 O(n²)가 됐습니다. 두 무리 사이라면 어디든 되고, 양쪽에서 먼 50 ms를 골랐습니다. 출력이 뜻에 맞는 말이 되도록 Git의 사용자 정의 용어를 씁니다.

#!/bin/sh
# check_speed.sh: fast (exit 0) under LIMIT_MS, slow (exit 1) over it
ms=$(PYTHONPATH=. python3 ../bench_skus.py) || exit 125
echo "unique_skus: $ms ms (limit ${LIMIT_MS:-50})"
python3 -c "import sys; sys.exit(0 if $ms < ${LIMIT_MS:-50} else 1)"
$ git bisect start --term-old=fast --term-new=slow
$ git bisect slow main
$ git bisect fast 0129d59
$ git bisect run ../check_speed.sh
…
unique_skus: 0.99 ms (limit 50)
…
unique_skus: 613.02 ms (limit 50)
…
6ca4e3b711ecad8482f445fdd802702555ed2990 is the first slow commit

8번, 4.06초였고 대부분은 느린 커밋에서 잰 시간입니다. 실제 회귀가 600배인 경우는 드뭅니다. 10~20% 저하라면 반복 횟수를 늘려 최솟값을 쓰고, 기계를 조용하게 하고, 기준값 양쪽을 손으로 확인한 다음에 결과를 믿으세요. 범인 커밋을 찾은 뒤 코드 어디서 시간이 드는지는 Python 프로파일링과, C라면 캐시 친화적 루프와 lldb를 참고하세요.

flaky한 확인은 bisect가 거짓말하게 만든다

bisect는 확인이 결정적이라고 가정합니다. 진짜 확인에 10% 확률의 가짜 실패를 섞고(실행과 커밋마다 시드를 정해 결과를 재현 가능하게 함) bisect를 50번 돌렸습니다. 46번은 커밋 41을 찾았고, 4번은 커밋 15, 38, 40, 40을 범인으로 지목했습니다. good 커밋에서 가짜 bad가 한 번만 나와도 탐색은 틀린 절반으로 가고, Git은 그걸 다시 확인하지 않습니다. 3번 연속 실패할 때만 bad로 보는 래퍼는 50번 중 49번 맞혔습니다(1번은 여전히 40을 지목). 재시도는 피해를 줄일 뿐 없애지 못합니다. 먼저 강좌 5의 방법으로 flaky 테스트를 고치세요.

알아 두면 좋은 옵션: git bisect start --first-parent(Git 2.29 이상)는 메인 라인의 머지 커밋만 따라가므로, 기능 브랜치 중간에 깨진 커밋이 있을 때 유용합니다. git bisect skip <범위>는 고장 난 것을 아는 범위를 손으로 건너뜁니다. bisect가 거대한 커밋에 도착했다면 커밋을 작게 만들라는 신호이고, rebase와 cherry-pick이 도움이 됩니다. 범인을 찾아 공유 브랜치에서 안전하게 되돌리려면 git revert와 reflog를 보세요.

회고 습관과 시리즈 체크리스트

커밋을 찾은 것은 이야기의 중간이지 끝이 아닙니다. 회귀가 났다면 누구도 탓하지 않는 형식으로 다음 항목의 짧은 회고(포스트모템)를 씁니다.

항목 이 강좌의 예
증상과 첫 보고 3.33달러에 10% 할인이 2.99로 표시
범인과 찾은 방법 커밋 497854f, git bisect run, 8번, bisect.log 첨부
테스트가 못 잡은 이유 소수 셋째 자리가 5 이상인 값의 테스트가 없었다
추가한 회귀 테스트 total([3.33], 10) == 3.0을 스위트에 영구 추가
다음번을 싸게 만드는 것 확인 스크립트를 tools/에 보관, 커밋을 작고 빌드 가능하게

마지막 줄은 자주 빠집니다. bisect용 스크립트를 저장소에 남겨 두면(옛 커밋에 없는 경로에 두거나 쓰기 전에 밖으로 복사) 다음 탐색은 1분 안에 시작합니다. 튼튼한 셸 스크립트와 로깅처럼 확인을 매일 밤 cron으로 로그와 함께 돌리면 회귀가 들어온 그날 잡을 수 있습니다.

테스트·디버깅 강좌 체크리스트. 도움을 청하기 전, 또는 버그를 닫기 전에:

  1. 에러를 끝까지 읽고 내 코드의 첫 프레임을 찾는다(강좌 1).
  2. 가능한 한 작은 프로그램이나 입력으로 재현하고, 추측 대신 로깅이나 디버거를 쓴다(강좌 2).
  3. 버그를 보여 주는 실패하는 테스트를 쓰고, 통과할 때까지 코드를 고친다(강좌 3).
  4. 느리거나 외부에 있는 의존성은 가짜로 바꾸되, 진짜를 돌리는 테스트를 최소 하나 남긴다(강좌 4).
  5. 테스트가 flaky하면 실패율을 재고, 숨은 입력을 찾고, 다시 잰다(강좌 5).
  6. 예전에 됐다면 확인을 종료 코드로 만들고 git bisect run에게 커밋을 찾게 한다. 그리고 회고를 쓴다.

솔직한 한계와 버전 메모

  • Git 2.54.0 (Apple Git-157)과 Python 3.9.6에서만 실행했습니다. git bisect run은 아주 오래전부터 있었고, --term-old/--term-new는 Git 2.7, --first-parent는 Git 2.29에 들어왔습니다. 126/127 재확인은 2.54에서 관찰한 동작이고 옛 릴리스에서는 시험하지 않았습니다.
  • 생성 스크립트의 sed -i ''는 macOS/BSD 문법입니다. GNU sed에서는 빈 문자열 없이 sed -i를 씁니다(여기서는 미실행).
  • 건너뛰기 뒤 Git이 어떤 커밋을 고르는지는 내부 선택 방식에 달렸습니다. 히스토리가 다르면 순서는 달라질 수 있지만 답은 같아야 합니다.
  • 시간은 기계 한 대에서 잰 값입니다. 1회 비용은 대부분 프로세스 시작이므로, 기억할 것은 41번과 8번의 비율입니다.
  • 시리즈 전체의 위치는 프로그래밍 학습 로드맵에서 볼 수 있습니다.

연습 문제

  1. 확인 스크립트가 make test를 실행합니다. 옛 커밋 일부에서는 make가 "No rule to make target"으로 실패합니다. 이때 스크립트는 어떤 종료 코드를 돌려줘야 하고, 테스트 실패와는 어떻게 구분하나요?
  2. 마지막 정상 릴리스부터 오늘까지 커밋이 1,000개입니다. git bisect run은 대략 몇 번 확인해야 하고, 무엇이 그 수를 훨씬 크게 만드나요?

정답

  1. 빌드만 먼저 실행해 실패하면 exit 125. 그다음 테스트를 돌려 그 종료 코드(또는 어떤 실패든 exit 1)를 돌려줍니다. "빌드 불가"와 "테스트 실패"를 나누는 것이 위에서 본 범인 오지목을 막습니다.
  2. 약 log2(1000) ≈ 10번. 건너뛰는 커밋이 많거나(범위를 좁히지 않고 횟수만 늘림), 확인이 flaky하면(탐색을 틀린 절반으로 보냄) 횟수가 늘거나 답이 틀립니다.

이전: 테스트·디버깅 강좌 5: flaky 테스트 · 시리즈 마지막 강좌입니다. 강좌 1: 에러 메시지와 스택 트레이스 읽기부터 다시 보세요.

자주 묻는 질문

git bisect run은 스크립트의 어떤 종료 코드를 기대하나요?

0은 good, 125를 뺀 1~127은 bad, 125는 이 커밋을 테스트할 수 없으니 건너뛰기, 128 이상은 bisect 중단입니다. Git 2.54.0에서는 종료 코드 126이나 127(명령을 찾을 수 없음)이면 알려진 good 커밋에서 스크립트를 다시 돌리고, 거기서도 실패하면 엉뚱한 커밋을 지목하는 대신 'error: bogus exit code 127 for good revision'으로 멈췄습니다.

git bisect는 몇 단계가 필요한가요?

범위 안 커밋 수의 log2 정도입니다. 64개면 6. 커밋 64개짜리 저장소에서 가능한 범인 위치 63가지를 모두 bisect했더니 62가지가 스크립트 6회, 1가지가 5회였고 답은 모두 맞았습니다. 종료 코드 125로 건너뛴 커밋은 횟수를 늘립니다. 빌드 안 되는 커밋 2개가 섞인 실제 탐색은 8회였습니다.

git bisect로 성능 저하도 찾을 수 있나요?

네. 코드 시간을 재서 기준값 미만이면 0, 넘으면 1로 끝나는 스크립트를 쓰고 git bisect start --term-old=fast --term-new=slow로 시작합니다. 기준값은 실측한 두 수준 사이에 둡니다. 이번에는 저하 전 0.98~1.01 ms, 저하 후 612.65~613.58 ms라 50 ms가 안전했습니다. 8회, 4.06초 만에 'is the first slow commit'이 나왔습니다.

  • #테스트
  • #고급
  • #git
  • #git bisect
  • #디버깅
  • #회귀
  • #성능

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑