Git— 스냅샷으로 이해하는 버전 관리
3장

병합 — merge와 fast-forward

두 브랜치의 히스토리를 하나로 합치는 방법은 두 가지다 — 포인터만 옮기거나, 새로운 합의 커밋을 만들거나.

병합이 필요한 순간

기능 브랜치에서 작업이 완료되면 그 변경 사항을 메인 브랜치에 통합해야 한다. 이것이 병합(merge)이다.

병합은 두 브랜치가 분기된 이후 각자에서 발생한 변경 사항을 하나의 스냅샷으로 결합하는 작업이다. Git은 이를 자동으로 처리하기 위해 공통 조상(merge base)을 찾는다. 두 브랜치가 마지막으로 같은 커밋을 가리키던 시점이 공통 조상이다.

병합 알고리즘은 공통 조상의 스냅샷, 한쪽 브랜치의 최신 스냅샷, 다른 쪽 브랜치의 최신 스냅샷 — 이 세 개의 스냅샷을 비교해 충돌 없이 합칠 수 있는지 판단한다. 같은 파일의 같은 줄이 양쪽에서 다르게 수정된 경우에만 충돌이 발생한다.

공통 조상 (Merge Base) — 병합하려는 두 브랜치의 히스토리에서 최초로 갈라진 커밋. Git은 git merge-base branch1 branch2로 이를 계산한다. 3-way merge 알고리즘의 기준점이 된다.

Fast-forward 병합 — 포인터만 이동

가장 단순한 병합 형태다. 한쪽 브랜치가 다른 쪽의 직선 히스토리상에 있을 때, 새 커밋 없이 포인터만 앞으로 이동한다.

상황: main이 커밋 B를 가리키고, feature가 B→C→D로 세 커밋 앞서 있다. feature에서 main을 분기한 이후 main에는 새 커밋이 없다. 이 경우 git merge feature를 실행하면 Git은 "main을 D로 이동하면 feature의 모든 이력을 포함할 수 있다"고 판단해 포인터만 갱신한다.

Fast-forward 이후의 커밋 그래프에는 병합 커밋이 존재하지 않는다. 히스토리는 완전히 선형이다. 병합이 일어났다는 사실 자체가 그래프에서 사라진다.

Fast-forward 강제 비활성화
# 항상 병합 커밋을 만들고 싶다면 (기록 보존)
git merge --no-ff feature

# 반드시 fast-forward만 허용 (아니면 실패)
git merge --ff-only feature
직관: Fast-forward는 "이미 선로가 연결되어 있으니 기차를 앞으로 끌어당기면 된다"는 것이다. 새 분기점이 없었으므로 합류점도 필요 없다.
함정: GitHub의 "Merge pull request" 버튼은 기본적으로 fast-forward 가능해도 병합 커밋을 강제 생성한다(--no-ff). PR이 언제 병합되었는지를 그래프에서 명확히 볼 수 있도록 하기 위해서다. "Squash and merge", "Rebase and merge" 등 다른 전략도 선택할 수 있다.

3-way 병합 — 병합 커밋 생성

두 브랜치가 공통 조상 이후 각자 새 커밋을 만들었다면 fast-forward가 불가능하다. 이때 Git은 세 개의 스냅샷을 비교해 새로운 병합 커밋을 생성한다.

병합 커밋은 두 개의 parent를 가지는 특별한 커밋이다. 커밋 오브젝트에 parent 줄이 두 개 있다. 이 커밋이 그래프에 합류점으로 남아 "이 두 라인이 여기서 합쳐졌다"는 사실을 영구히 기록한다.

3-way 병합 알고리즘의 동작 원리: 공통 조상(O), main의 최신 커밋(A), feature의 최신 커밋(B)을 비교해 각 파일의 각 줄에 대해 다음 규칙을 적용한다.

O와 동일하고 A에서만 변경 — A의 변경을 채택한다.
O와 동일하고 B에서만 변경 — B의 변경을 채택한다.
A와 B 모두 O와 다르지만 동일하게 변경 — 어느 쪽이든 채택한다.
A와 B 모두 O와 다르고 서로 다르게 변경충돌(conflict). 사람이 직접 해결해야 한다.

충돌이 발생하면 Git은 충돌 마커(<<<<<<<, =======, >>>>>>>)를 삽입한 파일을 작업 디렉터리에 남기고, 스테이징 영역에 충돌 상태를 기록한다. 사용자가 파일을 편집해 충돌을 해소하고 git add한 뒤 git commit하면 병합이 완료된다.

충돌 해결 워크플로
git merge feature        # 충돌 발생 시 중단됨
git status               # 충돌 파일 확인
# 충돌 파일을 편집기로 열어 <<<, ===, >>> 마커 해결
git add src/conflicted.ts
git commit               # 병합 커밋 완성
# 또는 병합 취소하려면
git merge --abort

병합 시각화

Fast-forward와 3-way 병합 비교 1 / 7
SPACE 재생 · ← → 이동 · R 리셋

병합 전략 선택하기

프로젝트와 팀의 워크플로에 따라 병합 전략을 의도적으로 선택해야 한다. 각 전략의 트레이드오프를 이해하자.

병합 커밋(--no-ff): 브랜치의 존재와 병합 시점이 그래프에 명확히 남는다. PR 단위의 작업 구분이 가능하고, git revert로 PR 전체를 한 번에 되돌릴 수 있다. 단, 그래프가 복잡해 보일 수 있다.

Fast-forward(--ff-only): 선형 히스토리를 유지해 git log가 읽기 쉽다. 단, 브랜치가 언제 만들어지고 병합되었는지를 그래프만으로는 알 수 없다. 단독 작업이나 짧은 수명의 브랜치에 적합하다.

Squash merge: 브랜치의 모든 커밋을 하나로 압축해 main에 추가한다. 기능 브랜치의 지저분한 WIP 커밋들을 숨기고 main 히스토리를 깔끔하게 유지할 수 있다. 단, 세부 작업 내역이 사라지므로 이진 탐색(git bisect) 시 단위가 커진다.

핵심: 어떤 전략이 "옳다"는 것은 없다. 팀 내에서 하나의 전략을 일관되게 따르는 것이 가장 중요하다. main 브랜치의 히스토리가 팀이 읽고 이해해야 하는 "공식 기록"임을 기억하자.

Octopus 병합과 재귀 병합

대부분의 병합은 두 브랜치 사이에서 일어나지만, Git은 세 개 이상의 브랜치를 동시에 병합하는 옥토퍼스 병합도 지원한다.

git merge branch1 branch2 branch3처럼 여러 브랜치를 나열하면 한 번의 명령으로 병합할 수 있다. 결과 커밋은 세 개 이상의 parent를 갖는다. 독립적인 기능 브랜치들을 릴리스 브랜치에 통합할 때 주로 쓰인다.

재귀(recursive) 병합 전략은 공통 조상이 여러 개인 경우(크리스-크로스 병합 등)를 처리하기 위해 가상의 공통 조상을 먼저 병합해 사용하는 고급 알고리즘이다. Git 2.33 이후에는 ort 전략이 기본값으로 채택되어 더 빠르고 정확하다.

병합 전략 명시적 지정
git merge -s ort feature       # ort 전략 (Git 2.33+ 기본값)
git merge -s recursive feature # recursive (구 기본값)
git merge -s octopus b1 b2 b3  # 3개 브랜치 동시 병합
git merge -s ours feature      # feature를 "병합했다는 기록만" 남기고 내용은 무시
← 브랜치와 HEAD리베이스 →