GitHub Merge 정책 정하기 : Merge commit 과 Rebase merging 비교

GutHub 에서는 Repository 에 대한 설정 메뉴에서, Pull Request (이하 PR) 를 어떤 방식으로 base 브랜치에 머지시킬지에 대한 정책을 선택할 수 있게 제공하고 있습니다.

크게 다음과 같은 세가지의 옵션을 제공하고 있는데요,

  • Allow merge commits
  • Allow squash merging
  • Allow rebase merging

이중에서 Squash merge 의 경우에는 다른 둘에 비해 활용 빈도가 떨어지기에, 이 글에서는 Merge commit 방식과 Rebase merging 방식에 대해서만 제 생각을 정리해 보고자 합니다.

물론 세가지 방식을 모두 혼용해서 Repository 를 운영한다고 해도 문제될 부분은 없으나, 개인 혼자서 운영하는 프로젝트가 아닌 이상 하나의 방식을 일관성있게 사용하는 것이 일반적이며, 그런 이유로 인해 Repository 의 PR 머지 정책을 어떻게 가져가는 것이 더 나을지 따져보는 것은 매우 중요하다고 할 수 있겠습니다.

미리 말씀드리자면, 저는 Merge commit 방식을 선호하는 편이라 해당 방식에 편향적인 글이 될 수 있을 것 같습니다.

하지만 사람마다의 선호도나 프로젝트의 상황이 모두 다르며, 한가지 방식이 다른 방식에 비해 무조건 우월하다고 볼 수는 없으므로, 이 글의 내용을 참고하셔서 본인의 프로젝트에 가장 적합한 방식을 찾을 때에는 어떠한 점들을 고려해야 할 지 생각해 보시면 좋을 것 같습니다.

1. Rebase Merging

이 방식의 최대 장점은 선형적인 History 를 유지할 수 있다는 점입니다. 동일한 코드 수정을 동일한 순서로 수행한 두 Repository 를 gitk 툴에서 확인하여 보면, Rebase Merging 방식인 첫번째 예제가 Merge commit 방식인 두번째 예제보다 전체 흐름을 더욱 쉽게 파악할 수 있습니다.

예제 : Rebase Merging 방식의 history

예제 :Merge Commit 방식의 history

또한, 매 PR 마다 별도의 Merge Commit 이 생성되지 않으므로, History 확인 시 한 화면에 더 많은 수의 (실제 코드가 수정된) 커밋들을 확인할 수 있다는 장점도 있습니다.

이러한 선형적인 History 는, git bisect 를 통해 현재 발견된 문제점이 최초 등장한 시점을 찾아보고자 할 때에도 현재 bisect 작업이 어디까지 진행되었는지 확인하기에 훨씬 직관적입니다.

정확한 비교를 위해, 위에서 보았던 예제 repository 들에서 "Add tuple test code" 라는 제목의 커밋이 출력하는 결과인 "(2, 3)" 라는 문자열이 잘못된 결과라고 가정하고, 해당 문자열이 언제 처음 등장하는지를 git bisect 로 추적해 보도록 하겠습니다.

  • Rebase Merging 방식을 사용하는 Repository 의 경우 : git bisect 가 직관적으로 예상할 수 있는 순서대로 커밋들을 검사해 문제 커밋을 찾아냅니다.

실행 결과)

$ git bisect start # git bisect 를 시작합니다
$ python program.py # 잘못된 결과 문자열 (2,3) 이 출력되는지 검사합니다
Hello, World!
changed
(2, 3)
$ git bisect bad develop # (2,3) 이라는 잘못된 문자열이 출력되므로 bad 로 마킹합니다
$ git bisect good d0f9164 # "Store the output string in a variable" 라는 이름의 커밋이 rebase merge 된 d0f9164 까지는 해당 문자열이 출력되고 있지 않았음을 이미 안다고 가정하겠습니다
Bisecting: 1 revision left to test after this (roughly 1 step)
[9c4d6c2f2dd015f8eba9e33711d16cbf344caab7] Add new function
$ python program.py # 잘못된 결과 문자열 (2,3) 이 출력되는지 검사합니다
Hello, World!
Hello, World!
$ git bisect good # (2,3) 이라는 잘못된 문자열이 출력되지 않으므로 good 으로 마킹합니다       
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[77fe9302e6ab8af4e4ba489cd8b3fd0141e4f6a2] Add test code for global variable
$ python program.py # 잘못된 결과 문자열 (2,3) 이 출력되는지 검사합니다
Hello, World!
changed
$ git bisect good # (2,3) 이라는 잘못된 문자열이 출력되지 않으므로 good 으로 마킹합니다
0f9132397b43f06d002ad079206be388b654dacd is the first bad commit
commit 0f9132397b43f06d002ad079206be388b654dacd
Author: nochoco <nochoco.lee@gmail.com>
Date:   Tue May 25 23:06:19 2021 +0900

    Add tuple test code

 myfunc.py | 2 ++
 1 file changed, 2 insertions(+)
$ # 잘못된 결과 문자열을 출력하는 커밋을 찾았습니다
  • Merge commit 방식을 사용하는 Repository 의 경우 : git bisect 가 성공적으로 문제 커밋을 찾아내나, 중간 과정에서 거치는 순서가 직관적으로 이해하기 어렵습니다.

실행 결과)

$ git bisect start # git bisect 를 시작합니다
$ python program.py # 잘못된 결과 문자열 (2,3) 이 출력되는지 검사합니다
Hello, World!
changed
(2, 3)
$ git bisect bad develop # (2,3) 이라는 잘못된 문자열이 출력되므로 bad 로 마킹합니다
$ git bisect good 0cf0493 # 마찬가지로 "Store the output string in a variable" 라는 이름의 커밋이 머지된 0cf0493 까지는 해당 문자열이 출력되고 있지 않았음을 이미 안다고 가정하겠습니다
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[2dfdf82f39c202b95207473b33793dda6f22e2fa] Add tuple test code
$ python program.py # 잘못된 결과 문자열 (2,3) 이 출력되는지 검사합니다
Hello, World!
(2, 3)
$ git bisect bad # (2,3) 이라는 잘못된 문자열이 출력되므로 bad 로 마킹합니다
Bisecting: 0 revisions left to test after this (roughly 1 step)
[df4d19498774647c58a5ca72b5283429dd983243] Merge pull request #3 from nochoco-lee/feature/add_file
$ python program.py # 잘못된 결과 문자열 (2,3) 이 출력되는지 검사합니다
Hello, World!
$ git bisect good # (2,3) 이라는 잘못된 문자열이 출력되지 않으므로 good 으로 마킹합니다 
2dfdf82f39c202b95207473b33793dda6f22e2fa is the first bad commit
commit 2dfdf82f39c202b95207473b33793dda6f22e2fa
Author: nochoco <nochoco.lee@gmail.com>
Date:   Tue May 25 23:06:19 2021 +0900

    Add tuple test code

 myfunc.py | 2 ++
 1 file changed, 2 insertions(+)
$ # 잘못된 결과 문자열을 출력하는 커밋을 찾았습니다

2. Merge commit

이 방식의 장점은 커밋들의 동일성이 유지된다는 점입니다. Rebase Merging 방식의 경우, 동일한 내용의 패치일지라도 PR 을 올렸을때와는 완전히 다른 별개의 커밋이 Merge 시점에 새로 만들어지는 문제점이 있습니다. 그게 무슨 상관일까 싶기도 하겠지만, 기본적으로 커밋 ID 기준으로 작업할 수 있는 git 의 일부 기능들을 사용할 수 없게됩니다. 예를 들어, 커밋 ID 를 통해 해당 커밋이 두 브랜치에 모두 존재하는지의 확인 등이 불가능해집니다.

예제

  • feature/myfunc_modifications 브랜치의 커밋인 6e1600bdevelop 브랜치에 rebase 로 머지시킨 경우
     $ git branch --contains 6e1600b # 동일한 패치 내용이 적용된 develop 브랜치는 목록에 나타나지 않습니다
     feature/myfunc_modifications
  • feature/myfunc_modifications 브랜치의 커밋인 2dfdf82 를 develop 브랜치에 merge commit 을 생성하여 머지시킨 경우
     $ git branch --contains 2dfdf82 # 동일한 패치 내용을 가지고 있는 두 브랜치가 모두 목록에 나타납니다         
     develop                        
     feature/myfunc_modifications

특히 Workflow 로 많은 인기를 끌고있는 GitFlow 를 사용하신다거나, 프로젝트의 특성상 몇개의 long-lived branch 를 유지할 필요가 있다거나 하는 등의 경우, 위와 같이 커밋 ID 를 통해 해당 패치가 적용된 브랜치를 찾아내는 기능이 사용 불가능하다면 꽤나 불편한 경우가 생길 가능성이 있습니다.

또한, Rebase 를 통해 PR 을 머지하는 경우 Merge Conflict 이 발생할 가능성이 많습니다. Rebase 나 Merge 나 Merge Conflict 발생하는건 마찬가지 아닌가? 라고 생각할 수도 있겠지만, 하나의 PR 에 2개 이상의 커밋이 존재하는 경우, Rebase 방식은 개별 커밋을 대상 브랜치에 하나씩 Replay 시키기 때문에 각 커밋이 모두 Conflict 을 발생시키지 않아야 문제없이 진행되지만, Merge 의 경우 두 브랜치의 최종 변경사항 상태가 충돌나는 경우에만 Conflict 을 발생시키기에 불필요한 Merge Conflict 를 줄일 수 있습니다. 말로만 설명하면 이해가 어려울 수 있으니 간단한 예제를 보겠습니다.

아래와 같은 python 프로그램이 있다고 가정할 때,

from myfunc import test_function

def variable_test():
    global var
    print(var)
    var = "changed"

var = "Hello, World!"
variable_test()
test_function(var)

 

위 소스가 포함되어 있는 커밋 A 로부터
feature/success_log 라는 브랜치와 feature/main_procedure_log 브랜치 두개를 만든 후,

feature/success_log 에는 다음과 같은 커밋 B 하나를, (맨 마지막줄에 로그를 하나 추가한 패치입니다)

--- a/program.py
+++ b/program.py
@@ -8,4 +8,4 @@ def variable_test():
 var = "Hello, World!"
 variable_test()
 test_function(var)
-
+print("Everything's successful!!")

 

feature/main_procedure_log 브랜치에는 다음과 같은 커밋 C 와 (마찬가지로 맨 마지막줄에 다른 로그를 하나 추가한 패치입니다)

+++ b/program.py
@@ -9,3 +9,4 @@ var = "Hello, World!"
 variable_test()
 test_function(var)

+print("main procedure")

다음과 같은 커밋 D 두개를 추가했다고 가정해 보겠습니다. (커밋 C 에서 추가한 로그를 상단으로 이동시킨 패치입니다)

--- a/program.py
+++ b/program.py
@@ -5,8 +5,9 @@ def variable_test():
     print(var)
     var = "changed"

+print("main procedure")
+
 var = "Hello, World!"
 variable_test()
 test_function(var)

-print("main procedure")

두 브랜치의 히스토리는 다음과 같을 것입니다.

이제 feature/success_log 브랜치와 feature/main_procedure_log 브랜치로부터 각각 PR 을 하나씩 생성한 후에, feature/success_log 로부터 생성한 PR 은 먼저 Merge 하고, 이후 feature/main_procedure_log 브랜치로부터 생성한 PR 을 Merge 하려고 하면 어떻게 될까요?

Merge commit 생성 방식의 경우에는 문제가 없지만 (https://github.com/nochoco-lee/merge_commits/pull/10), Rebase merging 방식에서는 Conflict 이 발생함을 알 수 있습니다. (https://github.com/nochoco-lee/rebase_merging/pull/10)

마지막으로, Merge Commit 을 생성하는 방식의 장점 중 하나는, stable 한 커밋과 그렇지 않은 커밋을 구별할 수 있다는 것입니다. 하나의 PR 을 올린 후, CI 단계에서 fail 나는 테스트가 있어 추가 수정을 한다거나 리뷰 커멘트에 달린 수정 의견을 받아들여 추가적인 커밋을 덧붙인 경우, Rebase 를 통해 base 브랜치에 머지하게 되면 테스트가 fail 되었던 커밋이나 리뷰에서 지적된 문제점을 가진 커밋, 그리고 최종적으로 모든 문제점을 수정한 커밋이 모두 동일한 레벨로 history 상에 등장하기에, 나중에 어떤 특정 시점의 버전을 checkout 하여 시험을 해보고 싶을 때 어느 버전이 안정적인 버전인지를 파악하기가 어렵게 됩니다.

물론 이렇듯 PR 내의 stable 하지 않은 커밋은 모두 수정하여 amend / force push 할수도 있습니다만, amend / force push 의 경우 해당 feature 브랜치에 누군가가 추가적으로 커밋을 올려 놓은 경우 그 내용이 덮어 씌워질 수가 있으며, (이 경우도 --force-with-lease 옵션을 통해 다른 사람의 커밋을 덮어씌우는 상황 자체는 피할 수는 있긴 합니다) 제일 큰 문제는 해당 브랜치를 다른 개발자가 로컬에 받아놓은 상태라면 (작업까지 진행하던 중이라면 더더욱) 원격 브랜치와 로컬 브랜치의 history 가 달라졌기에 해당 개발자는 상태 불일치에 대한 추가적인 해결 작업을 거쳐야 하는 번거로움을 겪게 됩니다.

특히, 간단한 수정사항의 경우에는 CI 도구에서 자동으로 커밋을 추가해 주거나, 커밋을 손쉽게 할 수 있도록 suggestion 을 추가해 주는 경우가 많다는 점을 생각해 본다면 amend / force push 기능을 사용하기 어려운 경우도 생길 수 있을 것입니다.

반면 Merge Commit 생성 방식의 경우, PR 을 Merge 한 커밋은 기본적인 확인을 모두 거친 커밋일 것이므로, 위와 같은 문제에서 자유롭게 stable 한 커밋을 손쉽게 구별해 낼 수 있습니다.

반응형

+ Recent posts