git reset --hard를 치고 나서 3초 뒤에 등이 서늘해지는 경험은 대부분 한 번쯤 한다. 브랜치를 지웠는데 아직 머지 전이었다든지, 두 시간 작업한 커밋이 화면에서 사라졌다든지.
좋은 소식은 이것이다. 한 번이라도 커밋된 것은 거의 잃지 않는다. Git은 커밋을 지우지 않는다. 다만 가리키는 이름이 사라져서 안 보일 뿐이고, 그 이름을 되찾는 기록이 따로 남아 있다. 이 글은 실제로 자주 벌어지는 사고 아홉 가지를 순서대로 다룬다.
먼저 알아야 할 것: reflog
브랜치는 커밋을 가리키는 이름표다. reset, checkout, merge, rebase는 이 이름표를 옮긴다. 커밋 자체는 그대로 있고 이름표만 움직인다. 이름표가 어디를 거쳐 지금 자리에 왔는지 기록한 것이 reflog다.

git refloga3f21c9 (HEAD -> main) HEAD@{0}: reset: moving to HEAD~2
9b7e104 HEAD@{1}: commit: 댓글 알림 메일 발송 추가
5c1d8a3 HEAD@{2}: commit: 알림 템플릿 분리
a3f21c9 HEAD@{3}: commit: 알림 테이블 스키마읽는 법은 간단하다. 위쪽이 최근이고, HEAD@{n}은 "n번 전에 HEAD가 있던 자리"다. 위 기록은 커밋 세 개를 만든 뒤 reset으로 두 개를 날렸다는 뜻이다. 날린 커밋 9b7e104는 여전히 저장소 안에 있다.
기억해 둘 세 가지가 있다.
- reflog는 로컬 전용이다. clone해도 따라오지 않고, push되지도 않는다. 내 컴퓨터의 기록일 뿐이다.
- 기본 보존 기간은 도달 가능한 항목 90일, 도달 불가능한 항목 30일이다(
gc.reflogExpire). - 아직 커밋된 적 없는 변경은 reflog에도 없다.
add조차 하지 않고git restore로 버린 것은 되살릴 수 없다.
사고 1: reset --hard로 커밋을 날렸다
git reflog # 날리기 직전 해시를 찾는다
git reset --hard 9b7e104 # 그 자리로 되돌린다되돌리기 전에 확인부터 하는 편이 안전하다.
git show 9b7e104 --stat # 내용이 맞는지 본다
git switch -c rescue 9b7e104 # 새 브랜치로 먼저 꺼내 본다reset --hard의 직전 자리는 ORIG_HEAD에도 저장되므로 방금 벌어진 일이라면 이것이 가장 빠르다.
git reset --hard ORIG_HEADORIG_HEAD는 reset, merge, rebase처럼 HEAD를 크게 옮기는 명령이 직전 위치를 남겨두는 자리다. 다만 그다음 명령이 덮어쓰므로 "한 번만 되돌릴 수 있는 실행 취소"라고 생각하면 된다.
사고 2: 머지 전 브랜치를 지웠다
git branch -D feature/notify
# Deleted branch feature/notify (was 9b7e104).친절하게도 지운 자리의 해시를 출력해 준다. 터미널을 이미 닫았다면 reflog에서 찾는다.
git reflog | grep notify
git switch -c feature/notify 9b7e104reflog에도 안 보인다면 마지막 수단이 있다. Git은 아무도 가리키지 않는 객체를 곧바로 지우지 않는다.
git fsck --full --no-reflogs --unreachable --lost-foundunreachable commit 9b7e104b0c1e2f3a4d5b6c7d8e9f0a1b2c3d4e5f찾은 해시를 git show로 확인하고 브랜치로 꺼내면 된다. 단, git gc --prune=now를 이미 돌렸다면 이 방법도 통하지 않는다. 사고 직후에 gc를 수동으로 돌리지 않는 것이 중요한 이유다.
사고 3: 엉뚱한 브랜치에 커밋했다
main에서 작업하다가 커밋 세 개를 쌓은 뒤에야 브랜치를 안 팠다는 걸 깨달은 경우다. 아직 push하지 않았다면 깔끔하게 정리된다.
git switch -c feature/notify # 지금 자리에서 브랜치를 판다. 커밋 세 개가 따라온다
git switch main
git reset --hard origin/main # main은 원격 상태로 되돌린다순서가 중요하다. 먼저 새 브랜치로 커밋을 붙잡아 두고, 그다음에 main을 되돌린다. 반대로 하면 붙잡기 전에 이름표가 사라진다.
커밋 중 일부만 옮겨야 한다면 cherry-pick을 쓴다.
git switch -c feature/notify origin/main
git cherry-pick 5c1d8a3 9b7e104 # 필요한 커밋만 골라 복사
git switch main
git reset --hard origin/maincherry-pick은 커밋을 옮기는 것이 아니라 같은 변경을 새 커밋으로 복사한다. 해시가 달라진다는 뜻이고, 그래서 나중에 원본 브랜치를 머지하면 같은 변경이 두 번 들어와 충돌할 수 있다. 릴리스 브랜치에 핫픽스를 옮길 때는 출처를 남기는 습관이 도움이 된다.
git cherry-pick -x 5c1d8a3 # 메시지에 (cherry picked from commit ...) 추가사고 4: rebase 도중에 엉켰다
리베이스는 커밋을 하나씩 다시 적용하는 과정이라 중간에 멈춘다. 이때 선택지는 셋뿐이다.
git rebase --continue # 충돌을 풀고 이어서
git rebase --skip # 이 커밋은 버리고 이어서
git rebase --abort # 시작 전 상태로 완전히 되돌린다--abort는 안전하다. 리베이스를 시작할 때의 자리를 그대로 복원한다. 판단이 서지 않으면 일단 --abort하고 다시 생각하는 편이 낫다.
이미 리베이스를 끝냈는데 결과가 잘못됐다면 reflog로 돌아간다.
git reflog
# 5c1d8a3 HEAD@{5}: rebase (start): checkout origin/main
git reset --hard HEAD@{6} # rebase 시작 직전 자리리베이스 중간에 이런저런 조작을 하다 보면 HEAD@{n} 번호를 세기 어려워진다. 이럴 때는 시간으로 찾는 편이 빠르다.
git reflog --date=iso | head -20
git reset --hard "main@{30 minutes ago}"사고 5: stash를 잃어버렸다
git stash drop을 했거나, pop 중 충돌이 나서 정리하다 목록에서 사라진 경우다. stash도 커밋이므로 객체는 남아 있다.
git fsck --unreachable | grep commit
git show <해시> # 내용 확인
git stash apply <해시> # 그대로 적용stash 커밋은 메시지가 WIP on <브랜치> 꼴이라 git show로 몇 개만 훑어봐도 금방 찾는다.
사고 6: 어느 커밋이 버그를 넣었는지 모른다
"2주 전에는 됐는데 지금은 안 된다"는 상황에서 커밋 200개를 눈으로 볼 수는 없다. bisect가 이분 탐색으로 찾아준다. 200개면 여덟 번만 확인하면 된다.

git bisect start
git bisect bad # 지금(HEAD)은 문제가 있다
git bisect good v1.4.0 # 이 태그에서는 정상이었다Git이 중간 커밋으로 옮겨준다. 확인하고 답만 하면 된다.
# 테스트해 보고
git bisect good # 또는 git bisect bad몇 번 반복하면 범인을 지목한다.
9b7e104 is the first bad commitgit bisect reset # 원래 자리로 돌아온다확인 과정을 명령 하나로 자동화할 수 있으면 전 과정을 한 줄로 끝낼 수 있다.
git bisect start HEAD v1.4.0
git bisect run npm test -- tests/auth.test.tsbisect run은 종료 코드로 판정한다. 0이면 good, 1에서 124 사이면 bad, 125면 판정 불가(건너뜀)다. 스크립트를 쓸 때 이 규칙만 지키면 된다.
#!/bin/sh
# 빌드 자체가 안 되면 판정을 건너뛴다
npm ci --silent || exit 125
npm run build --silent || exit 125
node scripts/reproduce.mjs사고 7: 충돌 해결 중에 길을 잃었다
git status # 충돌 파일 목록
git diff --diff-filter=U # 충돌 부분만 diff로한쪽을 통째로 고르고 싶을 때가 있다. 락 파일이나 생성물이 대표적이다.
git checkout --ours package-lock.json # 내 브랜치 것
git checkout --theirs package-lock.json # 상대 브랜치 것
git add package-lock.json여기서 ours와 theirs의 뜻이 merge와 rebase에서 반대라는 점이 함정이다. 리베이스는 내 커밋을 상대 위에 하나씩 얹는 구조라, 리베이스 도중의 ours는 얹히는 바탕(대개 main)이고 theirs가 내 커밋이다. 헷갈리면 이름 대신 내용을 보는 편이 안전하다.
git checkout --conflict=diff3 app/page.tsxdiff3 표시는 양쪽 결과에 더해 공통 조상까지 보여준다. 무엇이 어느 쪽에서 바뀐 것인지가 그때 분명해진다. 아예 기본값으로 켜둘 만하다.
git config --global merge.conflictStyle zdiff3되돌리려면 이렇게 한다.
git merge --abort # 머지 중이면
git rebase --abort # 리베이스 중이면사고 8: 비밀을 커밋해서 push했다
가장 급한 사고다. 순서를 지켜야 한다.
첫째, 키를 폐기한다. 이력에서 지우는 것보다 먼저다. push된 순간 그 값은 이미 유출됐다고 봐야 한다. GitHub은 공개 저장소의 커밋을 실시간으로 훑는 봇들의 표적이고, 삭제하더라도 그사이 캐시나 포크에 남는다. AWS 키라면 즉시 비활성화하고 새로 발급한다.
둘째, 이력에서 지운다. 최신 커밋 하나뿐이라면 간단하다.
git rm --cached .env.local
git commit --amend --no-edit
git push --force-with-lease여러 커밋에 걸쳐 있다면 git-filter-repo를 쓴다. 예전의 filter-branch는 느리고 함정이 많아 Git 공식 문서도 더는 권하지 않는다.
pip install git-filter-repo
git filter-repo --invert-paths --path .env.local
git push --force --all
git push --force --tags셋째, 팀에 알린다. 이력을 갈아엎었으므로 각자의 로컬이 원격과 어긋난다. 정석은 다시 clone하는 것이다. 이미 만든 브랜치가 있으면 새로 받은 저장소 위에 cherry-pick으로 옮긴다.
이런 사고를 미리 막는 쪽이 훨씬 싸다. GitHub의 푸시 보호를 켜면 알려진 형식의 토큰이 담긴 push 자체를 막아준다.
사고 9: 파일 하나만 예전 상태로 되돌리고 싶다
커밋 전체를 되돌릴 필요는 없고, 특정 파일만 그때 그 내용으로 필요한 경우다.
git restore --source 9b7e104 -- lib/auth.ts # 그 커밋 시점의 파일로
git show 9b7e104:lib/auth.ts > /tmp/old.ts # 덮어쓰지 않고 꺼내 보기만그 파일이 언제 지워졌는지 모른다면 이렇게 찾는다.
git log --diff-filter=D --oneline -- lib/auth.ts지운 커밋이 나오면 그 직전 커밋에서 꺼낸다.
git restore --source 9b7e104~1 -- lib/auth.ts되살릴 수 있는지 판단하는 순서
무엇을 잃었든 판단은 같은 순서로 내려간다. 되돌리는 명령을 고르기 전에 이 순서를 먼저 밟으면, 살릴 수 있는 것을 성급하게 지우는 일이 없다.

잃지 않기 위한 습관 셋
커밋을 자주 한다. reflog가 지켜주는 것은 커밋된 것뿐이다. 작업 트리 변경은 아무것도 지켜주지 않는다. 정리되지 않은 중간 상태라도 일단 커밋해 두면 나중에 reset --soft나 인터랙티브 리베이스로 얼마든지 다듬을 수 있다.
되돌리기 전에 꺼내 둔다. reset --hard로 직행하지 말고 git switch -c rescue <해시>로 먼저 브랜치를 만들면, 잘못 짚었을 때 다시 찾을 필요가 없다.
사고 직후에 gc를 돌리지 않는다. git gc --prune=now나 git reflog expire --expire=now --all은 되살릴 수 있었던 객체를 실제로 지운다. 무언가 잘못됐다고 느낀 직후라면 이 명령들에서 손을 뗀다.
다음 글에서는 이력을 깨끗하게 유지하는 쪽을 다룬다. merge와 rebase를 언제 어떻게 고르는지, 인터랙티브 리베이스로 커밋을 정리하는 실제 절차가 무엇인지 본다.
댓글 0
첫 댓글을 남겨보세요.