Skip to content

Git 심화

gitlogo

Chapter 1. Git 심화 개념#

Git을 잘 이해하기 위해서는 Git의 구조에 대한 파악이 필요하다.

Git 저장소는 하나의 거대한 그래프 구조를 가진다. 각각의 커밋들은 (실제 Git의 백엔드 구조가 아닌, 겉으로 보았을 때) 데이터 객체들과 부모(parent) 커밋에 대한 참조를 가진다. 부모 커밋에 대한 참조는 부모 커밋의 고유 해시값으로 이루어지며, 하나의 커밋은 0개(최초 커밋일 시), 1개(일반적인 경우), 2개 이상(merge 커밋인 경우)의 부모 커밋을 가질 수 있다.

git_commits

여기서, 각각의 커밋들은 그래프 이론의 꼭짓점(Vertex) 또는 노드(Node)에 해당하며, 각 커밋들이 가진 부모 커밋에 대한 참조를 통해 이어지는 커밋들간의 연결은 그래프 이론의 변(Edge)에 해당한다.

Git이 특정 커밋 버전 스냅샷의 모든 파일 내용들을 가지고 있는 것은 용량 면에서 매우 비효율적이므로, 커밋의 데이터에는 오직 자신의 부모 커밋과의 차이점만을 담은 변경 사항만을 기록한다. 그럼에도 불구하고, 일부 경우를 제외하고 Git의 대부분의 커밋들은 그래프 구조를 통해 서로 연결되어 있기 때문에, 우리는 Git을 통해 관리되는 모든 파일 히스토리를 확인할 수 있는 것이다.

Git의 커밋을 참조하기 위해서는 40자리의 16진수로 이루어진 고유 해시값을 사용하면 된다. 예를 들어, 사용자가 다른 버전의 커밋으로 현재 Working Tree를 업데이트하려면(전에 설명했듯이 이 과정을 체크아웃이라고 한다), 아래와 같이 입력한다.

# 예시로 있는 해시 값은 임의의 값으로, 상황에 따라 달라짐
git checkout af73bb5fb4d1de260d67c3312a46bae2c3336446  # 전체 해시 모두 입력
git checkout af73bb5f                                  # 앞 8자리만 입력해도 됨

하지만 특정 버전으로 돌아가기 위해 이 해시값을 일일이 외우고 있는 것은 말도 안 되는 일이다. 그래서 Git은 Commit의 편리한 참조를 위해 커밋을 가리키는 포인터 기능을 제공한다. 이를 레퍼런스 혹은 짧게 줄여서 ref라고 한다. 레퍼런스는 명시적으로 로컬 브랜치(Local Branch), 원격 브랜치(Remote Branch), 그리고 태그(Tag)의 형태로 사용자에게 표현된다.

Chapter 2. Branch와 Tag#

로컬 브랜치#

브랜치를 사용하면, 마치 나무가 하나의 줄기에서 여러 가지를 뻗듯 코드의 개발 흐름을 쉽게 여러 방향으로 뻗을 수 있다. 현재의 개발 흐름에서 새로운 가지를 뻗어 기존의 코드 흐름이 방해받지 않게 새로운 기능을 개발하고, 기능이 잘 개발되었는지에 따라 메인 개발 플로우에 현재 기능을 합치거나, 폐기할 수도 있다.

브랜치는 많은 사람들이 다른 VCS 대신 Git을 사용하는 이유로 꼽을 만큼, 강력하고 중요한 Git의 핵심 기능이다. 따라서 브랜치에 대해 잘 이해하는 것은 Git의 효과적인 활용에 필수적이다.

alt text

예를 들어 살펴보자. 현재의 개발 흐름이다. main 브랜치에 hello, world!를 출력하는 기능을 구현해 놓은 상태이다.

브랜치는 커밋 사이의 이동을 쉽게 할 수 있도록 하는 일종의 포인터이다. 현재 main 브랜치가 가리키고 있는 커밋은 커밋 edd367c6 (add hello function) 이다. 만약 현재 상태에서 추가적인 커밋을 만들면 그 커밋은 자동으로 edd367c6 커밋에 연결되고 main 브랜치는 새로 만들어진 커밋을 가리킨다.

개발자는 bonjour le monde!를 출력하는 새로운 기능을 구현하고 싶은데, 무언가를 잘못 건드려서 기존의 기능까지 방해받는 것을 피하고 싶다. 그래서 main과는 별개의 흐름으로 관리되는 bonjour 브랜치를 생성한다.

git branch bonjour

branch2

새로운 브랜치 bonjour가 생성되었다. 하지만 아직 현재 브랜치는 main으로 설정된 상태이다. 이는 git graph에서 볼드체로 표시되어 있어 확인할 수 있다.

브랜치를 전환하려면 아래 둘 중 하나를 입력한다.

git switch bonjour    # 권장하는 문법
git checkout bonjour  # 권장하지 않지만, 브랜치는 곧 커밋을 가리키는 포인터이므로 사용이 가능한 문법이다.

두 명령은 완전히 같은 동작을 수행한다. 하지만 Git은 checkout이라는 명령이 너무 많은 곳에서 사용되고 있어 사용자에게 혼란을 줄 수 있기 때문에, 브랜치를 전환할 때는 switch 명령을 사용할 것을 권장하고 있다.

branch3

브랜치가 전환되었다! 이제 새로운 커밋을 만들면 main이 아닌 bonjour가 그 커밋을 가리킨다.

Warning

Working Tree가 clean 하지 않다면, 즉 아직 commit되지 않은 변경 사항이 있다면 브랜치를 변경할 수 없다. 보다 정확히 말하면, Working Tree가 clean하지 않다면 다른 커밋으로의 체크아웃이 불가능하다. 물론 브랜치는 커밋을 가리키는 포인터이므로 두 표현은 같은 표현이다.

이는 Working Tree에 commit되지 않은 변경 사항이 있을 때 다른 커밋으로 체크아웃하여 Working Tree를 덮어씌워버리면 이전에 작업하던 내용을 완전히 잃어버리기 때문이다.

그 변경 사항이 중요한 것이라면 git stash를 통해 임시 처리할 수 있고, 중요하지 않은 사항이라면 git clean을 통해 모두 지울 수 있다.

개발자가 bonjour le monde!를 출력하는 기능의 개발을 완료하고 커밋을 작성하였다.

alt text

위 그림과 같이, 새로운 커밋 0bfdb33e가 생성되었고, bonjour 브랜치가 그 커밋을 가리키고 있다. main이 가리키는 커밋은 변하지 않고 그대로 있는 상태라는 것을 확인할 수 있다. 그래서, git switch main을 입력하여 다시 main 브랜치로 돌아간다면 bonjour 브랜치에서 작업한 내용은 보이지 않을 것이며, 여기서 bonjour 브랜치에서 개발하던 내용과는 상관없이 기존 기능을 수정하는 작업을 할 수 있다. 이제 개발자가 할 일은 기능을 충분히 검증한 다음, 기능이 충분히 잘 동작한다면 조금 뒤에 살펴볼 병합(merge)을 통해 변경 사항을 main 브랜치에 적용하거나, 만약 기능에 문제가 있어 수정이 필요하다면 현재 브랜치 bonjour에서 수정을 위한 추가 커밋을 작성한 다음 병합할 수 있고, 혹은 기능이 필요가 없어졌거나 완전히 잘못되어 새로 시작하고 싶다면 아래 명령어를 입력하여 다시 main 브랜치로 돌아간 다음 브랜치를 삭제할 수 있다.

git switch main         # main 브랜치로 복귀
git branch -D bonjour   # bonjour 브랜치 강제로 삭제

git branch 명령어의 -D 옵션은 브랜치를 강제로 삭제하는 명령어이다. 일반적인 브랜치 삭제 명령어는 git branch -d <branch name>인데, 만약 이 명령어로 위 예시의 bonjour 브랜치를 삭제한다면 error: The branch 'bonjour' is not fully merged. 라며 삭제가 되지 않는다. 이는 현재 체크아웃된 브랜치인 main을 기준으로 보았을 때, bonjour 브랜치의 히스토리에 포함된 브랜치 중 main 브랜치에 포함되지 않은 커밋이 있기 때문이다.

즉, 이 명령어를 수행하면 새로운 커밋 0bfdb33e가 git graph 상에서 사라진다! 하지만, 브랜치는 커밋 자체가 아닌 커밋을 가리키는 포인터일 뿐이기 때문에, 0bfdb33e 커밋은 자신을 가리키는 포인터가 사라졌을 뿐 여전히 Git 데이터베이스에 잘 저장되어 있으며, 만약 커밋 해시를 기억한다면 이 커밋을 다시 사용할 수 있고, 나중에 새로운 브랜치를 만들어서 붙여 줄 수도 있다.

HEAD 포인터

만약 두 개의 브랜치가 같은 커밋을 가리키고 있다면, Git은 현재 작업 중인 브랜치가 무엇인지 어떻게 파악할까?

Git에는 HEAD 포인터라는 특수한 포인터가 있어서 현재 작업 중인 로컬 브랜치를 가리킨다. 즉, 브랜치를 옮기는 작업은 HEAD 포인터가 가리키는 브랜치를 다른 브랜치로 변경하는 작업이다.

HEAD 포인터는 브랜치가 아닌 특정 커밋을 직접 가리킬 수도 있는데, 이 상태를 Detached HEAD 라고 한다. HEAD가 브랜치에서 떨어져 나와 따로 놀고 있다는 의미이다. 만약 브랜치를 체크아웃하지 않고 특정 커밋 해시를 직접 입력하여 체크아웃한다면 Detached HEAD 상태가 된다. 이 상태에서도 커밋은 가능하지만, 커밋이 브랜치를 통해 관리되지 않으므로 사용하기에 상당히 번거롭다. Detached HEAD 상태에서 여러 개의 커밋을 만들려 한다면 새로운 브랜치를 생성해 주는 것이 좋다.

원격 브랜치#

로컬 브랜치는 사용자 로컬 컴퓨터에 저장된 Git 데이터베이스에 존재한다. 반대로, 원격 브랜치는 원격 저장소에 저장된 Git 데이터베이스 내에 존재한다. 로컬에서 원격 브랜치의 변경 내용을 추적하기 위해 리모트 트래킹 브랜치(Remote-Tracking Branch) 를 사용한다. 리모트 트래킹 브랜치는 임의로 조작할 수 없으며, 마지막으로 원격 브랜치를 조회하였을 때 그 브랜치가 가리키고 있던 커밋을 똑같이 가리킬 뿐이다.

Local Branch Remote Branch Remote-Tracking Branch
저장 위치 로컬 컴퓨터 원격 서버 로컬 컴퓨터
조작 방법 commit, merge, rebase, reset push fetch, push
설명 로컬에서 직접 작업하는 브랜치 원격 서버에 업로드한 브랜치 로컬에 존재하며 원격 브랜치를 그대로 따라가는 브랜치

pull

pull 명령어는 특수한 경우이다. pullfetchmerge를 한번에 수행하기 때문에 로컬 브랜치와 리모트 트래킹 브랜치를 한번에 변경한다.

Info

리모트 트래킹 브랜치도 체크아웃이 가능하다. 단 리모트 트래킹 브랜치는 임의로 조작할 수 없으므로 리모트 트래킹 브랜치에 체크아웃하면 Detached HEAD 상태가 된다.

리모트 트래킹 브랜치와 그에 해당하는 로컬 브랜치를 새로 만들기 위해서는 아래와 같이 입력한다. 아래의 helloorigin은 예시로 든 이름이며, 브랜치 이름과 원격 저장소 이름은 얼마든지 달라질 수 있다.

# 기본적인 형태. 로컬 브랜치와 리모트 트래킹 브랜치의 이름은 다를 수 있지만 혼동을 피하기 위해 대부분의 경우 같게 설정한다.
git checkout -b hello origin/hello

# 줄인 형태. 자동으로 원격 브랜치의 이름과 같은 이름의 로컬 브랜치를 만들어 준다.
git checkout --track origin/hello

# 더 줄인 형태. 실제로는 이 형태의 명령어를 제일 많이 쓰고, 위 두 명령어는 쓸 일이 잘 없다.
# 입력한 이름의 브랜치가 있는 원격 저장소가 딱 하나 있고, 그 브랜치가 로컬에는 없다면 Git은 자동으로 해당 리모트 트래킹 브랜치와 로컬 브랜치를 모두 만들어 준다.
git checkout hello

리모트 트래킹 브랜치는 자동으로 업데이트되지 않으며, 사용자가 수동으로 원격 브랜치의 최신 변경사항을 받아와야 한다. 아래 명령어로 업데이트할 수 있다. 역시 origin 이름은 예시이며 달라질 수 있다.

git fetch origin
git fetch           # remote가 하나인 경우 remote 이름을 입력하지 않아도 된다.

위 명령을 수행하면, 원격 서버로부터 로컬에는 없는 새로운 정보를 모두 내려받고, origin/hello 리모트 트래킹 브랜치를 최신 커밋으로 이동시킨다.

리모트 트래킹 브랜치의 내용을 로컬 브랜치에 적용하려면 조금 뒤에 자세히 살펴볼 merge 명령을 이용한다.

# git switch hello      # 변경 내용을 적용하려는 브랜치에 체크아웃된 상태에서 merge를 진행해야 한다.
git merge origin/hello
git merge               # merge 명령에 아무런 인수도 입력하지 않을 경우 자동으로 해당 브랜치와 이름이 같은 리모트 트래킹 브랜치와의 병합을 진행한다.

fetchmerge를 한번에 수행하는 명령어가 바로 이전에 사용해 보았던 pull 명령어이다.

리모트 트래킹 브랜치만 골라서 지우는 것도 가능하다.

git branch -d -r origin/hello

하지만 리모트 트래킹 브랜치는 원격 브랜치를 따라가는 것이 원칙이기 때문에 이 명령은 잘 사용하지 않는다.

pull과는 반대로, 내가 로컬에서 만든 변경사항을 원격 브랜치에 적용하려면 push를 사용한다. push로는 원격 브랜치 생성, 수정 뿐만 아니라 삭제도 가능하다.

git push --set-upstream origin hello  # 로컬 브랜치를 처음으로 원격에 업로드할 때, 이와 같이 입력하여 새로운 원격 브랜치를 생성한다.
git push                   # 두 번째 push 부터는 이렇게만 써도 된다.
git push -d origin hello   # 원격 브랜치를 삭제한다. 리모트 트래킹 브랜치도 당연히 함께 삭제된다. 원격 브랜치를 삭제해도 로컬 브랜치는 그대로 남아 있다.

Danger

로컬과 서버의 커밋 히스토리는 독립적이므로, 같은 브랜치라도 그 히스토리가 달라질 수 있다.

danger1

예를 들어, 위 그림과 같이 로컬의 master 브랜치에 몇 개의 커밋을 생성하였는데, 다른 누군가가 원격의 master 브랜치를 업데이트한 상황을 가정해 보자. 이 상태에서 git fetch를 수행하면,

danger2

아래 그림과 같이 개발의 흐름이 둘로 갈라지게 된다. 내가 만든 a38de 커밋도 f4265 커밋을 가리키고, 다른 누군가가 만든 31b8e 커밋도 f4265 커밋을 가리키기 때문이다. 이렇게 같은 브랜치 내에서 개발 흐름이 갈라져 버리면 히스토리를 알아보기도 어렵고, 여러 사람이 같은 부분을 서로 다르게 수정하여 병합 중 충돌이 발생할 수도 있다.

따라서, 주기적으로 git fetch를 실행하여 원격 서버의 최신 변경 데이터를 받아오고, 특히 커밋 전에는 fetch를 실행하는 것을 권장한다. 또한, 내가 커밋한 변경사항은 가능하면 바로바로 원격 서버에 push하여 공유하는 것이 좋다. 이는 여러 사람이 같이 쓰는 브랜치일수록 더욱 중요하다.

이와 같은 문제를 최대한 막기 위해 고안된 방법이 바로 git flow, github flow와 같은 브랜치 전략이다.

태그#

태그(Tag)는 특정 커밋만을 가리키는 일종의 포인터 상수 같은 개념이다. 일반적으로 릴리즈 버전을 표시하는 데 사용된다. 태그에는 브랜치처럼 추가적인 커밋을 연결하거나 앞으로 되돌릴 수 없으므로, 태그에 체크아웃하면 Detached HEAD 상태가 된다.

태그에는 Lightweight 태그와 Annotated 태그가 있다. Lightweight 태그는 정보로 태그 이름만을 가지고, Annotated 태그는 태그 이름뿐만 아니라 태그 설명, 태그를 만든 사람, 이메일, 날짜 등의 정보들이 기록된다.

태그를 추가하는 방법은 아래와 같다.

# 현재 체크아웃된 커밋에 태그 추가하기
git tag v1.4-lw                   # lightweight 태그
git tag -a v1.4 -m "version 1.4"  # annotated 태그

# 다른 커밋에 태그 추가하기
git tag -a v1.2 9fceb02

-a 옵션은 해당 커밋이 annotated 태그임을 의미한다. v1.4-lwv1.4는 태그 이름이며, -m 옵션은 태그 설명을 추가하기 위해 사용한다.

일반적으로는 다양한 정보가 포함된 Annotated 태그가 주로 사용된다.

Chapter 3. Merge와 Conflict Resolve#

로컬 브랜치를 설명하는 데 사용한 Hello world 예제에서, bonjour 브랜치의 개발 및 검증이 완료되어 이제 main 브랜치에 그 기능을 합치려고 한다. 이와 같은 상황에 사용하는 기능이 병합(Merge)이다. 기본적으로 병합은 아래 명령어를 통해 진행한다.

git merge <branch>
위 명령을 수행하면, 현재 체크아웃된 브랜치에 <branch> 에 들어간 브랜치를 병합한다. <branch>는 로컬 브랜치(예: main)가 될 수도 있고, 리모트 트래킹 브랜치(예: origin/main)가 될 수도 있다.

Fast-Forward Merge#

Fast-Forward Merge는 가장 간단한 형태의 병합이다. Fast-Forward는 '빨리 감기' 라는 뜻으로, 현재 브랜치가 병합하려는 브랜치의 변경 사항을 빨리 감기 하듯이 쭉 훑고 지나가서 병합하려는 브랜치와 동일한 커밋을 가리키기 때문에 붙은 이름이다.

ffmerge1

위 그림에서 bonjour 브랜치를 main 브랜치에 병합한다면, Git이 할 일은 단지 main 브랜치도 0bfdb33e (add bonjour function) 커밋을 포함하도록 main 브랜치 포인터를 0bfdb33e로 옮겨 주는 일 뿐이다.

main 브랜치에서 git merge bonjour을 입력하여 병합을 진행하면,

Updating edd367c..0bfdb33
Fast-forward
 Makefile      | 7 +++++--
 inc/bonjour.h | 6 ++++++
 src/bonjour.c | 7 +++++++
 3 files changed, 18 insertions(+), 2 deletions(-)
 create mode 100644 inc/bonjour.h
 create mode 100644 src/bonjour.c

간단한 변경사항 표시와 함께 브랜치가 Fast-Forward 방식으로 병합되었음이 표시된다.

ffmerge2

Git graph를 확인하면, 달라진 것은 main 브랜치가 한 칸 위로 올라와서 bonjour 브랜치와 같은 커밋을 가리키는 것밖에 없다.

bonjour 브랜치의 개발을 완료하였고 해당 내용을 main에 병합하였으니 bonjour 브랜치는 할 일을 다 했다. 이제 bonjour 브랜치는 삭제한다.

git branch -d bonjour

3-way Merge#

Fast-Forward 병합은 매우 간단하다. 사용자가 신경써야 할 것도 적고, 중간에 문제가 생길 일도 거의 없다. 하지만 Fast-Forward 병합을 할 수 없는 상황이라면 상황이 조금 더 복잡해진다.

3wmerge1

위에서 살펴본 예시에서, hello world 출력 기능에 중대한 문제가 발견되어, 다른 개발자가 그 문제를 해결하고 수정된 사항을 main 브랜치에 Push하였다. 이 상황에서는 Fast-Forward 병합을 진행할 수 없다. 단순히 main 브랜치 포인터를 0bfdb33e 커밋으로 옮겨 놓는다면 새로 올라온 커밋 65c5136e의 변경사항은 모두 무시되기 때문이다.

Git은 이러한 상황에서 3-Way Merge를 수행한다. 현재 브랜치의 최신 커밋, 병합할 브랜치의 최신 커밋, 그리고 두 브랜치의 공통 조상 커밋을 비교하여 병합을 진행한다고 하여 3-Way Merge라는 이름이 붙었다. 3- way Merge는 단순히 브랜치 포인터를 최신 브랜치로 옮겨 놓는 것이 아니라, 양쪽 브랜치의 변경사항을 모두 합쳐 새로운 커밋을 하나 만들고 브랜치 포인터를 그 커밋을 가리키도록 한다. 이렇게 새로 생성된 커밋을 머지 커밋이라고 한다. 머지 커밋은 여러 개 브랜치의 변경사항을 모두 가지기 때문에 부모가 여러 개이다.

git merge bonjour를 입력하여 bonjour 브랜치를 main에 병합한다면,

Merge branch 'bonjour'
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.

이전과 달리 바로 병합이 진행되는 것이 아니고, 이러한 내용이 적힌 텍스트 편집기가 열린다. 이는 새로 만들 병합 커밋의 커밋 메시지를 작성하는 과정이다. 메시지 작성 과정은 커밋할 때와 동일하여, 만약 병합을 취소하고 싶다면 커밋 메시지를 비워 놓고 텍스트 편집기를 종료하면 된다.

3wmerge2

텍스트 편집기를 닫으면 병합이 완료된다. 2개의 부모를 가지는 머지 커밋 afe8d437이 새로 생성되었고, main이 그 커밋을 가리키고 있는 것을 확인할 수 있다. 이제 bonjour 브랜치는 완전히 병합되었으니 삭제하면 된다.

이번 예제에서는 병합이 아무런 문제 없이 수행되었다. 하지만, 3-Way Merge를 진행하는 중 같은 파일의 같은 부분이 서로 다르게 수정되었다면 충돌(Conflict)이 발생하게 된다.

Tip

Fast-Forward Merge는 Merge 커밋을 생성하지 않지만, 커밋 히스토리에 병합을 진행하였다는 사실을 기록해야 되는 등의 이유로 Merge 커밋이 필요할 때가 있다. git merge--no-ff 옵션을 추가하면 Fast-Forward Merge를 진행할 수 있는 상황에도 강제로 3-Way Merge를 실행한다.

Conflict Resolve#

충돌이 발생하면, 모든 충돌을 해결할 때까지 병합을 완료할 수 없다. 같은 파일의 같은 곳에 서로 다른 변경사항이 있다면 Git은 둘 중 어떤 것을 가져가고 어떤 것을 버려야 할지 알 수 없기 때문이다.

conflict1

현재 Git graph의 형태이다. 분홍색으로 표시된 main 브랜치에서는 hello 함수에 리턴값을 추가하는 수정을 하였고, 파란색으로 표시된 새로운 브랜치인 feat_hello_num에서는 정수를 하나 입력받아 같이 출력하는 기능을 추가하였다.

현재 main 브랜치, 즉 606b858b (Merge branch 'bonjour') 커밋에서의 hello.c 파일의 상태는 아래와 같다.

#include <stdio.h>
#include "hello.h"

int hello()
{
    printf("Hello, world!\n");
    return 0;
}

그리고, feat_hello_num 브랜치, 즉 a94f3aa7 (add number in hello function) 커밋에서의 hello.c 파일의 상태는 아래와 같다.

#include <stdio.h>
#include "hello.h"

void hello(int num)
{
    printf("hello, world, %d!\n", num);
}

마지막으로, 두 브랜치의 공통 조상인 edd367c6 (add hello function) 커밋에서의 hello.c 파일의 상태는 아래와 같다.

#include <stdio.h>
#include "hello.h"

void hello()
{
    printf("hello, world!\n");
}

세번째 줄 void hello()main에서는 int hello()로, feat_hello_num에서는 void hello(int num)으로 바뀌었다. 이렇게 되면 mainfeat_hello_num 두 브랜치를 병합할 때, Git은 어떤 변경사항을 가져가야 할지 알지 못한다. 따라서, 사용자가 명시적으로 어떤 변경사항을 선택할 것인지 알려줘야 하는데, 이 과정을 충돌 해결(Conflict Resolve)라 한다.

main 브랜치에서 git merge feat_hello_num을 입력하여 병합을 시도하면, 아래와 같이 충돌이 발생하여 병합에 실패하였음을 알려준다.

Auto-merging inc/hello.h
CONFLICT (content): Merge conflict in inc/hello.h
Auto-merging main.c
CONFLICT (content): Merge conflict in main.c
Auto-merging src/hello.c
CONFLICT (content): Merge conflict in src/hello.c
Automatic merge failed; fix conflicts and then commit the result.

그리고 hello.c 파일의 내용이 아래와 같이 바뀌었다.

#include <stdio.h>
#include "hello.h"

<<<<<<< HEAD
int hello()
{
    printf("Hello, world!\n");
    return 0;
=======
void hello(int num)
{
    printf("hello, world, %d!\n", num);
>>>>>>> feat_hello_num
}

<<<<<<< HEAD, >>>>>>> feat_hello_num 같은 생소한 줄들이 추가되었다. <<<<<<< HEAD======= 사이에 있는 부분은 현재 HEAD 포인터가 가리키는 브랜치인 main 브랜치의 변경 사항을 의미하고, 이 부분을 Current Change라 부른다. 반대로 =======>>>>>>> feat_hello_num 사이에 있는 부분은 feat_hello_num 브랜치의 변경 사항을 의미하고, Incoming Change라 부른다.

Conflict를 해결하기 위해서는 이 두 부분을 하나로 합쳐준 다음, <<<<<<, >>>>>>, ======= 따위를 지워 주면 된다. 예를 들어, Current Change를 그대로 유지하고 Incoming Change를 버리고 싶으면, <<<<<<< HEAD======= 사이의 코드만 남겨놓고 나머지를 모두 지우면 된다.

Git은 어디에서 충돌이 발생하는지만 알려줄 뿐, 해당 충돌을 어떻게 처리할지는 사용자의 자유이다. 두 변경사항 중 하나를 선택할 수도 있지만, 아예 다 지우고 새롭게 코드를 작성할 수도 있다.

우리는 Current Change를 남기도록 하겠다. 즉, feat_hello_num에서 추가된, 정수 하나를 입력받아 같이 출력하는 기능을 버리도록 하겠다.

#include <stdio.h>
#include "hello.h"

int hello()
{
    printf("Hello, world!\n");
    return 0;
}

HEAD로 표시된 부분을 제외한 나머지 부분을 전부 지우고 위와 같이 정리하면 된다. 충돌 해결을 완료했다면 파일을 스테이징하고 커밋하면 병합이 완료된다.

충돌 해결 중 병합을 중지하고 싶으면 터미널에 git merge --abort를 입력한다. 모든 상태가 병합 진행 이전으로 돌아간다.

Tip

Visual Studio Code를 사용한다면, 클릭 몇 번으로 간단하게 충돌 해결을 진행할 수 있다.

vscode_resolve

위 그림과 같이, vscode는 자동으로 충돌이 발생한 부분을 강조해서 보여준다. 위에 보이는 Accept Current ChangeAccept Incoming Change 등을 클릭하여, 필요한 부분만 남기고 나머지를 한번에 정리할 수 있다.

Conflict Resolve는 상당히 번거로운 작업이다. 큰 규모의 프로젝트에서 다수의 충돌이 발생하였다면 충돌을 해결하는 데에만 많은 시간이 소요된다. 따라서 되도록이면 충돌을 발생시키지 않도록 개발 흐름을 정리하는 것이 좋다. 가장 간단한 방법은 하나의 브랜치는 코드의 여러 부분을 건드리지 않도록 하나의 기능만을 담당하도록 하는 것이다. 위에서 언급했던 브랜치 전략이 결국 여러 개의 서로 다른 브랜치가 같은 공간을 수정하지 않도록 막기 위한 것이다.

Chapter 4. Reset과 Revert#

개발을 진행하다 뭔가 잘못되었음을 느껴 커밋을 되돌리는 방법에는 두 가지가 있다. 하나씩 살펴보도록 하자.

git commit --amend

만약 바로 직전의 커밋 하나가 마음에 들지 않아서 새로 커밋해야겠다면, 커밋을 되돌렸다가 다시 새로운 커밋을 만드는 귀찮은 과정 없이 git commit--amend 옵션을 붙여 주는 것으로 간단하게 바로 이전 커밋을 대체할 수 있다.

Reset#

Reset은 커밋을 정말로 뒤로 되돌리는 기능이다. 엄밀히 말하면, 특정 브랜치 포인터를 이전의 커밋으로 되돌려 놓는 작업이다.

git reset <commit_hash>  # 특정 커밋까지 되돌린다.
git reset HEAD~2    # 현재 브랜치의 커밋 2개를 되돌린다.
git reset HEAD^2    # 위 명령과 완전히 같은 명령이다.

리셋을 실행하고 커밋 그래프를 확인하면, Git 저장소가 정말로 커밋을 수행하기 전 상태로 되돌아가 있는 것을 확인할 수 있다. 즉, 리셋은 이전 작업 기록을 날릴 수도 있는 위험한 작업임을 인지하고 있어야 한다.

리셋에는 soft, mixed, hard 리셋이 있다.

git reset --soft HEAD~2
git reset --mixed HEAD~2
git reset HEAD~2          # 옵션을 주지 않으면 기본적으로 mixed 리셋을 수행한다.
git reset --hard HEAD~2

  • soft 리셋은 되돌린 커밋에 해당하는 변경 사항들을 Working Tree에 복원하고 Staging Area에 올려놓는다.
  • mixed 리셋은 되돌린 커밋에 해당하는 변경 사항들을 Working Tree에 복원한다.
  • hard 리셋은 되돌린 커밋에 해당하는 변경 사항을 모두 지운다.

hard 리셋을 수행하면, 되돌려버린 커밋 해시를 기억하고 있지 않는 이상, 되돌린 커밋들의 변경사항들을 모두 잃게 되니 사용에 극도의 주의가 필요하다.

리셋을 진행하면 브랜치 포인터가 뒤로 되돌아가기 때문에, 원격 브랜치의 포인터가 더 최신의 커밋을 가리키는 상황이 발생할 수 있다. 원격 브랜치가 로컬 브랜치보다 최신 버전을 가리키고 있기 때문에 이 상태에서는 push를 진행할 수 없다. 만약 원격 브랜치의 히스토리를 되돌리고 싶다면 git push -f를 입력하여 강제로 원격 브랜치를 업데이트할 수 있다. 하지만, 원격 브랜치가 뒤로 돌아가 버린다면 그 브랜치에서 같이 작업하는 다른 개발자들에게 큰 혼란을 줄 수 있으므로 여러 사람이 같이 쓰는 브랜치에서는 이를 절대로 해서는 안 된다.

따라서, Reset을 사용할 수 있는 상황은 아래 2가지 뿐이다.

  • 브랜치가 원격에 아예 존재하지 않거나, 원격에 존재하지만 Reset하려는 커밋까지의 변경사항이 아직 Push되지 않은 경우
  • 브랜치가 원격에 존재하고 Reset하려는 커밋까지 push되었지만, 이 브랜치는 본인 혼자만 쓰고 있는 것이 명확한 경우

Revert#

위에서 살펴본 바와 같이 Reset은 특정 브랜치의 커밋 히스토리를 깔끔하게 뒤로 되돌릴 수 있다는 장점이 있지만, 정말 많은 위험성을 가지고 있다. Revert는 커밋 히스토리의 명료함을 약간 희생하는 대신 위험성을 최소화한 되돌리기 방법이다.

Revert는 Reset과 달리, 브랜치를 실제로 뒤로 되돌리는 것이 아니라, 되돌리기를 원하는 변경 사항들을 모두 되돌려 놓은 새로운 커밋을 만든다.

reset-and-revert

Revert는 아래 명령어를 입력하여 진행한다.

git revert <commit_hash>  # 특정 커밋 해시의 수정 사항을 되돌린다.
git revert HEAD~2         # 현재 브랜치의 최신 커밋으로부터 2번째에 있는 커밋의 내용을 되돌린다.

# revert는 reset과 다르게 그 커밋까지의 모든 변경사항을 되돌리는 것이 아니고 그 커밋 하나의 변경사항만 되돌린다.
# 여러 커밋의 변경사항을 모두 되돌리고 싶으면 되돌릴 커밋 해시를 모두 입력한다.
git revert <commit1_hash> <commit2_hash> <...>

# revert를 수행하면 revert 커밋이 자동으로 생성된다.
# 커밋 메시지를 새로 작성하거나 새로운 변경을 추가하는 등의 이유로 커밋을 바로 진행하지 않고
# 되돌릴 내용을 Staging Area에까지만 올려놓으려면 -n 옵션을 추가한다.
git revert -n <commit_hash>

Question

Revert 커밋을 다시 Revert할 수도 있고, Revert 커밋을 Reset할 수도 있다. 두 경우의 실행 결과 코드 결과물은 같지만, 커밋 히스토리가 달라진다. 어떻게 달라질지 한번 생각해 보자.

Chapter 5. GitHub 활용 협업#

Pull Request#

일반적으로, 브랜치 간의 병합은 다른 사람들과의 협업이 필요한 부분이다. 아직 병합이 준비되지 않았는데 나 혼자만의 판단으로 병합을 해 버리고 원격에 Push해 버린다던가, 병합 도중 충돌이 발생했을 때 내 마음대로 충돌을 해결한 다음 Push해 버리면 문제가 발생할 수도 있다.

따라서 GitHub는 브랜치 병합을 유연하게 진행할 수 있도록 Pull Request (PR) 라는 기능을 제공한다. 실제로 병합을 진행하기 전에, 같이 작업하는 사람들에게 이 병합을 진행하는 것이 어떻겠냐고 제안하는 과정인 것이다.

GitHub 리포지토리 페이지에서 'Pull Requests' 탭으로 들어가면, 지금까지의 Pull Request를 확인하고 새로운 Pull Request를 만들 수 있다.

new_pr

'New Pull Request' 버튼을 누르면 새로운 PR을 작성할 수 있다.

pr_compare

'base'에는 원본 브랜치, 'compare'에는 병합을 진행할 브랜치를 선택하면, 변경 사항을 비교하여 충돌이 발생하는지 아닌지를 비교해 보여준다. 현재는 충돌 없이 병합이 가능한 상황이다. 만일 충돌이 존재할 경우에는 온라인 편집기에서 충돌을 직접 해결해 줄 수 있다.

'Create Pull Request'를 누르면, 다음 단계로 넘어가서 PR에 대한 설명을 작성하는 창이 표시된다. PR 설명은 마크다운 문법이 지원되어, 알아보기 쉽게 작성할 수 있다. 설명이 딱히 필요하지 않다면 비워 놓을 수도 있다.

pr_assign

Reviewer와 Assignee에 본인 혹은 다른 개발자들을 지정할 수 있다. Reviewer는 이 PR을 검토할 사람, Assignee는 이 PR의 생성에 관여한 사람들을 등록한다. Reviewer 나 Assginee에 등록된다고 특별한 권한이나 제한 사항 등이 생기는 것은 아니고, PR에 관련된 책임 소재 등을 기록하여 나중에 볼 수 있도록 하기 위함이다.

Label은 지금 열려고 하는 PR이 어떤 역할인지를 표시한다. 라벨은 붙여도 되고, 붙이지 않아도 된다. 기본 라벨에는 버그 수정(bug), 문서 작성(documentation), 새로운 기능(enhancement) 등이 있으며, 자유로운 추가 및 제거가 가능하다.

Project와 Milestone은 GitHub Project와 관련된 기능이다.

정보를 모두 입력하고 Create Pull Request를 클릭하면 Pull Request가 생성된다. 지금까지의 작업을 PR을 연다(Open a PR)고 한다.

pr_open

열려 있는 PR의 모습이다. PR의 검토가 완료되면 'Merge Pull Request'를 눌러 PR을 Merge한다. 그러면 자동으로 원격 브랜치에 Merge 커밋이 생성되고, PR의 상태가 'Open'에서 'Merged'로 변경된다.

검토 결과, Merge를 진행하면 안 되는 상황이라고 판단되면 Merge를 진행하지 않고 PR을 닫을 수 있다. 오른쪽 아래의 'Close Pull Request 버튼을 누르면 변경사항을 병합하지 않고 PR을 닫는다.

Issue#

GitHub의 Issue는 해결이 필요한 버그, 추가할 만한 새로운 기능 아이디어 등 팀과 함께 논의해야 하는 사항들을 추적할 수 있는 도구이다. GitHub 리포지토리의 Issue 탭에서 새로운 이슈를 생성하고, 이슈가 해결됨 또는 해결되지 않고 닫힘을 표시할 수 있다.

마크다운 문서 작성#

GitHub의 README 파일, PR 및 Issue 작성 양식을 보면, Markdown 형식이 지원된다고 되어 있는 것을 확인할 수 있다. 마크다운(Markdown)은 마크업 언어의 일종으로, 간단하고 사용하기 쉬울 뿐만 아니라 HTML로 쉽게 변환이 가능하여 웹 환경에도 적용이 용이하다. 마크다운 언어는 GitHub 뿐만 아니라 Reddit, Stack Overflow 등 다양한 플랫폼에서 사용되며, jekyll이나 mkdocs 등 블로그 호스팅 서비스에서도 사용된다. 현재 보고 있는 이 글도 마크다운으로 작성되어 있다.

https://www.markdownguide.org/에서 자세한 마크다운 문법에 대해 살펴볼 수 있다.

Tip

Visual Studio Code의 Markdown Preview 확장 기능을 사용하면 마크다운 문서를 작성하면서 그 결과물을 실시간으로 볼 수 있다.

마크다운 문서 작성 중 Ctrl+Shift+T 를 눌러 미리보기 창을 열 수 있다.

참고 문헌#