main에서 CI가 빨간불이지만 어제는 초록불이었습니다. 그사이 어딘가에 버그가 기어들어왔습니다. 200개의 커밋을 스크롤하며 디프를 읽고 추측할 수 있습니다. Slack에 물어보고 누군가 관련 코드를 건드렸던 것을 기억하길 바랄 수 있습니다. 아니면 Git에게 일을 맡길 수도 있습니다.

git bisect는 커밋 히스토리를 위한 이진 검색 도구입니다. 한 커밋을 bad로, 다른 커밋을 good으로 표시합니다. Git이 중간 지점을 checkout합니다. 테스트하고 good 또는 bad로 표시하면 Git이 반복합니다. 8단계로 256개 중 하나를 고립시킬 수 있습니다. 12단계로 4,096개 중 바늘을 찾을 수 있습니다. 이것은 Git이 가진 것 중 히스토리 자체를 위한 디버거에 가장 가까운 것입니다.

수동 이진 분할이 시간 낭비인 이유

개발자들은 이미 의식하지 못한 채 수동으로 bisect를 하고 있습니다. 더 오래된 커밋을 체크아웃하고 테스트를 실행한 다음 “아직 깨졌네, 더 뒤로 가야겠다”고 생각합니다. 그런 다음 더 오래된 커밋을 체크아웃하면 테스트가 통과합니다. 이제 버그가 그 두 지점 사이 어딘가에 있다는 것을 압니다. 그래서 중간 지점을 고르고 계속 좁혀갑니다.

이것이 이진 검색입니다. git bisect는 기록 관리를 자동화하여 이미 테스트한 커밋을 놓치지 않게 합니다. 더 중요한 것은 “화요일의 큰 refactor에 있었을 것 같아”라는 게으른 휴리스틱을 방지한다는 것입니다. 이 휴리스틱은 2시간 동안 잘못된 토끼굴로 보냅니다.

진정한 가치는 속도가 아닙니다. 비록 더 빠르지만요. 진정한 가치는 정확성입니다. 답답한 마음에 저녁 6시에 버그를 찾을 때 실수를 하게 됩니다. checkout 후 재빌드를 잊을 것입니다. 잘못된 커밋을 두 번 테스트할 것입니다. 테스트 결과를 잘못 읽고 bad 커밋을 good으로 표시해 검색 공간을 파괴할 것입니다. git bisect는 짜증날 때 갖지 못하는 규율을 강제합니다.

이진 검색이 실제로 작동하는 방식

Git은 시간순으로 검색하지 않습니다. topology를 따라 검색하며, 커밋 그래프를 탐색해 known-good과 known-bad 커밋 사이의 중간 지점을 찾습니다. 히스토리에 머지가 있을 때 이것이 중요한데, 시간적 중간 지점이 양쪽 endpoint에서 도달할 수 없을 수도 있기 때문입니다.

내부에서 일어나는 일은 다음과 같습니다. bisect를 시작하고 범위를 제공합니다:

git bisect start
git bisect bad HEAD          # current commit is broken
git bisect good v2.1.0       # this release was fine

Git은 두 지점 사이의 커밋 수를 계산합니다. 정확히 중간 지점을 체크아웃하고 테스트를 기다립니다. 재현을 실행하고 버그가 있는지 확인한 다음 Git에게 알려줍니다:

git bisect bad   # this commit has the bug
git bisect good  # this commit is clean

Git은 검색 공간의 절반을 버리고 반복합니다. 하나의 커밋만 남으면 멈추고 첫 번째 bad 커밋을 보여줍니다. 출력에는 커밋 해시, 작성자, 날짜, 메시지가 포함됩니다. 모호함이 없습니다. 이 커밋이 버그를 도입했습니다. 끝입니다.

실제 명령어로 하는 구체적인 연습

오늘 아침 integration tests가 실패하기 시작했다고 가정합시다. 마지막 태그된 릴리스 v1.4.0에서는 통과했던 것을 압니다. 전체 세션은 다음과 같습니다:

# Start the session
git bisect start

# Mark the current HEAD as bad
git bisect bad HEAD

# Mark the last known good release
git bisect good v1.4.0

# Git checks out a midpoint commit automatically
# You run your reproduction script or test suite:
npm test -- --grep "checkout flow"

# Tests fail. Mark it bad.
git bisect bad

# Git checks out another midpoint. Run tests again.
npm test -- --grep "checkout flow"

# Tests pass. Mark it good.
git bisect good

# Repeat until Git tells you:
# "<commit-hash> is the first bad commit"

마지막에 Git은 bad 커밋에 머물게 합니다. git show로 검사하고 수정을 만들어낸 다음 정리할 수 있습니다:

git bisect reset

이는 시작하기 전에 있던 브랜치로 돌아갑니다. reset을 잊으면 detached HEAD에 머물게 되어 다음 커밋이 브랜치에 없는 이유를 궁금해하게 됩니다. 저도 해봤습니다. 부끄러운 일입니다.

스크립트로 전체 프로세스 자동화하기

수동 버전은 여전히 테스트를 실행하고 good이나 bad를 반복해서 입력해야 합니다. 재현이 성공 시 0을, 실패 시 0이 아닌 값을 반환하는 단일 명령어라면 전체를 Git에 맡길 수 있습니다:

git bisect start
git bisect bad HEAD
git bisect good v1.4.0

# Hand over control to an automated script
git bisect run npm test -- --grep "checkout flow"

Git은 커밋을 checkout하고 명령어를 실행하며 결과를 자동으로 분류합니다. 완료되면 키보드를 만지지 않고도 같은 “first bad commit” 출력을 얻습니다. 여기서 git bisect는 유용한 도구에서 없어서는 안 될 도구가 됩니다.

스크립트는 테스트 스위트일 필요가 없습니다. 의미 있는 종료 코드를 반환하는 실행 가능한 것이라면 무엇이든 됩니다. 특정 로그 메시지를 확인하는 최소한의 셸 스크립트는 다음과 같습니다:

#!/bin/bash
# reproduce-bug.sh
# Exit 0 if the bug is NOT present (good)
# Exit 1 if the bug IS present (bad)

if curl -s http://localhost:3000/api/health | grep -q "database_timeout"; then
    exit 1  # bug is present
fi
exit 0  # bug is not present

실행:

chmod +x reproduce-bug.sh
git bisect run ./reproduce-bug.sh

종료 코드 계약은 엄격합니다. 종료 0은 “good”을, 종료 1에서 124는 “bad”를, 종료 125는 “이 커밋은 건너뛰어라, 테스트할 수 없다”를 의미합니다. 종료 125는 커밋이 컴파일되지 않거나 관련 없는 설정 변경 때문에 서버가 시작할 수 없을 때 유용합니다. Git은 그 커밋을 건너뛰고 주변을 검색합니다.

bisect이 망가지는 곳: 주의사항

git bisect는 버그가 단조롭다고 가정합니다. 커밋이 한 번 버그를 도입하면 모든 하위 커밋도 bad입니다. 버그가 깜박이며 커밋 사이에서 나타났다 사라진다면 이진 검색은 무너집니다. 말도 안 되는 결과가 나오거나 Git이 단일 커밋을 고립시킬 수 없다고 불평할 것입니다.

비결정적 버그가 가장 나쁜 범인입니다. 10%의 확률로 실패하는 race condition은 가끔 bad 커밋을 good으로 표시해 검색을 손상시킵니다. 재현이 flaky하다면 먼저 flakiness를 수정하세요. 아니면 스크립트에서 테스트를 여러 번 실행하고 매번 통과할 때만 good으로 표시하세요. 이는 더 느리지만 더 신뢰할 수 있습니다.

빌드 아티팩트는 또 다른 함정입니다. 빌드 도구 버전을 변경하는 커밋에서 그렇지 않은 커밋으로 전환하면 오래된 node_modules나 컴파일된 바이너리가 checkout된 코드와 맞지 않을 수 있습니다. 프로젝트에 빌드 단계가 있다면 재현 스크립트 내부에서 항상 정리하고 재빌드하세요.

#!/bin/bash
# safer-reproduce.sh
rm -rf node_modules dist
npm ci
npm run build
npm test -- --grep "checkout flow"

이는 반복당 수 초를 더하지만, 버그가 실제로 오래된 아티팩트에 있는 전체 범주의 오탐을 제거합니다.

bisect이 하지 않는 것

git bisect는 커밋을 찾습니다. 왜 그 커밋이 bad인지는 알려주지 않습니다. 40개의 파일을 건드리는 5,000행의 refactor가 첫 번째 bad 커밋일 수 있고, 여전히 어떤 행이 범인인지 diff를 읽어야 합니다. bisect는 범위를 “지난달 어딘가”에서 “이 diff 어딘가”로 좁힙니다. 나머지는 여전히 당신의 일입니다.

또한 codebase에 존재하지만 테스트에서 잡히지 않았던 버그를 도와주지도 않습니다. 버그가 도입되었을 때 테스트 스위트가 이미 초록불이었다면 bisect는 구해주지 않습니다. 먼저 재현이 필요합니다. bisect는 검색 도구지 탐지 도구가 아닙니다.

블레임이나 로그 대신 bisect를 사용해야 할 때

git blame은 어떤 파일에 버그가 있는지 이미 알 때 훌륭합니다. git log --grep은 커밋 메시지에 대해 뭔가 기억날 때 훌륭합니다. bisect는 둘 다 모를 때를 위한 것입니다. A 지점에서는 작동했고 B 지점에서는 실패했으며 그 사이가 너무 커서 추론할 수 없다는 것만 압니다.

팀이 모든 커밋에 대해 CI를 실행한다면 CI 히스토리를 읽어 더 빠르게 bisect할 수 있는 경우도 있습니다. 하지만 CI는 테스트하는 것만 잡습니다. 성능 저하, 시각적 버그, 테스트가 놓친 미묘한 논리 오류는 CI 로그에 나타나지 않습니다. bisect는 CI가 검증하는 것과 관계없이 스크립트로 만들 수 있는 모든 재현에 작동합니다.

bisect를 워크플로우의 일부로 만드는 방법

특별한 설정이 필요하지 않습니다. 명령어는 Git에 내장되어 있습니다. 하지만 실제로 필요할 때 bisect를 더 원활하게 만드는 두 가지 습관이 있습니다.

첫째, 릴리스에 태그를 달으세요. bisect는 known-good 커밋이 필요하고, 태그가 이를 제공하는 가장 쉬운 방법입니다. 마지막 릴리스가 3주 전이었고 그것이 깨끗했던 것을 안다면 git bisect good v1.4.0이 한 줄의 고정점입니다.

둘째, 재현을 최소한으로 유지하세요. 30초 걸리는 재현은 8번 반복까지 참을 수 있습니다. 10분 걸리는 재현은 고문입니다. bisect하기 전에 5분을 써서 재현을 가능한 가장 작은 명령으로 줄이세요. 미래의 당신이 감사할 것입니다.

끝나면 즉시 git bisect reset을 실행하세요. uncommitted 변경이 있는 detached HEAD는 작업을 잃기에 딱 좋은 방법입니다. 저는 어렵게 배웠습니다. 여러분은 그럴 필요가 없습니다.