Merge와 Rebase 알아보기
브랜치를 하나로 합치는 방법 🤔
Git을 통해 프로젝트를 진행하다 보면 기능별로 브랜치를 만들어 구현하게 됩니다. 그리고 완성이 된 브랜치는 별다른 문제가 없다면, 상위 브랜치에 병합을 해줄 수 있습니다. Git에서는 병합을 위한 명령어로 merge가 있습니다. merge는 병합하고자 하는 브랜치의 상위 브랜치로 이동한 후, git merge <병합하려는 브랜치명> 명령으로 수행할 수 있습니다.
서로 다른 브랜치에서 같은 파일을 수정했을 때 발생하는 충돌(Conflict)과 같은 문제가 없다면, 브랜치들을 정상적으로 병합해 줍니다. 그리고 git log 명령으로 커밋 히스토리를 확인해보면, 다음과 같이 병합이 잘 된 것을 확인할 수 있습니다.
브랜치를 따서 작업한 후 병합을 했음에도 불구하고 로그 그래프가 깔끔하고, 병합과 관련된 로그가 남지 않은 것을 볼 수 있습니다. 이는 Fast-Forward 방식으로 병합 되었기 때문입니다. Git에서는 서로 다른 브랜치를 병합할 때 두 가지 방식으로 진행되는데, 이어서 알아보겠습니다.
Fast-Forward 방식
어떤 이슈가 발생해서 이를 처리하기 위해 master 브랜치로부터 iss53이라는 이름의 브랜치를 만들었다 가정하겠습니다. 이슈를 해결하기 위해 iss53 브랜치로 이동하여 코드를 새로 추가하거나, 기존의 코드를 수정할 것입니다. 그리고 모두 해결이 되면 커밋(C3)을 하고 병합을 하기 위해 master 브랜치로 이동할 것입니다.
그리고 git merge로 iss53에서 작업한 내용들을 master에 병합을 시도합니다. 그런데 이 경우에는 master가 별도의 작업을 하지 않았기 때문에, C2를 가리키고 있던 포인터를 C3를 가리키도록 옮기면 됩니다. 이렇게 별도의 병합 과정 없이 포인터만 최신 커밋으로 옮기는 방식을 Fast-Forward라 합니다. 실질적으로 병합이 아닌 그저 포인터만 옮기는 것이기 때문에 커밋 메시지가 남지 않는데, 만약 기록을 남기고 싶다면 git merge --no-ff <브랜치명> 명령으로 병합을 수행하면 됩니다.
3-Way Merge 방식
아마 프로젝트를 진행하게 되면 대부분 이 방식으로 병합을 진행하게 될 것입니다. 위 그림에서는 iss53 브랜치를 병합할 때, 조상이 되는 master 브랜치에서 C2 커밋 이후에 변화가 없었기 때문에, 단순히 포인터만 옮기는 식으로 합칠 수 있었습니다. 하지만 다음과 같은 경우는 조금 다르게 병합을 진행합니다.
막상 병합을 하려 하니 master 브랜치는 C2가 아닌 새로운 커밋을 가리키고 있습니다. 이런 경우 각 브랜치가 작업해서 남긴 커밋 2개(C4, C5)와 공통된 조상인 C2 커밋을 함께 병합하여 새로운 커밋을 생성합니다. 이러한 방식을 3-Way Merge라 합니다. 병합을 하다 보면 충돌이 발생하는 경우가 있는데, 모두 이 방식의 병합에서 발생하는 문제입니다.
베이스를 새롭게 설정하는 방법 🚀
직전의 예시에서 병합을 할 때, 병합하고자 하는 지점이 파생되었던 커밋과 달라 3-Way Merge 방식으로 진행해야 했습니다. 그런데 지점이 다르다 해도 각 브랜치가 공통된 파일을 수정한 것이 아니기 때문에, 지점만 일치시켜 준다면 Fast-Forward로 깔끔하게 기록을 관리할 수 있지 않을까요? 바로 여기서 지점을 재배치하는 git rebase 명령을 사용해볼 수 있습니다.
3-Way Merge 병합을 소개할 때 예시로 들었던 그림을 비슷하게 구현하면 다음과 같은 로그를 확인할 수 있습니다.
feature 브랜치가 기능을 구현하고 있을 때, main 브랜치에서도 특정 파일을 수정하여 커밋을 남겼습니다. 이 경우 병합을 하게 되면 3-Way Merge 방식으로 병합됩니다. 하지만 여기서 리베이스를 하게 되면, HEAD가 가리키고 있는 최신 커밋 지점의 변경 이력을 그대로 feature 브랜치에 반영하여 main의 최신 커밋을 기반이 되는 지점으로 가리킬 수 있습니다. feature 브랜치에서 git rebase main 명령을 사용하면 리베이스되어, Fast-Forward 방식으로 병합됩니다.
Fast-Forward로 인해 위 사진과 같이 로그가 깔끔해 집니다. 그런데 여기서 커밋의 해시값(hash)이 이전과 달라진 것을 확인해야 합니다. 리베이스는 상위 브랜치의 최신 작업 내용을 있는 그대로 가져오는 것이 아니라, 상위 브랜치의 내용과 서브 브랜치의 내용들을 하나씩 참조하면서 합치는 식으로 새로운 커밋을 만드는 것입니다.
이처럼 병합에 대한 기록 자체가 필요하지 않은 경우에는 리베이스를 사용해볼 수 있습니다. 하지만 모든 상황에 대해 기록하는 것을 중요시하는 팀에 소속되어 있다면 기존과 같이 git merge를 사용하면 됩니다.
Rebase 주의사항
리베이스를 사용할 때는 항상 주의해야 합니다. 대표적으로 원격 저장소에 이미 올라간 커밋에 대해서는 절대로 리베이스하면 안 됩니다. 리베이스는 기존의 커밋을 그대로 사용하는 것 같지만, 위의 사진에서도 볼 수 있듯 새로운 해시값(hash)을 가진 커밋을 만드는 작업입니다. 그렇기 때문에 겉보기에는 원격 저장소와 같은 것 같지만, 해시값이 다르기 때문에 누군가 코드를 push한 다음 pull 했을 때 심각한 충돌이 발생할 수 있습니다. 동일한 이유로 같은 브랜치에서 작업하는 사람이 2명 이상일 때에도 리베이스는 피해야 합니다.
Rebase 활용하기
보통 최신 커밋의 메시지를 수정하고 싶은 경우, git commit --amend -m "메시지" 명령을 사용해 변경할 수 있습니다. 하지만 이전의 커밋 메시지를 수정하고 싶을 수도 있습니다. 바로 이때 리베이스를 사용해서 수정할 수 있습니다. 이 뿐만 아니라 리베이스를 통해 커밋들을 하나로 합칠 수도 있고, 하나의 커밋을 여러 개로 나눌 수도 있습니다. git rebase -i <hash값> 명령을 실행하면 되는데, 해당 hash를 가진 커밋 이후의 기록들에 대해서 리베이스를 수행할 수 있습니다.
위에서 확인했던 로그를 그대로 가져와 리베이스 예제로 사용해 보겠습니다.
로그에서 최신 커밋(Edit file...)과 직전의 커밋(Add login)을 하나로 합치고 싶습니다. 이런 경우 리베이스하고자 하는 커밋 바로 이전의 해시값을 가지고 명령을 실행해야 합니다. git rebase -i ec91c81 명령(hash 대신 HEAD~2도 가능)을 수행하게 되면, 에디터가 열리면서 다음과 같은 내용을 보여줍니다.
pick c6118a8 Add login.html
pick d421152 Edit file extension (html -> js)
# Rebase ec91c81..d421152 onto ec91c81 (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
# commit's log message, unless -C is used, in which case
# keep only this commit's message; -c is same as -C but
# opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# . create a merge commit using the original merge commit's
# . message (or the oneline, if no original merge commit was
# . specified); use -c <commit> to reword the commit message
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
리베이스를 수행하고자 하는 커밋들의 해시값과 함께 왼쪽에 리베이스와 관련된 명령인 pick이 적혀있습니다. pick은 기본값이며, 이 값이 적힌 줄의 커밋은 별다른 수정 없이 그대로 가져가자는 명령입니다. 사용할 수 있는 명령어는 아래의 주석에서 확인 가능한데, 저희는 두 커밋을 합칠 것이기 때문에 두 번째 줄의 pick을 squash 또는 s로 변경하고 저장 후 닫겠습니다.
squash는 바로 직전의 커밋과 합치는 명령이기에, 첫 줄의 커밋은 그대로 두면 됩니다 👏
파일을 저장한 다음 종료하면, 다시 한 번 더 에디터가 열리면서 다음의 내용을 보여줄 것입니다.
# This is a combination of 2 commits.
# This is the 1st commit message:
Add login.html
# This is the commit message #2:
Edit file extension (html -> js)
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date: Sun Oct 24 04:32:04 2021 +0900
#
# interactive rebase in progress; onto ec91c81
# Last commands done (2 commands done):
# pick c6118a8 Add login.html
# squash d421152 Edit file extension (html -> js)
# No commands remaining.
# You are currently rebasing branch 'main' on 'ec91c81'.
#
# Changes to be committed:
# new file: login.js
#
이 파일은 두 커밋을 합친 이후에 대해 커밋 메시지를 남기는 파일입니다. squash 명령으로 커밋을 하나로 합치게 되면, 합치고자 하는 커밋들의 메시지가 기본적으로 모두 포함되어 있습니다. 이전의 커밋 메시지는 필요가 없다면, 내용을 지우고 새로운 제목을 적으면 됩니다. 이 과정이 번거롭다면 처음부터 squash 대신 fixup 명령을 사용하면 됩니다. 두 명령은 같은 동작을 하지만, fixup의 경우 새로운 커밋 메시지를 입력하는 파일이 열리지 않고 바로 직전의 커밋 메시지를 그대로 사용하면서 합친다는 차이가 있습니다.
여튼 squash 명령으로 두 커밋을 합친 다음 로그를 확인해 보겠습니다.
보통 이렇게 합치지는 않지만 이런 식으로 리베이스를 이용해 합칠 수 있다는 것을 보여 드리고 싶었습니다 😅
이렇게 합치는 것 외에도 특정 커밋의 메시지를 수정하고자 한다면 edit(e) 명령을 사용해볼 수 있습니다. edit 명령을 실행하게 되면 해당 커밋으로 이동하기 때문에, 그 상태에서 git commit --amend -m "메시지" 명령으로 메시지를 수정할 수도 있고, git reset --hard HEAD~1 명령으로 직전으로 이동하여 커밋 단위를 분할하여 만들 수도 있습니다. 원하는 작업을 마친 이후에는 git rebase --continue 명령을 입력해서 리베이스를 마저 진행하면 끝입니다.