본문 바로가기
삵
Development

git bisect로 회귀 버그 커밋 찾기

동동교동삼거리·2026년 9월 30일·조회 2

어제까지 멀쩡하던 기능이 어느 순간 깨져 있다. 언제 깨졌는지는 아무도 모른다. 이런 회귀 버그를 만나면 저는 한동안 커밋 로그를 위아래로 훑으며 눈으로 범인을 찾곤 했다. 커밋이 수백 개 쌓인 브랜치에서는 이 방식이 금방 한계에 부딪힌다. git bisect는 바로 이럴 때 쓰라고 있는 도구다.

정상이던 커밋(good)과 깨진 커밋(bad)만 알려주면 git bisect가 그 사이 구간을 이진 탐색으로 절반씩 좁혀 처음 깨진 커밋(first bad commit)을 찾아준다. 커밋이 1000개라도 로그 밑이 2의 10승이라 열 번 안팎이면 끝난다. 매번 손으로 판정할 필요도 없다. git bisect run에 테스트 스크립트를 물리면 종료 코드를 보고 good/bad를 자동으로 매긴다. 아래에서 수동 방식부터 자동 실행, skip 처리, 마무리까지 차례로 살펴본다.

1. bisect가 하는 일

bisect는 이진 탐색(binary search)에서 온 이름이다. good과 bad 사이의 커밋 구간을 반으로 자르고, 가운데 커밋을 체크아웃해 "여기는 정상인가 깨졌나"를 판정한다. 정상이면 문제는 그 뒤 절반에 있고, 깨졌으면 앞 절반에 있다. 이렇게 매번 후보를 절반으로 줄여 한 커밋만 남을 때까지 반복한다.

전제가 하나 있다. 어느 커밋을 기점으로 정상에서 깨짐으로 한 번만 바뀌어야 한다. 중간에 깨졌다 고쳐졌다를 반복하면 이진 탐색이 엉뚱한 커밋을 지목할 수 있다. 대부분의 회귀 버그는 이 전제를 만족한다.

2. good과 bad 지정

먼저 세션을 시작한다. 지금 HEAD에서 버그가 재현되고, 예전 태그 v1.4.0에서는 멀쩡했다고 하자.

$ git bisect start
$ git bisect bad            # 현재 HEAD는 깨졌다
$ git bisect good v1.4.0    # 이 태그는 정상이었다
Bisecting: 271 revisions left to test after this (roughly 8 steps)
[a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0] Refactor order total calculation

세 줄을 한 줄로 줄일 수도 있다. git bisect start <bad> <good> 순서다. bad가 먼저다.

$ git bisect start HEAD v1.4.0

시작하면 bisect가 알아서 구간 한가운데 커밋을 체크아웃한다. 위 출력의 roughly 8 steps가 앞으로 판정할 횟수다. 271개 후보가 여덟 번이면 끝난다는 뜻이다.

3. 수동 판정으로 감 잡기

자동화에 들어가기 전에 손으로 한두 번 돌려보면 동작이 눈에 들어온다. 체크아웃된 상태에서 빌드하고 테스트해본 뒤, 결과에 따라 good 또는 bad를 입력한다.

$ ./gradlew test --tests OrderTotalTest    # 직접 재현
$ git bisect good      # 이 커밋에선 통과했다
Bisecting: 135 revisions left to test after this (roughly 7 steps)
[f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7] Add coupon stacking rule

한 번 판정할 때마다 후보가 절반으로 준다. 이 과정을 끝까지 손으로 반복해도 되지만, 판정 기준을 명령 하나로 표현할 수 있다면 git bisect run에 맡기는 편이 빠르다.

4. git bisect run 자동 실행

git bisect run은 각 커밋을 체크아웃할 때마다 지정한 명령을 실행하고, 그 종료 코드(exit code)로 good/bad를 판정한다. 종료 코드 규약이 이 도구의 핵심이다.

  • 0: good. 현재 커밋은 정상이다.
  • 1 ~ 127 (단 125 제외): bad. 현재 커밋은 깨졌다.
  • 125: skip. 이 커밋은 판정 불가라 건너뛴다.
  • 128 이상: bisect를 중단(abort)한다.

테스트 러너 상당수가 실패 시 1로 끝나므로 별도 가공 없이 바로 물릴 수 있다. 앞의 Gradle 테스트를 그대로 넘겨보자.

$ git bisect run ./gradlew test --tests OrderTotalTest
running './gradlew test --tests OrderTotalTest'
...
Bisecting: 67 revisions left to test after this (roughly 6 steps)
...
running './gradlew test --tests OrderTotalTest'
f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7 is the first bad commit
commit f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7
Author: Jane Kim <jane@example.com>
Date:   Mon Sep 14 11:02:33 2026 +0900

    Add coupon stacking rule

 bisect found first bad commit

마지막에 <sha> is the first bad commit과 커밋 정보가 찍힌다. 여기가 범인이다. 커밋 메시지, 작성자, 변경 파일이 바로 나오니 원인 파악의 출발점으로 삼으면 된다.

테스트 케이스를 새로 짜서 물리기

기존 테스트가 회귀를 잡아주지 못하면, 버그를 재현하는 짧은 스크립트를 따로 만들어 넘기는 방법을 저는 자주 쓴다. 스크립트가 종료 코드 규약만 지키면 무엇이든 된다.

#!/usr/bin/env bash
# check.sh — 종료 코드 0이면 good, 1이면 bad
set -e
curl -sf http://localhost:8080/api/order/total?id=42 \
  | grep -q '"total":18000'
$ chmod +x check.sh
$ git bisect run ./check.sh

5. 빌드가 깨지는 커밋은 skip

이진 탐색이 지목한 중간 커밋이 하필 빌드조차 안 되는 경우가 있다. 이 커밋을 bad로 세면 판정이 오염된다. 여기서 한 번 걸렸다. 빌드 실패를 곧 버그로 오해해 엉뚱한 커밋을 first bad로 뽑은 것이다.

해결은 빌드 실패를 종료 코드 125로 바꿔 bisect에게 "이 커밋은 건너뛰라"고 알리는 것이다. ||로 연결하면 앞 명령이 실패했을 때만 exit 125가 실행된다.

$ git bisect run sh -c './gradlew compileJava || exit 125; ./gradlew test --tests OrderTotalTest'

빌드가 실패하면 125로 skip, 성공하면 이어서 테스트를 돌려 0/1로 판정한다. 수동으로 세션 도중 특정 커밋만 건너뛰려면 git bisect skip을 쓴다. 범위 표기도 된다.

$ git bisect skip                    # 지금 커밋 건너뛰기
$ git bisect skip v2.5..v2.6         # 이 구간 통째로 건너뛰기

6. 병합 커밋을 건너뛰는 --first-parent

기능 브랜치를 자주 병합하는 저장소라면, 병합된 브랜치 내부의 중간 커밋들이 탐색 대상에 끼어 판정을 어렵게 한다. 브랜치 내부는 빌드가 반쯤 깨져 있기도 하다. 이럴 때 --first-parent를 주면 병합 커밋의 첫 번째 부모만 따라가며 메인 라인만 탐색한다. 회귀를 끌어들인 병합 커밋 자체를 범인으로 지목하게 된다.

$ git bisect start --first-parent HEAD v1.4.0

7. 세션 마무리, bisect reset

탐색이 끝나면 HEAD가 탐색 도중 커밋에 머물러 있다. 원래 작업하던 위치로 돌아오려면 반드시 git bisect reset으로 세션을 닫아야 한다.

$ git bisect reset
Previous HEAD position was f8a9b0c Add coupon stacking rule
Switched to branch 'main'

인자 없이 실행하면 bisect start 직전에 있던 커밋으로 되돌아간다. 특정 커밋으로 나가고 싶으면 인자를 준다. bisect가 남긴 refs/bisect/bad 참조로 first bad commit에 바로 체크아웃할 수도 있다.

$ git bisect reset bisect/bad     # first bad commit으로 이동해 원인 분석 시작
$ git bisect reset HEAD           # 지금 커밋에 그대로 머무름

8. 잘못 판정했을 때 되돌리기

수동으로 돌리다 good을 bad로 잘못 눌렀다면 세션 전체를 버릴 필요는 없다. git bisect log로 판정 이력을 파일에 저장하고, 잘못된 줄을 지운 뒤 git bisect replay로 다시 재생하면 된다.

$ git bisect log > bisect.log
$ vi bisect.log            # 잘못 찍은 good/bad 줄 삭제
$ git bisect reset
$ git bisect replay bisect.log

정리하면, 회귀 버그를 만났을 때 정상 커밋과 깨진 커밋만 짚어주고 판정 기준을 명령 한 줄로 표현할 수 있다면 git bisect run이 나머지를 대신 해준다. 로그를 눈으로 훑는 대신 종료 코드 규약만 지키면 되니, 재현 스크립트 하나 만들어 두는 습관을 들이면 두고두고 쓸 수 있다.

자주 묻는 질문

git bisect run에 넘긴 스크립트의 종료 코드는 어떻게 해석되나?

종료 코드 0이면 good(정상), 1부터 127까지 중 125를 뺀 값이면 bad(깨짐)로 판정한다. 125는 skip으로 이 커밋은 판정 불가라 건너뛰고, 128 이상이면 bisect 세션을 중단한다. 대부분의 테스트 러너가 실패 시 1을 반환하므로 별도 가공 없이 바로 물릴 수 있다.

중간 커밋이 빌드조차 안 될 때는 어떻게 하나?

빌드 실패를 bad로 세면 판정이 오염된다. 종료 코드 125로 바꿔 skip 처리한다. 예를 들어 git bisect run sh -c './gradlew compileJava || exit 125; ./gradlew test' 처럼 빌드가 실패하면 125로 건너뛰고 성공했을 때만 테스트를 돌리게 한다. 수동 세션에서는 git bisect skip을 쓴다.

bisect가 찾은 first bad commit은 어디에 남나?

탐색이 끝나면 '<sha> is the first bad commit'이 출력되고, refs/bisect/bad 참조가 그 커밋을 가리킨 채 남는다. git bisect reset bisect/bad를 실행하면 해당 커밋으로 바로 체크아웃해 원인 분석을 시작할 수 있다.

탐색이 끝난 뒤 bisect reset을 꼭 해야 하나?

그렇다. 세션이 끝나도 HEAD는 탐색 도중 커밋에 머물러 있다. git bisect reset을 인자 없이 실행하면 bisect start 직전 위치로 돌아온다. 이 정리를 하지 않으면 detached HEAD 상태로 남아 작업이 꼬일 수 있다.

병합 커밋이 많은 저장소에서 판정이 지저분하면?

git bisect start에 --first-parent를 주면 병합 커밋의 첫 번째 부모만 따라가며 메인 라인만 탐색한다. 병합된 기능 브랜치 내부의 반쯤 깨진 중간 커밋을 건드리지 않고, 회귀를 끌어들인 병합 커밋 자체를 지목한다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.