본문 바로가기
Tools2026년 9월 11일16분 읽기

Git 고급 실전 — rebase·reflog·bisect로 커밋을 내 손에 넣기

YS
김영삼
조회 7
Git 고급 실전 — rebase·reflog·bisect로 커밋을 내 손에 넣기

rebase는 커밋 히스토리를 다시 쓰는 도구고, reflog는 그 과정에서 뭘 날려먹어도 되돌릴 수 있는 안전망이다. bisect·worktree·cherry-pick까지 붙이면 "커밋을 내 손안에서 자유롭게 옮기는" 수준이 된다.

merge와 pull만 쓰다가 히스토리가 지저분해지고, 되돌리는 게 무서워서 그냥 놔둔 경험이 있다면 이 글이 맞다. 실무에서 매일 쓰는 명령만 골라, 언제 쓰고 언제 절대 쓰면 안 되는지까지 정리했다.

고백부터 하자면, 나도 한동안 git rebase가 무서웠다. "히스토리를 고친다"는 말이 곧 "데이터를 날린다"처럼 들렸으니까. 그런데 reflog의 존재를 제대로 이해하고 나서는 오히려 rebase가 제일 편한 도구가 됐다. Git은 웬만해선 커밋을 진짜로 지우지 않는다. 참조만 잃어버릴 뿐이다. 이 문장 하나만 몸으로 이해해도 절반은 끝난다.

명령 한 줄 용도 히스토리 변경? 위험도
rebase -i커밋 재정렬·합치기·수정예 (해시 변경)중~높음
reflogHEAD 이동 기록으로 복구아니오 (읽기)없음
bisect이진 탐색으로 버그 커밋 색출아니오없음
worktree한 저장소에서 여러 브랜치 동시 체크아웃아니오낮음
cherry-pick특정 커밋만 골라 현재 브랜치에 적용예 (새 해시 생성)중간
push --force-with-lease고쳐 쓴 히스토리를 안전하게 밀기원격 갱신중간

인터랙티브 rebase로 커밋을 정리한다

git rebase -i는 이미 만든 커밋들을 열어서 순서를 바꾸고, 합치고(squash), 메시지를 고치고, 아예 지우는 편집기다. 목적은 하나다. PR을 올리기 전에 "오타 수정", "다시 오타 수정", "진짜 마지막" 같은 지저분한 커밋들을 사람이 읽을 수 있는 하나의 이야기로 만드는 것.

최근 커밋 4개를 다듬고 싶으면 이렇게 시작한다.

# 최근 4개 커밋을 편집 대상으로 연다
git rebase -i HEAD~4

# 편집기가 열리면 오래된 커밋이 위, 최신이 아래다.
# 각 줄 맨 앞의 pick 을 원하는 동작으로 바꾼다.

pick a1b2c3d 로그인 폼 마크업 추가
squash e4f5g6h 오타 수정          # 위 커밋에 합침(메시지 유지)
fixup  i7j8k9l 다시 오타          # 위에 합치되 이 메시지는 버림
reword m0n1o2p 유효성 검사 추가   # 커밋은 두되 메시지만 다시 씀
drop   q3r4s5t 디버그 print 넣음  # 이 커밋 자체를 삭제

여기서 실무 팁 하나. squashfixup의 차이를 헷갈리는 사람이 많은데, squash는 합칠 때 두 커밋 메시지를 모두 편집기에 보여주고 fixup은 아래 커밋 메시지를 조용히 버린다. "오타 수정" 같은 커밋은 대부분 fixup이 정답이다. 메시지를 살릴 이유가 없으니까.

그리고 이건 문서에 잘 안 나오는데, 커밋을 만들 때부터 나중에 합칠 걸 알고 있으면 git commit --fixup=<해시>로 만들어 두고, 나중에 git rebase -i --autosquash를 돌리면 Git이 알아서 fixup 커밋을 대상 커밋 밑으로 정렬해준다. 손으로 줄 옮기는 수고가 사라진다.

# A 커밋을 나중에 고칠 거라면
git commit --fixup=a1b2c3d

# 정리할 때 한 방에 자동 정렬 + 합치기
git rebase -i --autosquash HEAD~5
참고 rebase 도중 충돌이 나면 당황하지 말고 파일을 고친 뒤 git add 하고 git rebase --continue. 도저히 안 되겠으면 git rebase --abort로 시작 전 상태로 완전히 되돌아간다. abort는 아무것도 잃지 않는다. 이 두 개만 알면 rebase가 무섭지 않다.

reflog — "커밋 날렸다" 싶을 때 진짜 안전망

git reflog는 HEAD가 지금까지 가리켰던 모든 위치의 기록이다. rebase로 커밋이 사라진 것처럼 보여도, hard reset으로 작업을 날린 것 같아도, 그 커밋들은 여전히 저장소 안에 있고 reflog가 그 주소를 기억한다. 참조를 잃었을 뿐 객체는 살아 있다는 얘기.

예를 들어 실수로 git reset --hard HEAD~3을 쳐서 커밋 3개가 날아간 상황. 복구는 이렇게 한다.

# HEAD가 거쳐온 이력을 본다
git reflog

# 출력 예시:
# 9f3c1a2 HEAD@{0}: reset: moving to HEAD~3
# 7b8d4e5 HEAD@{1}: commit: 결제 모듈 리팩터링   ← 되살리고 싶은 지점
# 2c6a9f0 HEAD@{2}: commit: 장바구니 수량 버그 수정
# ...

# 그 시점으로 브랜치를 되돌린다
git reset --hard HEAD@{1}

# 또는 그 커밋만 새 브랜치로 건져낸다
git branch recovered 7b8d4e5

개인적으로 rebase를 시작하기 전엔 습관처럼 현재 위치를 마음속에 찍어둔다. 잘못돼도 git reflog에서 rebase 직전 HEAD@{n}을 찾아 git reset --hard 하면 통째로 원상복구되기 때문이다. 한 번은 팀원이 밤새 작업한 브랜치를 강제로 리셋해버린 걸 이걸로 3분 만에 살려준 적이 있다. 그날 커피를 얻어먹었다.

주의 reflog는 무한하지 않다. 참조되지 않는 커밋은 기본적으로 약 90일(gc.reflogExpire) 뒤 가비지 컬렉션 대상이 된다. 즉 "어제 날린 커밋"은 거의 항상 살릴 수 있지만, 몇 달 전 것은 장담 못 한다. 그리고 reflog는 로컬 전용이다. clone한 다른 사람 PC엔 없다.

bisect — 어느 커밋이 버그를 넣었는지 찾는다

git bisect는 "잘 되던 버전"과 "깨진 버전" 사이를 이진 탐색으로 좁혀 문제를 심은 커밋을 정확히 짚어내는 도구다. 커밋이 1,000개여도 약 10번의 검사(log₂1000 ≈ 10)면 범인을 찾는다. 눈으로 diff를 뒤지는 것과 차원이 다르다.

수동 방식은 이렇다.

git bisect start
git bisect bad                 # 현재(HEAD)는 버그 있음
git bisect good v1.4.0         # 이 태그/커밋에선 정상이었음

# Git이 중간 커밋으로 체크아웃해준다. 테스트해보고 판정:
git bisect good   # 이 커밋은 정상
git bisect bad    # 이 커밋은 버그 있음
# ... 몇 번 반복하면 ...
# a1b2c3d is the first bad commit  ← 범인 확정

git bisect reset  # 원래 브랜치로 복귀 (반드시!)

진짜 강력한 건 자동화다. 통과/실패를 판정하는 스크립트만 있으면 git bisect run이 전 과정을 무인으로 돌린다. 종료 코드 0이면 good, 1~127(단 125 제외)이면 bad로 해석한다.

# 특정 테스트 하나로 판정을 자동화
git bisect start HEAD v1.4.0
git bisect run npm test -- --run auth.spec.ts

# 컴파일 자체가 안 되는 커밋은 건너뛰게 하려면
# 스크립트에서 exit 125 를 반환하면 'skip' 처리된다
참고 bisect의 함정은 "재현 가능한 판정 기준"을 만드는 데 있다. 버그가 특정 환경에서만 나거나 플래키(flaky)하면 이진 탐색이 엉뚱한 커밋을 지목한다. 나는 bisect run 스크립트를 짜기 전에, 먼저 그 버그를 100% 잡아내는 최소 명령을 손으로 한 번 확인하는 걸 원칙으로 한다.

worktree — stash 없이 여러 브랜치 동시 작업

git worktree는 하나의 저장소(.git)를 공유하면서 서로 다른 브랜치를 각각의 폴더에 동시에 체크아웃하는 기능이다. 급한 핫픽스가 들어왔을 때 지금 하던 작업을 stash 하고 브랜치를 갈아탈 필요가 없다. 새 폴더를 하나 만들어 거기서 핫픽스를 처리하면 끝.

# 지금 저장소 옆에 hotfix 브랜치용 새 작업 폴더를 만든다
git worktree add ../myapp-hotfix hotfix/login-500

# 이제 ../myapp-hotfix 로 가서 평소처럼 작업/커밋/푸시
# 원래 폴더의 작업 상태는 그대로 유지된다

# 새 브랜치를 동시에 만들면서 추가
git worktree add -b release/2.1 ../myapp-release main

# 작업 끝나면 목록 확인 후 정리
git worktree list
git worktree remove ../myapp-hotfix

stash와 비교하면 체감이 확실하다. stash는 컨텍스트를 잠시 서랍에 넣는 거라, 빌드 캐시나 node_modules, 실행 중인 dev 서버가 전부 브랜치를 따라 흔들린다. worktree는 폴더가 물리적으로 분리돼 있어서 A 폴더에선 dev 서버를 띄워두고 B 폴더에서 태연히 다른 브랜치를 빌드할 수 있다. 대형 모노레포에서 이 차이는 시간으로 돌아온다.

상황 stash worktree
30초짜리 잠깐 전환적합과함
두 브랜치 오래 병행불편적합
dev 서버 유지한 채 핫픽스서버 재시작 필요그대로 유지
AI 에이전트로 병렬 빌드충돌 위험격리됨

cherry-pick — 필요한 커밋 한 개만 가져오기

git cherry-pick은 다른 브랜치에 있는 특정 커밋만 골라 현재 브랜치에 복제해 붙이는 명령이다. 전체를 merge하지 않고 "그 버그 수정 하나만" 릴리스 브랜치로 옮길 때 쓴다. 원본 커밋은 그대로 있고, 현재 브랜치엔 내용이 같지만 해시가 다른 새 커밋이 생긴다.

# 커밋 하나 가져오기
git cherry-pick a1b2c3d

# 범위로 여러 개 (b 다음부터 d까지)
git cherry-pick b4e5f6a..d7e8f9b

# 커밋을 붙이되 바로 커밋하진 않고 스테이징만
git cherry-pick -n a1b2c3d

# 충돌 나면 해결 후
git add .
git cherry-pick --continue   # 또는 --abort 로 취소

주의할 점. cherry-pick을 남발하면 같은 변경이 서로 다른 해시로 여러 브랜치에 흩어져서, 나중에 merge할 때 "이미 적용된 변경"이 중복 충돌을 일으킨다. 나는 핫픽스처럼 정말 독립적인 커밋에만 쓰고, 큰 기능은 얌전히 브랜치를 merge하거나 rebase한다. cherry-pick은 편의 도구지 히스토리 관리 전략이 아니다.

안전한 force-push — --force 대신 --force-with-lease

rebase나 amend로 히스토리를 고쳐 쓰면 원격과 히스토리가 달라져서 일반 push가 거부된다. 이때 git push --force를 쓰면 남의 커밋까지 덮어쓸 수 있다. 대신 --force-with-lease를 쓰면 "내가 마지막으로 본 원격 상태 그대로일 때만" 밀어서, 그 사이 누가 push 했으면 안전하게 거부된다.

# 위험: 원격에 뭐가 있든 무조건 덮어씀
git push --force

# 안전: 내가 아는 원격 상태와 다르면 거부됨
git push --force-with-lease

# 더 엄격하게 특정 브랜치 상태까지 명시
git push --force-with-lease=main:9f3c1a2
주의 git fetch 직후에 --force-with-lease를 쓰면 로컬의 원격 추적 브랜치가 갱신되면서 "내가 본 상태"가 최신으로 바뀌어, lease의 보호 효과가 사라질 수 있다. push 전에 습관적으로 fetch부터 하는 사람은 이 조합을 조심해야 한다. 그리고 두 사람 이상이 공유하는 브랜치는 force-push 자체를 원칙적으로 금지하는 게 맞다.

언제 rebase를 쓰지 말아야 하나

답부터. 이미 남과 공유된(push된) 브랜치의 공개 히스토리는 rebase하지 않는다. rebase는 커밋 해시를 바꾸기 때문에, 남이 그 커밋 위에서 작업 중이라면 그 사람 히스토리가 통째로 꼬인다. rebase는 "아직 나만 보는 로컬 커밋"을 정리하는 도구다.

  • 내 로컬 feature 브랜치를 PR 전에 정리 → rebase 마음껏.
  • 여러 명이 같이 쓰는 develop/main → rebase 금지, merge로.
  • 이미 리뷰가 붙은 PR 브랜치 → 웬만하면 커밋을 새로 쌓고, 정리는 최종 squash merge에 맡긴다.

merge와 rebase는 취향 싸움처럼 보이지만 목적이 다르다. merge는 "언제 어떻게 합쳐졌는지"라는 사실을 보존하고, rebase는 "읽기 좋은 선형 히스토리"를 만든다. 나는 개인 브랜치는 rebase로 깔끔하게 정리한 뒤, 공유 브랜치로는 merge(또는 squash merge)로 올린다. 이 조합이 실무에서 사고가 가장 적었다.

자주 묻는 질문

rebase 하다가 완전히 꼬였는데 어떻게 원상복구하나요?

rebase 진행 중이라면 git rebase --abort로 시작 전 상태로 즉시 돌아갑니다. 이미 rebase를 끝내버린 뒤라면 git reflog에서 rebase 직전의 HEAD@{n}을 찾아 git reset --hard HEAD@{n}을 하면 됩니다. 둘 다 데이터를 잃지 않습니다.

reset --hard로 날린 커밋도 살릴 수 있나요?

거의 항상 가능합니다. hard reset은 브랜치 참조만 옮길 뿐 커밋 객체는 남아 있고, git reflog에 그 위치가 기록됩니다. 해당 해시로 git reset --hard <해시> 하거나 git branch 이름 <해시>로 건지세요. 단 gc가 도는 90일 이후엔 보장되지 않습니다.

squash와 fixup은 뭐가 다른가요?

둘 다 위 커밋에 합치지만, squash는 두 커밋 메시지를 모두 편집기에서 보여주며 합칠 메시지를 정하게 하고, fixup은 합쳐지는 커밋의 메시지를 조용히 버립니다. "오타 수정" 같은 자잘한 커밋은 fixup이 편합니다.

bisect로 찾은 커밋이 진짜 원인이 아닐 수도 있나요?

판정 기준이 불안정하면 그렇습니다. 버그가 플래키하거나 환경 의존적이면 good/bad 판정이 흔들려 엉뚱한 커밋을 지목합니다. bisect 전에 그 버그를 100% 재현하는 최소 명령을 먼저 확정하고, 그걸 git bisect run에 넘기세요.

worktree와 그냥 clone을 하나 더 하는 것의 차이는?

worktree는 .git 저장소(객체·브랜치·reflog)를 공유하고 작업 폴더만 분리합니다. 그래서 디스크를 덜 쓰고 브랜치·스태시가 즉시 공유됩니다. 별도 clone은 완전히 독립된 저장소라 fetch/push로만 동기화되고 용량도 두 배로 듭니다.

--force와 --force-with-lease 중 뭘 써야 하나요?

기본적으로 항상 --force-with-lease입니다. 그 사이 원격에 다른 사람 커밋이 올라왔으면 push를 거부해 사고를 막아줍니다. 그냥 --force는 그 안전장치를 꺼버리는 것이라, 혼자 쓰는 브랜치라는 확신이 없으면 쓰지 마세요.

cherry-pick과 rebase는 언제 갈라 쓰나요?

특정 커밋 "하나 또는 몇 개"만 다른 브랜치로 옮길 땐 cherry-pick, 내 브랜치 "전체"를 최신 베이스 위로 다시 얹을 땐 rebase입니다. cherry-pick을 남발하면 같은 변경이 여러 해시로 흩어져 나중에 중복 충돌이 나니 핫픽스 이식 정도로만 쓰는 걸 권합니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록