Git merge·rebase·squash, 같은 코드를 합쳐도 기록은 다르다

PR 리뷰를 거치며 수정 커밋이 여섯 개나 쌓였는데, 동료는 이미 그 커밋 위에서 작업을 이어 가고 있다. main에 넣을 때 merge, rebase, squash 중 무엇을 골라야 할까?

Git merge·rebase·squash, 같은 코드를 합쳐도 기록은 다르다
세 줄 요약
  • 동료가 내 커밋을 받아 작업 중이라면, 최신 main은 작업 브랜치에 merge해 기존 커밋을 유지할 수 있다. 아직 공유하지 않은 내 변경을 새 main 위에 차례로 옮기려는 경우에는 rebase를 쓸 수 있다.
  • 완성한 PR을 main에 넣을 때는 나중에 찾아볼 작업 단위를 고른다. 기능 하나를 만들며 쌓인 리뷰 수정을 한 커밋으로 남기려면 squash가 맞고, 개별 커밋과 합류 과정까지 남기려면 merge commit을 선택한다.
  • squash한 브랜치를 계속 쓰면 다음 PR에 이전 커밋이 다시 보일 수 있다. 끝난 PR의 다음 작업은 갱신된 main에서 새 브랜치로 시작하는 편이 단순하다.

리뷰를 반영하느라 늘어난 커밋을 정리하고 싶어도, 동료가 그 커밋을 받아 후속 작업을 시작했다면 내 로그만 보고 결정하기 어렵다. rebase로 커밋을 새로 만들면 동료의 작업은 여전히 옛 커밋 뒤에 남는다. 최신 main이 필요할 뿐이라면 작업 브랜치에 merge해 그 연결을 유지할 수 있다.

그렇다고 main에도 리뷰 수정 하나하나를 모두 남겨야 하는 것은 아니다. 작업 브랜치에 main을 가져오는 일과 완성한 PR을 main에 넣는 일은 별도로 선택한다. 작업 중에는 merge로 동료와 공유한 이력을 유지하고, 기능이 완성되면 PR을 squash해 main에 한 커밋으로 남길 수도 있다. 이후 작업은 갱신된 main에서 시작해야 이전 PR의 커밋이 다시 따라오는 일을 피하기 쉽다.

같은 변경이 남기는 세 가지 이력

공통 커밋 A에서 두 사람이 작업을 시작했다고 가정한다. main에는 B가 추가됐고, feature에는 X와 Y가 생겼다. 아래 글자는 실제 커밋 해시가 아니라 연결 관계를 보여 주는 이름이다. X·Y는 비어 있지 않고, 같은 변경이 이미 main에 들어가 있지 않은 경우다.

A---B       main
 \
  X---Y     feature

main에서 feature를 merge하면 B와 Y를 부모로 갖는 새 커밋 M이 두 갈래를 잇는다. B·X·Y는 원래 커밋 그대로 남는다. 반면 feature를 B 위로 rebase하면 X와 Y의 변경을 순서대로 적용한 X′·Y′가 생긴다. 파일 수정 내용이 비슷해도 출발점과 부모가 달라졌으므로 원래 X·Y와 다른 커밋이다. Pro Git의 병합 설명과 rebase 설명이 다루는 차이다.

merge:
A---B-------M   main
 \         /
  X-------Y

rebase 후 main을 전진시킨 경우:
A---B---X′---Y′  main

squash로 받은 경우:
A---B---S        main

S에는 X와 Y의 변경을 합친 결과가 담긴다. 하지만 S의 부모는 B 하나다. 파일에 feature의 내용이 들어갔다고 해서 X·Y가 main의 조상으로 연결되는 것은 아니다. 이 차이가 다음 PR에서 옛 커밋이 다시 보이는 현상으로 이어진다. 원래 feature를 지우거나 재작성했다는 뜻도 아니다.

merge라고 항상 M이 생기지는 않는다. main이 A에 그대로 있고 feature만 X·Y로 진행했다면, main의 위치를 Y로 옮기는 fast-forward가 가능하다. 반대로 명시적인 합류점을 남기는 --no-ff 병합은 커밋을 만든다. 따라서 로그에서 선이 곧다는 이유만으로 rebase했다고 단정할 수 없다. git merge 문서는 이 두 동작을 구별한다.

여섯 커밋을 하나로 남길 때와 나눠 남길 때

로그인 화면을 만드는 가상의 PR을 생각해 보자. 첫 구현 뒤 버튼 간격 수정, 테스트 보완, 리뷰 반영 등으로 여섯 커밋이 쌓였다. 팀이 나중에 찾을 작업 단위가 ‘로그인 화면 추가’라면 한 개의 squash 커밋이 그 단위와 맞는다. 여섯 번의 시행착오가 main에서 여섯 개의 독립 변경으로 남을 필요는 없다.

반대로 첫 커밋이 공통 인증 함수 정리, 둘째가 새 로그인 기능, 셋째가 문서 변경이라면 각각의 이유를 설명할 수 있다. 이들을 한꺼번에 묶으면 함수 정리만 따로 읽으려는 사람이 더 큰 변경을 살펴야 한다. 원래 커밋의 합류 과정을 보존하는 merge나, 개별 변경을 순서대로 남기는 rebase가 이 목적에 맞는다.

남기려는 기록맞는 방식그 대신 달라지는 것
두 브랜치가 합쳐진 시점과 원래 커밋merge commit로그에 분기와 합류가 남는다.
의미 있는 개별 변경이 이어지는 직선 이력rebase 후 통합재적용된 커밋은 새 식별자를 갖는다.
PR 한 건에 해당하는 완성된 변경 하나squash merge중간 커밋은 main에 개별 커밋으로 들어가지 않는다.

일반 rebase가 여섯 커밋을 자동으로 하나로 줄이지는 않는다. 다만 interactive rebase에서 squash나 fixup을 지정하면 여러 커밋을 합칠 수 있다. 즉 rebase 도중 squash를 할 수는 있어도 두 단어가 같은 작업을 뜻하지는 않는다. Pro Git의 이력 수정 설명에는 이 조작이 별도로 나온다.

한 PR에 관련 없는 기능 두 개를 넣은 채 squash하면 제목 하나로 설명하기 어려운 커밋이 된다. 병합 방식으로 그 기획까지 고쳐지지는 않는다. 예컨대 로그인 기능과 결제 정책 변경은 각각의 PR로 나누고, 로그인 PR 안에서 나온 리뷰 수정들을 묶는 식으로 작업 단위와 커밋 단위를 맞출 수 있다.

작업 브랜치에 최신 main 가져오기

아직 끝나지 않은 feature에서 최신 공통 코드를 사용하려는 상황이라면, 현재 feature에 main을 가져온다. 아래 예제는 원격 이름이 origin이고, main을 통상적인 방식으로 추적하며, 로컬에 feature가 이미 있는 경우다. 시작 전 미커밋 변경은 커밋하거나 별도로 보관해 작업 트리를 깨끗하게 만든다.

git status
git fetch origin
git switch feature
git log --oneline --graph --decorate HEAD origin/main

fetch는 원격의 커밋과 참조를 받아 origin/main을 갱신한다. 이 단계에서 로컬 main이나 feature의 내용까지 자동으로 합쳐지는 것은 아니다. 뒤의 로그는 현재 HEAD와 origin/main의 계보를 함께 보여 준다. 두 이름이 같은 줄에 있는지, 공통 조상 뒤로 양쪽이 갈라졌는지 읽을 수 있다. fetch 문서와 log 옵션 문서에서 각 명령의 범위를 확인할 수 있다.

동료도 쓰는 feature의 기존 커밋을 유지하려면 이 상태에서 git merge origin/main으로 합친다. 내 컴퓨터에서만 작업한 커밋을 새 main 위에 정리하려면 대신 git rebase origin/main을 실행한다. 두 명령은 이 예제의 대안이며 차례대로 둘 다 실행하는 절차가 아니다.

rebase가 끝나도 바뀐 것은 feature다. main에 기능을 넣은 것이 아니므로 이후 PR을 열어 검토·통합하는 단계가 남는다. 또 로컬 main이 오래됐을 때 git rebase main을 실행하면 그 오래된 지점이 기준이 된다. 예제에서 fetch 뒤 origin/main을 지정한 이유는 방금 받아 온 원격 상태를 사용하기 위해서다.

공유한 커밋을 rebase하면 생기는 일

동료가 X·Y를 받아 그 뒤에 Z를 작성했다고 가정하자. 내가 X·Y를 X′·Y′로 바꿔 올려도 동료의 Z는 여전히 원래 Y 뒤에 붙어 있다. 동료는 자신의 변경을 새 이력으로 옮기거나 두 계보를 다시 합쳐야 한다. Git의 upstream rebase 복구 설명이 다른 사람이 기반으로 삼은 브랜치를 재작성하지 말라고 하는 이유다.

r/learnprogramming의 한 질문자는 동료 브랜치에 강제 푸시한 뒤 동료의 로컬 사본에서 작업을 복구했다고 설명하며 Git을 제대로 이해할 방법을 물었다. 원격 브랜치를 내 로컬 이력으로 바꾸는 조작은 다른 사람이 올린 커밋까지 그 브랜치의 계보에서 밀어낼 수 있다.

이미 올렸던 feature를 rebase하면 일반 push가 거절될 수 있다. 원격의 옛 tip이 새 로컬 tip의 조상이 아니기 때문이다. --force-with-lease는 예상한 원격 tip과 실제 tip이 일치할 때만 덮어쓰도록 제한하지만, 동료의 미공개 Z나 협업 합의까지 살펴보지는 않는다. 기대값을 생략한 형태는 로컬의 원격 추적 참조에 의존하므로 편집기 등의 백그라운드 fetch가 보호 효과를 약하게 만들 수도 있다. push 공식 문서에 명시된 한계다.

이 때문에 공유 브랜치에서는 merge로 기존 연결을 유지하는 선택이 단순하다. 개인 PR 브랜치의 재작성을 허용하는 팀이라면 담당자와 후속 브랜치가 있는지 확인한 뒤 정리한다. push 거절 메시지만 보고 --force를 붙이면, 원격에 추가된 다른 사람의 작업을 함께 밀어낼 수 있다.

GitHub의 병합 버튼과 로컬 명령

GitHub의 Create a merge commit 방식은 PR의 원래 커밋과 명시적인 합류점을 남긴다. Squash and merge는 PR 변경을 하나로 받고, Rebase and merge는 개별 변경을 새 커밋으로 대상 브랜치에 추가한다. 마지막 방식은 기존 main 전체를 재작성하는 기능이 아니다. GitHub의 PR 병합 설명에 따르면 GitHub rebase는 committer 정보와 SHA를 새로 만든다.

로컬 main에서 실행하는 git merge --squash feature는 PR의 squash 버튼과 달리 작업 트리와 스테이징 영역에 합친 결과만 준비한다. 커밋까지 자동 생성하지 않으므로 사용자가 결과를 검토하고 git commit으로 기록한다. 이 경우 변경을 받는 쪽은 main이다. 앞 절처럼 feature에 main을 가져오는 방향과 반대다.

버튼에 세 방식이 모두 보이지 않을 수도 있다. 저장소가 허용한 병합 방식과 브랜치 규칙에 따라 선택지가 달라진다. 선형 이력을 요구하는 GitHub 보호 브랜치에서는 squash나 rebase 병합을 허용해야 한다. 이는 Git에서 merge 기능을 지원하지 않아서가 아니라 저장소가 받아들이는 이력의 조건이다. GitHub 병합 설정 안내가 이 관계를 설명한다.

squash 이후의 다음 PR

첫 PR을 squash해 main에 S를 만들었는데 feature에서 계속 Z를 추가하면, 다음 PR에 X·Y가 다시 나타날 수 있다. S에는 그 변경이 들어 있어도 S와 Y를 부모·자식으로 잇는 연결은 없기 때문이다. GitHub는 오래 유지하는 head 브랜치를 반복해서 squash하면 같은 충돌을 다시 해결하게 될 수 있다고 안내한다. 이미 반영한 코드가 반드시 두 번 들어간다는 의미는 아니다.

앞의 도식에 후속 커밋 Z를 더하면 아래 관계가 된다. S가 X·Y의 변경을 담고 있어도 두 갈래의 공통 조상은 여전히 A다.

A---B---S       origin/main
 \
  X---Y---Z     feature

작업 중인 브랜치에서도 이 관계인지 살피려면 원격 상태를 받은 뒤 공통 조상과 feature에만 있는 커밋을 조회한다. 아래는 앞 절과 같은 origin/main·feature가 있는 경우의 확인 명령이며 브랜치 이력을 바꾸지 않는다.

git fetch origin
git merge-base origin/main feature
git log --oneline origin/main..feature

merge-base는 공통 조상의 커밋 해시를 보여 준다. log의 두 점 범위는 feature에서 도달하지만 origin/main에서는 도달하지 못하는 커밋을 고른다. 위 도식이라면 X·Y·Z가 그 대상이다. 목록에 X·Y가 있다고 그 코드가 main에 없다는 뜻은 아니다. S에 반영된 변경과 아직 반영하지 않은 Z를 구분한 뒤 다음 작업을 옮겨야 한다.

앞의 로그인 예제에서 기능을 완성해 첫 PR을 끝냈다면 다음 수정은 새 main에서 시작하면 된다. 기존 feature에 새 미완료 커밋이 없고 작업 트리가 깨끗한 경우, 아래처럼 아직 사용하지 않은 새 이름을 지정한다.

git fetch origin
git switch --no-track -c login-followup origin/main

이제 login-followup은 S가 포함된 main에서 출발한다. --no-track은 새 기능 브랜치가 origin/main을 자신의 upstream으로 자동 설정하지 않게 한다. 이전 feature를 삭제하거나 덮어쓰는 명령은 아니다. switch의 새 브랜치 생성 옵션에 해당한다.

이미 옛 feature에 후속 커밋을 쌓았다면 이 명령만으로 그 변경이 새 브랜치에 옮겨지지는 않는다. 새 main에 없는 변경을 따로 옮기는 작업이 남는다. 계속 운영할 통합 브랜치라면 매번 squash하기보다 merge로 합류 관계를 보존하는 정책이 더 잘 맞을 수 있다. 짧은 기능 PR과 장기간 유지하는 공유 브랜치에 같은 규칙을 적용하지 않는 이유다.

충돌이 멈춘 위치에서 이어가기

merge와 rebase 어느 쪽도 서로 충돌하는 수정의 의도를 대신 결정하지 못한다. 예를 들어 main은 로그인 함수의 인자 이름을 바꿨고 feature는 그 함수를 호출하는 방식을 바꿨다면, 새 이름과 새 호출 의도가 함께 작동하도록 수정해야 한다. 화면에서 한쪽 버전만 고르면 필요한 변경이 빠질 수 있다.

rebase는 커밋을 순서대로 재적용하다 문제가 있는 커밋에서 멈춘다. git status로 충돌 파일을 확인하고, 해당 파일을 편집해 충돌 표시를 제거한 뒤 git add 실제파일경로로 해결 결과를 스테이징한다. 이 표기의 실제파일경로는 status에 나온 경로로 바꾼다. 모든 충돌 파일을 처리한 뒤 git rebase --continue를 실행한다. 다음 커밋에서도 충돌하면 같은 과정이 다시 필요하다. GitHub의 rebase 충돌 해결 안내에도 이 순서가 나온다.

git rebase --skip은 오류를 무시하고 같은 변경을 보존하는 버튼이 아니다. 현재 적용하려던 패치를 건너뛴다. 진행 중인 rebase를 취소하고 시작 상태로 돌아가려면 git rebase --abort를 쓴다. 일반 merge 충돌을 해결한 경우에는 파일을 스테이징한 뒤 git merge --continue로 완료하고, 진행 중 취소는 git merge --abort다. 시작 전 미커밋 수정이 있으면 merge 취소로 원래 상태를 완전히 복원하기 어려울 수 있어 앞 예제는 깨끗한 작업 트리를 전제로 했다.

충돌 표시가 사라지고 커밋 생성이 끝난 다음에는 바뀐 기능을 실행한다. 로그인 예제라면 정상 로그인뿐 아니라 잘못된 입력과 오류 응답도 테스트 대상이다. 이력 연결에 성공했다는 사실만으로 함수 이름 변경과 호출 변경이 함께 작동하는지는 확인되지 않는다.