깃의 병합과 충돌이란?
😃 오늘 알아볼 내용
이전에 배운 내용은 브랜치였다.
브랜치 같은 경우에는 원본에 영향을 주지 않고 개발을 할 수 있는 환경을 만들기 위해서이다. 근데 개발이 완료가 되었고 원본을 업그레이드 하려면 어떻게 해야 할까?
그럴 때 하는게 깃의 병합이다. 한 번 알아보자.
🤝🏻 병합과 충돌이란?
병합은 분리된 브랜치를 한 브랜치로 합치는 작업을 말한다.
수동으로 병합을 한다고 생각하면 어떻게 할까? 일일이 수정 내역을 찾아서 원본에 넣어줘야 할 것이다. 또는 수정된 코드를 원본으로 하는 방법이 있을 것이다.
근데 이거는 코드의 규모가 작을 때나 가능하고 만약 변경 사항이 많다면 코드를 옮기는 작업 자체가 길어질 것이고 이는 비효율적일 것이다.
이를 도와주는 것이 깃의 자동 병합이다.
깃은 원본을 기준으로 두 파일의 변경 이력을 비교해서 변경된 파일 내용이 발견되면 자동으로 수정된 코드 내용을 병합해준다.
이때 깃은 브랜치를 기준으로 한다. 이전에 학습한대로 브랜치는 같은 저장소 내에서 서로 독립적으로 작업을 분리한 영역이다. 병합은 분리된 각각의 브랜치에서 수정된 사항을 하나의 브랜치로 합친다. 단 병합하고자 하는 브랜치는 같은 로컬 저장소에 있어야 한다.
깃이 도와주면서 병합 과정이 수월해졌으나 깃이라고 해서 모든 병합을 완벽하게 해내는 것은 아니다. 이를 충돌이라고 한다.
한 번 천천히 병합부터 알아보자.
🤔 병합이 어떻게 이루어질까?
깃의 병합은 브랜치를 기반으로 실행하고 각 브랜치를 비교해서 자동 병합하는 형태이다.
따라서 병합을 하려면 브랜치를 만들어 브랜치 안에서 수정 작업을 해야한다.
병합할 때는 상대적인 기준으로 판별하는 알고리즘이 존재하는데 기본적인 알고리즘 2가지만 확인해보자.
Fast-Forward 병합
가장 간단한 방법으로 혼자 개발할 때 자주 사용된다.
혼자 개발할 때는 브랜치가 생성된 커밋에 따라 순차적으로 분기된다. 또한 수정도 순차적으로 할 경우가 많다. 즉 브랜치가 분기되지만 전체적으로 보면 순차적이라는 뜻이다.
이러한 순차적인 commit에 맞추어 병합을 처리하는 방법이 Fast-Forward 병합인 것이다.
예시와 함께 보면 이 병합은 간단하다.
일예로 브랜치 A를 만들고 진행을 위와 같이 했다고 해보자.
commit 순서가 시간 순서라고 생각하면 된다.
master 브랜치 commit2 시점에서 브랜치를 생성하고 새로운 commit을 3개 생성했다고 가정한 그림이다.
이 상황에서 브랜치 A를 master 브랜치로 병합한다고 생각해보자.
그러면 아래 그림처럼 될 것이다.
위 그림처럼 브랜치 A의 commit들이 master 브랜치로 병합이 되는 그림이다.
master 브랜치에는 commit이 하나도 없었기 때문에 브랜치 A가 그대로 master 브랜치로 이동한 것처럼 보인다.
병합한 이후에는 master 브랜치의 마지막 commit 위치와 브랜치 A의 마지막 commit 위치가 같다.
Fast-Forward 병합은 작업한 브랜치를 원본 브랜치에 병합할 때 작업한 브랜치의 시작 commit을 원본 브랜치 이후의 commit으로 가리킨다. 이는 단순히 commit 위치를 최신으로 옮기는 것과 비슷하다.
3-way 병합
위 Fast-Forward는 실제로는 많이 일어나지 않는 방식이다.
앞서 설명했던 것 처럼 위 방식은 순차적으로 일어날 경우, 혼자 개발할 경우 많이 나오는 모습인 것이다.
일반적으로는 3-way 병합이 더 많이 일어난다.
이 3-way 병합은 위 방식처럼 master에 commit이 없는 경우가 아닌 몇 commit이 있는 상황이다. 아래 그림처럼 생각하면 된다.
위 그림의 상황을 설명하자면
- commit 2까지는 master 브랜치에서 생성한다.
- 이후 브랜치 A를 생성하고 commit 4까지 진행한다.
- 다시 master 브랜치로 checkout을 하고 master 브랜치에서 commit 6까지 진행한다.
이런 상황인 것이다.
위 그림에서 브랜치가 3개가 있다고 해서 3-way 병합이라고 부르는 것이다.
깃은 위와 같은 상황에서 어떻게 병합을 할까?
3-way 병합은 공통 조상 commit을 자동으로 찾아준다. 이후 공통 조상 commit을 기준으로 브랜치를 병합한다. 이후 병합을 성공적으로 완료한 후에는 새로운 commit을 하나 생성한다. 이 새로운 commit을 병합 commit이라고 한다.
공통 조상을 기준으로 해서 브랜치를 병합하는 이유가 무엇일까?
일단 병합을 한다는 것은 변경된 사항을 추가한다는 것이다.
commit 2가 공통 조상이었을 때 상태를 보자.
이 때 원본에 파일이 4개가 있다고 가정하고 상태를 A, B, C, D라고 해보자.
브랜치 A에서 변경된 파일의 상태를 A’, B, C’, D,
master 브랜치에서 변경된 파일의 상태를 A, B’, C’’, D’ 라고 생각해보자.
이런 경우 만약 원본이 없다고 생각해보자. 과연 새롭게 생성되는 commit의 입장에서 뭐가 원본이고 뭐가 개선본인지 알 수 있을까?
A가 원본인가? A’가 원본인가? 알 수 없다.
이를 위해서 3-way 병합은 원본 조상의 상태를 확인한다.
원본의 상태를 알면 각 브랜치에서 어떻게 변한 것인지 알 수 있다.
브랜치 A는 A, C가 변경된 것이고, master 브랜치는 B, C, D가 변경된 것이다.
그러면 A는 A’로, B는 B’로, D는 D’로 바꾸고 새로운 병합 commit을 생성하면 될 것이다. 하지만 C는 어떻게 해야할까? C’로? C’’로?
이러한 경우가 충돌이 일어난 경우이다.
👊🏻 충돌이 일어나면 어떻게 해결해야 할까?
위 경우를 생각해보자.
충돌이 일어난 이유는 결국 새로운 병합 commit을 생성할 때 어떤 변경사항을 적용할지 모르는 경우다.
그렇다면 병합을 할 때 어떠한 것을 추가하면 될지 알려주면 안될까?
일단 이번 경우는 한 번 실제로 충돌을 일으켜보자.
이번 실습의 아래 캡쳐본은 VSCode의 Git Graph 익스텐션을 통해서 생성했다.
깃 init 후 parents commit을 먼저 만들어주고,
branchA commit, master commit을 동일 파일에 대해 수정 내용을 바꿔서 생성했다.
이제 이 두 브랜치를 병합하면 된다.
master 브랜치로 이동해서 아래 명령을 해주면 된다.
git merge 브랜치이름
본인의 경우는 branchA이니 해당 이름으로 진행해보겠다.
크게 3가지를 관찰할 수 있다.
일단 git merge 명령에 대한 응답으로 충돌이 어느 파일에서 났고, 이로 인해서 자동 병합이 실패했다고 나온다.
이는 Uncommitted Changes 라는 것이 생긴 것도 확인할 수 있으며, VSCode 상에서 충돌이 난 파일에서는 HEAD, branchA 라는 것도 볼 수 있다.
충돌이 일어난 경우 자동적으로 병합 commit이 생성되지 않고, 충돌 메시지를 출력하며 병합 작업을 중단한다.
이 경우 수동으로 우리가 어떠한 내용을 변경사항으로 보면 좋을지 깃에게 알려줘야 하는 것이다. 아니면 병합을 취소해달라고 할 수 있다.
git merge --abort
git merge 명령에 —abort 옵션을 추가해주면 뒤로 되돌릴 수 있다.
실제로 사용해보면 이전과 동일한 상태로 돌아가는 것을 볼 수 있다.
수동 병합은 그러면 어떻게 처리할까? 우선 다시 충돌을 일으켜보았다.
그러고 충돌이 일어난 파일로 가보자
여기에 크게 HEAD 라는 말과 브랜치 이름이 있다.
HEAD 라는 말은 기준이 되는 브렌치, 이 경우에는 master 브랜치가 되고 해당 내용을 보여주는 것이다.
브랜치 이름이 있는 부분은 병합하고자 하는 브랜치의 내용을 보여준다.
이렇게 충돌이 일어난 두 코드를 보여주면서 어떤 것을 수정사항으로 고려할지 선택하게 만드는 것이다. 두 변경사항 중 원하는 것을 선택해서 다시 작성하거나 새롭게 내용을 작성해도 좋다.
이렇게 수동으로 직접 처리할 수도 있지만 VSCode의 경우는 위에 1번째 라인과 2번째 라인 사이의 버튼들을 통해서 처리할 수 있다. 순서대로
- master 브랜치의 내용을 수락하겠다.
- 병합되는 브랜치의 내용을 수락하겠다.
- 둘 다 붙여주라 마!
의 기능을 수행하는 버튼으로 수동으로 하지 않고 버튼 클릭으로 해결할 수 있다.
변경할 내용을 선택하고 그에 맞게 수정했다면 이제 평범하게 add와 commit 명령어를 통해 병합 commit을 생성할 수 있다. 아니면 —continue 옵션을 사용해도 좋다.
git commit -m "원하는 내용"
git merge --continue
continue 옵션을 사용하면 병합된 내용을 메시지로 남겨주기 때문에 본인이 병합 내역을 깃 그래프나 log 만으로 확인하고자 하면 좋은 선택지이다.
이렇게 우리는 충돌 상황에서 어떻게 해결할 수 있는지 알아보았다.
본인이 혼자 개발하거나 소규모가 아닌 이상 충돌 상황은 자주 마주하게 될 것이다.
이는 정상이며 당황하지 않고 팀의 규칙이나 적절한 선택으로 충돌을 해결할 수 있으니 천천히 처리를 해보자.
⛺️ 리베이스는 또 무엇인가?
리베이스라는 방식으로 브랜치를 합치는 방법도 있다.
리베이스의 경우는 커밋의 트리 구조를 재배열하는 것이지만 변경 결과가 병합과 유사하다. 말만 들으면 잘 감이 안온다.
일단 브랜치 개념을 떠올려보자.
master를 제외한 모든 브랜치는 뿌리가 있다. 또한 브랜치란 특정 commit을 가리키는 포인터이다. 그리고 가리키는 그 commit은 브랜치가 파생된 기준이 된다.
정리하면 브랜치는 commit 하나를 기준으로 새로운 작업을 진행할 수 있는 분리된 작업 경로를 의미한다. 이때의 브랜치가 파생된 해당 commit을 base라고 한다.
리베이스(rebase)란 결국 이 base를 다시 설정해준다는 것이다.
근데 왜 베이스를 변경할까?
이는 commit 그래프의 진행 모습을 단순화하기 위해서이다.
브랜치가 많아지면 commit을 관리하거나 파악하기 어려운 경우도 있다. 각각의 브랜치를 확인해봐야하고 순서도 생각해야 할 것이다. 이런 복잡한 진행상황을 한눈에 파악할 수 있게 코드의 분기점을 변경해서 하나로 합치는 것이다.
병합과 리베이스는 어떻게 다른 것일까?
병합은 앞서 본 것 처럼 두 브랜치를 하나로 합치는 과정이다.
이를 위해서 공통 조상을 찾고 서로 다른 브랜치를 3-way 방식으로 병합을 하는 것이다.
반면에 리베이스는 두 브랜치를 비교하지 않는다. 그저 순차적으로 커밋을 병합해버린다.
리베이스를 하면 먼저 공통 조상을 찾는 것은 동일하다. 하지만 비교를 위해서 찾는 것이 아닌 베이스 commit을 바꾸기 위해서 찾는 것이다.
공통 조상을 찾은 다음에는 해당 위치에 있던 베이스 commit 기준으로 파생된 브랜치의 commit 들을 잠시 임시 공간에 보관해준다.
이후 기존 브랜치의 commit 들을 순차적으로 진행하고, 완료가 되었다면 임시 공간에 보관 중이던 commit 들을 차례로 붙여주는 작업이 리베이스다.
리베이스를 직접 해보자
앞전에 병합을 했던 master 브랜치에서 브랜치를 다시 생성하고 코드에 변경사항을 추가해보자.
본인은 기존의 파일에 한 줄을 추가해주는 방식을 사용했다.
완료했다면 다시 master 브랜치로 돌아와서 새로 한 줄을 만들고 commit을 해보자. 여러번 해도 상관 없다. 본인은 2번 정도 했다.
위 결과물은 순서대로 master 기준 코드, 깃 그래프, branchB의 상황이다.
이제 우리가 원하던 리베이스를 한 번 해보자.
git rebase 브랜치이름
리베이스의 경우 조심해야 할 것이 있다.
리베이스는 병합 기준 브랜치가 merge 명령어와 반대이다. 따라서 본인은 branchB에서 master를 리베이스 할 것이다.
어라라? 조금 이상하다.
맞다 rebase 의 경우에도 충돌이 일어날 수 있다. 동일하게 —continue, —abort 옵션이 있다고 친절하게 알려주는 모습과 익숙한 HEAD, 브랜치 이름도 확인할 수 있다.
원하는 변경 사항을 선택하고 이를 적용한 다음 —continue 옵션을 사용해보자.
드디어 우리가 원하던 그림이 나왔다.
하지만 뭔가 이상하다. 병합을 했을 경우에는 병합을 한 두 브랜치 모두 HEAD가 이동했었는데
이 경우에는 master의 HEAD 위치가 변하지 않았다.
이는 리베이스의 경우 commit 위치를 재조정할 뿐 브랜치의 HEAD를 옮겨주는 것이 아니기 때문이다.
즉 리베이스된 브랜치를 병합을 해야한다는 것이다.
병합때 조심할 것은 이번에는 master로 체크아웃해서 해야한다.
드디어 원하는 그림이 나왔다.
아! 만약 위 그림과 같은 상황에서 이제는 필요 없어진 branchB를 제거하고 싶다면 아래 명령어를 참고해서 제거할 수 있다.
git branch -d 브랜치이름
이렇게 까지 하면 선형 구조의 그래프를 볼 수 있을 것이다.
어떤가? 깔끔하지 않은가?
🙇🏻♂️ 다음에는 무엇을?
드디어 병합에 대한 내용을 조금이나마 다룰 수 있게 되었다.
이번에는 글을 쓰면서 본인도 많이 배울 수 있었던 것이 리베이스 관련해서는 아에 아는 것이 없었는데 이번 기회를 통해서 실습도 해볼 수 있었고 어떠한 상황에 쓰이면 좋을지에 대한 생각이 조금 들었다.
이제 핵심은 다룬 것 같아 보인다.
남은 내용들은 아마 이제 스테시, 리셋과 리버트, 태그, 서브모듈 정도가 있을 것 같다.
그중 실제 프로젝트를 진행하면서 가장 좀 애매했던 리셋과 리버트에 대해서 다음 시간에는 다뤄볼 것 같다.
오늘도 잡스러운 글 끝까지 읽어주신 분들께 감사드리며 물러나겠다. 총총총…