Git에서 되돌리기: reset, revert와 restore

1. 개요

누구나 Git을 사용하다 보면 되돌려야 할 일이 생긴다: 파일을 실수로 수정하거나, git add를 너무 많이 하거나, 커밋하면 안 되는 것을 커밋하는 일 말이다. Git에는 이런 경우를 위한 세 가지 주요 명령어가 있다: restore, reset, revert. 이름은 비슷하지만, 각 명령어는 서로 다른 영역에 영향을 미친다.

이 글은 같은 예제 저장소를 이용해 각 명령어를 설명한다. 이 글을 다 읽고 나면, 어떤 상황에서 어떤 명령어를 써야 할지 알 것이다. 실수로 reset --hard를 실행했을 때 커밋을 되살리는 방법도 배우게 된다.

2. 샘플 저장소 준비

먼저 Git의 세 가지 영역을 기억해야 한다:

  • Working tree: 디스크에서 현재 편집 중인 파일들.
  • Staging area (index): 다음 커밋에 들어갈 내용들.
  • Commit history: 저장된 커밋들. HEAD는 현재 커밋을 가리킨다.

네 개의 커밋을 가진 작은 저장소를 만들어보자:

mkdir order-service && cd order-service
git init -b main

echo "class Order {}" > Order.java
git add Order.java && git commit -m "Order 클래스 추가"

echo "class Customer {}" > Customer.java
git add Customer.java && git commit -m "Customer 클래스 추가"

echo "class Order { int total; }" > Order.java
git commit -am "Order에 total 추가"

echo "class PaymentService {}" > PaymentService.java
git add PaymentService.java && git commit -m "PaymentService 추가"

git log --oneline

git commit -am 명령어는 이미 추적 중인 모든 파일을 stage한 후 커밋한다. git log 결과는 다음과 같을 것이다 (머신마다 해시는 다름):

d4e5f60 (HEAD -> main) PaymentService 추가
c3d4e5f Order에 total 추가
b2c3d4e Customer 클래스 추가
a1b2c3d Order 클래스 추가

3. git restore: 커밋되지 않은 변경사항 되돌리기

git restore는 Git 2.23부터 추가되었다. 이 명령어는 파일 작업만 한다. HEAD를 옮기거나 커밋 이력을 수정하지 않는다. 구버전 Git에서는 git checkout -- <file> 이 역할을 했다.

3.1. Working tree의 변경사항 버리기

Order.java를 수정했다가 마음을 바꿨다고 하자:

echo "class Order { int total; String note; }" > Order.java
git status --short
git restore Order.java
cat Order.java
 M Order.java
class Order { int total; }

git status --short에서 M은 파일이 수정되었지만 아직 stage되지 않았다는 뜻이다. git restore Order.java는 staging area의 내용을 working tree로 복사한다. 아직 stage한 것이 없으므로, 이 내용은 HEAD의 내용과 같다.

주의: git restore로 버린 변경사항은 완전히 사라진다. Git이 저장한 적이 없으므로 reflog도 못 찾는다. 확신이 없으면 restore 대신 git stash를 사용하자.

3.2. 파일을 Staging area에서 제거하기

실수로 git add를 한 파일을 커밋하고 싶지 않다:

echo "class Order { int total; String note; }" > Order.java
git add Order.java
git restore --staged Order.java
git status --short
 M Order.java

--staged 옵션은 HEAD의 내용을 staging area로 복사한다. 결과적으로 파일은 “unstage”되고, 방금 수정한 내용은 working tree에 그대로 남아있다. 예전 명령어는 git reset HEAD Order.java였다.

Staging과 working tree 모두를 한 번에 버리려면:

git restore --staged --worktree Order.java

3.3. 예전 커밋에서 파일 복원하기

--source 옵션으로 어떤 커밋의 파일이든 가져올 수 있다:

git restore --source=HEAD~2 Order.java
cat Order.java
git status --short
class Order {}
 M Order.java

HEAD~2는 “Customer 클래스 추가” 커밋이다. 당시 Order.java는 아직 total을 갖지 않았다. 기본적으로 명령어는 working tree에만 쓰므로, 파일은 M 상태다 (stage되지 않음). 확인해본 후 필요하면 git add하고 커밋하면 된다. 아니라면 git restore Order.java로 되돌리면 된다.

4. git reset: 브랜치 포인터 옮기기

git reset <commit>은 현재 브랜치(와 HEAD)를 지정한 커밋으로 옮긴다. --soft, --mixed, --hard 세 가지 모드는 staging area와 working tree를 덮어쓸지 말지로 다르다.

모드 브랜치/HEAD Staging area Working tree
--soft 대상 커밋으로 이동 그대로 둠 그대로 둠
--mixed (기본값) 대상 커밋으로 이동 대상 커밋에 따라 재설정 그대로 둠
--hard 대상 커밋으로 이동 대상 커밋에 따라 재설정 대상 커밋에 따라 재설정

아래 모든 예제는 d4e5f60 커밋에서 깨끗한 상태에서 시작한다.

4.1. –soft: 커밋 제거, stage된 변경사항 유지

git reset --soft HEAD~1
git log --oneline -1
git status --short
c3d4e5f (HEAD -> main) Order에 total 추가
A  PaymentService.java

“PaymentService 추가” 커밋이 브랜치에서 사라졌다. PaymentService.java 파일은 남아있고 A 상태 (stage됨)다. 커밋 메시지를 수정하거나 여러 커밋을 하나로 합치고 싶을 때 유용하다:

git commit -m "PaymentService 추가 - 결제 처리"

4.2. –mixed: 커밋 제거 및 Unstage

git reset HEAD~1
git status --short
?? PaymentService.java

기본 모드다. 브랜치가 한 커밋 뒤로 물러나고, staging area가 재설정된다. PaymentService.java는 새 파일이므로 지금은 untracked (??)다. 커밋을 나누고 싶을 때 --mixed를 쓴다: 부분씩 stage한 후 여러 커밋으로 나눈다.

4.3. –hard: 모든 것 버리기

git add PaymentService.java && git commit -m "PaymentService 추가"
git reset --hard HEAD~1
ls
HEAD is now at c3d4e5f Order에 total 추가
Customer.java  Order.java

커밋, staging area, 그리고 추적 중인 파일의 working tree 모두가 c3d4e5f로 돌아간다. PaymentService.java는 사라진다.

주의: reset --hard는 추적 중인 파일의 커밋되지 않은 변경사항을 삭제한다. 이 변경사항들은 복구 불가능하다. 반대로, untracked 파일은 삭제하지 않는다. Untracked 파일을 지우려면 git clean -fd를 써야 하며, 이 명령어도 위험하다.

4.4. reflog로 커밋 되살리기

reset으로 제거된 커밋은 즉시 사라지지 않는다. Git은 HEAD가 움직일 때마다 reflog에 기록한다:

git reflog -3
c3d4e5f HEAD@{0}: reset: moving to HEAD~1
9f8e7d6 HEAD@{1}: commit: PaymentService 추가
c3d4e5f HEAD@{2}: commit: PaymentService 추가 - 결제 처리

원하는 커밋으로 reset하면 된다:

git reset --hard 9f8e7d6
git log --oneline -1
9f8e7d6 (HEAD -> main) PaymentService 추가

Reflog는 머신 로컬에만 존재한다. 기본적으로 더 이상 참조되지 않는 항목은 30일 후 만료되어 가비지 컬렉터가 정리한다. 따라서 빨리 복구하는 것이 좋다.

4.5. reset을 쓰면 안 되는 경우

공유 브랜치에 이미 push한 커밋에는 reset을 쓰면 안 된다. Reset 후 로컬과 원격 이력이 달라지므로, git push --force를 강제로 해야 한다. 동료가 이미 pull한 커밋들이 이력이 뒤엉킬 수 있고, 실수로 다시 push할 수도 있다.

꼭 이력을 덮어써야 한다면, 예를 들어 개인 브랜치에서라면, 더 안전한 명령어를 써라:

git push --force-with-lease

--force-with-lease는 원격에 새 커밋이 있는데 아직 fetch하지 않았으면 push를 거부한다. 이 방식으로 다른 사람의 작업을 덮어쓰지 않는다.

5. git revert: 새 커밋으로 되돌리기

git revert는 이력을 지우지 않는다. 이 명령어는 되돌릴 커밋의 반대 내용을 가진 새 커밋을 만든다. 이력은 길어질 뿐 다시 쓰이지 않으므로, 이미 push한 브랜치에는 안전한 방법이다.

5.1. 하나의 커밋 되돌리기

git revert --no-edit HEAD
git log --oneline -3
ls
1a2b3c4 (HEAD -> main) Revert "PaymentService 추가"
9f8e7d6 PaymentService 추가
c3d4e5f Order에 total 추가
Customer.java  Order.java

--no-edit은 에디터를 열지 않고 기본 메시지를 사용한다. 원래 커밋 9f8e7d6은 이력에 남아있다. 새 커밋 1a2b3c4는 PaymentService.java를 삭제한다. 새 커밋의 메시지 본문에는 This reverts commit 9f8e7d6... 같은 줄이 있어 출처를 추적할 수 있다.

이력 중간의 커밋도 revert할 수 있고, 꼭 HEAD일 필요는 없다:

git revert --no-edit c3d4e5f
cat Order.java
class Order {}

이후 커밋들이 같은 줄을 수정했다면 Git은 충돌을 보고한다. 충돌을 해결한 후 git add를 하고 git revert --continue를 실행한다. 중단하려면 git revert --abort를 쓴다.

5.2. 여러 커밋을 하나로 Revert하기

기본적으로 각 커밋 revert마다 새 커밋이 생긴다. 묶고 싶으면 --no-commit을 써라:

git revert --no-commit HEAD~3..HEAD~1
git commit -m "Customer와 Order 변경사항 되돌리기"

A..B 문법은 A 이후부터 B까지의 커밋을 선택한다. Git이 각 커밋을 역순으로 staging area에 적용하고, 나중에 한 번에 커밋한다.

5.3. Merge 커밋 되돌리기

Merge 커밋은 부모가 둘이다. 따라서 Git은 어떤 쪽을 “주선”으로 유지할지 알아야 한다:

git revert -m 1 <hash-merge-commit>

-m 1은 첫 번째 부모를 선택한다. 보통 merge한 브랜치 (예: main)다. 결과적으로 다른 브랜치가 가져온 모든 변경사항이 되돌려진다.

주의: Merge를 revert한 후, 같은 브랜치를 다시 merge하면, Git은 그 커밋들을 “이미 merge됨”으로 본다. 그 변경사항들은 돌아오지 않는다. 다시 가져오려면 revert 커밋 자체를 revert해야 한다.

6. 어느 명령어를 써야 할까?

상황 써야 할 명령어
Stage되지 않은 파일의 변경사항 버리기 git restore <file>
파일 Unstage하기 git restore --staged <file>
파일의 이전 버전 가져오기 git restore --source=<commit> <file>
마지막 커밋 수정/합치기, push 전 git reset --soft HEAD~1
커밋 나누기, push 전 git reset HEAD~1
로컬 커밋 완전히 버리기 git reset --hard <commit>
이미 push한 커밋 되돌리기 git revert <commit>

간단한 규칙을 기억하자:

  • restore는 파일을 다룬다.
  • reset은 브랜치를 다루며, 로컬에서만 쓴다.
  • revert는 공유 이력을 다룬다.

7. 흔한 실수들

  • 공유 브랜치에서 reset을 revert로 착각하기. 가장 흔한 실수다. 이미 push했으면 revert를 써라.
  • 커밋되지 않은 변경사항이 있을 때 reset --hard 실행하기. 실행 전에 git status를 보자. 변경사항이 있으면 먼저 git stash를 하자.
  • reset --hard가 디렉터리를 깨끗이 한다고 생각하기. Untracked 파일은 여전히 남아있다. git clean -n으로 미리 보고, git clean -fd로 지운다.
  • Merge 커밋에서 HEAD~1과 HEAD^의 차이를 모르기. HEAD^2는 두 번째 부모, HEAD~2는 할아버지 (부모의 부모)다. 선형 이력이면 HEAD~1과 HEAD^는 같다.
  • 습관으로 git checkout <file> 쓰기. 여전히 작동하지만, checkout이 너무 많은 역할을 해서 착각하기 쉽다. 현대 Git에서는 restore와 switch가 더 명확하다.

8. 결론

이 글에서 Git의 세 가지 되돌리기 방법을 봤다:

  • git restore는 working tree나 staging area의 파일 내용을 복원하되, 이력은 건드리지 않는다.
  • git reset은 브랜치를 다른 커밋으로 옮기며, --soft, --mixed, --hard 세 단계가 있다. 이 명령어는 push 전 커밋에만 써야 한다.
  • git revert는 역방향 커밋을 만드는 것으로, 공유 브랜치에는 안전한 선택이다.

실수했을 때는 git reflog를 생각해보자. “사라진” 커밋 대부분은 그곳에서 기다리고 있다.

gitversion-controlresetrevertrestore