merge와 rebase, 실무에서 고르는 기준

merge와 rebase 중 무엇을 쓸지는 취향 논쟁처럼 다뤄지지만, 실제로는 기준이 꽤 명확하다. 그 커밋을 남이 이미 가지고 있는가. 이 질문 하나로 대부분의 상황이 갈린다.

이 글은 두 명령이 실제로 무엇을 하는지 그래프로 확인한 다음, 팀에서 그대로 적용할 수 있는 규칙과 인터랙티브 리베이스 실전 절차를 정리한다.

두 명령이 하는 일

featuremain에서 갈라져 나온 뒤 양쪽에 커밋이 쌓인 상황이다. 여기서 main의 변경을 내 브랜치로 가져오는 방법이 두 가지다.

merge와 rebase가 각각 만들어내는 커밋 그래프 비교
merge는 갈라진 흔적을 남기고, rebase는 한 줄로 편다. rebase에서는 커밋 해시가 전부 바뀐다
git switch feature
git merge main     # main의 변경을 머지 커밋으로 합친다

merge는 부모가 둘인 커밋을 새로 만든다. 원래 커밋들은 해시 그대로 남고, 갈라졌다 합쳐진 모양이 이력에 그대로 기록된다.

git switch feature
git rebase main    # 내 커밋들을 main 끝에 다시 얹는다

rebase는 내 커밋을 하나씩 떼어내 main 위에 다시 적용한다. 변경 내용은 같아도 부모가 달라지므로 해시가 전부 바뀐다. 그림의 C', D'가 그 뜻이다. 원래 커밋 C, D는 아무도 가리키지 않는 상태로 남았다가 결국 정리된다.

이 한 가지 사실에서 나머지가 전부 따라 나온다.

황금률: 남이 가진 커밋은 리베이스하지 않는다

동료가 feature를 받아서 그 위에 작업 중인데 내가 feature를 리베이스해 강제 push하면, 동료의 로컬에는 이제 존재하지 않는 커밋 위에 쌓인 작업이 남는다. 동료가 git pull을 하면 같은 변경이 옛 해시와 새 해시로 두 벌 들어와 엉킨다.

이것이 Git 공식 문서가 말하는 리베이스의 황금률이다. 실무 규칙으로 옮기면 이렇다.

대상 방식 이유
나만 쓰는 기능 브랜치 rebase 자유롭게 깨질 사람이 없다
리뷰 중인 내 PR 브랜치 rebase 가능. 강제 push는 --force-with-lease 리뷰어가 이미 본 diff가 흔들리므로 미리 알린다
둘 이상이 같이 쓰는 브랜치 merge 리베이스하면 상대 로컬이 깨진다
main, release 절대 rebase 금지 모두가 가지고 있다

최신화는 rebase, 통합은 팀 규칙

실무에서 두 명령을 쓰는 자리는 사실 다르다.

작업 중 최신화는 rebase가 낫다. main이 앞서 나갔을 때 내 브랜치를 따라잡는 상황인데, 여기서 merge를 쓰면 "main을 머지함" 같은 의미 없는 커밋이 브랜치에 쌓이고 PR diff가 지저분해진다.

git fetch origin
git rebase origin/main

작업 트리가 지저분해도 멈추지 않게 하려면 autostash를 켜둔다.

git config --global rebase.autoStash true
git config --global pull.rebase true

pull.rebase를 켜면 git pullfetch 뒤에 rebase를 돌린다. 원격에 이미 있는 남의 커밋은 건드리지 않고 내 로컬 커밋만 그 위로 옮기므로 황금률에 어긋나지 않는다.

PR 통합은 팀이 하나를 정해서 지킨다. GitHub 기준으로 셋 중 하나다.

방식 이력 모양 되돌리기 bisect
Merge commit 브랜치가 갈라진 모양 그대로 revert -m 1로 한 번에 중간 커밋이 깨져 있을 수 있다
Squash merge main이 한 줄. PR 하나가 커밋 하나 revert 한 번 커밋 단위가 커서 범위가 넓다
Rebase merge main이 한 줄. 커밋 개수 유지 커밋마다 따로 되돌려야 커밋 단위가 작아 가장 정확하다

Squash merge가 가장 무난하다. main의 커밋 하나가 PR 하나에 정확히 대응하므로 이력이 읽기 쉽고, 되돌릴 단위도 명확하다. 대신 작업 중에 남긴 중간 커밋이 전부 사라진다. 커밋을 의미 단위로 잘 나눠 쓰는 팀이라면 rebase merge가 bisect에 유리하다.

주의할 점 하나. Squash merge로 통합하면 PR 브랜치의 원래 커밋들과 main의 스쿼시 커밋은 서로 다른 해시다. 그래서 머지된 브랜치를 지우지 않고 계속 쓰면 다음 PR에서 같은 변경이 또 올라온다. 스쿼시 방식을 쓰는 팀은 머지 후 브랜치 삭제를 규칙으로 둬야 한다.

인터랙티브 리베이스: 커밋을 발표 가능한 상태로

작업 중 커밋은 지저분한 것이 정상이다. "일단 저장", "오타", "다시 되돌림" 같은 커밋을 그대로 올리면 리뷰어가 읽을 것이 두 배가 된다. 올리기 직전에 정리한다.

git rebase -i origin/main

편집기에 커밋 목록(todo)이 열린다. 위가 오래된 커밋, 아래가 최신이다. log와 순서가 반대라 처음에는 헷갈린다.

fixup으로 표시한 커밋이 위 커밋에 접히고 drop한 커밋은 빠져 여섯 개가 세 개로 정리되는 그림
노란 커밋은 바로 위 커밋에 접히고, 점선 커밋은 사라진다

쓰는 명령은 사실상 여섯 개다.

명령 하는 일
pick 그대로 둔다 (기본값)
reword 내용은 두고 메시지만 고친다
squash 위 커밋에 합치고, 메시지는 둘 다 편집한다
fixup 위 커밋에 합치고, 이 커밋 메시지는 버린다
drop 커밋을 통째로 뺀다
edit 그 커밋에서 멈춘다. 내용을 고칠 수 있다

줄 순서를 바꾸면 커밋 순서가 바뀌고, 줄을 지우면 drop과 같다. 저장하고 닫으면 위에서부터 실행한다.

edit으로 멈춘 뒤에는 이렇게 작업한다.

# 파일을 고치고
git add lib/auth.ts
git commit --amend        # 그 커밋에 합쳐 넣는다
git rebase --continue

커밋 하나를 둘로 쪼갤 때도 edit을 쓴다.

git reset HEAD~1          # 커밋을 풀어 작업 트리로
git add -p                # 절반만 담고
git commit -m "첫 번째 조각"
git add .
git commit -m "두 번째 조각"
git rebase --continue

--autosquash: 정리를 미리 예약한다

리뷰 지적을 반영할 때 "리뷰 반영" 커밋을 새로 쌓으면 나중에 또 정리해야 한다. 처음부터 어느 커밋에 합칠지 표시해서 커밋한다.

git commit --fixup=9b7e104          # 9b7e104에 합칠 커밋이라고 표시
git commit --squash=9b7e104         # 합치되 메시지도 편집하겠다

--fixup으로 만든 커밋은 메시지가 fixup! <원래 제목>이 된다. 정리할 때가 되면 이렇게 한다.

git rebase -i --autosquash origin/main

todo 목록이 이미 정렬되어 있고 명령어까지 fixup으로 채워져 있다. 확인하고 저장만 하면 끝난다. 이 옵션은 아예 켜두는 편이 낫다.

git config --global rebase.autoSquash true

--fixup=amend:<해시>를 쓰면 내용과 메시지를 함께 고치는 커밋으로 표시된다. 어느 커밋에 붙일지 고르기 번거로우면 git log --oneline -10으로 해시를 보고 지정하거나, 파일 기준으로 자동 선택하는 --fixup을 쓰는 대신 마지막 커밋이면 그냥 --amend를 쓴다.

--update-refs: 쌓아 올린 브랜치를 한 번에

기능을 여러 PR로 쪼개 feature/a 위에 feature/b를 얹어 작업하는 경우, 아래쪽을 리베이스하면 위쪽 브랜치는 옛 커밋에 남는다. Git 2.38부터는 함께 옮겨준다.

git rebase -i --update-refs origin/main
git config --global rebase.updateRefs true

todo 목록에 update-ref refs/heads/feature/a 같은 줄이 끼어 있는 것이 보이면 제대로 동작하는 것이다.

충돌을 두 번 풀지 않는다

긴 브랜치를 여러 번 리베이스하면 같은 충돌을 반복해서 만난다. rerere를 켜면 지난번 해결을 기억했다가 자동으로 적용한다.

git config --global rerere.enabled true

켜두면 충돌을 해결할 때마다 조용히 기록하고, 같은 충돌이 다시 나오면 이미 풀린 상태로 보여준다. 자동 적용된 결과는 반드시 눈으로 확인해야 하지만, 열 번 풀 것을 한 번으로 줄여준다.

강제 push를 안전하게

리베이스한 브랜치는 원격과 이력이 어긋나므로 강제 push가 필요하다. --force는 쓰지 않는다.

git push --force-with-lease

원격이 내가 마지막으로 fetch한 그 상태일 때만 덮어쓴다. 그사이 동료가 push했다면 거부한다.

여기에 함정이 하나 있다. --force-with-lease내 로컬의 원격 추적 브랜치와 실제 원격을 비교한다. 그런데 IDE나 에디터가 백그라운드로 git fetch를 돌리면 추적 브랜치가 조용히 갱신되어, 동료의 새 커밋이 "내가 이미 본 상태"로 둔갑한다. 이 구멍을 막는 옵션이 Git 2.30에 들어왔다.

git push --force-with-lease --force-if-includes

--force-if-includes는 내가 그 커밋 위에서 실제로 작업했는지까지 확인한다. 자동 fetch를 쓰는 환경이라면 둘을 함께 쓴다.

이럴 때는 rebase를 쓰지 않는다

  • 이미 공유된 브랜치: 앞의 황금률이다.
  • 머지 커밋이 의미를 담고 있을 때: 릴리스 브랜치를 main에 합친 기록처럼, 갈라졌다 합쳐진 사실 자체가 정보인 경우다.
  • 충돌이 커밋마다 반복될 때: 리베이스는 커밋 하나하나에 대해 충돌을 풀게 한다. 커밋 스무 개를 옮기는데 매번 같은 파일이 충돌한다면, merge 한 번으로 끝내는 편이 정직하고 빠르다. rerere로도 감당이 안 되는 상황이 여기다.
  • 이력을 감사 기록으로 쓰는 조직: 규제 산업에서는 이력 재작성 자체가 금지되기도 한다.

정리

# 작업 중 최신화
git fetch origin && git rebase origin/main
 
# 올리기 직전 정리
git rebase -i --autosquash origin/main
 
# 리뷰 반영은 fixup으로 예약
git commit --fixup=<해시>
 
# 강제 push는 항상 이 조합
git push --force-with-lease --force-if-includes

기준은 하나다. 내 손 안에 있는 커밋은 마음껏 다듬고, 남에게 나간 커밋은 건드리지 않는다. 깨끗한 이력은 미학의 문제가 아니라, 여섯 달 뒤 git loggit bisect로 원인을 찾을 때 걸리는 시간의 문제다.

다음 글에서는 여기까지의 개념을 눈으로 익히기 좋은 학습 사이트 하나를 처음부터 끝까지 풀어본다.