Git 고급 실무 가이드: Interactive Rebase, bisect 디버깅, worktree 병렬 작업, reflog 복구까지
핵심 요약
이 글에서 확인할 내용
"이 커밋 왜 여기 있어요?" — 로그가 지저분해지는 순간 팀은 신뢰를 잃는다 필자가 일 평균 주문 8만 건을 처리하는 커머스 프로젝트에서 결제 모듈을 담당하던 시절, 어느 배포 이후 특정 할인 쿠폰 조합에서만 최종 결제 금액이 미세하게 틀어지는 회귀 버그가 접수된 적이 있습니다. 문제는 그 배포가 640개에 가까운 커밋을 한꺼번에 머지한 릴리스였다는 점이었습니다.
- 1. Interactive Rebase — fixup·autosquash로 커밋 히스토리 재설계하기
- 2. git bisect — 회귀 원인 커밋을 이진 탐색으로 사냥하기
- 3. git worktree — 브랜치를 폴더 단위로 병렬 작업하기
"이 커밋 왜 여기 있어요?" — 로그가 지저분해지는 순간 팀은 신뢰를 잃는다
필자가 일 평균 주문 8만 건을 처리하는 커머스 프로젝트에서 결제 모듈을 담당하던 시절, 어느 배포 이후 특정 할인 쿠폰 조합에서만 최종 결제 금액이 미세하게 틀어지는 회귀 버그가 접수된 적이 있습니다. 문제는 그 배포가 640개에 가까운 커밋을 한꺼번에 머지한 릴리스였다는 점이었습니다. 어느 커밋이 원인인지 하나씩 되짚어보는 방식으로는 하루가 꼬박 걸릴 상황이었지만, git bisect run으로 자동화된 이진 탐색을 돌리자 열 번의 빌드·테스트 사이클 만에 원인 커밋을 특정할 수 있었습니다. 같은 문제를 반나절이 아니라 90분 만에 해결한 이 경험은 우리 팀이 Git을 "버전 관리 도구"가 아니라 "디버깅 도구"로 다시 보게 만든 계기였습니다.
이 글은 add, commit, push, 그리고 기본적인 merge·rebase는 이미 익숙한 실무자를 대상으로, 그 다음 단계에서 실제로 자주 마주치는 다섯 가지 상황 — 지저분한 커밋 이력 정리, 회귀 원인 커밋 추적, 여러 브랜치 동시 작업, 실수로 날려버린 커밋 복구, 반복되는 병합 충돌, 그리고 대형 모노레포의 클론·체크아웃 성능 — 을 실제 명령어와 출력 예시로 다룹니다. 브랜치 전략이나 거대 PR을 쪼개는 방법은 이미 대규모 리팩터링 PR을 안전하게 쪼개는 법에서 깊이 다뤘으므로 이 글에서는 반복하지 않고, CI 파이프라인과의 연동은 GitHub Actions CI/CD 가이드로 연결합니다. 모든 예시는 2026년 7월 현재 최신 안정 버전인 Git 2.55 기준으로 검증했습니다.
1. Interactive Rebase — fixup·autosquash로 커밋 히스토리 재설계하기
기능 하나를 완성하는 동안 커밋 로그는 보통 지저분해집니다. "WIP", "오타 수정", "테스트 통과 확인" 같은 커밋이 뒤섞이고, 정작 리뷰어가 봐야 할 논리적 변경은 그 사이에 묻힙니다. Git 공식 문서의 git-rebase는 -i(interactive) 플래그로 커밋 목록을 텍스트 에디터에 열어 순서를 바꾸거나, 합치거나(squash), 메시지만 흡수시키거나(fixup), 내용을 다시 편집(edit)할 수 있게 해줍니다.
실무에서 가장 자주 쓰는 조합은 git commit --fixup과 --autosquash입니다. 기능 커밋을 먼저 만들고 나서 리뷰 중 사소한 수정이 필요해졌다고 가정해봅니다.
git log --oneline -6
a1f92e3 test(cart): 쿠폰 중복 적용 케이스 테스트 추가
7c3d8b1 fix(cart): 재고 없는 상품 필터링 누락 수정
9f8e7d6 feat(cart): 쿠폰 중복 적용 방지 로직 추가
5b2a1c4 chore: eslint 경고 제거
3d9f0a2 feat(cart): 장바구니 합산 로직 리팩터링
0a1b2c3 chore: 초기 커밋
9f8e7d6에서 도입한 로직에 리뷰어가 지적한 사소한 오류가 발견되었다면, 새 수정 커밋을 만드는 대신 원본 커밋에 흡수시킬 커밋임을 명시합니다.
git add src/features/cart/applyCoupon.ts
git commit --fixup=9f8e7d6
# 자동 생성 메시지: "fixup! feat(cart): 쿠폰 중복 적용 방지 로직 추가"
git rebase -i --autosquash HEAD~6
--autosquash는 fixup!, squash! 접두어가 붙은 커밋을 자동으로 대상 커밋 바로 아래로 재배치하고 액션도 fixup/squash로 미리 채워둡니다. 에디터를 열었을 때 순서를 손으로 옮길 필요가 없다는 뜻입니다. 매번 --autosquash를 타이핑하기 번거롭다면 git config --global rebase.autoSquash true로 기본 동작을 바꿔둘 수 있고, 예외적으로 순서를 그대로 유지하고 싶을 때는 --no-autosquash로 끄면 됩니다.
주의할 점은 명확합니다. 이미 원격 저장소에 push되어 다른 팀원이 pull 받았을 브랜치는 rebase 대상에서 제외합니다. 부득이하게 공유 브랜치를 rebase해야 한다면 git push --force가 아니라 git push --force-with-lease를 씁니다. --force-with-lease는 로컬이 마지막으로 알고 있는 원격 커밋과 실제 원격 상태가 다르면 push를 거부하므로, 다른 팀원이 그 사이에 커밋을 추가했을 때 실수로 덮어쓰는 사고를 막아줍니다.
2. git bisect — 회귀 원인 커밋을 이진 탐색으로 사냥하기
회귀 버그를 수동으로 추적하는 것은 선형 탐색입니다. 커밋 640개 중 원인을 하나씩 체크아웃하며 확인하면 최악의 경우 640번을 반복해야 합니다. git-bisect 공식 문서가 제공하는 방식은 이진 탐색이므로, 커밋 수가 n개일 때 필요한 단계는 최대 ⌈log₂(n)⌉번입니다. 640개 커밋이라면 이론상 10번 안팎이면 충분합니다. 앞서 언급한 결제 모듈 사고에서 실제로 10번의 빌드로 원인을 찾은 것도 이 수학적 배경 덕분입니다.
수동 이진 탐색도 유용하지만, 매 단계 사람이 직접 "이 커밋은 정상인가"를 판단해야 한다면 여전히 느립니다. git bisect run은 이 판단 자체를 스크립트에 위임합니다.
git bisect start
git bisect bad HEAD # 현재 커밋은 버그가 있음
git bisect good v2026.04.1 # 이 태그 시점에는 정상이었음
git bisect run ./scripts/check-coupon-total.sh
check-coupon-total.sh는 종료 코드로만 상태를 알려주면 됩니다. 이 종료 코드 규약이 bisect run의 핵심입니다.
#!/bin/sh
# scripts/check-coupon-total.sh
set -e
pnpm install --frozen-lockfile >/dev/null 2>&1 || exit 125 # 빌드 자체가 불가능하면 이 커밋은 스킵
pnpm build >/dev/null 2>&1 || exit 125
node scripts/fixtures/run-coupon-case.mjs
# run-coupon-case.mjs 내부에서 기대값과 실제값을 비교해 process.exit(0) 또는 process.exit(1) 호출
종료 코드 0은 "good", 1부터 127 사이(125 제외)는 "bad"로 해석됩니다. 125는 특별한 의미를 가지는데, 해당 커밋에서는 애초에 테스트가 불가능하다는 뜻이므로 bisect가 그 커밋을 건너뛰고 탐색을 계속합니다. 예를 들어 의존성 파일 자체가 깨져서 빌드가 실패하는 커밋이 탐색 구간에 섞여 있을 때 이 처리가 없으면 탐색이 잘못된 결론에 도달할 수 있습니다. 128 이상의 코드는 bisect run 자체를 중단시키므로 스크립트에서 의도치 않게 큰 종료 코드를 반환하지 않도록 주의해야 합니다.
탐색이 끝나면 Git이 원인 커밋의 해시와 메시지를 출력하고, git bisect log로 전체 탐색 과정을 재현 가능한 스크립트 형태로 남길 수 있습니다. 작업이 끝나면 반드시 git bisect reset으로 원래 브랜치로 복귀합니다. 이 스크립트는 그대로 CI 잡으로 옮겨서 "야간 회귀 감시" 파이프라인에 넣을 수도 있습니다. CI 연동 세부 설정은 GitHub Actions CI/CD 가이드를 참고합니다.
3. git worktree — 브랜치를 폴더 단위로 병렬 작업하기
기능 브랜치 작업 도중 긴급 핫픽스가 들어오는 상황은 예외가 아니라 일상입니다. git stash로 작업을 임시 보관하고 git checkout으로 전환하는 방식은 매번 node_modules 재설치나 빌드 캐시 무효화 같은 부수 비용을 발생시킵니다. git-worktree 공식 문서는 하나의 .git 저장소를 공유하면서 여러 개의 독립된 작업 디렉토리를 동시에 체크아웃할 수 있게 해줍니다.
우리 팀이 자주 쓰는 시나리오는 Node.js 버전 호환성 검증입니다. 라이브러리 업그레이드 PR을 리뷰하면서 동시에 현재 운영 브랜치에서 발생한 장애도 대응해야 하는 상황을 예로 들어봅니다.
# 현재 위치: ~/work/waylog-api (feature/upgrade-node-22 브랜치)
git worktree add ../waylog-api-hotfix main
cd ../waylog-api-hotfix
git checkout -b hotfix/session-expire-crash
# 이 디렉토리는 완전히 독립적인 node_modules, .env, 빌드 산출물을 가짐
pnpm install
pnpm test -- --testPathPattern=session
# 수정 완료 후
git add -A
git commit -m "fix(auth): 세션 만료 시 refresh token 재시도 로직 누락 수정"
git push origin hotfix/session-expire-crash
git worktree list
# /Users/dev/work/waylog-api 3f9a2c1 [feature/upgrade-node-22]
# /Users/dev/work/waylog-api-hotfix 7e1d4b8 [hotfix/session-expire-crash]
# 작업이 끝나면 정리
git worktree remove ../waylog-api-hotfix
git worktree에서 자주 놓치는 제약이 두 가지 있습니다. 하나는 같은 브랜치를 두 worktree에서 동시에 체크아웃할 수 없다는 점입니다. 시도하면 fatal: 'hotfix/session-expire-crash' is already checked out at ... 에러가 발생합니다. 다른 하나는 디렉토리를 rm -rf로 직접 지워버렸을 때는 Git이 그 사실을 자동으로 인지하지 못한다는 점입니다. git worktree remove 대신 수동으로 삭제했다면 git worktree prune을 실행해 메타데이터를 정리해줘야 git worktree list에 유령 항목이 남지 않습니다.

4. reflog와 fsck — 날려버린 커밋과 브랜치 복구하기
git reset --hard를 잘못된 커밋에 대고 실행했거나, rebase 도중 실수로 커밋을 지워버렸거나, 강제 push로 원격 브랜치를 덮어쓴 경험은 Git을 오래 쓴 사람이라면 한 번쯤 겪습니다. 다행히 Git은 커밋 오브젝트를 즉시 삭제하지 않습니다. git-reflog 공식 문서에 따르면 HEAD와 각 브랜치가 가리켰던 이력은 로컬 저장소에 별도로 기록되며, 기본 설정 기준으로 다른 참조에서 여전히 도달 가능한 항목은 90일(gc.reflogExpire), 어디에서도 도달 불가능한 항목은 30일(gc.reflogExpireUnreachable) 동안 보관됩니다.
git reflog show HEAD
7e1d4b8 HEAD@{0}: reset: moving to HEAD~3
a3f2c1d HEAD@{1}: commit: feat(order): 배송 상태 업데이트 웹훅 추가
9c8b7a6 HEAD@{2}: commit: feat(order): 배송 상태 enum 정의
# a3f2c1d 시점으로 브랜치를 되살림
git branch recovered-webhook a3f2c1d
git log --oneline recovered-webhook -3
reflog는 어디까지나 로컬 저장소의 기록입니다. 강제 push로 원격 브랜치 자체를 덮어썼다면 원격에는 그 흔적이 남지 않으므로, 로컬에 최신 상태를 갖고 있던 사람의 reflog가 유일한 복구 경로가 될 수 있습니다. GitHub을 쓰고 있다면 최근에 삭제된 브랜치는 저장소 이벤트 로그에서 일정 기간 복구가 가능하다는 점도 알아두면 좋습니다. GitHub 문서 — 삭제된 브랜치 복구에 이 기능이 설명되어 있습니다.
reflog 보관 기간이 이미 지났거나, 아예 어떤 참조에서도 가리킨 적 없는 커밋(예: git commit --amend로 덮어써진 이전 버전)을 찾아야 할 때는 fsck가 마지막 수단입니다.
git fsck --full --no-reflogs --unreachable --dangling
dangling commit 9f1a3e0
dangling blob c72e8d1
git show 9f1a3e0 --stat
git branch recovered-from-fsck 9f1a3e0
--dangling은 어떤 커밋·트리·블롭에서도 참조되지 않는 오브젝트를 나열하고, --unreachable은 그중 현재 브랜치들에서 도달할 수 없는 것들을 걸러냅니다. 다만 git gc가 이미 실행되었다면 도달 불가능한 오브젝트가 실제로 삭제되었을 수 있으므로, 사고를 인지한 즉시 git gc를 실행하지 않는 것이 복구 성공률을 높이는 첫 번째 원칙입니다.
5. rerere — 반복되는 충돌을 Git이 기억하게 만들기
긴 수명의 브랜치를 여러 번 rebase하거나, release 브랜치를 main에 반복적으로 머지백하는 워크플로우에서는 같은 파일, 같은 라인에서 매번 똑같은 충돌이 발생하는 경우가 흔합니다. 대표적으로 pnpm-lock.yaml 같은 락파일이나, 국제화 리소스 파일이 그렇습니다. git-rerere 공식 문서("reuse recorded resolution")는 한 번 해결한 충돌 패턴을 기억해뒀다가, 동일한 충돌이 다시 나타나면 자동으로 같은 방식으로 해결해주는 기능입니다.
git config --global rerere.enabled true
git config --global rerere.autoUpdate true # 자동 해결된 결과를 바로 스테이징까지 처리
활성화 이후 충돌이 발생하면 rerere가 조용히 .git/rr-cache에 충돌 패턴과 해결 결과를 기록합니다.
git rebase main
# CONFLICT (content): Merge conflict in packages/i18n/ko.json
git rerere status
# packages/i18n/ko.json
# 이번에는 직접 해결
vim packages/i18n/ko.json
git add packages/i18n/ko.json
git rerere diff # rerere가 기록한 해결 패턴 확인
git rebase --continue
다음 번 같은 두 브랜치 조합에서 packages/i18n/ko.json이 동일한 방식으로 충돌하면, rerere가 이전 해결 방식을 자동 적용하고 rerere.autoUpdate가 켜져 있다면 스테이징까지 마쳐줍니다.
rerere를 쓸 때 반드시 알아야 할 한계가 있습니다. rerere는 "같은 충돌 패턴"을 기억할 뿐, 그 해결이 지금도 여전히 올바른지는 검증하지 않습니다. 코드의 의미가 바뀌었는데 우연히 diff 패턴만 이전과 비슷하다면, rerere가 이제는 틀린 해결책을 조용히 재적용할 수 있습니다. 그래서 우리 팀은 rerere가 자동 해결한 파일은 CI 테스트를 반드시 통과시키는 것을 병합 조건으로 두고, git rerere forget <path>로 특정 파일의 캐시를 주기적으로 비워 오래된 해결 패턴이 무한정 재사용되지 않도록 관리합니다.
6. sparse-checkout과 partial clone — 대형 모노레포 성능 최적화
모노레포가 커지면 git clone 한 번에 수십 분이 걸리고, 로컬 디스크에는 당장 작업하지도 않을 패키지의 파일까지 전부 내려받게 됩니다. Git은 이 문제를 두 가지 독립적인 축으로 해결합니다. 하나는 partial clone(어떤 블롭을 언제 받을지)이고, 다른 하나는 sparse-checkout(작업 디렉토리에 어떤 경로를 실제로 배치할지)입니다.
# blob 콘텐츠는 필요할 때까지 받지 않고, 커밋·트리 메타데이터만 우선 클론
git clone --filter=blob:none --sparse https://github.com/waylog/waylog-monorepo.git
cd waylog-monorepo
# cone 모드로 필요한 디렉토리만 작업 트리에 배치
git sparse-checkout set --cone --sparse-index apps/web packages/ui packages/config
git sparse-checkout list
# apps/web
# packages/ui
# packages/config
# 다른 패키지 작업이 추가로 필요해지면
git sparse-checkout add apps/admin
두 옵션은 서로 다른 문제를 풀기 때문에 반드시 함께 쓸 필요는 없지만, 조합하면 효과가 배가됩니다. --filter=blob:none만 쓰면 커밋 히스토리 전체와 트리 구조는 받아오되 블롭은 실제 체크아웃 시점에 지연 요청되므로 초기 클론은 빨라지지만, 결국 체크아웃하는 순간 필요한 블롭은 받아야 합니다. 여기에 sparse-checkout을 얹으면 애초에 체크아웃 대상 경로 바깥의 블롭은 요청 자체를 하지 않으므로 디스크 사용량과 네트워크 전송량이 함께 줄어듭니다. 참고로 예전에 쓰이던 git sparse-checkout init 서브커맨드는 현재 deprecated 상태이며, 2026년 기준으로는 clone --sparse 또는 sparse-checkout set --cone을 바로 쓰는 것이 공식 문서의 권장 방식입니다.
아래 표는 세 가지 대형 저장소 대응 옵션의 트레이드오프를 정리합니다.
| 옵션 | 줄어드는 것 | 부작용 | 적합한 상황 |
|---|---|---|---|
Shallow clone (--depth) | 히스토리 깊이 | git blame, bisect 등 히스토리 의존 명령 제한 | CI 빌드처럼 히스토리가 불필요한 일회성 클론 |
Partial clone (--filter=blob:none) | 초기 전송량 (블롭 지연 요청) | git blame·diff가 블롭을 매번 원격 요청해 느려질 수 있음 | 전체 히스토리는 필요하지만 전체 파일 콘텐츠는 불필요한 경우 |
| Sparse-checkout (cone 모드) | 작업 디렉토리 크기, 디스크 사용량 | 스코프 밖 경로 파일이 아예 보이지 않아 IDE 전역 검색 결과가 제한됨 | 모노레포에서 특정 패키지만 상시 작업하는 경우 |
세 옵션은 상호 배타적이지 않고 직교(orthogonal)적입니다. CI에서는 --depth 1로 히스토리를 자르고, 로컬 개발 환경에서는 --filter=blob:none --sparse로 초기 클론 시간을 줄이면서 필요한 패키지만 sparse-checkout set으로 체크아웃하는 조합이 일반적입니다. 다만 sparse-checkout으로 범위를 좁히면 IDE의 전역 검색이나 "Find Usages" 같은 기능이 스코프 밖 코드를 인식하지 못하므로, 여러 패키지를 넘나드는 리팩터링을 자주 하는 팀이라면 오히려 발목을 잡을 수 있다는 점도 함께 고려해야 합니다.
7. 결론: Git 고급 기능, 언제 무엇을 꺼내야 하는가
여기서 다룬 다섯 가지 기능은 각각 다른 문제를 겨냥합니다. Interactive rebase는 "이력을 어떻게 정리해서 보여줄 것인가"의 문제이고, bisect는 "무엇이 문제를 일으켰는가"를 찾는 문제이며, worktree는 "동시에 여러 컨텍스트에서 작업해야 하는" 문제입니다. reflog·fsck는 사고가 이미 벌어진 뒤의 복구, rerere는 반복 노동의 제거, sparse-checkout·partial clone은 저장소 규모가 팀의 발목을 잡기 시작했을 때 꺼내는 카드입니다. 이 도구들을 평소에 써보지 않은 팀은 정작 필요한 순간에 명령어를 찾아보느라 골든타임을 놓칩니다.
팀 차원에서 점검해둘 체크리스트
- 아직 push하지 않은 브랜치인가? 그렇다면
git commit --fixup과rebase -i --autosquash로 리뷰 전에 이력을 정리한다. 이미 공유된 브랜치라면 rebase 대신 추가 커밋으로 대응하고, 불가피하게 rebase해야 한다면--force-with-lease만 사용한다. - 회귀 버그의 원인 커밋 범위를 좁힐 수 있는 자동화된 테스트가 있는가? 있다면 수동으로 커밋을 하나씩 확인하지 말고
git bisect run으로 즉시 자동화한다. - 여러 브랜치를 동시에 오가야 하는 상황이 반복되는가?
git stash로 버티고 있다면git worktree add로 전환하고, 컨텍스트 전환 비용(재설치, 캐시 무효화)을 먼저 없앤다. - 사고가 발생했을 때
git gc부터 실행하지 않는다는 원칙이 팀에 공유되어 있는가?reflog와fsck --unreachable --dangling으로 복구를 시도하기 전에 가비지 컬렉션이 실행되면 복구 가능성이 줄어든다. - 저장소 크기가 팀의 클론·체크아웃 속도를 실제로 늦추고 있는가? 체감상 느리다는 인상만으로 판단하지 말고, CI에는 shallow clone, 로컬 개발에는 partial clone과 cone 모드 sparse-checkout을 조합해 실제 병목 구간을 좁혀서 적용한다.
이 다섯 가지는 서로 독립적으로 도입할 수 있습니다. 오늘 당장 팀 전체가 rerere를 켤 필요는 없지만, 반복되는 락파일 충돌로 매주 같은 시간을 낭비하고 있다면 그 팀에게는 오늘이 rerere를 도입할 시점입니다. 도구는 문제가 실제로 발생하는 순간에 꺼내 쓰는 것이지, 미리 전부 외워두는 것이 아닙니다.