git push --force로 커밋을 덮어썼을 때 — 복구와 재발 방지
git push --force를 실행한 직후, 원격 브랜치에서 동료의 커밋이 사라진 것을 발견했습니다. 원격 히스토리가 내 로컬 상태로 통째로 바뀌었고, 그 사이 동료가 올렸던 작업이 브랜치에서 보이지 않습니다. 결론부터 말하면, 덮어써진 커밋은 아직 어딘가에 남아 있을 가능성이 높습니다. Git은 커밋을 즉시 삭제하지 않고, 사라진 커밋의 사본은 대개 여러 곳에 흩어져 있습니다. 다만 시간이 지날수록 사본이 하나씩 사라지므로, 빠르게 움직일수록 복구가 쉽습니다.
이 글은 복구 경로 세 가지를 확실한 순서대로 정리하고, 마지막에 같은 사고를 구조적으로 막는 설정을 다룹니다.
무슨 일이 벌어졌나 #
force push는 원격 브랜치 포인터를 내 로컬 브랜치가 가리키는 커밋으로 강제로 옮기는 동작입니다. 일반 push가 “원격에 없는 커밋이 있으면 거부"하는 안전장치를 두는 반면, force push는 그 확인을 건너뜁니다.
[force push 전 — 원격]
A --- B --- C --- D ← main (D는 동료의 커밋)
[내 로컬 — B에서 히스토리를 다시 씀]
A --- B --- C' ← main
[force push 후 — 원격]
A --- B --- C' ← main (C와 D가 브랜치에서 사라짐)여기서 중요한 사실이 하나 있습니다. C와 D는 삭제된 것이 아니라 어느 브랜치도 가리키지 않는 도달 불가 상태가 됐을 뿐입니다. 서버의 가비지 컬렉션이 돌기 전까지 커밋 데이터 자체는 남아 있고, 해시만 알면 다시 브랜치로 살릴 수 있습니다. 그리고 그 커밋의 완전한 사본이 동료의 로컬 저장소에도, 경우에 따라 내 저장소에도 존재합니다.
경로 1 — 그 커밋을 가진 동료의 로컬 #
가장 확실한 복구 경로입니다. 사라진 커밋 D를 만든 동료(또는 그 커밋을 pull 했던 누구든)의 로컬 저장소에는 D가 온전히 남아 있습니다. Git 저장소는 서로 대등한 사본이므로, 그 사본으로 원격을 다시 덮으면 끝납니다.
이번에는 잘못된 상태가 원격에 있으므로, 복구하는 쪽이 강제 푸시를 실행해야 합니다. 실행자는 그 커밋을 가진 동료입니다.
git fetch # 원격의 현재(잘못된) 상태를 확인
git push --force-with-lease origin main # 자신의 로컬 상태로 원격을 복구--force-with-lease는 마지막으로 fetch 한 원격 상태와 실제 원격이 같을 때만 덮어쓰므로, 복구 도중 또 다른 push와 엇갈리는 2차 사고를 막아 줍니다.
경로 2 — 내 저장소의 remote-tracking reflog #
동료가 부재중이거나 사라진 커밋을 가진 사람이 없더라도, 내 저장소에 단서가 남아 있을 수 있습니다. force push 이전에 fetch나 pull을 한 적이 있다면, 내 origin/main이 예전에 어디를 가리켰는지가 reflog에 기록되어 있습니다.
$ git reflog show origin/main
f3d9a21 refs/remotes/origin/main@{0}: update by push: forced-update
8c4d2e7 refs/remotes/origin/main@{1}: fetch: fast-forward
1a2b3c4 refs/remotes/origin/main@{2}: fetch: fast-forwardforced-update라고 적힌 항목이 사고 시점입니다. 그 직전 항목인 8c4d2e7이 force push 전에 원격 main이 가리키던 커밋, 곧 사라진 커밋 D입니다. fetch를 통해 내 저장소에 객체가 내려와 있으므로, 이 해시를 원격 브랜치로 그대로 밀어 올리면 복구가 됩니다.
git push --force-with-lease origin 8c4d2e7:main<해시>:main 형식은 로컬 브랜치를 거치지 않고 특정 커밋을 원격의 main으로 지정하는 문법입니다. 복구 후 git fetch로 원격 상태를 다시 받아 정상으로 돌아왔는지 확인합니다.
경로 3 — 호스팅 서비스에 남은 기록 #
로컬 어디에도 단서가 없다면 호스팅 서비스의 기록을 뒤집니다. GitHub 기준으로 세 곳에 흔적이 남습니다.
- Activity 뷰 — 저장소의 Activity 페이지에서 Force pushes 필터를 걸면, 사고 이벤트와 함께 덮어쓰기 이전 커밋으로의 비교 링크가 표시됩니다. 여기서 이전 해시를 바로 얻을 수 있습니다.
- Events API — push 이벤트에는 이전 상태가
before필드로 남습니다.
curl -s https://api.github.com/repos/<owner>/<repo>/events \
| grep -B2 -A2 '"before"'- PR 페이지 — 사라진 커밋이 어느 PR에 포함된 적이 있다면, PR의 커밋 목록에 해시가 그대로 남아 있습니다.
해시를 찾았다면 서버에는 아직 객체가 남아 있을 가능성이 높으므로, 내 저장소로 가져와 원격 브랜치를 복구합니다.
git fetch origin 8c4d2e7
git push --force-with-lease origin 8c4d2e7:main이 경로까지 막혔다면 마지막 수단으로 GitHub Support에 문의합니다. 서버 측 가비지 컬렉션이 돌기 전이라면 복구를 도와줄 수 있는 여지가 남아 있습니다.
복구 후 정리 #
원격이 원상 복구됐어도 팀 정리가 한 단계 남았습니다. 사고와 복구 사이에 fetch나 pull을 실행한 동료의 로컬에는 잘못된 상태가 남아 있을 수 있습니다. 사고 시각과 복구 완료를 팀에 공지하고, 그 사이에 원격과 동기화한 사람은 git fetch 후 자신의 브랜치와 origin/main이 어긋나지 않았는지 git status로 확인하도록 안내합니다. 어긋난 사람은 로컬 작업을 보존한 채(필요하면 브랜치로 분리) 원격 기준으로 정리하면 됩니다.
마지막으로 사고 원인을 확인합니다. 대부분은 rebase 후 push가 거부되자 반사적으로 --force를 붙인 경우입니다. 다음 절의 설정으로 같은 경로를 구조적으로 차단합니다.
재발 방지 — 설정으로 막는다 #
--force대신--force-with-lease를 기본 습관으로 만듭니다. 원격에 내가 모르는 커밋이 있으면 push를 중단해 주므로, 이번 같은 사고의 대부분이 실행 전에 걸러집니다. 두 옵션의 차이는 Git 실무 워크플로우 #3 interactive rebase — squash·fixup으로 커밋 정리에서 다뤘습니다.- 보호 브랜치에서 force push를 차단합니다. GitHub의 branch protection에서 main 같은 공유 브랜치에 Allow force pushes를 끄면(기본값), 습관과 무관하게 서버가 강제 푸시를 거부합니다. 사람의 주의력 대신 설정이 사고를 막는 가장 확실한 층입니다.
- 공유 브랜치는 rebase하지 않습니다. force push가 필요해지는 상황 자체를 만들지 않는 원칙입니다. 근거는 Git 실무 워크플로우 #2 rebase vs merge — 결정 기준과 금기에서 정리했습니다.
마무리 — 대응 순서 #
force push 사고를 만났을 때의 순서를 정리합니다.
- 추가 push를 멈추고 팀에 사고를 알립니다. 사본이 사라지기 전에 움직이는 것이 가장 중요합니다.
- 사라진 커밋을 로컬에 가진 동료를 찾습니다. 있다면 그 동료가
--force-with-lease로 원격을 복구합니다. - 없다면 내
git reflog show origin/main에서 이전 해시를 찾아<해시>:main으로 밀어 올립니다. - 그래도 없다면 GitHub의 Activity 뷰, Events API, PR 페이지에서 이전 해시를 찾아 fetch 후 복구합니다.
- 복구 후 팀에 공지하고, branch protection과
--force-with-lease습관으로 재발을 막습니다.
커밋은 생각보다 쉽게 사라지지 않습니다. 사고 직후의 몇 시간 동안 침착하게 사본을 추적하는 것이 복구의 전부입니다.