git 알아보기 #6 : git merge/rebase

git merge

앞에서 특정 기능을 개발하거나 테스트 하기위해 branch를 생성해서 작업을 했다고 생각해보자. 기능을 다 구현하고나면, 이걸 main 브랜치에 반영해야 다른사람들이 사용하거나 릴리즈시에 반영이 될 것이다. 또는, 별도의 버전으로 관리한다고 해도 main 브랜치에서 진행된 작업 내용을 이 브랜치에 적용하고 싶을 것이다. 이렇게 하나의 branch의 변경사항들을 다른 branch에 병합시키고 싶을 때 사용하는게 git merge이다.

git merge는 프로그램의 규모가 클수록, 변경사항이 많을 수록 단순하지 않다. 이런경우, 별도로 개발이 진행된 두 브랜치의 수많은 수정사항들이 서로 충돌(conflict)을 일으킬 것이다. 그래서 git merge의 핵심은 이 충돌들을 해결하는데에 있다.

git merge의 수행은 현재 브랜치에서 다른 병합할 브랜치를 가져와 병합이 이루어진다. 기본적인 git merge 명령어의 사용법은 다음과 같다.

git merge <another branch to merge in>

만약, main branch에 new_feature branch를 합치고 싶다면, 다음과 같이 한다.

git switch main
git merge new_feature

현재 브랜치가 main이라면 굳이 switch가 필요하진 않다. main 브랜치 상태에서 git merge 명령으로 병합할 다른 브랜치 이름을 적으면 된다. 만약에 conflict가 생기지 않는다면, 자동으로 fast-forward 병합이 진행되어 딱히 추가적인 일이 없다. 하지만 conflict가 발생한다면 아래와 같이 에러가 뜬다.

Zsh
~/g/myproject │ main !1>  git merge new_feature
Auto-merging sayings.py
CONFLICT (content): Merge conflict in sayings.py
Automatic merge failed; fix conflicts and then commit the result.

꼭 알아둬야 할 것은 merge는 수많은 충돌이 발생할 수 있고, 이것들을 하나씩 해결해 나가려면 스위치처럼 딸깍 작동이 되지 않는다. 그렇기 때문에 merge가 진행되다가 중간에 conflict등이 발생하면, merge가 진행중인 “상태”로 유지된다. 복잡한 프로세스이기 때문에 모든게 정리될 때까지 상태가 유지되는 것이다. git status를 보면,

Zsh
~/g/myproject │ main merge ~1 !1>  git status
On branch main
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 modified:   sayings.py

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

내용에 Unmerged paths 가 보일 것이다. 그리고 “fix conflicts and run ‘git commit’ “문구가 보이는데, 다 고치고 commit을 해야 끝난다는 얘기이다. 또한, mark resolution, 즉, conflict가 해결됐음을 표시하려면 git add를 써서 stage에 올리라는 문구가 보인다. 자동으로 병합되지 못했으므로 사용자가 직접 처리하고 add를 하면, 해결된 것으로 간주하겠다는 얘기이다. 즉, 사용자가 어떻게 고치든 고쳐서 git add로 staging하고, git commit을 하면 merge가 완료되는 것이다.

conflict가 발생한 sayings.py 파일 내용을 살펴보자.

Python
...
<<<<<<< HEAD
def call(name):
    print(f"Hey! {name}")
=======
def shout(name):
    print(f"{name.upper()}!!!")
>>>>>>> new_feature
...

위와같이 git에서 conflict가 생긴 지점을 파일에 표시해준다. 표시된대로 위쪽이 HEAD, 현재 브랜치인 main 이고 아래쪽이 병합할 new_feature의 내용이다. 사용자가 내용을 알아서 합치고나서 구분자 라인인 <<<<<<< , ======, >>>>>>> 부분들을 제거해주면된다. 각 파일을 고치는 일은 이렇게 직접 텍스트를 수정해도 되지만, 각종 GUI merge tool들을 쓸 수도 있고 그게 간편할 것이다. 여기서는 텍스트 수정만 다루겠다.

이제 다음과 같이 수정하자.

Python
def shout(name):
    print(f"{name.upper()}!!!")

def call(name):
    print(f"Hey! {name}")

한쪽의 수정사항을 택할 수 있지만, 여기서는 양쪽의 수정사항을 둘 다 반영했다.

다음에 git add . 로 conflict가 발생한 파일과 fast-forward 로 자동 병합된 파일까지 수정된 파일을 전부 추가해준다.

Zsh
~/g/myproject │ main> merge +1 !1>  git add .
~/g/myproject │ main> merge +2>  git status  
On branch main
All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:
	modified:   readme.txt
	modified:   sayings.py

그러면 위와같이 자동으로 conflict가 고쳐졌다고 나오며, 아직 merging 상태이니 commit을 하라고 뜬다. 마지막으로 commit을 해주면 merge 작업이 완료된다.

Zsh
~/g/myproject │ main> merge +2>  git commit -m "new-feature is merged to main"
[main fac78e4] new-feature is merged to main

git log를 그래프로 살펴보자.

그래프 이미지를 보면, main branch와 new_feature branch를 합쳐서 새로운 commit이 생성된걸 확인 할 수 있다.

테스트를 좀 더 해보자. main branch에서 readme.txt에 수정사항을 만들었다.

main/readme.txt
main changed

this is the first file.
file name is 01.txt
I'm studying git.
Why am I so idiot?

Let's dance on stage!

파일의 제일 앞에 main changed라는 문구를 넣었다. 다음에 new_feature로 브랜치를 변경하여 readme.txt의 마지막줄에 다음과 같이 추가했다.

Zsh
~/g/myproject │ main>  git switch new_feature
Switched to branch 'new_feature'
new_feature/readme.txt
this is the first file.
file name is 01.txt
I'm studying git.
Why am I so idiot?

new_feature has changed

이제 다시 main으로 돌아가서 병합해보자.

Zsh
~/g/myproject │ main>  git merge new_feature
Auto-merging readme.txt
CONFLICT (content): Merge conflict in readme.txt
Automatic merge failed; fix conflicts and then commit the result.

앗, 이런. 또다시 conflict가 발생했다. 이건 의도하지 않은건데, main에서 수정했던 내용을 내가 까먹고 있었다. 단순한 conflict 해결은 앞에서 했으므로 조금 다르게 접근해보자. 우선, 이렇게 의도하지 않은 conflict가 발생했을 때, 병합을 어떻게 할지 당황스러워서 일단, 원래대로 되돌리고 싶을 때가 있을 것이다. 이럴 때는 “–abort” 옵션을 써서 되돌릴 수 있다.

Zsh
git merge --abort

Zsh
~/g/myproject │ main merge ~1>  git merge --abort  

~/g/myproject │ main>  git status
On branch main
nothing to commit, working tree clean

git status로 merge 상태가 풀렸음을 확인 할 수 있다.

다음에 할 작업은 main의 변경사항을 new_feature 브랜치에 병합하는 것이다. 실무에서 작업을 한다고 가정했을 때, main과 new_feature의 브랜치가 변경사항이 늘어날수록 나중에 병합이 복잡해질 것이다. 그렇다고 기능구현이 완료되지 않은걸 main에 병합할 수는 없다. 이럴 때, 주기적으로 main의 변경사항을 new_feature에 반영해주면 main의 변경사항을 따라잡아가며 기능 구현이 가능하고, 나중에 main에 반영시에도 추가한 기능 부분만 큰 어려움없이 병합이 가능해진다.

new_feature로 브랜치를 변경하여 여기에 main을 병합해보자.

Zsh
~/g/myproject │ main>  git switch new_feature
Switched to branch 'new_feature'

~/g/myproject │ new_feature>  git merge main
Auto-merging readme.txt
CONFLICT (content): Merge conflict in readme.txt
Automatic merge failed; fix conflicts and then commit the result.

당연히, 앞에서와 같이 충돌이 생긴다. 이전에 설명한대로 conflict를 해결하자.(여기선 생략)

main 브랜치로 다시 변경한 후,

Zsh
~/g/myproject │ new_feature>  git switch main
Switched to branch 'main'

merge가 완료된걸 그래프로 보면 다음과 같다.

그래프를 보면, main이 new_feature로 병합되면서 new_feature 브랜치에 새로운 commit이 생성된걸 볼 수 있다. 그래프가 조금 혼란스러운건, main이 마치 new_feature에서 브랜치를 딴 것같은 형태로 앞에서와는 달리 뒤집혀있다. main을 new_feature로 merge를 해서 그런건지, new_feature가 가장 최신의 commit을 가져서 그런지 모르겠지만, 표현 방식이 조금 아쉬운 부분이다. 현재 브랜치는 main으로 변경했으므로, HEAD의 위치는 main에 표시되어 있는걸 볼 수 있다.

이제 다시 new_feature를 main에 병합해보자. new_feature는 이미 두 브랜치가 병합된 상태인데, main에는 반영되지 않았으므로 예상컨데, 충돌없이 fast-forward 병합이 진행될 것이다. 여기서 fast-forward의 의미를 확실히 알 수 있다. 이미 병합된 commit이 존재하므로 우리는 그저 main 브랜치의 포인터만 “앞으로 진행”시키면 되는 것이다.

Zsh
~/g/myproject │ main>  git merge new_feature
Updating 78f1da3..a83283b
Fast-forward
 readme.txt | 1 +
 1 file changed, 1 insertion(+)

예상대로 Fast-forward 가 진행됐으며 readme.txt가 변경되었다고 표시된다. git log 그래프를 살펴보면,

새로운 commit이 생성되지 않고 new_feature에 병합하며 만들어진 commit으로 main이 이동만 했다.

git rebase

git rebase도 merge 방법중 하나이다. base라는건 branch의 시작점을 의미한다. 다르게말해 rebase는 branch의 시작점을 새로 잡는다는 의미이기도 하다. 이건 graph를 그려보는게 가장 이해가 쉽다.

앞에서 사용하던 예제를 계속 이어서 사용해보자. 먼저, 새 기능을 구현한다고 가정하고 animal 이라는 브랜치를 만들자.

Zsh
~/g/myproject │ main>  git switch -c animal
Switched to a new branch 'animal'

이 브랜치에서 sayings.py를 편집하여 다음을 추가하고 commit 하자.

animal/sayings.py
...
def bark():
    print(f"bow wow")
...

Zsh
~/g/myproject │ animal !1>  git commit -am "add dog sound"
[animal dd434e0] add dog sound
 1 file changed, 3 insertions(+)

-am을 옵션을 사용해서 add 단계를 생략했다.

또 하나 수정해서 commit 하자.

animal/sayings.py
...
def meow():
    print("meow~")
...

Zsh
~/g/myproject │ animal !1>  git commit -am "add cat sound"
[animal e5e4aa7] add cat sound
 1 file changed, 3 insertions(+)

이제 다시 main으로 돌아가자.

Zsh
~/g/myproject │ animal>  git switch main
Switched to branch 'main'

main 에서도 수정사항을 만든다. 일부러 충돌이 생기게 sayings.py를 수정하겠다.

main/sayings.py
...
def love(name):
    print(f"I love {name}")

def ask(name):
    print(f"Who is {name}?")
...

Zsh
~/g/myproject │ main !1>  git commit -am "add love and ask func."
[main b7ae4b0] add love and ask func.
 1 file changed, 6 insertions(+)

눈에 들어오도록 그래프를 계속 그려왔는데, Git graph로도 그래프를 한 번 볼까?

animal 브랜치는 그래프에서 new_feature가 있는 a83283bd commit 이 시작점이며, 이게 바로 베이스이다. main 브랜치는 별도로 변경사항이 생겨 commit이 추가된걸 볼 수 있다. rebase 라는건 저 animal 브랜치를 똑! 떼어내서 main 브랜치에 접붙이듯 붙이는걸 말한다. 그러면 animal의 base는 b7ae4b01 commit이 된다.

git merge는 다른걸 가져와 합치는 작업이기 때문에, HEAD가 최종적으로 병합이 이루어지는 main 브랜치에서 명령을 수행했다. 주체가 main이란 얘기다. 반면에, rebase는 animal 브랜치가 주체이고 이 브랜치에 이사갈 base만 주어지는 것이기 때문에, 현재 HEAD가 animal 브랜치인 상태에서 명령어를 입력한다.

Zsh
~/g/myproject │ main>  git switch animal
Switched to branch 'animal'

git rebase 의 기본 사용법은 다음과 같다.

git rebase <new base>

animal을 main으로 rebase 해보자.

Zsh
~/g/myproject │ animal>  git rebase main
Auto-merging sayings.py
CONFLICT (content): Merge conflict in sayings.py
error: could not apply dd434e0... add dog sound
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"

내용을 잘보면 충돌(conflict)가 발생했는데 흥미롭다. “error: could not apply dd434e0…” 이라고 나온다. 이건 animal 브랜치를 만든 후, 첫번째 commit 내용이다. 눈치 챘을지 모르겠지만, 브랜치 전체를 똑 떼어내서 새로운 base에 붙이는 작업은 commit 하나씩 fetch를 만들어 merge작업이 진행이 된다는 얘기이다. 즉, commit이 여러개이고 충돌이 각각 발생을 하는 경우, 한땀한땀 merge작업을 해야한다. 또한, git merge도 여러단계에 거쳐 진행되다가 –abort 옵션을 사용해 취소하듯 git rebase –abort로 취소할 수도 있다.

하나씩 merge를 해보자. 코드를 들여다보면,

Python
<<<<<<< HEAD
def love(name):
    print(f"I love {name}")

def ask(name):
    print(f"Who is {name}?")
=======
def bark():
    print(f"bow wow")
>>>>>>> dd434e0 (add dog sound)

충돌이 난 내용을 diff를 이용해 표시해주고 있다. merge때와 마찬가지로 내용을 합쳐주자. 둘 다 살리는 쪽으로 진행하겠다.

sayings.py
def love(name):
    print(f"I love {name}")

def ask(name):
    print(f"Who is {name}?")

def bark():
    print(f"bow wow")

Zsh
~/g/myproject │ @b7ae4b01 rebase-i 1/2 +1>  git add sayings.py

~/g/myproject │ @b7ae4b01 rebase-i 1/2 +1>  git commit sayings.py -m "conflict resolved"
[detached HEAD 1d9d3e7] conflict resolved
 1 file changed, 3 insertions(+)

메세지에서 detached HEAD라고 뜬다. 직접 그래프를 그리고 있긴 하지만 Git graph로 보면,

merge 된 commit이 새로 생기고 HEAD가 거기로 이동했음을 알 수 있다. 아직은 rebase가 진행중인 과정이라 animal 브랜치는 그대로 유지되는게 보인다. 계속 진행해보자. rebase 를 계속하기 위해서는 다음 –continue 옵션을 사용한다.

git rebase --continue
Zsh
~/g/myproject │ @1d9d3e7e rebase-i 1/2  git rebase --continue    ✔ │ 21:25:43 
Successfully rebased and updated refs/heads/animal.

두번째 commit은 충돌없이 merge가되서 자동으로 진행이 됐다.

merge가 다 이루어진 후,

기존의 animal 브랜치가 삭제되고 최종적으로 main을 base로 하는 브랜치가 되었다. commit 하나씩 merge가 되는 과정을 거치지만, 최종적으로 animal 브랜치가 새로운 베이스로 지정해준 commit인 b7ae4b01(main) 에 붙은 브랜치가 되었다.

Git graph로 살펴보자.

기존에 별도로 존재하던 animal 브랜치의 각 commit들이 그대로 main 위쪽으로 하나씩 merge되어 붙은걸 볼 수 있다.

마지막으로 main 브랜치를 HEAD 위치, 즉, 새로 옮겨진 animal 브랜치로 이동시켜야 하는데 이미 main을 base로 commit이 진행된 상태기 때문에 간단하게 다음의 명령어로 수행이 된다.

Zsh
~/g/myproject │ animal>  git switch main
Switched to branch 'main'

~/g/myproject │ main>  git merge animal
Updating b7ae4b0..59513b6
Fast-forward
 sayings.py | 6 ++++++
 1 file changed, 6 insertions(+)

별다른 문제없이 fast-forward 형태로 진행된걸 볼 수 있다.

Git graph를 살펴보면,

자연스럽게 직선상에 main과 animal 브랜치가 존재한다.

정리해보면, rebase를 해도 결과물 자체는 merge와 동일하다. 달라지는건 commit 히스토리이다. merge는 브랜치들이 각각 유지된채로 내용만 합쳐진다. 이경우, 브랜치가 매우 많아지게 되면 혼란스러울 수도 있고, main브랜치에는 commit 히스토리는 없이 최종 결과물만 병합이 되므로 알아보기 힘들 수 있다. 반면, rebase는 main에 각각의 commit 히스토리가 그대로 보여 이력관리가 편리하다. 대신에 기존 브랜치는 아예 사라지게 된다.

위에서 보여준 과정에서 실수한 부분은 commit 메세지를 제대로 작성하지 않은 부분이다. rebase하면서 merge가 될 때, 기존 브랜치에서 작성했던 메세지를 유지해야 하는데, 단순히 “conflict resolved”라고 적었더니, rebase이전에 있던 commit 메세지들이 기존 브랜치가 사라지며 같이 사라져 곤란해졌다.

merge나 rebase나 장단점이 있는데, 일단 rebase를 하면, 브랜치 관리가 깔끔하고 main에서 모든 history가 보인다는 점이 매력적이다. 그렇지만 commit 하나하나 병합해야 하며 매번 충돌이 발생할 경우 이를 해결해줘야 한다. 가장 치명적인 문제는 여기에서는 보이지 않지만, 뒤에서 다룰 remote repository를 이용해 여러사람이 공동 작업을 하는 경우, 다른 사람과 같이 작업하던 브랜치를 아예 날려버릴 수도 있어서 매우 주의가 필요하다. 가이드에서는 공동작업하는 git에 노출된 브랜치는 rebase 를 하지 말라고 권하고 있다. 혼자하는 작업은 rebase를 맘껏 써도 되지만, 공동작업시에는 merge를 쓰자… 정도로 정리될거 같다.

Previous post linux terminal 출력을 wordpress에 삽입하기

관련 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다