본문 바로가기
IT 기본지식

[Git] 3-way merge와 rebase를 이용한 fast-forward merge

by 내기록 2023. 4. 9.
반응형

 

 

 

목차 LIST

     

    3-way 병합하기

     

    3-way merge는 쉽게 말해서 내 브랜치 커밋, 다른 브랜치 커밋을 병합해서 새로운 커밋을 생성하는 방법입니다.

    어떤 상황에서 사용되는지 아래 예시로 살펴보겠습니다.

     

    예시

     

    상황 : '댓글 달기' 기능 개발을 완료하고 [master] 브랜치에 머지하고 릴리즈 했습니다. 그리고 [master] 브랜치에서 [feature] 브랜치를 따서 '좋아요' 기능을 개발하고 있는데, 앞서 개발한 '댓글 달기' 기능에 버그가 발견되었습니다. 작업 중이던 [feature] 브랜치의 작업은 다행히 커밋이 된 상황. 이때, hotfix 브랜치를 사용해서 댓글 달기 기능의 버그를 수정하려고 합니다.

     

    1. 상황 설정

    # [feature1] 브랜치 생성 및 이동
    (master) $ git checkout -b feature1
    Switched to a new branch 'feature1'
    
    # [feature1] 브랜치의 변경사항 commit
    (feature1) $ git add gittest.py
    (feature1) $ git commit
    [feature1 e41b846] add new file
     1 file changed, 1 insertion(+)
     create mode 100644 gittest.py
     
     # [feature1] 브랜치의 커밋이 제대로 생성되었음을 확인
    (feature1) $ git log --oneline --graph --all -n2
    * e41b846 (HEAD -> feature1) add new file
    * 3d94a73 (tag: v0.1, origin/master, master) add asynciotest

    이 시점에서 장애가 발생했습니다. 그나마 다행인 점은 [feature1]브랜치의 변경사항을 커밋을 한 상태에서 장애가 발생했다는건데, 커밋을 하지 않은 상태에는 stash를 사용합니다.

     

    2. hotfix 브랜치를 생성해서 버그를 고치자

     

    버그를 고치기 위해 [master] 브랜치에 [hotfix] 브랜치를 생성하고 버그를 고친 후에 커밋을 합니다.

    # [master] 브랜치 기반으로 [hotfix] 브랜치를 생성하고 이동까지 진행
    (master) $ git checkout -b hotfix master
    Switched to a new branch 'hotfix'
    
    # [feature1]은 좋아요 기능을 위한 브랜치
    # hotfix 브랜치가 생성되고, HEAD는 hotfix 브랜치를 가리킨다. 
    # master 기반으로 생성되었으므로 3d94a73 커밋을 가리킴
    (hotfix) $ git log --oneline --all -n2
    e41b846 (feature1) add new file
    3d94a73 (HEAD -> hotfix, tag: v0.1, origin/master, master) add asynciotest
    # [hotfix] 브랜치에서 버그 수정 했다고 가정하고 git commit
    (hotfix) $ echo "print('howfix')" >> gittest.py
    (hotfix) $ git add gittest.py
    (hotfix) $ git commit
    [hotfix 0393659] hotfix
     1 file changed, 1 insertion(+)
     create mode 100644 gittest.py
     
     # 방금 생성된 커밋ID 0393659를 hotfix 브랜치가 가리키고 있음
    (hotfix) $ git log --oneline -n1
    0393659 (HEAD -> hotfix) hotfix

     

     

    3. hotfix 브랜치를 master 브랜치에 병합하자.

     

    [hotfix] 브랜치를 [master] 브랜치에 병합합니다. [master] 브랜치의 최신 커밋을 기반으로 [hotfix] 브랜치 작업을 했기 때문에 Fast-forward 병합이 가능합니다. -> master 브랜치 기반으로 hotfix 브랜치를 생성했기 때문에 Fast-forward 병합이 가능

    # [master] 브랜치로 이동해서 [hotfix] 브랜치와 merge
    (hotfix) $ git checkout master
    Switched to branch 'master'
    (master) $ git merge hotfix
    Updating 3d94a73..0393659
    Fast-forward
     gittest.py | 1 +
     1 file changed, 1 insertion(+)
     create mode 100644 gittest.py
    
    # git push
    (master) $ git push -u origin master
    Enumerating objects: 4, done.
    Counting objects: 100% (4/4), done.
    Delta compression using up to 8 threads
    Compressing objects: 100% (2/2), done.
    Writing objects: 100% (3/3), 346 bytes | 346.00 KiB/s, done.
    Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
    To https://github.com/sunrise/git_test.git
       3d94a73..0393659  master -> master
    Branch 'master' set up to track remote branch 'master' from 'origin'.

     

    4. 아직 끝나지 않았다. hotfix의 변경사항을 [feature1] 브랜치에도 반영해야 한다.

     

    [feature1] 브랜치와 [master]브랜치는 아래 그래프처럼 서로 다른 분기로 진행되고 있습니다. 이 경우에는 fast-forward 병합이 불가하므로 3-way 병합을 해야 합니다. 따라서 병합 커밋이 생성됩니다.

     

    # feature1 브랜치로 체크아웃
    (master) $ git checkout feature1
    Switched to branch 'feature1'
    
    # 로그 확인
    (feature1) $ git log --oneline --all
    0393659 (origin/master, master, hotfix) hotfix
    e41b846 (HEAD -> feature1, origin/feature1) add new file
    3d94a73 (tag: v0.1, origin/main, main) add asynciotest
    8aa7e99 init
    
    # master 브랜치와 병합 시도
    (feature1) $ git merge master
    CONFLICT (add/add): Merge conflict in gittest.py
    Auto-merging gittest.py
    Automatic merge failed; fix conflicts and then commit the result.
    
    # 실패 원인 확인
    (feature1) $ git status
    On branch feature1
    Your branch is up to date with 'origin/feature1'.
    
    You have unmerged paths.
      (fix conflicts and run "git commit")
      (use "git merge --abort" to abort the merge)
    
    Unmerged paths:
      (use "git add <file>..." to mark resolution)
    	both added:      gittest.py
    
    Untracked files:
      (use "git add <file>..." to include in what will be committed)
    	.idea/
    	test_0327.py
    
    no changes added to commit (use "git add" and/or "git commit -a")

    [feature1] 브랜치와 [master] 브랜치 병합이 실패했습니다. 그 이유는 git status 명령어로 확인할 수 있습니다.

     

    untracked 파일을 add 해주고, git add test_0327.py

    gittest.py 파일을 열어서 conflict를 수정합니다.

     

    (feature1) $ git add test_0327.py 
    
    # conflict 수정
    (feature1) $ vi gittest.py
    (feature1) $ cat gittest.py 
    print("git test")
    print('howfix')
    
    # 스테이징
    (feature1) $ git add gittest.py 
    
    # 깃 상태 확인
    (feature1) $ git status
    On branch feature1
    Your branch is up to date with 'origin/feature1'.
    
    All conflicts fixed but you are still merging.
      (use "git commit" to conclude merge)
    
    Changes to be committed:
    	modified:   gittest.py
    	new file:   test_0327.py
    
    Untracked files:
      (use "git add <file>..." to include in what will be committed)
    	.idea/
    
    # 머지 커밋 생성
    # git commit 명령으로 3-way 병합 진행
    (feature1) $ git commit
    [feature1 800d5e2] Merge branch 'master' into feature1
    
    # 로그 확인
    (feature1) $ git log --oneline --all --graph -n4
    *   800d5e2 (HEAD -> feature1) Merge branch 'master' into feature1
    |\  
    | * 0393659 (origin/master, master, hotfix) hotfix
    * | e41b846 (origin/feature1) add new file
    |/  
    * 3d94a73 (tag: v0.1, origin/main, main) add asynciotest

     

     

    Git으로 rebase 하기

    3-way merge를 하면 병합 커밋이 생성되기 때문에 트리가 다소 지저분해진다는 단점이 있습니다. 이때 트리를 깔끔하게 하기 위해 `rebase(재배치)`를 사용할 수 있습니다.

     

    Rebase (재배치): 묵은 커밋을 방금 한 커밋처럼

    PR을 보냈는데 코드 충돌이 발생했습니다. 내 브랜치로 병합하고 충돌을 해결한 다음 다시 풀 리퀘스트를 보내면 되지만
    이렇게되면 PR에 새로 개발한 코드 외에도 충돌을 해결하느라 생긴 병합 커밋이 생깁니다.
    이를 피하고 깔끔하게 변경한 부분만 PR을 보내기 위한 방법을 알아보겠습니다.

    충돌이 발생했을 때, 충돌을 해결해서 병합 커밋을 만들고 PR을 보내게 되면 PR에 불필요한 병합 커밋의 이력이 남아 아쉽습니다. 진짜 변경점은 커밋 1개 뿐인데 충돌을 해결하느라 지저분해진것이죠.

     

    깔끔한 방법이 바로 Rebase입니다.

    $ git rebase <브랜치명>

    Feature 브랜치를 Main 브랜치와 커밋하려고 하는데, Feature은 과거 커밋을 베이스로 생성되어 Main 브랜치와 차이가 있는 상태입니다. 만약 Feature 브랜치를 현재 Main 브랜치의 커밋을 베이스로 만들었다면 문제 없이 커밋이 됐을 것입니다. 이렇게 커밋의 베이스를 떼서 다른 곳으로 붙여봅니다.

     

    https://blog.devgenius.io/understanding-git-merge-and-git-rebase-ae8bccf50829

    아래 이미지를 보면 [Feature] 브랜치가 [Main] 브랜치를 기반으로 생성된 것처럼 이동한 것을 알 수 있습니다. 이게 바로 Rebase 즉, 베이스를 다시 잡는 것입니다.

    https://blog.devgenius.io/understanding-git-merge-and-git-rebase-ae8bccf50829

     

    리베이스를 하게되면 충돌이 일어난 파일을 하나씩 수정해야 합니다. 모든 충돌이 해결되고 리베이스가 성공한 이후에는 강제 푸시를 진행합니다. * 리베이스는 이력을 조작하는 행위이기 때문에 반드시 혼자 사용하는 브랜치에서만 작업해야 합니다. 다른 사람이 이 히스토리를 보고 있다면 완전히 꼬이게 됩니다.

     

    즉, rebase의 원리는 다음과 같습니다.

    1. HEAD와 대상 브랜치의 공통 조상을 찾는다. (아래 그림의 B)
    2. 공통 조상 이후에 생성한 커밋들(E,F) 을 대상 브랜치 뒤로 재배치한다.
    ㄴ 공통 조상인 B 이후의 커밋인 E,F를 [master] 브랜치의 최신 커밋인 D 뒤쪽으로 재배치합니다.

    rebase를 했을 때, 정확히는 D뒤에 동일한 E, F가 붙는 것이 아닌 새롭게 생성된 커밋인 E`, F`가 됩니다.

    즉 원래 커밋과 다른 커밋이라는 뜻으로 커밋 체크섬 값도 달라집니다. rebase는 주로 로컬 브랜치를 깔끔하게 정리하고 싶을 때 사용하므로 원격에 푸시한 브랜치를 rebase 할 때는 조심해야 합니다. 여러 git 가이드에서 원격 저장소에 존재하는 브랜치는 rebase를 하지 않는 것을 권장합니다.

     

    예시

     

    위에서 진행했던 3-way 병합을 취소하고 rebase로 다시 병합해보겠습니다.

    # feature 브랜치로 전환
    (master) $ git checkout feature1
    
    # 현재 브랜치를 한 단계 되돌린다 즉, 병합 커밋 삭제
    (feature1) $ git reset --hard HEAD~
    HEAD is now at e41b846 add new file
    
    # 로그 확인
    (feature1) $ git log --oneline --graph --all -n3
    * 0393659 (origin/master, master, hotfix) hotfix
    | * e41b846 (HEAD -> feature1, origin/feature1) add new file
    |/  
    * 3d94a73 (tag: v0.1, origin/main, main) add asynciotest

     

    위에서 진행했던 병합이 취소되었으니, rebase를 진행합니다. 이때 발생하는 충돌은 3-way때와 마찬가지로 해결하면 됩니다.

    # HEAD 브랜치의 커밋들을 master로 재배치
    (feature1) $ git rebase master
    CONFLICT (add/add): Merge conflict in gittest.py
    Auto-merging gittest.py
    error: could not apply e41b846... add new file
    Resolve all conflicts manually, mark them as resolved with
    "git add/rm <conflicted_files>", then run "git rebase --continue".
    You can instead skip this commit: run "git rebase --skip".
    To abort and get back to the state before "git rebase", run "git rebase --abort".
    Could not apply e41b846... add new file
    
    # 충돌 해결하고 git add
    (feature1) $ git add gittest.py 
    
    # rebase 계속 진행
    (feature1) $ git rebase --continue
    [detached HEAD 08cb166] add new file
     1 file changed, 1 insertion(+)
    Successfully rebased and updated refs/heads/feature1.
    
    # 로그 확인
    (feature1) $ git log --oneline --graph --all -n2
    * 08cb166 (HEAD -> feature1) add new file
    * 0393659 (origin/master, master, hotfix) hotfix

    참고로, rebase 명령은 재배치 대상 커밋이 여러 개일 경우 여러 번 충돌이 발생할 수 있습니다. 기존 커밋을 하나씩 단계별로 수정하기 때문에 `git rebase --confinue` 명령으로 충돌을 해결하고 rebase를 재개합니다.

     

    로그를 확인하면 [feature] 브랜치가 가리키는 커밋 체크섬이 변경된 것을 확인할 수 있습니다. (e41b846-> 08cb166) 앞서 설명한 것처럼 rebase를 하면 커밋 객체가 변경되기 때문입니다.

     

     

     

    이제 [master]와 [feature1] 브랜치를 merge해보겠습니다. 분기가 없으니 fast-forward merge로 진행됩니다.

    (feature1) $ git checkout master
    Switched to branch 'master'
    Your branch is up to date with 'origin/master'.
    
    # fast-forward 병합
    (master) $ git merge feature1
    Updating 0393659..08cb166
    Fast-forward
     gittest.py | 1 +
     1 file changed, 1 insertion(+)
     
     # 결과
    (master) $ git log --oneline --graph --all
    * 08cb166 (HEAD -> master, feature1) add new file
    * 0393659 (origin/master, hotfix) hotfix
    | * e41b846 (origin/feature1) add new file
    |/  
    * 3d94a73 (tag: v0.1, origin/main, main) add asynciotest
    * 8aa7e99 init

     

     

    fast-forward merge 결과는 아래와 같습니다.

     

     

     

     

    유용한 rebase : pull 했을 때 생기는 불필요한 머지커밋 없애기

    위와 같은 상황은 [master] 브랜치에 새로운 변경사항이 push 되었지만, 주황색 commit이 pull 하지 않고 commit을 진행한 상황입니다. 이때, pull 을 하지 않았기 때문에 이전 커밋을 부모로 한 커밋이 생성되며 뒤늦게 pull을 하게되면 불필요한 Merge commit이 발생됩니다. 즉, 자동으로 3-way 병합이 일어납니다.

     

    왜 pull을 하면 3-way 병합이 발생하는 걸까요? pull = fetch + merge 이기 때문입니다.

     

    그렇다면 어떤 방식으로 불필요한 commit을 없앨 수 있을까요?

    reset --hard로 병합 커밋을 되돌리고 Rebase를 사용하면 됩니다.

    이는 간단하고 효과적인 방법이라 종종 사용되니 기억해두면 좋습니다.

     

    # 병합 커밋을 되돌림
    $ git reset --hard HEAD~
    
    # rebase 수행으로 현재 커밋을 재배치함
    $ git rebase origin/master
    
    # 반영
    $ git push

     

    rebase 주의사항

    원격 저장소에 푸시한 브랜치는 rebase 하지 않습니다!

     

    예를 들어, 원격에 있는 c1 커밋을 rebase 하게되면, 원격에는 그대로 c1 커밋이 존재하고 로컬에는 다른 커밋인 c1`가 생성됩니다. 이때, 다른 사용자가 원격에 있던 c1을 merge할 수 있습니다. 그런데 변경된 c1`커밋도 나중엔 원격에 push 되어야 하는데 이렇게 되면 원격에는 사실상 같은 커밋인 c1, c1`가 동시에 존재하게 됩니다. 그리고 이 상황에서 누군가 충돌을 해결하기 위해 merge와 rebase를 사용하게 되면 상황은 굉장히 복잡해집니다.

    따라서 rebase는 원격에 존재하지 않는 로컬의 브랜치들에만 적용하는 것을 권장합니다.

     

     

    팁) 임시 브랜치 사용하기

    원래 작업하려고 했던 브랜치의 커밋으로 임시 브랜치를 만들면 해당 브랜치에서는 아무 작업이나 해도 상관이 없습니다. 나중에 그 브랜치를 삭제하기만 하면 모든 내용이 원상복구되기 때문입니다. 임시 브랜치가 필요 없어지는 시점에 `git brancd -D <브랜치명>` 으로 삭제합니다.

    # feature1 브랜치에서 임시 브랜치 생성
    $ git branch test feature1
    
    # test 브랜치로 체크아웃
    $ git checkout test
    
    # 하고싶었던 이것저것 하고 commit
    
    # master 브랜치로 체크아웃
    $ git checkout master
    
    # 임시 브랜치 삭제
    $ git branch -D test

    이렇게 임시 브랜치를 삭제하면 최종적으로는 아무 작업도 남지 않습니다. merge, rebase 등 다양한 작업을 미리 테스트해보고 싶을 때 간단하게 임시 브랜치를 만들어서 사용하고 삭제하면 좋습니다.

     

     

    3-way 병합과 rebase 비교

      3-way 병합 rebase
    특징 머지 커밋 생성 현재 커밋들을 수정하면서 대상 브랜치 위로 재배치
    장점 충돌이 한 번만 발생 깔끔한 히스토리
    단점 트리가 약간 지저분해짐 충돌이 여러번 발생할 수 있음

     

     

     

    References

    팀 개발을 위한 Git, GitHub 시작하기 < 협업 개발을 위한 Git과 GitHub을 처음 접하는 개발자라면 반드시 읽어보면 좋은 책입니다.

    반응형

    댓글