들어가며 — 같은 코드, 다른 역사
merge와 rebase 논쟁은 대개 취향 싸움처럼 소비됩니다. 깔끔한 일직선이 좋다는 쪽과 있었던 일을 그대로 남겨야 한다는 쪽이 각자의 스크린샷을 들고 나옵니다. 그런데 두 명령이 실제로 무엇을 만드는지를 커밋 해시 수준에서 보면, 논쟁의 절반은 저절로 정리됩니다.
핵심부터 말하면 이렇습니다. 머지는 커밋을 하나 더 만들고 기존 커밋은 건드리지 않습니다. 리베이스는 기존 커밋을 옮기는 것이 아니라, 내용은 같고 부모가 다른 새 커밋을 만들어 냅니다. 그래서 해시가 바뀝니다. 이 한 문장이 "공유된 브랜치는 리베이스하지 않는다"는 규칙의 유일한 근거이고, 규칙의 예외가 어디까지인지도 여기서 나옵니다.
커밋 해시가 말해 주는 것 — 리베이스는 커밋을 옮기지 않는다
갈라진 브랜치 하나를 준비했습니다.
$ git log --oneline --graph --all
* 8c377e2 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 6d466fd 결제 요청 타임아웃 조정
| * c76f2e6 (main) 로깅 포맷 통일
|/
* a2338b7 주문 API 스키마 정리
여기에 리베이스를 겁니다.
$ git rebase main
Rebasing (1/2)
Rebasing (2/2)
Successfully rebased and updated refs/heads/feature/checkout.
$ git log --oneline --graph --all
* 4a17a14 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 7040fea 결제 요청 타임아웃 조정
* c76f2e6 (main) 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
커밋 메시지도 같고 변경 내용도 같은데 해시가 전부 바뀌었습니다. 8c377e2가 4a17a14가 되고, 6d466fd가 7040fea가 됐습니다. 이유는 커밋 객체의 생김새를 보면 즉시 납득됩니다.
$ git cat-file -p HEAD
tree c9cff0bf1786d3c413655ad220869022721584a4
parent 7040fea9c7bc15cd1d77bb26d843c8ba81140fec
author Demo <d@e.com> 1785081971 +0900
committer Demo <d@e.com> 1785081971 +0900
결제 실패 재시도 추가
커밋 객체 안에는 부모 커밋의 해시가 들어 있고, 커밋의 해시는 이 내용 전체를 해싱한 값입니다. 부모가 달라지면 커밋 해시는 반드시 달라집니다. 즉 리베이스는 커밋을 이동시키는 명령이 아니라 재작성하는 명령이고, 재작성이라는 말은 정확히 "새 객체를 만들고 브랜치 참조를 그쪽으로 옮겼다"는 뜻입니다.
그러면 원래 커밋은 어디로 갔을까요. 사라지지 않았습니다. 아무도 가리키지 않게 됐을 뿐입니다.
$ git reflog show feature/checkout
4a17a14 feature/checkout@{0}: rebase (finish): refs/heads/feature/checkout onto c76f2e6
8c377e2 feature/checkout@{1}: commit: 결제 실패 재시도 추가
6d466fd feature/checkout@{2}: commit: 결제 요청 타임아웃 조정
a2338b7 feature/checkout@{3}: branch: Created from HEAD
리베이스 결과를 리뷰하고 싶다면 두 버전을 나란히 비교하는 전용 명령이 있습니다. 강제 푸시된 남의 브랜치를 다시 볼 때 특히 유용합니다.
$ git range-diff main feature/checkout@{2} feature/checkout
1: 6d466fd = 1: 7040fea 결제 요청 타임아웃 조정
-: ------- > 2: 4a17a14 결제 실패 재시도 추가
황금률의 진짜 경계선
"이미 푸시한 커밋은 리베이스하지 마라"는 문장은 자주 인용되지만 조금 부정확합니다. 문제의 본질은 푸시 여부가 아니라 다른 사람의 저장소에 그 커밋 객체가 있는지, 그리고 그 위에 누군가의 작업이 얹혀 있는지입니다.
동료의 클론에는 8c377e2가 그대로 남아 있습니다. 내가 4a17a14로 갈아 끼운 브랜치를 강제 푸시하면, 동료가 다음에 받아 올 때 자기 로컬의 옛 커밋과 새 커밋을 둘 다 가진 상태가 됩니다. 여기서 동료가 무심코 머지하면 같은 변경이 두 번 들어간 이력이 만들어지고, 충돌은 이 지점에서 시작됩니다.
그래서 규칙은 이렇게 다시 쓰는 편이 정확합니다. 다른 사람의 작업이 그 위에 쌓여 있는 커밋은 재작성하지 않는다. 이 기준으로 보면 흔히 말하는 예외들이 예외가 아니라 규칙의 정상적인 적용임을 알 수 있습니다.
- 아직 아무도 체크아웃하지 않은 내 리뷰 브랜치는 리베이스해도 됩니다. 많은 팀이 리뷰 중 리베이스를 관행으로 허용하는 이유입니다.
- 내 로컬에만 있는 커밋을 정리하는 리베이스는 언제나 안전합니다. 아래 설정은 그것을 기본 동작으로 만듭니다.
$ git config --global pull.rebase true
$ git config --global rebase.autoStash true
문제는 그다음입니다. 리베이스한 브랜치를 올리려면 강제 푸시가 필요한데, 여기서 흔히 보이는 조언이 위험합니다.
# 흔한 조언 — 남의 커밋도 같이 지웁니다
$ git push -f origin feature/checkout
# 최소한 이렇게
$ git push --force-with-lease origin feature/checkout
To github.com:example/shop.git
+ 8c377e2...4a17a14 feature/checkout -> feature/checkout (forced update)
여기서 한 걸음 더 들어가야 합니다. --force-with-lease는 원격 추적 참조가 내가 마지막으로 본 값과 같은지를 확인하는데, IDE나 백그라운드 작업이 방금 fetch를 해 버렸다면 그 참조는 이미 최신으로 갱신돼 있습니다. 내가 보지 못한 커밋이 들어왔는데도 검사는 통과합니다. 이 구멍을 막는 옵션이 따로 있습니다.
$ git config --global push.useForceIfIncludes true
$ git push --force-with-lease origin feature/checkout
스택처럼 쌓아 올린 브랜치가 여러 개라면, 아래 옵션이 아래쪽 브랜치들의 참조까지 함께 옮겨 줍니다. 이것을 모르면 리베이스할 때마다 하위 브랜치를 손으로 다시 붙여야 합니다.
$ git rebase --update-refs main
$ git config --global rebase.updateRefs true
머지 커밋은 정보인가 소음인가
머지 커밋을 없애야 한다는 주장의 근거는 대개 "로그가 지저분하다"입니다. 이 불만은 대부분 도구 사용법으로 해결됩니다.
머지 커밋은 부모가 둘입니다. 첫 번째 부모는 머지를 받은 쪽, 즉 main의 직전 상태이고, 두 번째 부모가 들어온 브랜치입니다. 첫 번째 부모만 따라가면 main 입장에서의 통합 순서만 남습니다.
$ git log --oneline --graph --first-parent main
* 40600e3 Merge branch 'feature/checkout'
* c76f2e6 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
릴리스 노트를 만들거나 "언제 무엇이 들어갔는지"를 볼 때 원하는 그림이 바로 이것입니다. 이분 탐색도 같은 방식으로 브랜치 내부를 건너뛰고 통합 단위로만 좁힐 수 있습니다.
$ git bisect start --first-parent
$ git bisect bad main
$ git bisect good v2.14.0
그래서 머지 커밋 자체는 소음이 아닙니다. 진짜 소음은 통합 의도가 없는 머지 커밋입니다. 리베이스 없이 습관적으로 받아 오다가 생기는 "Merge branch 'main' into feature" 같은 커밋이 그렇습니다. 이것은 아무 정보도 담고 있지 않고 그래프만 꼬아 놓습니다. 아래 설정이면 그 종류의 머지 커밋은 아예 생기지 않습니다.
$ git config --global pull.ff only
정리하면 대립 구도가 잘못됐습니다. 선택지는 머지 커밋을 허용할 것인가가 아니라 머지 커밋이 사람의 결정을 기록하게 할 것인가, 아니면 도구가 아무 때나 찍게 둘 것인가입니다.
스쿼시 머지가 주는 것과 가져가는 것
스쿼시 머지는 브랜치의 커밋 전부를 하나로 합쳐 얹습니다. 얻는 것은 분명합니다. main의 커밋 하나가 PR 하나에 정확히 대응하고, 그 커밋은 CI를 통과한 상태이며, 되돌리기는 커밋 하나를 revert하면 끝납니다. 리뷰 단위와 이력 단위가 일치한다는 것은 생각보다 큰 장점입니다.
가져가는 것도 분명한데 잘 이야기되지 않습니다.
첫째, 이분 탐색의 해상도가 PR 크기로 고정됩니다. 800줄짜리 PR이 스쿼시되면 이분 탐색은 그 800줄까지만 좁혀 주고, 그 안쪽은 손으로 읽어야 합니다. 반대로 개별 커밋이 남아 있으면 더 좁힐 수 있지만, 브랜치 중간 커밋은 CI를 통과한 적이 없어 빌드가 깨질 수 있습니다. 이때는 건너뛰라고 알려 주면 됩니다.
$ git bisect skip
둘째, 체리픽의 정밀도를 잃습니다. 핫픽스 브랜치에 그 PR의 로그 개선 한 줄만 옮기고 싶어도, 남아 있는 커밋은 800줄짜리 덩어리뿐입니다.
셋째, 이것이 가장 많이 물리는 함정인데, Git 입장에서 그 브랜치는 머지된 적이 없습니다. 스쿼시 커밋의 부모는 원본 브랜치가 아니기 때문입니다. 그 결과 아래 명령은 이미 반영된 브랜치를 목록에 보여 주지 않습니다.
$ git branch --merged main
hotfix/coupon-null
* main
같은 이유로 스쿼시 머지된 브랜치를 지우지 않고 계속 쓰다가 다시 머지하면, Git은 공통 조상 이후의 변경을 전부 새것으로 취급해 이미 반영된 내용까지 충돌로 들이밉니다. 스쿼시 머지를 쓰는 팀은 머지 직후 소스 브랜치를 지운다는 규칙을 함께 가져가야 합니다.
리베이스 도중 같은 충돌이 반복되는 이유
머지는 충돌을 한 번만 해결하는데 리베이스는 커밋마다 같은 자리를 다시 물어봅니다. 짜증의 원인이지만 동작은 정확합니다.
머지는 양쪽 끝점과 공통 조상, 이렇게 세 지점만 비교합니다. 반면 리베이스는 커밋을 하나씩 다시 적용하고, 그때마다 삼자 병합을 새로 수행합니다. 같은 파일의 같은 구역을 내 커밋 세 개가 연달아 건드렸다면 그 구역의 충돌을 세 번 만나게 됩니다. 리베이스는 한 번의 병합이 아니라 N번의 병합입니다.
해결책은 같은 해결을 기억시키는 것입니다.
$ git config --global rerere.enabled true
$ git rebase main
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Recorded preimage for 'src/checkout/retry.ts'
error: could not apply 6d466fd... 결제 요청 타임아웃 조정
# 충돌을 해결하고 계속
$ git add src/checkout/retry.ts
Recorded resolution for 'src/checkout/retry.ts'
$ git rebase --continue
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Resolved 'src/checkout/retry.ts' using previous resolution.
마지막 줄이 rerere가 일하는 소리입니다. 충돌 이전 상태와 내가 만든 결과를 짝지어 저장해 두었다가, 같은 충돌 상태가 다시 나타나면 저장된 결과를 그대로 적용합니다. 리베이스를 중단했다가 다시 시도할 때도 그대로 살아 있습니다.
충돌 해결 자체를 쉽게 만드는 설정도 같이 걸어 두는 편이 좋습니다. 기본 충돌 표시는 양쪽 결과만 보여 주지만, 아래 설정은 공통 조상의 원본까지 함께 보여 줍니다. 상대가 무엇을 바꿨는지가 아니라 둘 다 무엇에서 출발했는지를 알아야 올바르게 합칠 수 있습니다.
$ git config --global merge.conflictStyle zdiff3
빨리 감기, 병합 커밋 강제, 그리고 팀 정책 고르기
브랜치가 갈라지지 않았다면 머지는 새 객체를 아예 만들지 않고 참조만 앞으로 옮깁니다.
$ git merge feature/checkout
Updating c76f2e6..4a17a14
Fast-forward
retry.txt | 2 ++
1 file changed, 2 insertions(+)
반대로 병합 사실을 반드시 기록하고 싶다면 강제로 머지 커밋을 만듭니다.
$ git merge --no-ff feature/checkout
Merge made by the 'ort' strategy.
retry.txt | 2 ++
1 file changed, 2 insertions(+)
여기까지 오면 정책 선택은 취향이 아니라 팀이 무엇을 쓰는지의 문제가 됩니다. 이분 탐색을 실제로 돌리는가, 릴리스 추적이 필요한가, 리뷰 단위가 커밋인가 PR인가.
| 정책 | main 이력 모양 | PR 경계 기록 | 이분 탐색 해상도 | 되돌리기 | 잘 맞는 팀 |
|---|---|---|---|---|---|
| 머지 커밋 강제 | 갈래가 보이는 그래프 | 머지 커밋에 남음 | 커밋 단위, 첫 부모로 좁히기 가능 | 머지 커밋을 첫 부모 기준으로 revert | 릴리스 추적과 감사 이력이 중요한 팀 |
| 리베이스 후 빨리 감기 | 완전한 일직선 | 남지 않음 | 커밋 단위로 가장 촘촘함 | 커밋 하나씩 revert | 커밋 하나하나를 리뷰 단위로 다루는 팀 |
| 스쿼시 머지 | 일직선, PR당 커밋 하나 | 커밋 하나가 곧 PR | PR 단위로 고정 | 커밋 하나 revert로 끝 | PR 단위로 리뷰하고 브랜치를 바로 지우는 팀 |
| 리베이스 후 머지 커밋 | 일직선 위의 통합 지점 | 머지 커밋에 남음 | 커밋 단위 | 머지 커밋 revert | 이력의 촘촘함과 경계 기록을 둘 다 원하는 팀 |
기준을 하나만 고르라면 이렇게 묻는 편이 실용적입니다. 장애가 났을 때 이 저장소에서 원인 커밋을 어떻게 찾을 계획인가. 이분 탐색을 실제로 쓰는 팀은 커밋의 촘촘함이 자산이고, PR 설명과 이슈 링크로 추적하는 팀은 커밋 하나가 PR 하나인 편이 훨씬 편합니다. 답이 정해지면 나머지는 따라옵니다.
마치며 — 규칙 하나를 기억한다면
merge와 rebase에 대한 조언은 많지만 근거는 하나입니다. 리베이스는 커밋을 옮기지 않고 새로 만들며, 따라서 해시가 바뀝니다. 여기서 공유 브랜치 규칙이 나오고, 강제 푸시가 필요한 이유가 나오고, 같은 충돌이 반복되는 이유가 나오고, 스쿼시된 브랜치가 머지된 것으로 인식되지 않는 이유도 나옵니다.
Git 객체 모델이 명령의 동작을 어떻게 결정하는지는 Git 내부 구조로 이해하는 명령어 편에서 이어서 다뤘습니다. 되돌리는 쪽이 급하다면 상황별 되돌리기 편이 더 빠른 길입니다.
현재 단락 (1/117)
merge와 rebase 논쟁은 대개 취향 싸움처럼 소비됩니다. 깔끔한 일직선이 좋다는 쪽과 있었던 일을 그대로 남겨야 한다는 쪽이 각자의 스크린샷을 들고 나옵니다. 그런데 두 명...