Git 되돌리기: reset·revert·restore 선택 기준과 rebase로 이력 정리하기
이 글의 핵심
이미 push한 커밋을 reset으로 지우고 force push하면 팀원의 작업이 꼬이지만, 로컬 커밋이라면 reset이 가장 간단합니다. 이 글은 reset이 HEAD, 인덱스, 작업 디렉터리 세 층을 각각 어떻게 바꾸는지 정리하고, worktree로 병렬 작업하기, merge-tree로 충돌을 미리 확인하기, reflog와 GC 관계까지 다룹니다.
[Git 실전 가이드 #4] 되돌리기·rebase·정리
이전 글: Git 원격 저장소와 협업(#3)에서 push·pull·PR을 다뤘습니다.
커밋은 프로젝트의 스냅샷이라고 보시면 됩니다. 잘못 찍은 스냅샷을 고치거나, 여러 스냅샷 줄을 한 줄로 정리할 때 쓰는 대표 명령이 reset(HEAD를 옮겨 이력·스테이징·작업 디렉터리를 바꾸는 것), revert(되돌리는 내용을 담은 새 커밋을 추가해 이력을 유지하는 것), rebase(커밋을 다른 베이스 위에 다시 적용해 이력을 정리하는 것)입니다. 마지막 커밋만 취소하거나, 공유된 브랜치에서 안전하게 되돌리는 방법까지 각각의 주의점과 함께 정리했습니다.
어느 명령을 쓸지 고르는 기준은 사실상 하나입니다. 그 커밋을 다른 사람이 이미 받았는가? 아직 내 컴퓨터에만 있는 커밋이라면 reset이나 rebase로 이력을 자유롭게 고쳐도 아무도 영향을 받지 않습니다. 이미 원격에 올라가 누군가 pull했을 수 있다면, 이력을 고치는 대신 revert처럼 “고치는 커밋을 추가하는” 방법을 써야 합니다.
되돌리기 개요
| 목적 | 명령 | 특징 |
|---|---|---|
| ”방금 커밋”만 취소하고 수정 이어가기 | git reset --soft HEAD~1 | 커밋만 취소, 스테이징·작업 디렉터리는 유지 |
| 스테이징까지 취소 | git reset HEAD~1 (또는 —mixed) | 커밋·스테이징 취소, 파일 수정 내용은 유지 |
| 완전히 그 커밋 이전으로 (위험) | git reset --hard HEAD~1 | 커밋·스테이징·파일 변경 모두 제거 |
| 이미 push한 커밋을 “취소한 것처럼” 보이게 | git revert <커밋> | 새 “반대 커밋”을 만들어 이력 유지 |
| 브랜치 이력을 한 줄로 정리 | git rebase main | 커밋을 main 끝에 다시 붙임 (이력 변경) |
이미 원격에 push한 커밋을 없애고 싶을 때는 reset —hard로 되돌리면 다른 사람 이력과 꼬이므로, revert로 “취소하는 커밋”을 새로 만드는 것이 안전합니다. rebase는 이력을 바꾸므로 아직 push하지 않은 로컬 브랜치에서만 쓰는 것을 권장합니다.
git reset: 커밋·스테이징 취소
reset —soft
커밋만 취소하며, 스테이징 영역과 작업 디렉터리는 그대로 둡니다:
git reset --soft HEAD~1
# git reset: HEAD 포인터를 이동시키는 명령
# --soft: 커밋만 취소 (스테이징, 작업 디렉터리 유지)
# HEAD~1: 현재 HEAD의 바로 이전 커밋
#
# 동작:
# 1. HEAD를 이전 커밋으로 이동
# 2. 취소된 커밋의 변경사항은 스테이징 영역에 남음
# 3. 작업 디렉터리는 변경 없음
#
# 결과:
# git status → 변경사항이 "Changes to be committed" 상태
# git commit으로 다시 커밋 가능
사용 시나리오:
# 시나리오 1: 커밋 메시지 수정
git commit -m "Fix bug" # 오타 발견!
git reset --soft HEAD~1
git commit -m "Fix authentication bug" # 메시지 수정
# 시나리오 2: 파일 추가 후 재커밋
git commit -m "Add feature"
# 파일 하나 빠뜨렸다!
git reset --soft HEAD~1
git add forgotten_file.js
git commit -m "Add feature" # 모든 파일 포함
# 시나리오 3: 여러 커밋을 하나로 합치기
git reset --soft HEAD~3 # 최근 3개 커밋 취소
git commit -m "Implement user authentication" # 하나로 합침
HEAD 표기법:
HEAD~1 # 1개 이전 커밋
HEAD~2 # 2개 이전 커밋
HEAD~3 # 3개 이전 커밋
HEAD^ # 1개 이전 커밋 (HEAD~1과 동일)
HEAD^^ # 2개 이전 커밋 (HEAD~2와 동일)
# 특정 커밋 해시로도 가능
git reset --soft abc1234
~와 ^는 부모가 하나인 일반 커밋에서는 같지만, 병합 커밋에서는 의미가 갈립니다. HEAD~2는 “첫 번째 부모를 따라 두 단계 위”이고, HEAD^2는 “두 번째 부모”(병합해 들어온 브랜치 쪽)입니다. 병합 커밋 위에서 reset HEAD^2를 HEAD~2로 착각해 치면 전혀 다른 지점으로 이동하므로, 병합이 섞인 이력에서는 해시를 직접 지정하는 편이 안전합니다.
시나리오 1의 커밋 메시지 수정은 git commit --amend -m "새 메시지" 한 줄로도 할 수 있고, 시나리오 2도 git add forgotten_file.js && git commit --amend --no-edit으로 끝납니다. --amend는 마지막 커밋을 새 커밋으로 바꿔치기하므로, 역시 push하기 전에만 써야 합니다.
reset —mixed (기본)
커밋과 스테이징을 취소하며, 파일 내용 변경만 작업 디렉터리에 남깁니다. “커밋과 스테이징을 취소하며, 일부만 골라서 다시 add하고 싶을 때” 사용합니다.
git reset HEAD~1
# 또는
git reset --mixed HEAD~1
이후 git status하면 변경 파일이 “unstaged”로 보이며, git add로 다시 스테이징한 뒤 git commit할 수 있습니다.
reset —hard
커밋·스테이징·작업 디렉터리를 모두 그 커밋 이전 상태로 되돌립니다. 작업 중이던 미커밋 변경도 사라지므로 사용 전에 필요한 내용은 stash나 다른 브랜치에 백업해 두는 것이 좋습니다.
git reset --hard HEAD~1
이미 push한 브랜치에서 reset —hard 후 force push하면, 같은 브랜치를 쓰는 다른 사람의 이력과 충돌할 수 있어, 팀 협업 시에는 되도록 피하는 것이 좋습니다.
--hard가 무서운 이유를 정확히 알아 두면 과하게 겁먹지 않아도 됩니다. 커밋된 내용은 reset으로 브랜치가 옮겨 가도 사라지지 않고 reflog에 남아 있어 대부분 되살릴 수 있습니다. 진짜로 사라지는 것은 커밋하지 않은 수정입니다. 추적 중인 파일의 수정분과, git add만 하고 커밋하지 않은 새 파일이 여기에 해당합니다. 반대로 한 번도 add하지 않은 추적 안 되는 파일은 reset --hard가 건드리지 않습니다(그 파일을 지우는 것은 git clean -fd입니다). 그래서 --hard 전에 git status를 보고 “Changes not staged”나 “Changes to be committed”에 남기고 싶은 것이 있다면 git stash를 먼저 해 두면 됩니다.
파일 하나만 되돌릴 때: git restore
커밋이 아니라 파일 하나의 수정을 버리거나 스테이징을 취소하고 싶을 때는 reset보다 Git 2.23에 추가된 restore가 의도가 분명합니다.
git restore app.js # 작업 디렉터리 수정 버리기 (마지막 커밋 상태로)
git restore --staged app.js # 스테이징만 취소, 수정 내용은 유지
git restore --source=HEAD~3 app.js # 3커밋 전 버전으로 파일 내용 되돌리기
예전에는 이 일을 모두 git checkout -- app.js와 git reset HEAD app.js로 했는데, checkout이 브랜치 전환과 파일 복원을 함께 맡고 있어 브랜치 이름과 파일 이름이 같을 때 헷갈리는 문제가 있었습니다. 그래서 브랜치 전환은 git switch, 파일 복원은 git restore로 나뉘었습니다. git restore app.js로 버린 수정도 커밋된 적이 없으므로 되돌릴 수 없다는 점은 reset --hard와 같습니다.
git revert: 되돌리는 커밋 새로 만들기
revert란?
revert는 특정 커밋의 변경을 반대로 적용한 새 커밋을 만듭니다:
git revert HEAD
# git revert: 커밋을 되돌리는 새 커밋 생성
# HEAD: 가장 최근 커밋을 되돌림
#
# 동작:
# 1. HEAD 커밋의 변경사항을 분석
# 2. 그 변경을 반대로 적용 (추가 → 삭제, 삭제 → 추가)
# 3. 새로운 "Revert" 커밋 생성
# 4. 이력은 그대로 유지 (기존 커밋은 남아있음)
#
# 결과:
# A → B → C (HEAD)
# ↓
# A → B → C → C' (Revert "C", HEAD)
# C'는 C의 변경을 취소하는 내용을 담은 새 커밋
# 특정 커밋 되돌리기
git revert abc1234
# abc1234 커밋의 변경을 취소하는 새 커밋 생성
# 병합 커밋 되돌리기
git revert abc1234 -m 1
# -m 1: 첫 번째 부모를 기준으로 되돌림
# -m 2: 두 번째 부모를 기준으로 되돌림
# 병합 커밋은 부모가 2개이므로 -m 옵션 필수
병합 커밋 revert에는 많은 팀이 한 번씩 겪는 함정이 있습니다. main에 병합한 feature 브랜치를 git revert -m 1 <병합커밋>으로 되돌린 뒤, feature에서 버그를 고쳐 다시 병합하면, 처음 병합에 들어 있던 변경들은 돌아오지 않고 수정 커밋만 들어옵니다. Git은 그 커밋들이 이미 main에 병합된 것으로 기록하고 있고, revert는 그 내용을 지운 “새 변경”일 뿐이기 때문입니다. 이때는 먼저 revert 커밋을 다시 revert해서 원래 변경을 되살린 뒤 새 수정을 병합해야 합니다. 저는 이 상황을 만나면 revert 커밋 메시지에 “재병합 전 이 커밋을 revert할 것”이라고 적어 두는 편인데, 몇 주 뒤 다른 사람이 같은 브랜치를 다시 병합할 때 이 메모가 없으면 원인을 찾는 데 한참 걸리기 때문입니다.
reset vs revert 비교:
# reset: 이력 변경 (커밋 삭제)
# A → B → C (HEAD)
git reset --hard HEAD~1
# A → B (HEAD) ← C 커밋이 사라짐
# 위험: 이미 push했다면 다른 사람과 이력 불일치
# revert: 이력 유지 (새 커밋 추가)
# A → B → C (HEAD)
git revert HEAD
# A → B → C → C' (HEAD) ← C'는 C를 취소하는 커밋
# 안전: 이력이 남아있어 협업에 안전
revert 충돌 처리:
git revert HEAD
# 충돌 발생!
# 1. 충돌 파일 수정
vim conflicted_file.js
# 2. 스테이징
git add conflicted_file.js
# 3. revert 계속
git revert --continue
# 또는 revert 취소
git revert --abort
실전 사용 예시:
# 시나리오: 프로덕션에 버그 있는 커밋이 push됨
git log --oneline
# abc1234 Add buggy feature ← 이 커밋이 문제
# def5678 Update docs
# 789abcd Initial commit
# 해결: revert로 안전하게 되돌림
git revert abc1234
# "Revert 'Add buggy feature'" 커밋 생성
git push origin main
# 이력은 유지되면서 버그 수정됨
git rebase: 이력 한 줄로 정리
rebase의 의미
rebase는 “현재 브랜치의 커밋들을 다른 브랜치(또는 같은 브랜치의 특정 지점) 끝에 다시 붙이는 것”입니다. 결과적으로 이력이 한 줄로 정리되어 보기 쉬워지지만, 기존 커밋 해시가 바뀌므로 이미 push한 브랜치에 rebase하면 협업 시 문제가 됩니다.
사용 예 (feature를 main 최신 위에 올리기)
git checkout feature/login
git rebase main
main이 가리키는 최신 커밋 위에 feature/login의 커밋들이 “다시 적용”됩니다. 충돌이 나면 해당 파일을 수정한 뒤 git add → git rebase —continue를 반복합니다. rebase를 취소하려면 git rebase —abort를 사용합니다.
주의사항
- 이미 원격에 push한 브랜치를 rebase하면, 그 브랜치를 다시 push할 때 git push —force가 필요하며, 같은 브랜치를 pull하는 다른 사람의 이력이 꼬일 수 있습니다. 따라서 아직 push하지 않은 로컬 브랜치에서만 rebase하는 것이 안전합니다.
- main/master 같은 공용 브랜치에는 rebase를 하지 않는 것이 원칙입니다.
rebase가 “커밋을 옮기는 것”처럼 보이지만, 실제로는 각 커밋의 변경분(diff)을 새 베이스 위에 하나씩 다시 적용해 새 커밋을 만드는 작업입니다. 그래서 feature 브랜치에 커밋이 10개 있으면 충돌도 최대 10번 날 수 있고, 같은 파일의 같은 부분을 여러 커밋이 고쳤다면 같은 충돌을 반복해서 풀게 됩니다. 이런 경우 git config --global rerere.enabled true로 rerere(reuse recorded resolution)를 켜 두면, 한 번 푼 충돌의 해결 방법을 기억했다가 같은 충돌이 다시 나면 자동으로 적용해 줍니다.
interactive rebase: 커밋 정리하기
PR을 올리기 전에 “WIP”, “오타 수정”, “리뷰 반영” 같은 커밋을 정리하고 싶을 때는 git rebase -i를 씁니다.
git rebase -i HEAD~4 # 최근 4개 커밋을 편집기로 열기
pick a1b2c3d 로그인 API 추가
fixup d4e5f6a 오타 수정 # 바로 위 커밋에 합치고 메시지는 버림
squash 7a8b9c0 에러 처리 보강 # 위 커밋에 합치고 메시지를 함께 편집
reword 1d2e3f4 테스트 추가 # 내용은 그대로, 메시지만 수정
pick을 다른 명령으로 바꾸거나 줄 순서를 바꾸고 저장하면 Git이 위에서부터 차례로 적용합니다. edit는 그 커밋에서 멈춰 파일을 고치거나 커밋을 여러 개로 쪼갤 기회를 주고, 줄을 지우면 그 커밋이 사라집니다. 커밋할 때부터 git commit --fixup=a1b2c3d로 “이 커밋은 a1b2c3d를 고치는 것”이라고 표시해 두면, 나중에 git rebase -i --autosquash main이 fixup 줄을 자동으로 제자리에 배치해 줍니다.
rebase 도중 뭔가 잘못되었다는 느낌이 들면 git rebase --abort로 시작 전 상태로 돌아갈 수 있고, 이미 끝난 rebase가 마음에 들지 않으면 git reflog에서 rebase (start) 직전 항목을 찾아 git reset --hard HEAD@{n}으로 되돌리면 됩니다. rebase는 옛 커밋을 지우지 않고 새 커밋을 만들 뿐이라, reflog가 남아 있는 동안에는 언제든 되돌아갈 수 있습니다.
push 전후로 달라지는 선택 기준
- push 전: reset, rebase로 로컬에서만 이력을 정리해도 됩니다.
- push 후: 이미 올린 커밋을 “없던 일로” 하려면 revert로 취소 커밋을 만드는 방식이 안전합니다. force push는 팀과 합의된 경우에만 사용합니다.
- merge vs rebase: 팀에서 “main에 병합할 때는 항상 merge”로 통일해 두면 이력이 예측 가능하며, rebase는 개인 feature 브랜치에서만 쓰는 식으로 정하면 충돌을 줄일 수 있습니다.
자신의 PR 브랜치를 rebase한 뒤 원격에 다시 올릴 때처럼 force push가 꼭 필요하다면 git push --force 대신 git push --force-with-lease를 쓰십시오. --force는 원격 브랜치에 무엇이 있든 덮어쓰지만, --force-with-lease는 “내가 마지막으로 받았을 때의 원격 상태와 지금이 같을 때만” 덮어씁니다. 그 사이 동료가 같은 브랜치에 커밋을 올렸다면 stale info 에러로 거부되어, 남의 커밋을 모르고 지우는 사고를 막아 줍니다. 공용 브랜치는 GitHub·GitLab의 브랜치 보호 규칙으로 force push 자체를 막아 두는 것이 가장 확실합니다.
자주 묻는 질문 (FAQ)
Q. reset —hard 한 걸 복구할 수 있나요?
A. reset —hard 직후라면 git reflog로 이전 HEAD 위치를 찾을 수 있습니다. 예: git reflog → git reset --hard HEAD@{1}로 그 시점으로 되돌릴 수 있습니다. 한동안 지났거나 reflog가 정리된 경우에는 복구가 어려울 수 있으므로, 중요한 변경은 reset —hard 전에 브랜치나 stash로 남겨 두는 것이 좋습니다. 복구할 수 있는 것은 커밋되었던 내용뿐이라는 점도 기억하십시오. 커밋하지 않은 수정은 reflog에 없으므로 되살릴 수 없고, git add까지 했던 파일이라면 git fsck --lost-found로 남아 있는 blob을 찾아 내용을 건질 수 있는 경우가 있습니다.
Q. revert와 reset의 차이는?
A. reset은 “그 커밋으로 HEAD를 옮겨서” 이력을 바꿉니다(이전 커밋이 사라져 보임). revert는 “그 커밋을 없애는 새 커밋”을 만들어 이력을 지우지 않습니다. 이미 push한 커밋을 취소할 때는 revert를 쓰는 것이 안전합니다.
Q. rebase 중 충돌이 났을 때요?
A. 충돌한 파일을 편집한 뒤 git add <파일> 하고 git rebase —continue를 실행합니다. 계속 충돌이 나면 반복하며, rebase 자체를 포기하려면 git rebase —abort로 rebase 전 상태로 돌아갈 수 있습니다.
마무리
- reset —soft/—mixed: 커밋(과 스테이징)만 취소, 로컬에서 수정 이어갈 때.
- reset —hard: 커밋·스테이징·작업 디렉터리 모두 되돌림. push한 브랜치에는 사용 자제.
- revert: “취소하는 커밋”을 새로 만들어, push한 이력을 안전하게 되돌릴 때.
- rebase: 이력을 한 줄로 정리. 아직 push 안 한 브랜치에서만 사용 권장.
reset·revert·rebase로 커밋을 되돌리고 이력을 정리할 수 있으며, 이미 push한 브랜치에서는 revert가 안전합니다. 다음으로 Git 시리즈 목차에서 다른 주제를 골라 읽어보면 좋습니다.
이전 글: Git 실전 가이드 #3: 원격 저장소와 협업
시리즈 목차: Git 시리즈 전체 목차
reset이 건드리는 세 층 (요약)
flowchart LR
subgraph layers [한 커밋 기준]
H[HEAD / 커밋]
I[스테이징 Index]
W[작업 디렉터리]
end
H --> I --> W
설명: --soft는 HEAD만 이동, --mixed는 스테이징까지 풀고, --hard는 세 층을 모두 베이스 커밋과 맞춥니다.
고급: bisect·worktree·병합 시뮬레이션
git bisect로 회귀 커밋 찾기
히스토리가 길고 “언제부터 깨졌는지”를 모를 때, git bisect start → 알려진 나쁜 커밋 → 알려진 좋은 커밋을 지정한 뒤, 중간 커밋마다 빌드·테스트를 실행하여 좋/나쁨을 표시합니다. 자동화하려면 git bisect run ./scripts/check.sh처럼 0이면 좋음, 0이 아니면 나쁨 스크립트를 붙입니다. 플래키 테스트가 있으면 오판이 나므로, 먼저 테스트 안정화가 선행되어야 합니다.
git worktree로 병렬 작업
긴 rebase·bisect가 진행 중일 때 다른 브랜치에서 급한 핫픽스가 필요하면, 같은 저장소에 두 번째 작업 트리를 추가합니다. git worktree add ../hotfix-main main 형태로 독립 디렉터리에서 커밋·푸시를 진행하면, 원래 트리의 미완료 작업을 건드리지 않습니다.
merge-tree로 충돌 예행 연습
Git 2.38 이상에서는 git merge-tree --write-tree A B로 작업 디렉터리를 전혀 건드리지 않고 가상 병합 결과를 계산할 수 있습니다. 충돌이 없으면 종료 코드 0과 결과 트리 해시를, 충돌이 있으면 종료 코드 1과 충돌 파일 목록을 출력하므로, CI에서 “이 브랜치가 main과 깨끗하게 병합되는가”를 검사하는 스크립트에 그대로 쓸 수 있습니다. 예전 형식인 git merge-tree $(git merge-base A B) A B는 호환성 때문에 남아 있지만 결과 해석이 어려워 새 형식이 권장됩니다.
운영 팁: reflog와 GC
reflog는 HEAD와 브랜치의 이동 기록이지만, 영원히 남지는 않습니다. 기본 설정에서 도달할 수 있는 항목은 90일, 어느 브랜치에서도 도달할 수 없게 된 항목은 30일이 지나면 만료되고(gc.reflogExpire, gc.reflogExpireUnreachable), 그 뒤 git gc가 실행되면 해당 커밋 객체도 삭제될 수 있습니다. 또 reflog는 로컬 저장소에만 있는 기록이라 다른 컴퓨터나 새로 clone한 저장소에서는 볼 수 없습니다. 중요한 실험 전에는 git branch backup/rebase-before-cleanup처럼 포인터를 남겨 두면 기간 제한 없이 안전하게 돌아갈 수 있습니다.