Git Branch와 Merge
이전 글
들어가며
본 포스트에서는 이전 글에서 간략하게만 설명하고 넘어간 Branch에 대한 자세한 사항과 함께 다양한 방법을 통한 Merge에 대한 설명을 다루겠습니다.
Branch
레포지토리를 만들어 프로젝트를 진행하고 있고 현재까지 master 브랜치에 세 개의 커밋을 저장했다고 합시다.
그러던 중 두 가지 기능을 추가해야 하는 상황이 되었습니다. 브랜치의 개념이 없다면 다음과 같이 진행하게 되겠습니다.
만약 혼자 진행하는 프로젝트라면 이대로도 충분할 수 있습니다. 하지만 여러 개발자가 함께 참여하는 프로젝트라면 위 방식으로는 제대로 된 진행을 기대하기 어렵습니다. 기능 1 개발이 끝나기까지 기능 2는 개발할 수 없습니다. 서로 다른 로컬 레포에서 같은 브랜치에 서로 다른 변경 내역을 push하는 것은 불가능하기 때문에, 한 가지 기능은 반드시 진행되지 못하고 pending되어야 합니다. 혼자 개발하는 경우 순서에 구애받지 않고 기능 1, 2를 오가며 개발할 수도 있겠지만, 히스토리는 그만큼 난잡해지게 됩니다. 실제로는 브랜치 기능을 활용합니다.
기본적으로 주어지는 master 브랜치에서 두 가지 서로 다른 브랜치를 생성합니다. 각각 feature1, feature2라고 하겠습니다. 각각의 새 브랜치에서 작업하는 내역, 즉 새로 올라오는 커밋은 다른 브랜치에 영향을 주지 않고 각 브랜치에 계속 쌓이게 됩니다. 즉, 특정 커밋을 기점으로 병렬적으로 작업을 할 수 있습니다.
특정 커밋을 기점으로 하는 새 브랜치를 생성하고 또 이를 삭제하는 과정을 간단하게 수행할 수 있습니다. 또한 브랜치의 하위 브랜치를 생성할 수도 있는 등 매우 다양한 형태로 브랜치를 구성할 수 있고 이에 따라 다양한 브랜치 관리 전략이 존재합니다.
생성
실제로 브랜치를 생성해 봅시다. 저번 글에서 사용했던 sample 레포에서 이어서 작업합니다.
$ git log --oneline-graph
* d979f27 (HEAD -> main, origin/main) 이렇게 여러 줄에 걸쳐서 작성할 수도 ...
main 브랜치 하나만 있는 레포지토리이며 현재 하나의 커밋이 올라와 있는 것을 볼 수 있습니다. d979f27
커밋으로부터 분화한 브랜치를 하나 만들어 봅시다.
$ git branch 브랜치명
$ git branch feature1
위와 같이 입력할 경우 feature1 브랜치가 생성됩니다.
조회
로컬 레포의 브랜치를 조회하려면 다음과 같이 입력합니다.
$ git branch
feature1
* main
아까 생성한 feature1브랜치가 보이며, main 브랜치 옆에는 Asterisk(*)가 표시되어 있습니다. 이는 현재 작업 중인 브랜치라는 뜻입니다.
다음과 같이 a 옵션을 주면 원격 브랜치도 확인할 수 있습니다.
$ git branch -a
feature1
* main
remotes/origin/main
이전 글에서 main 브랜치는 push 명령어를 통해 원격에 저장하였으나 feature1 브랜치는 로컬에서 생성하였을 뿐 원격 레포에 저장한 적이 없으므로 원격 feature1 브랜치는 존재하지 않는 모습입니다.
전환
feature1 브랜치에서 작업하려면 switch 명령어를 사용합니다.
$ git switch feature1
'feature1' 브랜치로 전환합니다
이제 새로운 커밋은 main 브랜치가 아닌 feature 브랜치로 올라갑니다.
삭제
로컬 브랜치를 삭제하려면 브랜치 명령어에 -D
옵션을 주면 됩니다.
$ git branch -D 브랜치명
$ git branch feature1
원격 브랜치를 삭제하려면 브랜치명 앞에 콜론(:)을 붙여 다음과 같이 push 명령어를 사용합니다.
$ git push origin :브랜치명
$ git push origin :feature1
Merge
브랜치를 나누어 작업한 뒤 해당 내역을 다시 master 브랜치에 병합해야만 합니다. 이를 머지(Merge, 병합)한다고 하며 병합에도 여러 방식이 있습니다. 각 방식에 대해 알아보겠습니다.
Fast Forward
기존 브랜치보다 최신 버전인 브랜치를 병합하는 과정입니다.
위와 같은 상황에서 feature1 브랜치의 5번 커밋은 master 브랜치의 3번 커밋보다 최신 버전이라 분명히 말할 수 있습니다.
각 브랜치에는 현재 최신 커밋을 가리키는 HEAD라는 이름의 포인터가 존재합니다. master의 HEAD 포인터는 현재 3번 커밋을 가리키고 있습니다. 이때 feature1을 master 브랜치에 병합하는 경우 master 브랜치의 HEAD를 5번 커밋에 설정하는 것으로 끝납니다. 이렇듯 HEAD만을 옮겨 병합하는 방식을 Fast Forward Merge, 줄여서 FF Merge라고도 합니다.
FF 방식은 새 브랜치의 마지막 커밋이 기존 브랜치의 마지막 커밋보다 최신 버전이라는 것이 보장되는 경우에 한합니다. 또한 가능하다고 해서 반드시 FF 방식을 사용해야 하는 것도 아니므로, 필요에 의해 선택하도록 합시다.
명령어로는 다음과 같습니다.
git merge --ff 브랜치명
Recursive
현재, 방금 전 feature1 브랜치를 master 브랜치에 FF 병합하여 생긴 4, 5번 커밋이 master 브랜치에 생성되어 있습니다. 결과적으로 3번 커밋 이후 master 브랜치에 새로운 커밋이 생겼으며, 이 때문에 feature2의 HEAD(7번 커밋)가 master 브랜치의 HEAD(5번 커밋)보다 최신이라고 할 수는 없게 되었습니다. 만약 FF처럼 7번 커밋에 master의 HEAD를 옮긴다면 4, 5번 커밋의 내용이 유실됩니다.
이 경우에는 새로운 커밋을 만들어 서로 다른 변경 내역을 모두 포함할 수 있도록 합니다. 생성된 커밋 'M'을 Merge Commit이라고 하며, 이러한 방식을 Recursive Merge라고 합니다. 두 개의 조상으로부터 머지 커밋을 생성하는 방식이라 3 Way Merge라고도 합니다.
명령어로는 다음과 같습니다.
$ git merge 브랜치명
Octopus
Recursive Merge 방법에서 더 나아가 특정 브랜치에 둘 이상의 브랜치를 한 번에 병합하는 방식입니다.
여러 번에 걸쳐 병합할 필요가 없어 여러 브랜치를 한 번에 합칠 때 사용할 수 있을 것 같지만, 실질적으로 사용하는 일은 거의 없습니다. 특히 현업에서는 아예 고려 대상도 아닙니다. 이 방싱으로는 병합 중 발생하는 충돌을 해결하는 것도 고역이거니와, 작업 히스토리를 남기는 것이 중요하기 때문에 위와 같은 방식을 사용할 당위성이 없습니다. 여러 번에 걸쳐 병합하면 단계가 늘어 번거롭긴 하겠지만, 일반적으로 브랜치를 나누는 기준이 기능이라는 점을 생각하면 각각의 병합 히스토리는 각각의 기능을 개발한 기록이기도 합니다. 그러므로 웬만하면 깔끔한 히스토리 관리를 위해 이 방식은 사용하지 않도록 합시다.
명령어로는 다음과 같습니다.
$ git merge 브랜치명1 브랜치명2 브랜치명3 ...
Rebase
Recursive Merge 설명에 활용한 예시를 다시 한 번 보겠습니다.
Recursive Merge에서는 머지 커밋을 생성하여 위 두 브랜치를 병합했지만, 리베이스를 사용하여 병합할 수도 있습니다. master에 feature2를 리베이스하면 feature2에서 발생한 작업 내역은 master의 HEAD에서부터 작업한 것으로 취급하여 다음과 같은 모습이 됩니다.
리베이스를 사용하면 3번 커밋에서부터 발생한 feature2 변경 사항을 그대로 master 브랜치에 차례대로 적용하고, master를 Fast Forward하여 7'번 커밋에 HEAD를 옮깁니다. 이때 feature2에 커밋했던 내역은 master branch에 붙어 feature2 브랜치에서는 사라지게 됩니다.
이 방식을 사용하면 별도 브랜치에서 4, 5번 커밋과 병행하여 작업한 6,7번 작업이 마치 4, 5번 커밋 이후에 순차적으로 진행된 것처럼 보이게 하여 Recursive Merge보다 히스토리를 깔끔하게 유지할 수 있다는 장점이 있습니다.
하지만 문제점도 있습니다. 기존 커밋을 건드리기 때문에 원격 저장소에 Push된 커밋을 리베이스한다면 레포가 꼬이기 쉽고, 여기에 Force Push라도 한다면 원격 레포에서 작업 내역이 유실되므로 리베이스 사용은 신중하게 선택해야 합니다. 반드시 로컬 레포에서만 작업한 내역만을 리베이스하도록 합시다.
$ git rebase 브랜치명
Squash and Merge
Squash는 뭉갠다는 의미로, 타 브랜치에서 생성된 커밋들을 하나의 커밋으로 뭉친다는 의미를 가지고 있습니다. 최근 자주 사용하는 방식으로, 원격 브랜치와 충돌을 일으킬 염려도 적고 작업 내역이 사소한 커밋까지 하나하나 보이는 것이 아니라 큰 묶음으로 생기기 때문에 히스토리를 더욱 깔끔하게 관리할 수 있다는 장점이 있습니다.
feature2 예제를 다시 봅시다.
master에 feature2를 Squash 병합하면 feature2의 6, 7번 커밋을 하나의 커밋으로 만들어 master의 HEAD(5번 커밋)에 붙입니다.
명령어로는 다음과 같습니다.
$ git merge --squash 브랜치명
Merge Conflict
파일의 병합은 Git에 내장된 Myers 알고리즘 등 여러 Diff 알고리즘 중 하나를 선택하여 이루어집니다. 만약 병합하려는 브랜치가 기존 브랜치와 같은 파일을 수정했다면 새롭게 추가한 줄과 삭제한 줄을 파악하고, 같은 내용을 수정했다면 충돌(Conflict)이 발생합니다.
한 파일의 내용을 병합하는 과정을 실제로 살펴보겠습니다. master 브랜치에 다음과 같은 파일이 있다고 해보겠습니다. 그리고 여기에서 feature 브랜치를 생성하겠습니다.
// before
class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
}
}
해당 파일을 master 브랜치에서 다음과 같이 수정했습니다.
// master branch HEAD
class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
functionA();
}
void functionA() {
System.out.println("Called functionA");
}
}
해당 파일을 feature 브랜치에서는 다음과 같이 수정하였습니다.
// feature branch HEAD
class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
functionB();
}
void functionB() {
System.out.println("Called functionB");
}
}
master branch에서 Recursive Merge를 시도해 보겠습니다.
$ git merge feature
자동 병합: Main.java
충돌 (내용): Main.java에 병합 충돌
자동 병합이 실패했습니다. 충돌을 바로잡고 결과물을 커밋하십시오.
해당 파일을 살펴 보겠습니다.
class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
<<<<<<< HEAD
functionA();
}
void functionA() {
System.out.println("Called functionA");
=======
functionB();
}
void functionB() {
System.out.println("Called functionB");
>>>>>>> feature
}
}
파일에 이상한 표시가 생긴 게 보일 겁니다. 표시는 =======
를 기준으로 위, 아래로 나뉘며, 윗부분은 기존 브랜치, 아랫부분이 병합하고자 하는 브랜치에서 수정한 내용이며 이 둘이 상충된다는 뜻입니다.
class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
<<<<<<< HEAD
functionA();
}
void functionA() {
System.out.println("Called functionA");
=======
functionB();
}
void functionB() {
System.out.println("Called functionB");
>>>>>>> feature
}
}
이 두 개의 수정 내역을 어떻게 합칠지는 사용자가 선택해야 합니다. 위의 내용만 선택해도 되고, 아래 내용만 선택해도 되고, 적당히 절충해서 넣어도 됩니다. 해당 표시가 사라지만 해당 파일의 충돌이 해결(resolve)되었다고 봅니다. 저는 다음과 같이 수정하겠습니다.
class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
functionA();
functionB();
}
void functionA() {
System.out.println("Called functionA");
}
void functionB() {
System.out.println("Called functionB");
}
}
수정한 뒤의 상태는 다음과 같습니다.
$ git status
현재 브랜치 master
브랜치가 'origin/main'보다 3개 커밋만큼 앞에 있습니다.
(로컬에 있는 커밋을 제출하려면 "git push"를 사용하십시오)
병합하지 않은 경로가 있습니다.
(충돌을 바로잡고 "git commit"을 실행하십시오)
(병합을 중단하려면 "git merge --abort"를 사용하십시오)
병합하지 않은 경로:
(해결했다고 표시하려면 "git add <파일>..."을 사용하십시오)
양쪽에서 수정: Main.java
커밋할 변경 사항을 추가하지 않았습니다 ("git add" 및/또는 "git commit -a"를
사용하십시오)
이제 병합할 사항을 커밋하면 병합이 종료됩니다. 위 "양쪽에서 수정"이라는 내용은 해당 파일이 Modified 상태이며 아직 스테이징 영역에 있지는 않다는 뜻입니다. add 명령어로 스테이징 후 commit하면 새 Merge 커밋이 생기게 됩니다.
$ git add .
$ git commit -m "Merge feature branch"
[master 576c840] Merge feature branch
$ git log --all --graph --oneline
* 576c840 (HEAD -> master) Merge feature branch
|\
| * 6d147ca (feature) edit Main.java
* | 3e43508 edit Main.java
|/
* 36f65e8 add Main.java
이와 같이 병합 중 발생하는 충돌을 수정할 수 있습니다. 방식에 따라 커밋 대신 git merge --continue
명령이 필요한 경우도 있고, 중단하고 원래 상태로 되돌리려면 git reset --abort
명령을 실행하면 됩니다. 각종 IDE와 GUI를 제공하는 다양한 도구들을 사용하면 더욱 편리한 UI로 이 과정을 수행할 수도 있습니다.
Pull 상세
Pull 명령은 말 그대로 "끌어오는" 것으로, 원격 브랜치의 변경 내역을 현재 브랜치로 가져옵니다. 이때 Merge가 발생하며 Git 설치 시 옵션으로 이때 어떠한 방식으로 병합할지를 선택할 수 있습니다. 기본 설정은 Fast Forward 방식으로 이루어집니다.
Pull 명령어는 일반적으로 원격 브랜치와 현재 로컬 브랜치를 병합하는 데 사용합니다. 다시 말해, 원격 브랜치의 변경 내용을 로컬 브랜치에 업데이트하는 데 사용합니다. 예를 들어 원격 레포의 master 브랜치, 즉 remote/origin/master
브랜치의 변경 내역을 현재 작업 중인 브랜치에 적용하기 위해 다음과 같이 입력합니다.
git pull origin master
현재 작업 중인 로컬 브랜치에 원격 master 브랜치의 변경 내역을 업데이트합니다. remote/origin/master 브랜치를 현재 브랜치에 Merge 하는 과정으로 이해하시면 됩니다.
마치며
이번에는 브랜치와 병합에 대해 전반적으로 살펴 보았습니다. 다음 글에서는 Git을 사용하여 작업하는 중 활용할 수 있는 여러가지 편리한 기능들을 알아보겠습니다.