Git history 토대로 Merge 와 Rebase 이해하기
DaleStudy의 PR #13 을 리뷰받는 과정에서 달레님이 rebase 를 요청해 주셨다. 요청을 받았을 때 rebase 에 대해 잘 모른채로 AI 를 사용해서 처리하려 했다.(개념에 대해 자세히 알지 못하면서 AI 에 맡기고 검수하지 않았다는 것에 대해 반성합니다…) 그렇다보니 rebase 가 무엇을 기준으로 어떤 커밋을 다시 적용하는 작업인지 제대로 이해하지 못한 채 움직였고, 그 과정에서 PR 에는 rebase 로 깔끔해진 로그가 아닌 merge 로그가 섞여있는 여섯 개의 커밋으로 PR 이 올라갔다.
계속 잘못된 커밋이 계속 올라가다보니 달레님께서 rebase 에 관련된 블로그글 과 달레줄레 팟캐스트 6화(merge, rebase 관련) 를 제시해주셨고 자료를 통해 merge 와 rebase 개념의 차이를 이해할 수 있었다. 이해와 검증하지 않고 AI 에 맡기려했던 자신에 대한 부끄럼움을 반성하며 이해한 rebase 개념을 명확하게 머리속에 정리하고자 기록한다.
상황 설명
PR 에는 다음 여섯 개가 보였다.
docs: add MIT license and clarify brand scopeMerge branch 'DaleStudy:main' into mainMerge branch 'DaleStudy:main' into mainfix: align FAQ title with homepage sectionsfix: widen FAQ accordion rowsAllow showcase cards to use full grid width
이 가운데 실제로 내가 작업한 것은 마지막 세 개였고 앞의 문서 커밋과 두 개의 merge 커밋은 upstream/main 과 작업 브랜치를 동기화하려는 과정에서 발생한 것으로 보인다.
해당 상황에서 rebase 를 통한 결과는 현재 upstream 의 최신 기준 위에 실제 작업 세 개만 다시 올리는 것이다.
현재 upstream/main의 최신 기준
↓
FAQ title 작업 커밋
↓
FAQ width 작업 커밋
↓
showcase width 작업 커밋이제부터는 이 목표를 기준으로 merge 와 rebase 를 다시 이해해 보려고 한다.
Merge 와 rebase 이해하기
main 에 A, B, C 가 있고, feature 가 C 에서 갈라져 D 와 E 를 만들었다고 하자.
main: A --- B --- C
\\
feature: D --- EMerge 는 두 역사를 보존한다
merge 를 하면 main 과 feature 의 기존 역사를 모두 남긴 채, 두 부모를 가진 새로운 merge 커밋 M 을 만든다. 정확히는 해당 방법을 3-way merge 라 한다.
main: A --- B --- C ------ M
\\ /
feature: D --- ED 와 E 가 사라지는 것은 아니다. C 에서 갈라져 나온 feature 의 흐름과 main 의 흐름이 M 에서 합쳐진다. 따라서 merge 는 “어떤 두 역사가 언제 합쳐졌는가”를 보존하는 방식이다.
이 특성은 공유 브랜치의 통합 과정을 남길 때 유용하고 병합 과정 자체도 비교적 수월하게 진행할 수 있다. 하지만 개인 작업 브랜치에서 upstream 동기화를 반복하며 merge 를 사용하면, 실제 작업과 직접 관계없는 merge 커밋이 계속 쌓일 수 있다. 그러면 PR 에서 리뷰어가 읽어야 할 것은 작업 내용인데, 히스토리에는 동기화 과정까지 함께 들어온다. 그런 측면에서 merge 방식은 merge 로그가 많아지면 로그를 따라가기 어렵게 된다는 단점을 갖는다.
Rebase 는 작업 커밋을 새 기준 위에 다시 적용한다
rebase 를 하면 feature 의 D 와 E 를 main 의 최신 커밋 C 뒤에 다시 적용한다.
main: A --- B --- C
\
feature: D' --- E'D 와 E 의 변경 내용을 C 뒤에 다시 적용해 D’ 와 E’ 라는 새로운 커밋을 만든다. 그래서 커밋의 내용은 같아도 해시는 달라진다. 그렇기 때문에 rebase 는 깔끔하게 main 브랜치에 위에 쌓인 커밋 로그가 남게 된다. merge 로그없이 작업한 기록만 모아둔 커밋 로그다.
정리하면 다음과 같다.
- merge: 기존 두 역사를 보존하고 merge 커밋을 추가한다.
- rebase: 작업 커밋을 새 기준 위에 다시 적용하고 새로운 커밋을 만든다.
- rebase 후에는 커밋 해시가 바뀌므로, 이미 여러 사람이 사용하는 공유 브랜치에서는 신중해야 한다.
Rebase 의 가장 중요한 주의사항
달레님의 Git rebase 글 에서는 원격에 push 한 브랜치를 rebase 할 때의 기준을 다음과 같이 설명한다.
rebase 를 사용할 때 가장 중요한 원칙이 하나 있습니다.
다른 사람과 공유하는 브랜치의 커밋은 rebase 하지 않는다.
rebase 는 기존 커밋을 새로운 커밋으로 다시 만드는 작업이기 때문에, 이미 원격에 올리고 다른 사람이 내려받은 커밋을 rebase 하면 이력이 꼬여서 팀 전체가 곤란해질 수 있습니다.
안전한 사용 기준을 정리하면 다음과 같습니다.
상황 rebase 가능? 아직 push 하지 않은 로컬 커밋 자유롭게 가능 혼자 쓰는 기능 브랜치 (push 후) 강제 push 감수하면 가능 main 이나 develop 같은 공유 브랜치 절대 하면 안 됨
rebase 는 기존 커밋의 변경 내용을 새로운 기준 위에 다시 적용해 새로운 커밋을 만드는 작업이다. 이미 원격에 push 했고 다른 사람이 내려받은 커밋을 rebase 하면 커밋 해시가 바뀐다. 그 결과 팀원들의 로컬 브랜치와 원격 브랜치가 서로 다른 역사를 가리키게 되어 이후 pull 이나 push 과정에서 충돌과 이력 정리가 발생할 수 있다. 예시로 이해해보자.
사례 1: 아직 push 하지 않은 로컬 커밋
rebase 전
main: A --- B --- C
\
feature: D --- E
main에 F가 추가된 뒤
main: A --- B --- C --- F
\
feature: D --- Efeature 를 main 위로 rebase 하면 작업 커밋이 새 커밋으로 다시 만들어진다.
feature: A --- B --- C --- F --- D' --- E'D', E' 는 D, E 와 변경 내용은 같을 수 있지만 부모 커밋이 달라 해시가 다르다. 아직 push 하지 않은 로컬 커밋이라면 다른 사람이 기존 커밋을 사용하고 있지 않으므로 안전하다.
git switch feature
git rebase main사례 2: 혼자 쓰는 기능 브랜치에 이미 push 한 경우
main: A --- B --- C --- F
\
origin/feature: D --- E로컬 feature 를 최신 main 위로 rebase 하면 다음과 같이 된다.
feature: A --- B --- C --- F --- D' --- E'원격의 D, E 와 로컬의 D', E' 는 서로 다른 이력이다. 평범한 push 는 로컬 이력이 원격 이력을 포함할 때만 (fast-forward) 성공하는데, rebase 로 해시가 바뀌어 이 조건이 깨지므로 push 가 거부된다. 이력을 로컬 기준으로 덮어쓰려면 강제 push 가 필요하다. 혼자 사용하는 브랜치라면 다음 명령으로 원격 브랜치를 갱신할 수 있다.
git push --force-with-lease origin feature--force-with-lease 는 내가 마지막으로 확인한 원격 브랜치 이후 다른 사람이 새 커밋을 올렸다면 push 를 거부한다. 단순한 --force 보다 안전하지만, 브랜치를 혼자 사용한다는 전제가 먼저 충족되어야 한다.
사례 3: main 이나 develop 같은 공유 브랜치
공유 브랜치에서는 기존 커밋을 새 커밋으로 바꾸면 안 된다.
처음에는 main 에 A, B 가 있었고, 팀원 모두가 그 이력을 받아 두었다고 하자. 이후 팀원 B 가 C, D 를 origin/main 에 push 했다. 이후 팀원 B 는 D 에서 feature 를 만들어 E, F 를 작업 중이다. 한편 팀원 A 는 예전에 B 에서 갈라진 개인 브랜치 topic 에서 X 를 만들었지만, 아직 push 하지 않았다.
rebase 전: 원격과 두 사람의 local main은 D를 가리킴
origin/main: A --- B --- C --- D
B local main: A --- B --- C --- D
\
B feature: E --- F
A topic: A --- B --- X (아직 push하지 않음)팀원 A 가 topic 에 있는 상태에서, main 을 X 위로 rebase 하고 강제 push 했다고 하자. 이 명령은 현재 브랜치가 아닌 인자로 준 main 을 바꾼다. rebase 는 원래 main 에만 있던 C, D 를 X 위에 다시 적용해 C’, D’ 라는 새 커밋을 만든다.
# topic 브랜치에서 실행: main을 X 위로 다시 쓴다
git rebase X main
git push --force origin mainrebase 후: 원격 main만 새 이력을 가리킴
origin/main: A --- B --- X --- C' --- D'
B local main: A --- B --- C --- D
\
B feature: E --- FC 와 C', D 와 D' 는 내용이 비슷해도 해시가 다르다. 팀원 B 가 이미 받아 둔 C, D 는 공유된 커밋이었는데 강제 push 로 원격 히스토리에서 빠졌다. 그 결과 원격 main 은 A --- B --- X --- C' --- D' 를, 팀원 B 의 로컬 main 은 A --- B --- C --- D 를 가리키는 서로 다른 두 이력이 되었다.
이 상태에서 팀원 B 가 pull 하면 두 이력을 merge 하게 된다. 공통 조상은 B 뿐이라 X, C’, D’ 가 새 커밋으로 들어오고, 이미 갖고 있는 C, D 와 내용이 겹치는 C’, D’ 가 히스토리에 중복으로 남는다. push 하면 fast-forward 가 아니므로 거부된다. 팀원 B 의 feature(E, F) 도 사라진 D 위에 얹혀 있어, 나중에 feature 를 합치거나 rebase 할 때 C, D 와 C’, D’ 가 겹치면서 충돌하거나 중복 적용되는 문제를 겪는다.
이런 상황이 발생할 수 있기에 공유 브랜치에 변경을 통합할 때는 rebase 가 아닌 기존 커밋을 보존하는 merge 를 사용한다.
ABCDE 를 내 커밋 상황에 대입해 보기
ABCDE 예시에서 C 를 현재 upstream 의 최신 기준이라고 생각하면, 내 세 작업 커밋은 그 위에 올리고 싶은 D, E, F 에 해당한다. 그런데 실제 브랜치에는 그 사이에 upstream 동기화 과정에서 만들어진 merge 와 upstream 에서 따라온 문서 커밋이 섞여 있었다.
PR에서 보인 커밋
upstream 동기화로 들어온 문서 커밋
↓
Merge branch 'DaleStudy:main' into main
↓
Merge branch 'DaleStudy:main' into main
↓
내 작업: FAQ title
↓
내 작업: FAQ width
↓
내 작업: showcase width꼬인 상태:
A --- B --- C --- merge --- merge --- D --- E --- F
\\
upstream 동기화 커밋
원하는 상태:
A --- B --- C --- D' --- E' --- F'D’, E’, F’ 는 원래 작업의 변경 내용을 현재 기준 위에 다시 적용한 결과다. 내용은 같을 수 있지만 새로운 커밋이므로 해시는 달라진다.
정리 방법 1: rebase --onto 로 필요한 커밋만 잘라 얹기
꼬인 브랜치에서 작업 3 개만 골라 새 기준 위에 얹으려면 --onto 가 필요하다. 먼저 git fetch origin 으로 origin/main 을 최신 상태로 만든다.
평범한 git rebase origin/main 은 브랜치에서 origin/main 에 없는 커밋을 전부 얹으려 한다. 동기화 과정에서 섞여 들어온 merge 와 문서 커밋까지 다시 얹히므로 원하는 결과가 아니다. 그래서 “버릴 기준”과 “얹을 곳”을 따로 지정하는 --onto 를 쓴다. --onto 의 자세한 동작은 달레님의 Git rebase 글 을 함께 보면 이해하기 쉽다.
git rebase --onto <얹을 곳> <버릴 기준> <작업 브랜치>내 상황에 대입하면 다음과 같다.
git switch fix/6-faq-title-alignment
git rebase --onto origin/main fafa042(마지막 merge 커밋) fix/6-faq-title-alignment
# 얹을 곳 버릴 기준 대상 브랜치
# (최신 main) (작업 직전) (작업 브랜치)<버릴 기준>=fafa042: 이 커밋을 포함하지 않고 그 이후의 커밋만 골라낸다. 세 작업 커밋의 바로 직전 merge 커밋.<얹을 곳>=origin/main: 골라낸 커밋을 다시 얹을 새 기준.- 결과:
fafa042이후의 작업 3 개만 origin/main 위에 새 커밋 (D’, E’, F’) 으로 쌓인다.
rebase 는 커밋을 새로 만들므로 해시가 바뀐다. 원격 PR 브랜치도 갱신해야 한다.
git push --force-with-lease origin fix/6-faq-title-alignment--force-with-lease 는 마지막으로 확인한 원격 상태가 그대로일 때만 덮어쓴다. 혼자 쓰는 PR 브랜치라면 안전하다 (사례 2).
정리 방법 2: 백업 후 reset 하고 필요한 작업만 다시 쌓기
혹은 현재 상태를 통째로 보존해 둔 뒤 브랜치를 처음부터 다시 쌓는 방법이 있다. Git 이 고르는 범위를 믿는 대신, 내가 필요한 커밋을 직접 고른다.
# 1. 꼬인 현재 상태를 백업 브랜치로 보존
git switch fix/6-faq-title-alignment
git branch backup/fix-6-faq-title-alignment
# 2. 작업 브랜치를 최신 upstream으로 되돌림
git reset --hard origin/main
# 3. 백업에서 작업 3개만 골라 다시 적용
git cherry-pick <FAQ-title> <FAQ-width> <showcase-width>
# 4. 원격 PR 브랜치 갱신
git push --force-with-lease origin fix/6-faq-title-alignmentcherry-pick 은 지정한 커밋의 변경 내용을 새 커밋으로 만들어 현재 위치에 얹는다. 해시를 모른다면 백업 브랜치에서 변경 내용을 확인하며 다시 커밋해도 된다.
결과는 방법 1 과 같다.
--onto: 기존 히스토리에서 필요한 커밋 범위를 정확히 잘라 얹는다.reset방식: 현재 상태를 백업으로 남기고, 깨끗한 기준에서 필요한 작업을 직접 다시 쌓는다.
함께 이해하게 된 명령어: reset, revert, reflog
이 과정에서 히스토리를 다루는 세 명령어를 함께 이해하게 됐다.
reset—브랜치 포인터를 특정 커밋으로 옮긴다. --hard 는 작업 디렉터리와 스테이징 영역까지 그 커밋 상태로 맞춘다. 그래서 백업하지 않은 변경은 잃는다.
revert—기존 커밋을 지우지 않고, 반대 변경을 새 커밋으로 만든다. 공유 브랜치에서 변경을 안전하게 취소할 때 적합하다. merge 커밋은 어느 부모를 기준으로 되돌릴지 지정해야 한다.
git revert -m 1 <merge-commit>다만 커밋 자체는 히스토리에 남으므로, 개인 PR 브랜치처럼 커밋을 없애야 하는 정리에는 rebase 나 reset 이 맞다.
reflog—HEAD 와 브랜치 포인터가 움직인 로컬 기록이다. reset 이나 rebase 를 잘못 실행해도 이전 커밋이 즉시 사라지는 것은 아니다.
git reflog fix/6-faq-title-alignment
git branch backup/old-fix 'fix/6-faq-title-alignment@{1}' # 이전 위치를 브랜치로 보존다만 reflog 는 로컬 기록이고 영구 보존도 아니다.
정리하며
흔히 X 나 스레드에서 이런 AI 한테 모든 개발을 맡기면서 문제가 되는 상황을 많이 봤었는데 혼자 프로젝트 하면서 스스로가 그런 행동을 하고 있다는 것을 느꼈다.
- 앞으로는…
- 내가 한 행동에 대해서는 책임질 수 있는 사람이 된다.
- 동작 전체에 대해 반드시 이해한다.