Git 머지 전략 비교: fast-forward, --no-ff, rebase
들어가며
서버에서 git pull 을 했을 뿐인데 히스토리에 Merge branch 'main' of ... 라는 커밋이 생긴 적이 있으신가요?
반대로 기능 브랜치를 합쳤는데 머지 커밋이 하나도 없어서, 어디까지가 그 기능이었는지 찾기 어려웠던 적도 있을 겁니다.
둘 다 Git 이 브랜치를 어떤 방식으로 합쳤느냐의 차이입니다.
이번 글에서는 fast-forward, --no-ff, rebase 세 가지를 같은 예제로 나란히 놓고, 합친 뒤 히스토리가 어떤 모양으로 남는지 비교해 보겠습니다.
1. 먼저 알아 둘 것: 브랜치는 커밋을 가리키는 포인터
Git 의 브랜치는 커밋을 담는 상자가 아니라, 특정 커밋 하나를 가리키는 이름표입니다. 새 커밋을 만들면 이름표가 그 커밋으로 옮겨 갈 뿐입니다.
"합친다"는 것도 결국 이 이름표를 어디로 옮기고, 필요하면 커밋을 새로 만드느냐의 문제입니다. 아래 세 방식은 그 선택이 다릅니다.
2. fast-forward: 포인터만 앞으로
main 에서 feature 를 만든 뒤 main 에는 아무 커밋도 없었다면, feature 는 main 의 연장선 위에 있습니다.
이때 Git 은 새 커밋을 만들 필요가 없습니다. main 이름표를 feature 의 끝(B)으로 옮기기만 하면 됩니다.
결과는 일직선입니다. 머지 커밋이 없으니 히스토리는 깔끔하지만, A 와 B 가 원래 feature 에서 온 커밋이라는 흔적도 남지 않습니다.
git switch main
git merge --ff-only feature # 갈라져 있으면 합치지 않고 실패--ff-only 는 fast-forward 가 가능할 때만 합치라는 옵션입니다.
배포 서버처럼 스스로 커밋을 만들 일이 없는 곳에서는 git config pull.ff only 를 걸어 두면, 갈라진 상태를 조용히 머지해 버리는 사고를 막을 수 있습니다.
3. --no-ff: 일부러 머지 커밋을 남기기
fast-forward 가 가능한 상황에서도 --no-ff 를 주면 Git 은 머지 커밋 M 을 새로 만듭니다.
M 은 부모가 둘(C1, B)인 커밋이라, 히스토리에 "여기서 feature 가 들어왔다"는 갈래가 그대로 남습니다.
git switch main
git merge --no-ff feature이 방식의 장점은 되돌리기에서 드러납니다.
기능 전체를 빼야 할 때 git revert -m 1 <M> 한 번이면 됩니다. fast-forward 로 합쳤다면 A, B 를 하나씩 찾아 되돌려야 합니다.
대신 머지 커밋이 쌓이는 만큼 히스토리 그래프는 복잡해집니다.
4. rebase: 커밋을 새로 다시 쓰기
이번에는 feature 를 만든 뒤 main 에도 C2 가 생긴, 진짜로 갈라진 상황입니다.
이 상태에서 feature 에서 git rebase main 을 하면 Git 은 A, B 의 변경을 C2 위에 다시 적용해 새 커밋 A', B' 를 만듭니다.
원래 A, B 는 더 이상 어떤 브랜치도 가리키지 않게 됩니다(아래 그림의 점선).
git switch feature
git rebase main
git switch main
git merge --ff-only feature # 이제 fast-forward 로 합쳐진다rebase 뒤에는 feature 가 main 의 연장선이 되므로, 합칠 때 다시 fast-forward 가 됩니다. 그래서 히스토리가 끝까지 일직선으로 남습니다.
A' 는 A 와 내용은 같아도 해시가 다른 새 커밋입니다. 이미 다른 사람과 공유한 브랜치를 rebase 하면 상대의 히스토리와 갈라지고, force push 가 필요해집니다. rebase 는 아직 나만 쓰는 브랜치에서만 하는 것이 원칙입니다.
5. 한눈에 비교
| fast-forward | --no-ff | rebase | |
|---|---|---|---|
| 새 커밋 | 없음 | 머지 커밋 1개 | 내 커밋 수만큼 다시 만듦 |
| 히스토리 모양 | 일직선 | 갈래가 남음 | 일직선 |
| 기능 단위로 되돌리기 | 커밋마다 | 머지 커밋 하나 | 커밋마다 |
| 조건 | 갈라지지 않았을 때만 | 항상 가능 | 공유 전 브랜치 |
6. 헷갈리기 쉬운 점
git pull은 받아오기만 하는 명령이 아닙니다.fetch에merge(설정에 따라rebase)를 더한 명령입니다. 로컬과 원격이 갈라져 있으면 머지 커밋이 생기고, 최근 Git 은 방식을 정해 두지 않았다면 멈추고 어떻게 합칠지 묻습니다.- rebase 는 커밋을 "옮기는" 것이 아닙니다. 새로 만들고 옛 커밋은 버립니다. 해시가 바뀌는 이유가 여기 있습니다.
- squash 머지는 머지 커밋이 아닙니다. 브랜치의 변경을 새 커밋 하나로 합쳐 올리는 것이라 부모가 하나입니다.
7. 면접에서 자주 받는 질문
fast-forward 가 되는데도 --no-ff 로 머지하는 이유는요?
기능 브랜치 하나를 히스토리에 한 덩어리로 남기기 위해서입니다. 언제 무엇이 합쳐졌는지 갈래로 보이고, 되돌릴 때 머지 커밋 하나만 revert 하면 됩니다. 대신 히스토리가 일직선이 아니게 됩니다.
공유 브랜치를 rebase 하면 어떻게 되나요?
내 쪽 커밋 해시가 모두 바뀌어 원격 브랜치와 갈라집니다. 올리려면 force push 가 필요하고, 그 브랜치를 받아 두었던 다른 사람은 옛 커밋 위에 작업한 상태가 되어 충돌을 정리해야 합니다.
squash 머지와 --no-ff 머지는 뭐가 다른가요?
--no-ff 는 부모가 둘인 머지 커밋을 만들고 기능 브랜치의 커밋들도 그대로 히스토리에 남습니다. squash 는 변경 전체를 새 커밋 하나로 만들어 부모가 하나이고, 원래 커밋들은 대상 브랜치에 남지 않습니다.
정리
- fast-forward 는 이름표만 옮깁니다. 갈라지지 않았을 때만 가능하고 히스토리가 일직선입니다.
--no-ff는 머지 커밋으로 기능 묶음을 남깁니다. 되돌리기 쉬운 대신 그래프가 복잡해집니다.- rebase 는 커밋을 새로 다시 써서 일직선을 만듭니다. 공유한 브랜치에는 쓰지 않습니다.
결국 ff 는 포인터 이동, no-ff 는 묶음, rebase 는 다시 쓰기입니다. 팀에서 어떤 히스토리를 원하는지에 따라 고르면 됩니다.