git rebase: 동작 원리, 대화형 모드, 커밋 squash
git rebase는 현재 브랜치의 커밋을 새 기준점 위로 옮깁니다. Git은 내 커밋을 잠시 떼어 두고, 브랜치를 지정한 커밋 끝으로 옮긴 뒤 같은 변경을 새 커밋으로 다시 적용합니다. 그 결과 병합 커밋 없이 선형 히스토리를 얻고, git rebase -i로 커밋을 합치기(squash), 이름 바꾸기, 삭제할 수 있습니다. 다른 사람과 공유한 커밋은 다시 쓰면 안 됩니다.
git rebase가 하는 일
git rebase는 여러분의 브랜치를 히스토리의 다른 지점으로 "옮겨 심습니다". 예를 들어 일주일 전 main에서 feature를 떼어 냈는데, 그 뒤 main이 앞으로 나아갔다고 합시다. rebase를 하면 여러분의 브랜치는 마치 방금 최신 main의 끝에서 시작한 것처럼 보입니다.
git switch feature
git fetch origin
git rebase origin/main
내부에서는 세 단계가 일어납니다. Git은 두 브랜치의 공통 조상을 찾고, 그 이후 여러분의 커밋 각각이 담고 있던 변경을 저장하고, 브랜치를 origin/main으로 옮긴 뒤 저장해 둔 변경을 하나씩 다시 적용합니다. 결과는 같은 내용과 같은 메시지를 가지지만 해시가 다른 새 커밋입니다.
이전: A---B---C---F---G main
\
D---E feature
이후: A---B---C---F---G main
\
D'---E' feature
D'와 E'의 프라임 표시는 장식이 아닙니다. 해시가 다르고 부모도 다른 별개의 객체입니다. 이것이 아래 가장 중요한 규칙의 이유입니다.
황금률: 이미 공유한 것은 rebase하지 않기
rebase는 히스토리를 다시 씁니다. 커밋이 여러분에게만 있다면 안전합니다. 하지만 동료가 이미 브랜치를 내려받았다면, rebase 후 그들에게는 옛 D와 E가, 여러분에게는 새 D'와 E'가 남습니다. 다음 pull에서 히스토리가 두 번 쌓이고, 충돌을 다시 해결해야 합니다.
실용적인 규칙: rebase는 내 로컬 브랜치와 한 사람만 쓰는 개인 기능 브랜치에 씁니다. main, develop, 릴리스 브랜치 같은 공용 브랜치는 절대 rebase하지 않습니다.
rebase 후 브랜치 보내기
브랜치가 이미 서버에 있었다면 일반 git push는 거부됩니다. 로컬 히스토리가 더는 원격의 이어지는 히스토리가 아니기 때문입니다. 안전한 형태의 강제 push를 써야 합니다.
git push --force-with-lease
--force와 달리 --force-with-lease는 서버에 여러분이 본 적 없는 커밋이 생겼다면 덮어쓰기를 거부합니다. 그래서 다른 사람의 작업을 실수로 지우지 않습니다.
rebase 중 충돌 해결하기
커밋이 하나씩 적용되므로 충돌은 어느 커밋에서든 생길 수 있고, 여러 번 연달아 생길 수도 있습니다. Git은 멈추고 어느 커밋을 적용하지 못했는지 알려 줍니다.
$ git rebase origin/main
Auto-merging src/api.js
CONFLICT (content): Merge conflict in src/api.js
error: could not apply 4e1f9a2... Добавить кэш запросов
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
진행 순서:
- 충돌 파일을 열고 맞는 내용을 고르고,
<<<<<<<,=======,>>>>>>>마커를 지웁니다. - 파일을 해결됨으로 표시합니다:
git add src/api.js. 이때 커밋은 하지 않습니다. - 계속합니다:
git rebase --continue. Git은 다음 커밋을 적용합니다.
과정을 조작하는 나머지 명령:
| 명령 | 동작 |
|---|---|
git rebase --continue | 충돌을 해결한 뒤 계속 진행 |
git rebase --skip | 현재 커밋을 통째로 건너뜀(그 변경은 사라짐) |
git rebase --abort | rebase를 중단하고 브랜치를 처음 상태로 되돌림 |
"내 쪽"과 "남의 쪽"의 의미에 주의하세요. rebase 중에는 HEAD가 여러분의 브랜치를 옮겨 놓는 기준점이고, "들어오는" 변경은 여러분의 커밋입니다. 그래서 rebase 중의 git checkout --ours는 main의 버전을, --theirs는 여러분의 버전을 가져옵니다. merge에서는 반대입니다.
반복되는 충돌: rerere
오래 사는 브랜치를 자주 rebase하면서 같은 충돌을 계속 해결해야 한다면 git config --global rerere.enabled true를 켜세요. Git이 여러분이 어떻게 충돌을 해결했는지 기억해서, 다음번에는 그 해결책을 스스로 적용합니다.
대화형 rebase: 공유하기 전에 히스토리 다듬기
rebase에서 가장 쓸모 있는 기능은 대화형 모드입니다. 리뷰에 올리기 전에 내 커밋을 정리할 수 있습니다. "fix", "fix typo", "fix 또 fix" 같은 커밋을 하나의 의미 있는 커밋으로 합치고, 메시지를 고치고, 순서를 바꾸고, 불필요한 것은 버릴 수 있습니다.
git rebase -i HEAD~4 # 마지막 커밋 4개
git rebase -i origin/main # main에 없는 브랜치의 모든 커밋
Git은 편집기에 커밋 목록을 엽니다. 오래된 것부터 최신 순입니다(git log와 반대입니다).
pick 1a2b3c4 Добавить форму входа
pick 5d6e7f8 fix
pick 9a0b1c2 Валидация e-mail
pick 3d4e5f6 опечатка в валидации
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash" but keep only the previous commit's log message
# d, drop = remove commit
줄 맨 앞의 단어를 바꾸고, 저장한 뒤 편집기를 닫으면 Git이 계획을 위에서 아래로 실행합니다.
| 명령 | 효과 |
|---|---|
pick | 커밋을 그대로 둠 |
reword | 변경은 두고 메시지만 고침 |
edit | 그 커밋에서 멈춰 내용을 고치거나(git commit --amend), 여러 커밋으로 나눔 |
squash | 이전 커밋에 합치고, 메시지를 합쳐서 편집 |
fixup | 이전 커밋에 합치고, 이 커밋의 메시지는 버림 |
drop | 커밋을 변경과 함께 삭제 |
줄의 순서를 바꾸면 커밋의 순서가 바뀌고, 줄을 지우면 drop과 같습니다.
git squash: 커밋을 하나로 합치기
Git에는 git squash라는 명령이 따로 없습니다. 커밋을 squash하는 방법은 세 가지이며, 그중 대화형 rebase가 가장 유연합니다.
방법 1. 대화형 rebase
위 예시에서 네 개의 커밋을 두 개의 깔끔한 커밋으로 바꿔 봅시다.
pick 1a2b3c4 Добавить форму входа
fixup 5d6e7f8 fix
pick 9a0b1c2 Валидация e-mail
fixup 3d4e5f6 опечатка в валидации
하나의 묶음에서 첫 커밋은 pick으로 남겨야 합니다. squash와 fixup은 커밋을 바로 앞 커밋에 합치므로, 맨 처음 커밋에는 합칠 대상이 없습니다.
방법 2. fixup 커밋과 autosquash
어느 커밋을 고칠지 미리 알고 있다면, 수정 커밋을 처음부터 표시해서 만들 수 있습니다.
git commit --fixup=1a2b3c4
git rebase -i --autosquash origin/main
Git이 fixup 커밋을 맞는 위치에 자동으로 놓고 fixup으로 표시하므로, 계획을 저장하기만 하면 됩니다. 이 동작을 기본으로 하려면 git config --global rebase.autoSquash true를 설정하세요.
방법 3. 브랜치 전체를 커밋 하나로
git reset --soft $(git merge-base HEAD origin/main)
git commit -m "Вход по e-mail с валидацией"
reset --soft는 브랜치 포인터를 분기점으로 되돌리되, 모든 변경은 인덱스에 남겨 둡니다. 그다음 커밋 하나만 하면 됩니다. 또 다른 방법은 주 브랜치에 병합할 때 git merge --squash를 쓰거나, pull request의 "Squash and merge" 버튼을 쓰는 것입니다.
유용한 rebase 변형
git pull --rebase
일반 pull은 원격 브랜치를 내 브랜치에 병합하므로, 동료와 같은 시기에 커밋했다면 병합 커밋이 생깁니다. git pull --rebase는 대신 내 로컬 커밋을 최신 원격 커밋 위로 옮깁니다. 기본 동작으로 만들려면 git config --global pull.rebase true를 설정하세요. 이것이 rebase의 안전한 영역입니다. 내 로컬 커밋은 아직 아무도 보지 않았기 때문입니다.
--onto: 브랜치 일부를 옮기기
feature-b를 feature-a에서 떼어 냈는데, 이제 feature-b가 feature-a의 커밋 없이 바로 main에서 시작하기를 원한다고 합시다.
git rebase --onto main feature-a feature-b
읽는 방법: feature-b에만 있고 feature-a에는 없는 커밋을 가져와서, main 위로 옮겨 심으라는 뜻입니다.
--exec: 커밋마다 검사하기
git rebase -i --exec "npm test" origin/main
커밋을 적용할 때마다 테스트가 실행되고, 실패하면 rebase가 그 커밋에서 멈춥니다. 히스토리의 모든 커밋이 빌드되어야 할 때 유용합니다.
--update-refs: 연결된 브랜치 묶음
서로 의존하는 브랜치 사슬을 유지한다면, --update-refs(Git 2.38 이상) 옵션은 위쪽 브랜치를 rebase할 때 중간 브랜치도 함께 옮깁니다.
rebase 되돌리기
진행 중이라면 git rebase --abort를 씁니다. rebase가 끝났고 결과가 마음에 들지 않는다면, 옛 커밋은 사라지지 않았습니다. 참조 로그가 기억하고 있습니다.
$ git reflog
e5f6a7b HEAD@{0}: rebase (finish): returning to refs/heads/feature
e5f6a7b HEAD@{1}: rebase (pick): Валидация e-mail
...
c3d4e5f HEAD@{5}: commit: опечатка в валидации
$ git reset --hard HEAD@{5}
git reflog에서 rebase를 시작하기 전 항목을 찾아 그 위치로 브랜치를 되돌리세요. rebase 직후라면 더 짧은 방법도 있습니다: git reset --hard ORIG_HEAD. reflog는 로컬에만 있고 기본적으로 약 90일간 항목을 보관하므로, rebase가 잘못되어도 당황할 필요는 없습니다.
흔한 실수
- 공용 브랜치를 rebase하고 강제 push함. 동료들의 히스토리가 엉킵니다. 해결: 팀과 상의하고, 올바른 버전을 가진 사람의 reflog로 브랜치를 복구하고, 서버에서 보호된 브랜치의 강제 push를 막으세요.
--force-with-lease대신--force를 씀. 동료가 같은 브랜치에 방금 push한 커밋을 지울 수 있습니다.- 계획의 첫 줄에 squash를 씀. Git이 "cannot 'squash' without a previous commit" 오류를 냅니다.
- 작업 트리가 더러운 상태로 rebase를 시작함. Git이 실행을 거부합니다. 변경을 커밋하거나,
git stash에 넣거나,git rebase --autostash를 쓰세요. - 충돌을 해결한 뒤
git rebase --continue대신git commit을 함. 불필요한 커밋이 생깁니다.--continue후라면 대화형 rebase로 그 커밋을 지울 수 있습니다.
정리
git rebase는 내 커밋을 새 기준점에 옮겨 다시 만들므로, 히스토리가 일직선이고 깔끔해집니다. 대화형 모드는 임시 커밋을 의미 있는 히스토리로 바꿔 줍니다. squash와 fixup은 변경을 합치고, reword는 메시지를 고치고, drop은 불필요한 커밋을 버립니다. 핵심 규칙은 하나입니다. 아직 다른 사람과 공유하지 않은 것만 rebase하고, 자기 원격 브랜치를 다시 쓴 뒤에는 --force-with-lease로 보내세요. rebase와 커밋 정리는 Git 강의에서 실습으로 다룹니다.
자주 묻는 질문
git rebase는 코드 내용을 바꾸나요?
충돌이 없다면 최종 파일 내용은 병합과 같습니다. 다만 커밋은 새 해시와 새 부모로 다시 만들어집니다. 충돌을 다르게 해결했다면 중간 커밋의 내용도 달라집니다.
가장 첫 커밋 위에 브랜치를 rebase할 수 있나요?
git rebase -i --root를 쓰면 루트 커밋을 포함한 모든 커밋이 계획에 들어갑니다. 가장 첫 커밋의 메시지를 고치고 싶을 때처럼 드물게 필요합니다.
rebase가 커밋의 작성자와 날짜를 유지하나요?
작성자와 작성 날짜는 유지되고, 커미터와 커밋 날짜는 현재 시각으로 바뀝니다. --committer-date-is-author-date 옵션을 쓰면 커밋 날짜도 작성 날짜와 같게 둘 수 있습니다.
merge 커밋도 rebase할 수 있나요?
기본적으로 rebase는 히스토리를 일직선으로 만들면서 병합 커밋을 버립니다. 병합 구조를 유지하려면 git rebase --rebase-merges를 쓰세요.
rebase가 진행 중인지 어떻게 알 수 있나요?
git status가 interactive rebase in progress 또는 You are currently rebasing이라고 표시하고, 사용할 수 있는 명령을 안내합니다. 많은 셸과 IDE도 프롬프트나 상태 표시줄에 이를 보여 줍니다.