반응형

출처 : 위키피디아

 

1. 개요 

 

 앞선 포스팅에서는 Git Merge 에 대해 알아봤다. 코드 충돌이 나는 원인과 과정을 이해하고, Fast Forward Merge , 3-Way-Merge 가 어떤 상황에 발생하는지도 알았다. 다음은 병합과 연관되는 또 다른 명령어인 Rebase에 대해 알아보자. Rebase and Merge 전략이 있을 만큼 연관성있는 명령어이다.

 


2. Rebase 가 뭔가요? 🤔

Re-Base

 

말 그대로 브랜치의 Base Commit(= Base) 를 재설정 (= Re) 하는 명령어이다.

A 브랜치에서 B 브랜치에 대한 Rebase를 할 경우 A 브랜치의 Base Commit이 B 브랜치의 Head Commit으로 변경된다.

 


3. 재설정하면 뭐가 좋은데요? 🤔

 Fast Forward Merge를 통해 깔끔한 커밋 히스토리를 유지할 수 있다. 예를들어 Main 브랜치에서 A 라는 브랜치를 병합하기 전에, A 브랜치가 Main 브랜치에 Rebase 작업을 한다면 어떨까?

A 브랜치의 Base Commit이 Main 브랜치의 Head Commit으로 Re-base 될것이다.

 이후 Main 브랜치에서 A 브랜치를 병합하면 어떻게 될까?

Fast Forward Merge가 되어 깃 히스토리가 직렬로 되어 깔끔하게 관리할 수 있다.

 


4. 자세한 예를들어 설명해주세요. 😖

 

아래의 깃 히스토리를 보자.

개발자는 D 기능 개발을 위해 Main 브랜치로부터 D 브랜치를 생성하고, 작업을 하며 D-C1, D-C2 Commit을 한 상황이다. 이때 D 브랜치의 Base Commit은 M-C2이다.

 

Main 브랜치로부터 D 브랜치를 생성한 상황

 

 

그리고 다른 개발자는 E 기능을 개발하기 위해 마찬가지로 Main 브랜치로부터 E 브랜치를 생성하고, 작업을 하며 E-C1 Commit을 한 상황이다. E 브랜치의 Base Commit은 M-C2이다.

Main 브랜치로부터 E 브랜치를 생성한 상황

 

 

개발이 끝나고 테스트까지 마친 D 기능은 운영 반영을 위해 Main 브랜치에서 Merge 되게 된다. Main 브랜치의 Header Commit과 병합하려는 D 브랜치의 Base Commit이 일치하므로 Fast Forward 방식으로 병합된다.

 

FF 방식으로 병합된 Main, D 브랜치

 

 

곧이어 E 기능도 개발과 테스트가 끝났고, 운영 반영을 위해 Main 브랜치에서 Merge 하게 된다. 이때는 FF 병합이 불가능하므로 3-Way-Merge 방식의 병합이 진행된다.

3-Way-Merge를 통한 Main, E 브랜치 병합

 

 


5. Rebase 설명하랬더니 왜 Merge를...? 🙄

Rebase의 사용 목적을 이해하기 위함이다. 상황을 조금만 복잡하게 만들어보도록 하겠다.

연초에 들어서니 개발 및 수정 요청건이 많아져 F, G, H, I, J, K 기능을 개발해야 하는 상황이 발생했다. 마찬가지로 Main 브랜치로부터 각각의 브랜치를 생성한 후 개발을 진행했으며, 결과적으로 아래와 같은 깃 히스토리가 만들어지게됐다.

6개의 브랜치를 생성했다.

 

 

이후 작업이 완료된 브랜치에 대해 하나씩 운영반영을 할 경우 Main 브랜치에서 브랜치별로 Merge 하게 된다. 몇일에 걸친 운영 반영을 모두 마치고 보니 아래와 같이 병렬 형태의 깃 히스토리가 생성되어 있었다. 예로 든건 6개의 브랜치이나, 작업이 더 추가되어 브랜치가 늘어날 경우 깃 히스토리는 두꺼운 병렬 형태를 띄게 되고, Main 브랜치에 대한 작업 히스토리를 파악하기 어려워진다.

복잡해질 준비를 하는 깃 히스토리

 


6. Rebase를 하면 뭐가 달라지나요? 🤔

 

Rebase를 하면 어떻게 될까? 위 브랜치 중 F, G, H에 대해서만 시뮬레이션을 돌려보겠다.

본격적으로 들어가기 전 Rebase 의 정의를 리마인드 하도록 하겠다.

 

 

말 그대로 브랜치의 Base Commit(= Base) 를 재설정 (= Re) 하는 명령어이다.

A 브랜치에서 B 브랜치에 대한 Rebase를 할 경우 A 브랜치의 Base Commit이 B 브랜치의 Head Commit으로 변경된다.


 

먼저 F 브랜치 운영 반영을 위해 F 브랜치에서 Main 브랜치에 대해 Rebase를 한다.

Main 브랜치의 Head Commit은 Merge Commit이고, F 브랜치의 Base Commit 도 Main 브랜치의 Merge Commit 이므로 아무일도 일어나지 않는다.

F 브랜치 > Main 브랜치 Rebase

 

이제 Main 브랜치에서 F 브랜치를 Merge 하면 Fast Forward Merge 방식으로 병합된다.

 

Main 브랜치 > F 브랜치 Merge

 

 

다음, H 브랜치 작업 운영 반영을 위해 H 브랜치에서 Main 브랜치에 대해 Rebase 한다. 

Main 브랜치의 Head Commit은 F-C3 이고, H 브랜치의 Base Commit 은 Merge Commit 이므로

H 브랜치의 Base Commit은 F-C3로 변경된다.

 

H 브랜치 > Main 브랜치 Rebase

 

 

이제 Main 브랜치에서 H 브랜치를 Merge 하면 Fast Forward Merge 방식으로 병합된다.

Main 브랜치 > H 브랜치 Merge

 

 

 

 

다음, G 브랜치 작업 운영 반영을 위해 G 브랜치에서 Main 브랜치에 대해 Rebase 한다. 

Main 브랜치의 Head Commit은 H-C2 이고, G 브랜치의 Base Commit 은 Merge Commit 이므로

G 브랜치의 Base Commit은 H-C2로 변경된다.

 

G 브랜치 > Main 브랜치 Rebase

 

이제 Main 브랜치에서 G 브랜치를 Merge 하면 Fast Forward Merge 방식으로 병합된다.

Main 브랜치 > G 브랜치 Merge

 

 

직접 테스트를 해보면 아래와 같이 직렬 형태의 깃 히스토리가 남는 것을 알 수 있다. 알아보기도 쉽다.

즉, Rebase를 통한 Merge를 하면 보기 쉬운 형태로 깃 히스토리를 관리하게 된다.

Rebase - Merge 전략 활용

 

 

 

 


7. 위험이 도사리고 있는 Rebase 👺

Rebase를 사용하면 간단하게 Base Commit이 변경된다고 설명했지만, 사실 위험한 작업이다. 왜 위험할까?

Origin 입장에서 생각해보자.

G 브랜치에서 작업 후 PUSH를 했을테니 로컬, 원격 브랜치 모두 아래와 같이 G-C1, G-C2, G-C3 커밋이 있었을 것이다.

G 브랜치

 

그런데 G 브랜치에서 Main 브랜치에 대해 Rebase, 정확히 말하면 로컬 G 브랜치에서 Main 브랜치에 대해 Rebase를 한다. 그럼 로컬 G 브랜치의 깃 히스토리는 아래와 같이 변경된다. 이때 push를 하면 어떻게 될까?

G 브랜치 > Main 브랜치 Rebase

 

 

 

그렇다. 충돌이 발생한다. Rebase 에 의해 로컬 브랜치의 깃 히스토리가 변경되었고 원격 브랜치로 Push 하려 하니 원격 브랜치 입장에서 깃 히스토리가 일치하지 않아 충돌이 발생하는 것이다. 원격 브랜치 입장에서는 로컬 브랜치가 자신과 이름만 같은 브랜치인 것이다.

로컬, 원격 브랜치의 조상 Commit 이 다르니 3-Way-Merge 방식을 통해 아래와 같이 병합된다.

로컬 G 브랜치와 원격 G 브랜치간 충돌 / 3-way-merge

 

결과적으로 Rebase를 했더니 동일 브랜치간 Merge 작업이 추가되고, Main 에서 이를 Merge하면 이러한 히스토리도 남게된다. 이렇게 보니 그냥 Merge를 하는것 보다 못하는 상황이다.

 

앞서 예시에서 보여준 Rebase는 이러한 케이스가 없었다. 즉, 로컬, 원격 브랜치가 충돌이 일어나지 않았다는 건데, 이건 무엇을 의미할까?

 

바로 강제 Push 를 통해 로컬과 원격 브랜치 간 히스토리를 맞춰준 것이다. 즉, Rebase를 하면 강제 Push 작업이 동반된다.

 

이러한 강제 Push는 해당 브랜치에 협업자가 있을 경우 큰 문제를 야기할 수 있다. 특히 이러한 사실을 몰랐을 때 예기치 못한 상황에 당황할 수 있고, 자신이 Push 했던 작업 내역을 잃어버릴 수도 있다.

 

1번 개발자가 G 브랜치를 pull 받은 후 Rebase 한 이후 시점에 2번 개발자가 G 브랜치에 작업 내역을 push 한다면, 1번 개발자의 로컬 G 브랜치에는 2번 개발자가 작업한 내용이 반영되지 않은 상태이다. 이때 1번 개발자가 Rebase 한 내역을 원격 브랜치에 덮어쓰기 위해 강제 push를 하게 된다면 2번 개발자가 작업한 내용과 히스토리 모두 날아가게 된다.

 

다행히도 이 문제는 강제 push 시 force-with-lease 옵션을 사용하면 해결된다. 로컬 저장소와 원격 저장소를 비교했을 때, 원격 저장소에 새로운 커밋이 추가되어 있는 경우 강제 Push 작업을 취소시키는 옵션이다.

 


 

8. 양날의 검 Rebase

Rebase는 깔끔한 깃 히스토리를 관리하고, 매우 단순하게 동작하는 착한(?) 명령인줄 알았으나, 깃 히스토리를 조작하고, 강제 Push 를 동반해야하는 위험한 녀석이었다. 이러한 위험성을 인지하고, 협업자와 충분한 소통과 함께 사용한다면 큰 문제가 없을테지만, Git을 통한 협업이 어색하고, Rebase의 동작과 위험성을 충분히 인지하지 못한 상태에서 사용한다면 코드를 날려먹거나 협업 간 사소하지 않은 문제를 야기할 수 있을 테니 조심히 사용하자.

반응형

'Git' 카테고리의 다른 글

[Git] Git Merge 란? / 쉽게 이해하기 / Fast Forward / 3-way-merge  (0) 2024.04.22
반응형

git

반응형

개요

 평소 Git을 사용했고, 이슈별로 브런치를 생성하며 개발했지만 혼자이다보니 Main 브랜치로 Merge 해도 코드 충돌이 발생할 확률은 극히 적었다. 협업을 시작하니 특정 브랜치로 Merge 해야하는 상황이 많이 발생했고, 코드 충돌이 잦아졌다. 발생할 이유가 없다고 생각했던 브랜치들이 충돌하는 이유, Git 트리가 상황에 따라 다르게 구성되는 이유, 아무 내용도 없는 Merge Commit 등... 여러 궁금증들이 생겼다. 이를 해소하려면 이 상황의 근간이 되는 Merge를 이해해야 했다.

 Branch를 왜 만들고, Merge를 왜 하고, 코드 충돌은 왜 발생하고, Git 트리는 왜 그렇게 구성되는지를  이해해보도록 하겠다.

 

더 이상 code Conflict 경고에 죽지 않으리라.

 


Merge란?

 

Merge란 브랜치와 브랜치를 합치는 과정을 말한다.

 

깃을 처음 사용하면 Pull과 헷갈릴 수 있는데, Pull은 원격 리포지토리 있는 코드를 로컬 리포지토리로 받아오는, 즉 업데이트를 하는 개념이고 Merge는 합치는 개념이다.

 


Merge 과정에서 코드 충돌이 발생하는 이유

합치려는 브랜치가 동일한 코드를 수정할 경우 발생한다.

 

 

말은 쉬운데 생각해보면 뭔말인가 싶다. 위 내용을 보다 쉽게 이해하기 위해 깃을 사용하지 않았다면 어땠을까를 생각해봤다.

 


깃을 사용하지 않고 협업하기

첫째, 팀장님이 개발자 두 명에게 각각 A와 B 기능을 구현하라고 지시했다. 협업이 시작된것이다. 이 회사는 Git이나 SVN과 같은 SCM을 도입하지 않아 팀장님이 갖고 있는 Base Code를 복사해서 자신의 로컬 환경으로 옮겼다. (참고로 Base Code는 현재 운영중인 코드이다.) 이로써 개발자들은 독립적인 개발 환경을 갖추게 되었다. 

개발을 위해 Base Code를 복사한다.

 

 

 

둘째, 두 개발자 모두 개발, 코드리뷰, 리팩토링 과정을 거치며 성공적으로 기능 구현을 완료했다.

리뷰 / 수정을 반복해가며 코드를 완성한다.

 

 

셋째, A 개발자가 작업한 A 기능을 선 반영하고, 하루 뒤에 B 개발자가 개발한 B 기능을 반영하기로 했다. 팀장님은 A 기능을 반영하기 위해 자신의 Base Code와 A 개발자가 작업한 코드를 병합한 후 코드를 운영서버에 반영했다. 병합은 어렵지 않았다. 팀장님이 갖고 있던 Base Code는 A 개발자에게 Base Code를 전달한 이후로 수정한 이력이 없기 때문에 A 개발자의 코드와 그대로 가져와도 됐기 때문이다.

굳이 비교할 필요가 없는 병합

 

 

넷째, 다음 날 팀장님은 B 기능을 반영하기 위해 Base Code(지금은 A와 병합된 코드가 Base Code이다) 와 B 개발자가 작업한 코드를 병합하려했다. 앞서 A 개발자의 코드를 그대로 가져올 순 없었다. B 개발자가 개발한 코드에는 A에 대한 기능이 없기 때문이다. 때문에 업데이트된 Base Code와 Base Code + B 코드를 비교하며 수정된 부분을 추출하여 병합하기로 했다. 그런데 이게 웬걸, A 개발자가 기능 구현을 위해 수정했던 UserService의 코드를 B 개발자도 수정한 것이다. 앞서 말한 동일한 코드를 수정한 상황이다. 이때 코드 충돌이 발생한다.

비교가 필요한 병합

 

다섯째, 팀장님은 두 개발자를 호출하여 충돌이 발생한 코드 중 어떤 코드를 사용할지를 토론했다. B 개발자가 수정한 코드가 더 깔끔하고, A 개발자가 구현한 A 기능에도 문제가 되지 않아 B 개발자의 코드로 수정했다. 곧 이어 운영반영도 완료했다.

 


깃을 사용했다면 어땠을까?

 

첫째, A, B 두 개발자는 독립적인 개발환경을 마련하기 위해 Main 브랜치를 기준으로 각각 A 브랜치, B 브랜치를 생성했을 것이다.

Main 브랜치를 Base로 하여 A, B 브랜치 생성

 

 

둘째, Commit - Push - PR 의 반복을 통해 기능 구현을 마쳤을 것이다.

A, B 기능구현 끝

 

 

셋째, Main 브랜치에서 A 브랜치를 Merge 했을 것이다. 'Main 브랜치의 Head Commit (M3) 기준으로 생성된 A 브랜치'가 생성되고 난 후 Main 브랜치의 커밋 내역이 없으므로 Fast-Forward 방식으로 병합될 것이다. Fast-Forward 방식은 바로 아래 설명하도록 하겠다.

 

Main 브랜치와 A 브랜치를 FF 병합

Head Commit
Branch의 마지막 커밋

 

 

 

넷째, 앞서 A 브랜치와의 Merge로 인해 Main 브랜치의 Head Commit이 변경됐기에 Main 브랜치와 B 브랜치를 Merge 할 경우 Fast-Forward 병합이 불가능하다. 추가로 코드 충돌이 발생했기 때문에 3-way-merge 를 통해 Merge 하고 이에 대한 Merge Commit 이 생성될 것이다.

 

Merge Commit
Merge 시 발생한 모든 변경사항들과 충돌 코드에 대한 대한 처리 내역을 뭉쳐놓은 커밋

 


Fast Forward 병합이 뭔가요?

Fast Forward Merge
브랜치 간 병합 시 현재 브랜치의 HeadCommit 과 병합하려는 브랜치의 Base Commit이 일치할 경우, 현재 브랜치의 Head Commit이 병합하려는 브랜치의 Head Commit으로 이동되는 병합 방식이다.

 


 

현재 브랜치의 Head Commit과 병합하려는 브랜치의 Base Commit이 일치...요? 🤔

Head Commit은 마지막 커밋, Base Commit은 브랜치를 생성할 때 Base가 됐던 브랜치의 마지막 커밋이다. 즉, 현재 브랜치의 HeadCommit 과 병합하려는 브랜치의 Base Commit이 일치한다는 것은, 현재 브랜치의 마지막 지점과 병합하려는 브랜치의 생성 지점이 동일하다는 것이다.

 (이해가 잘 되지 않는다면 어떤 물체의 끝 지점이 다른 물체의 시작지점과 일치한다면 이 지점을 연결해주는것만으로 병합이 되는 원리로 이해해보자.)

 

예를들어 아래와 같이 Main 브랜치로부터 생성된 A, B 브랜치가 있다. Main 브랜치의 Head Commit은 M-C2, A 브랜치의 Head Commit은 A-C2, B 브랜치의 Head Commit은 B-C2 이고, A 브랜치와 B 브랜치 모두 Base Commit이 M-C2 이다.

Main 브랜치로부터 생성된 A, B 브랜치

 

 

 

Main 브랜치에서 A 브랜치를 병합할 경우 Main 브랜치의 Head Commit인 M-C2와 A 브랜치의 Base Commit인 M-C2가 같으므로 이 지점을 연결한 후 Main 브랜치의 Head Commit A-C2 브랜치로 이동시키는 Fast Forward 방식으로 병합된다.

 

Fast-Forward 방식으로 병합

 

 

 

실 테스트를 위해 main 브랜치 생성 후 main 브랜치로부터 a-branch를 생성했다. 그 후 a-branch 에서 Create-AClass, Create-BClass 라는 두 개의 Commit 을 생성하고, main 브랜치에서 a-branch 를 Merge 했다. 그 결과, Fast Forward 방식으로 병합되어 main 브랜치의 Head commit이 a-branch 브랜치의 Head commit으로 이동된 것을 확인할 수 있다.

Main 브랜치 생성 및 첫 Commit
main 브랜치에서 a-branch 브랜치 병합

 

 


그럼 Head Commit과 병합하려는 브랜치의 Base Commit이 일치하지 않으면 어떤일이 발생하나요? 🤔

 

예를 들어 설명해보겠다. 앞서 Main 브랜치가 A 브랜치와 Fast Forward 병합한 후 Main 브랜치와 B 브랜치를 병합하려는 상황이다. 현재 Main 브랜치의 Head Commit은 A-C2, B 브랜치의 Base Commit은 B-C2로 현재 Head Commit과 병합하려는 브랜치의 Base Commit이 일치하지 않는 상황이다. 이때에는 3-way-merge 방식으로 병합하게 된다. 병합한 내용은 Merge Commit 에 생성되고 아래와 같은 형태의 커밋 히스토리가 생성된다. 

Main 브랜치와 B 브랜치 Merge

 

 

실 테스트를 통해 main 브랜치에서 b-branch를 병합할 경우 아래와 같이 초록색 선의 b-branch가 main 브랜치와 병합되었다는 것을 알리는 형태로 깃 트리가 생성되며, Merge Commit도 생성된 것을 확인할 수 있다.

Main 브랜치와 b-branch Merge

 

 

 

 

이 외에도 현재 브랜치의 Head Commit과 병합하려는 브랜치의 Base Commit이 일치하지 않는 케이스가 있다. 바로 Base Commit을 갖는 브랜치에서 신규 Commit이 생긴 경우이다.

 

Main 브랜치에서 생성된 새로운 Commit

 

 

 Main 브랜치를 기준으로 C 브랜치를 생성하고 C 브랜치의 작업자가 두 개의 Commit 을 생성하는 사이, Main 브랜치에서 M-C3 커밋이 발생했다. 이때 Main 브랜치의 Head Commit은 M-C3, C 브랜치의 Base Commit은 Merge Commit로 현재 브랜치의 Head Commit과 병합하려는 브랜치의 Base Commit이 일치하지 않아 3-way-merge 방식으로 병합되게 된다.

 

Main 브랜치와 C 브랜치 Merge

 

 

 

실 테스트를 위해 c-branch를 만든 후 Create-CClass, Create-CInterface commit 후, Main 브랜치에서 Create-MainClass라는 커밋을 생성하였다. Main 브랜치에서 c-branch 를 Merge 하니 아래와 같은 형태의 깃 트리와 Merge Commit 이 생성됨을 확인할 수 있다.

Main 브랜치와 c-branch Merge

 

 


3-Way-Merge 병합은 뭐에요?

앞서 Fast-Forward 병합이 가능하지 않는 상황에 발생하는 병합으로 3개의 Commit을 통해 병합하는 방법이다. 두 브런치를 병합한다면 각 브런치의 Head-Commit 기준으로 비교하면 되지 않을까라고 생각할 수도 있지만 병합하기 힘든 케이스가 많다. 2개의 Commit 보다 3개의 Commit으로 비교하는 것이 훨씬 효율적이다. 효율적인 이유를 한번 알아보자. 

 

아래 글에서 매우 잘 설명해주신 것 같아 해당 글을 참고했다.

https://wonyong-jang.github.io/git/2021/02/05/Github-Merge.html

 

[Git] Merge(3-way merge) 이해하기 - SW Developer

다른 형상 관리툴들과는 달리 git은 branch를 생성할 때 파일을 복사하는 것이 아니라 파일의 스냅샷만 가지고 생성하기 때문에 자원의 부담없이 branch를 만들어 사용할 수 있다. 이러한 장점 때문

wonyong-jang.github.io

 

효율적인 이유를 알아보기 위해 필요한 커밋은 3개로 현재 브랜치의 커밋, 병합할 브랜치의 커밋, 두 브랜치의 공통 조상이 되는 커밋이다.

 

Main 브랜치에 A 브랜치를 병합하는 상황을 가정하고, 메인 브랜치의 커밋을 M-C, 공통 조상이 되는 커밋을 B-C, 병합할 브랜치의 커밋을 A-C라고 가정하겠다. 생성된 커밋 히스토리는 아래와 같다.

 

현재 Commit History

 

 

여기서 변경된 부분이 a, b, c, d 라고 가정한다면 아래와 같은 표로 표현할 수 있다.

Main, A 브랜치의 수정 부분 및 Base Commit의 코드

 

 

그럼 Base Commit 기준으로 Main 브랜치는 c, d 부분이 수정되었고, A 브랜치는 a, c 가 수정됐음을 인지할 수 있다. 그리고 이를 병합한다면 충돌이 일어난 c 부분만 작업자가 직접 병합하고 나머지는 자동으로 병합되는 상황이다.

Base Commit 기준으로 Main, A 브랜치의 수정 부분을 Merge 한 결과

 

 

만약 Base Commit 없이 Main 브랜치와 A 브랜치로만 병합해야한다면 어떨까? b는 둘다 똑같으니 문제되지 않지만, a와 a'는 둘 중 어떤 코드가 베이스 코드인지, 수정된 코드인지 알 수 없고, d'와 d도 마찬가지 상황에 처한다. 병합 시 a와 a', d'와 d 중 어느 코드를 적용해야할 지 모르는 상황이다. 이를 알기위해선 개발자가 직접 두 브랜치의 공통되는 커밋, 즉 조상 커밋을 찾고 어떤 부분이 수정됐는지를 체크해야한다.

결국 개발자의 편의를 위해, 그리고 효율성을 위해 3-way-merge로 병합하는 것이다.

Merge 하려니 앞이 깜깜..

 

반응형

'Git' 카테고리의 다른 글

[Git] Git Rebase 란? / 쉽게 이해하기 / 예시  (0) 2024.04.22

+ Recent posts